refactor(472-6 6-B 한 벌): 노선 바뀜 넘김 규칙 · 세션 고침 얹기 · [저장] patch 바탕을 순수 TS 로 떼어 냄 — 동작 같음

- logic/B06_Section_Route_Carry.ts = 측점 열쇠 목록 · 측점 열쇠 칸 · 누가거리 칸 넘김(Station_Keys 몸통) · Station_Keys = 세션 겉
- B06_Section_Route_Carry_Patch.ts = 조정창 · 옮긴 고침 · 관 옵션 얹기 · crossPatchesFromCache · 서버용 routeCarryPatches(옛 carryAfterRouteSave 차례) · Section_Adjusts · Section_Store = 겉 · 내줌
- 칸 목록 USER_TOUCHED_KEYS · STRUCTURE_ADJUST_KEYS = logic/B06_Section_Design_Keys.ts · PIPE_MATCH_M = common_util_pipe_match.ts(옛 자리는 내줌)
- 시험 헬퍼 · 글 단언 시험 = 옮긴 자리로(목록 한 벌 자리만 바뀜)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 11:33:28 +09:00
co-authored by Claude Opus 5.5
parent e26f69cd5b
commit de0141b8fc
35 changed files with 956 additions and 540 deletions
+4 -3
View File
@@ -8,13 +8,14 @@
*
* 값은 등록표 키 `structure-pick`(② 설계 초안 · 페이지를 오가도 남음)에 실린다.
* 어느 화면(3D · 종단 알약 · 목록 · 횡단 카드)에서 골라도 같은 값이 적히고, 다른 쪽은
* 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 도 여기 한 값뿐이다.
* 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 은 여기서 내줌(정의 = common_util).
* ========================================================================== */
import { PIPE_MATCH_M } from "../../common_util/common_util_pipe_match";
import { readState, writeState } from "../b_page_state";
/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51;
/** 관 · 구조물 찾는 거리(m) — 정의 = `common_util_pipe_match`(서버 Node 묶음도 같은 값 · 472-6). */
export { PIPE_MATCH_M };
export interface DesignSelection {
chainageM: number | null;
+4 -13
View File
@@ -31,6 +31,10 @@ import {
} from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import type { SavePart } from "../A00_Common/design/design_save_api";
import { STRUCTURE_ADJUST_KEYS } from "./logic/B06_Section_Design_Keys";
/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 목록 한 벌 = `logic/B06_Section_Design_Keys`(472-6). */
export { STRUCTURE_ADJUST_KEYS };
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로.
* 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */
@@ -101,19 +105,6 @@ export function culvertOptionsPart(sessionKey: string | null): SavePart | null {
return { body: pending, after: () => writePending(sessionKey, {}) };
}
/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */
export const STRUCTURE_ADJUST_KEYS = [
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
] as const;
/** 측점 조정값 세션 — 열쇠가 누가거리(`b` · `:역할` 붙은 것 포함)로 시작함. */
const STATION_ADJUST_STATES: readonly StateName[] = [
"culvertopt",
+4 -37
View File
@@ -43,6 +43,7 @@ import {
type BermSpec,
} from "@util/common_util_cross_berm";
import { readState } from "../A00_Common/b_page_state";
import { USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys";
import { savedDesigns } from "./B06_Section_Section_Canon";
import {
crossDesignChoices,
@@ -89,44 +90,10 @@ export interface CrossRefreshInput {
shouldApply?: () => boolean;
}
/**
* 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로
* 실어 보낸다(`B06_Section_Section_Store`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS`
* 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`).
*
* 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을
* 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
*/
export const USER_TOUCHED_KEYS = [
"display_half_width_m",
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
// 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9).
"berm",
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
"cut_slope_ratio_user",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
// 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님.
"structure_note_cad",
// [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
"detail_table",
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
"shoulder_extend_left_m",
"shoulder_extend_right_m",
// 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김
"berm_depth_left_m",
"berm_depth_right_m",
] as const;
/** 계산이 만들지 않는 **사용자 값** — 목록 한 벌 = `logic/B06_Section_Design_Keys`(서버 Node 입구도 · 472-6). */
export { USER_TOUCHED_KEYS };
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
/**
@@ -0,0 +1,436 @@
/* =============================================================================
* B06_Section_Route_Carry_Patch.ts
* 세션 고침 → 상세 설계 칸 얹기 · [저장] 횡단 patch 꼴 **한 벌**(472-6 6-B) — 화면 · 세션 · fetch 없음.
*
* · `layDraftAdjusts` · `layCarriedEdits` — 조정창 · 옮긴 고침 칸(읽는 함수를 받음 · 브라우저 세션 겉 =
* `B06_Section_Section_Adjusts`) · `layPipeOptions` — 관 옵션의 조정값 몫(387-4b).
* · `crossPatchesFromCache` — 캐시 설계의 사용자 칸 = [저장] patch 바탕(445 · 상세 가게가 내줌).
* · `routeCarryPatches` — 노선 바뀐 [저장]: 옛 노선 세션 칸 → 새 노선 서버 상세(측점 열쇠 넘김
* `logic/B06_Section_Route_Carry`) → 얹기 → patch(옛 브라우저 길 `carryAfterRouteSave` 와 같은 차례) ·
* 서버 Node 입구 `stations.carry` 가 부름(`A00_Common/design/logic/design_calc_node_carry.ts`).
* logic 밖 = 구조물 그림 기하(관 벽 · 단 · 구간 · 세월 · BOX 기본값)를 끔 — 서버 Node 묶음 알려진 끌림.
* ========================================================================== */
import type {
CrossDesign,
CrossSectionPatch,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom";
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import { PIPE_MATCH_M } from "../common_util/common_util_pipe_match";
import { inferStationInterval } from "../common_util/common_util_svg";
import { applySpanPatch, WALL_MIN_PURE_HEIGHT_M } from "./logic/B06_Section_UI_Cross_Culvert_Const";
import { tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeStoredLayouts } from "./B06_Section_Structure_Layouts";
import { ROW_CARRY_KEYS } from "./logic/B06_Section_Station_Swap";
import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches";
import { STRUCTURE_ADJUST_KEYS, USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys";
import {
carryChainageStates,
carryKeyedStates,
readKeyedIn,
stationKeysFrom,
type Keyed,
type KeyedState,
type Owner,
type Raw,
} from "./logic/B06_Section_Route_Carry";
export type Cells = Record<string, unknown>;
/** 누가거리(숫자) → 쪽마다 값(세션에 있는 쪽만 · 노견 확장 · 소단 깊이). */
export type SideValues = ReadonlyMap<number, Partial<Record<"left" | "right", number>>>;
type Obj = Record<string, unknown>;
const INLET = ["auto", "revet", "I", "L", "U"];
const isObj = (value: unknown): value is Obj => !!value && typeof value === "object";
const count = (value: unknown): number =>
Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0;
/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */
function wallAdjust(value: unknown): WallAdjust | null {
if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value };
if (!isObj(value)) return null;
const partial = value as Partial<WallAdjust>;
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
}
/** 저장 안 한 조정창 값(누가거리 열쇠 칸 · `read`)을 상세 설계 칸에 얹음 — 조정 4축 · 형식 · 단 수 · 구간 · 연동 ·
* 세월교 · BOX(387-4 · 브라우저 세션 = `withDraftAdjusts` · 서버 = 노선 바뀐 [저장] 넘김 · 472-6). */
export function layDraftAdjusts(
detail: SectionDetailResponse,
read: (name: string) => Cells,
): void {
const revet = read("revetx");
const basin = read("basinadjust");
const inlet = read("inletstruct");
const extra = read("extrawall");
const spans = read("extraspan");
const ford = read("fordadjust");
const box = read("boxadjust");
const link = read("revetlink") as { detached?: Cells; grade?: Cells };
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
const own = (cells: Cells): Array<[string, unknown]> =>
Object.entries(cells)
.filter(([key]) => key.startsWith(`${at}:`))
.map(([key, value]) => [key.slice(at.length + 1), value]);
for (const [role, value] of own(revet)) {
const adjust = wallAdjust(value);
if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust };
else if (value === null && design.revet_adjust?.[role]) {
// null = 되돌림 — 저장분 그 벽을 뺌(445 M2)
const { [role]: _gone, ...rest } = design.revet_adjust;
design.revet_adjust = rest;
}
}
if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string))
design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"];
if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] };
// 세션에 있는 쪽만 — 없는 쪽은 저장분 그대로(445 H3)
const saved = design.extra_wall_counts;
if (at in extra || `b${at}` in extra)
design.extra_wall_counts = {
outlet: at in extra ? count(extra[at]) : (saved?.outlet ?? 0),
basin: `b${at}` in extra ? count(extra[`b${at}`]) : (saved?.basin ?? 0),
};
for (const [wall, value] of own(spans)) {
const span = value as { lengthM: number; beforeM: number; afterM: number } | null;
if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue;
design.extra_spans = {
...(design.extra_spans ?? {}),
[wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM },
};
}
if (isObj(link.detached) && at in link.detached)
design.revet_link_detached = !!link.detached[at];
if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at];
if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]);
const sides = box[at] as { left?: Obj; right?: Obj } | undefined;
if (isObj(sides))
design.box_adjust = {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right },
};
}
}
/** 노선 저장 뒤 새 노선으로 옮긴 세션 고침(417) — 사면 쉬프트 · 소단 깊이 · 측점 폭 · 메모 · 상세 표를 설계 칸에 얹음 ·
* 같은 [저장]의 횡단 패치(`crossPatchesFromCache`)가 실음. 조절 4축 · 형식은 `layDraftAdjusts`(브라우저 세션 =
* `withCarriedEdits`). */
export function layCarriedEdits(
detail: SectionDetailResponse,
read: (name: string) => Cells,
sideValues: { shoulder: SideValues; berm: SideValues },
): void {
const cells: Array<[string, Cells]> = [
["display_half_width_m", read("crossw")],
["structure_note", read("structure-notes")],
["structure_note_cad", read("structure-note-cads")],
["detail_table", read("detail-table")],
];
const sides: Array<[string, SideValues]> = [
["shoulder_extend", sideValues.shoulder],
["berm_depth", sideValues.berm],
];
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
for (const [field, values] of cells) if (values[at] !== undefined) design[field] = values[at];
for (const [field, values] of sides) {
const own = values.get(Number(at));
for (const side of ["left", "right"] as const)
if (own?.[side] !== undefined) design[`${field}_${side}_m`] = own[side];
}
}
}
/**
* 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름).
*
* 초기 계산값은 원복용으로 서버에 그대로 있고, 사용자가 만진 값은 이 캐시 한 세트가
* 최종본이다. B05 [임시저장]도 이 세트를 함께 보내야 B06에서 만진 구조물 조정이
* 영구저장소에 남는다 — 안 보내면 캐시만 비워져 편집이 사라진다.
*
* 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)를 측점마다 **통째로** 싣는다 — [저장] patch 의 바탕
* (445 H3 · 세션 고침은 그 위에 `mergeCrossPatches`). `savedAt`(서버 행 누가거리)을 주면 그 자리 행이 없는
* 측점(옮긴 관 · 구조물 · 새 측점 = 서버가 기본 설계로 새로 세움)은 지반 등 입력(`ROW_CARRY_KEYS`)도 실음(445 H2).
*/
export function crossPatchesFromCache(
detail: SectionDetailResponse,
kindChanged: readonly number[] = [],
savedAt?: readonly number[],
): CrossSectionPatch[] {
const patches: CrossSectionPatch[] = [];
// 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값은 서버가 비움 · 캐시에 남은 옛 값을 다시 안 실음
const structureKeys: readonly string[] = STRUCTURE_ADJUST_KEYS;
const reset = (at: number) => kindChanged.some((c) => Math.abs(c - at) < PIPE_MATCH_M);
const rowless = (at: number) => !!savedAt && savedAt.every((c) => Math.abs(c - at) >= 0.01);
for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true });
for (const section of detail.cross_sections) {
const design = section.design;
if (!design) continue;
const patch: CrossSectionPatch = { chainage_m: section.chainage_m };
const skip = reset(section.chainage_m);
let touched = false;
// 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고
// 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이
// 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
const source = design as unknown as Record<string, unknown>;
const target = patch as unknown as Record<string, unknown>;
const keys = rowless(section.chainage_m)
? [...USER_TOUCHED_KEYS, ...ROW_CARRY_KEYS]
: USER_TOUCHED_KEYS;
for (const key of keys) {
const value = source[key];
if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue;
target[key] = value;
touched = true;
}
if (touched) patches.push(patch);
}
return patches;
}
/** 관 하나 — 자리 · 폼 칸(관 초안 · 저장 관 `pipe_points` 같은 꼴). */
export interface OptionPipe {
chainage_m: number;
options?: Record<string, unknown> | null;
}
const isDefault = (a: WallAdjust): boolean =>
Math.abs(a.x) < 1e-9 && a.d == null && a.h == null && a.m == null;
const round1 = (value: number): number => Math.round(value * 10) / 10;
/** 조정창 `update` 와 같은 꼴 — x · d 는 그대로 · h · m 만(폼 한 칸이 주인 · 빈 칸 = 자동 null). */
function setWall(design: CrossDesign, role: string, h: number | null, m: string | null): void {
const current = { ...ZERO_ADJUST, ...(design.revet_adjust?.[role] ?? {}) } as WallAdjust;
const next: WallAdjust = { ...current, h: h === null ? null : round1(h), m };
const bucket = { ...(design.revet_adjust ?? {}) };
if (isDefault(next)) delete bucket[role];
else bucket[role] = next;
design.revet_adjust = bucket;
}
/** 관 옵션의 조정값 몫을 상세에 얹음(폼 칸 전부 = 되보내기와 같은 규칙) — 같은 값이면 그대로. */
export function layPipeOptions(
detail: SectionDetailResponse,
pipes: ReadonlyArray<OptionPipe>,
): void {
const sections = detail.cross_sections;
for (const pipe of pipes) {
const options = pipe.options;
if (!options) continue;
const near = (at: number): boolean => Math.abs(at - pipe.chainage_m) < PIPE_MATCH_M;
// 물넘이포장 = 제원만(66-2) · 벽 없음
if (sections.some((s) => s.ford_pavement && near(s.ford_pavement.chainage_m ?? s.chainage_m)))
continue;
const owner = sections.find((s) => near(s.chainage_m));
const culvert = owner?.culvert;
const design = owner?.design;
if (!owner || !culvert || !design) continue;
const num = (key: string): number | undefined => {
const value = Number(options[key]);
return Number.isFinite(value) ? value : undefined;
};
for (const role of ["inlet", "outlet"] as const) {
const form = options[`${role}_revet_form`];
setWall(
design,
role,
num(`${role}_revet_height_m`) ?? null,
typeof form === "string" ? form : null,
);
}
if (culvert.hidden_pipe && !owner.ford && !owner.box) continue;
// 흙막이 n 단 = 지형이 세운 유출 다단(`pipeExtraCount` 와 같은 셈)
const outlet = design.extra_wall_counts?.outlet ?? 0;
const probe = {
...owner,
design: { ...design, extra_wall_counts: { basin: 0, ...design.extra_wall_counts, outlet } },
};
const tiers = outlet
? (computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet)
: 0;
for (let n = 1; n <= tiers; n++) {
const role = `extra${n - 1}`;
const value = (item: string): number | undefined => {
const raw = options[`soil_add_${n}_${item}`];
return raw === undefined || raw === "" || !Number.isFinite(Number(raw))
? undefined
: Number(raw);
};
const form = options[`soil_add_${n}_form`];
const height = value("height");
setWall(
design,
role,
height === undefined ? null : Number(Math.max(height, WALL_MIN_PURE_HEIGHT_M).toFixed(1)),
typeof form === "string" && form ? form : null,
);
const span = { lengthM: value("length"), beforeM: value("before"), afterM: value("after") };
if (Object.values(span).every((v) => v === undefined)) continue;
const at = tierSpanOf(owner, role);
const current = {
lengthM: Math.round((at.beforeM + at.afterM) * 10) / 10,
beforeM: Math.round(at.beforeM * 10) / 10,
afterM: Math.round(at.afterM * 10) / 10,
};
const next = applySpanPatch(current, span);
design.extra_spans = {
...(design.extra_spans ?? {}),
[role]: { length_m: next.lengthM, before_m: next.beforeM, after_m: next.afterM },
};
}
}
}
/** 노선 바뀐 [저장] 넘김 입력(브라우저 `routeCarryInputs` + 서버 저장분 · 472-6 6-B). */
export interface RouteCarryInput {
/** 새 노선 서버 상세 — 관 · 구조물 · 추가 측점 · 계획고 차례 뒤(옛 길 `loadSectionDetail` 과 같은 때). */
detail: SectionDetailResponse;
/** 옛 노선 세션 칸 원본 — 측점 열쇠 칸 다섯 · 누가거리 칸 · `kindreset`(상단측 뺌 · 요구 274). */
states: Record<string, unknown>;
/** 옛 노선 측점 열쇠 목록(브라우저가 세운 것 · 없으면 null). */
from_keys: Keyed[] | null;
/** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */
owners: Owner[];
/** 관 세션 장부(`pipe-ids`). */
pipe_ids: { next: number; ids: Array<{ id: string; at: number }> } | null;
/** 노선 저장 직전 저장 관(id · 누가거리). */
pipes_before: Array<{ id?: string | null; chainage_m: number }>;
/** 관 차례 뒤 저장 관(id · 누가거리 · 옵션). */
pipes_after: Array<OptionPipe & { id?: string | null }>;
}
/** 관 세션 장부 사본 — 찾기 · 없으면 새 차례(브라우저 `pipeIdNear` · `pipeIdAt` · `pipeChainageOf` 와 같은 규칙). */
function pipeBook(book: RouteCarryInput["pipe_ids"]) {
const ids = [...(book?.ids ?? [])];
let next = book?.next ?? 1;
const near = (at: number): string | null => {
let best: { id: string; at: number } | undefined;
let gap = PIPE_MATCH_M;
for (const entry of ids)
if (Math.abs(entry.at - at) < gap) [best, gap] = [entry, Math.abs(entry.at - at)];
return best?.id ?? null;
};
return {
near,
at: (at: number): string => {
const hit = near(at);
if (hit) return hit;
const id = `pipe-s${next++}`;
ids.push({ id, at });
return id;
},
chainageOf: (id: string): number | null => {
if (!id.startsWith("pipe-")) return null;
const hit = ids.find((entry) => entry.id === id);
if (hit) return hit.at;
const legacy = Number(id.slice("pipe-".length));
return Number.isFinite(legacy) ? legacy : null;
},
};
}
/** 누가거리(0.01 m 글) 열쇠 맵 → 누가거리(숫자) 맵(값이 있는 것만). */
function byChainage<V>(cells: ReadonlyMap<string, V>): Map<number, V> {
const out = new Map<number, V>();
for (const [key, value] of cells) {
const chainage = Number(key);
if (Number.isFinite(chainage) && value) out.set(chainage, value);
}
return out;
}
/** 카드 선택 한 벌(세션 `crossdesign` · `CrossDesignChoice` 꼴). */
interface CardChoice {
ground_type?: string;
section_mode?: string;
ditch_side?: string | null;
ditch_type?: string | null;
paved?: boolean;
two_stage_slope?: boolean;
ditch_choice?: boolean | null;
}
type Sides = Partial<Record<"left" | "right", number>>;
/**
* 노선 바뀐 [저장] 횡단 patch — 옛 브라우저 길(`carryAfterRouteSave` → `saveCachedCrossPatches`)과 같은 차례:
* 새 측점 열쇠 → 관 옵션 얹기 → 측점 열쇠 칸 · 누가거리 칸 넘김 → 조정 · 옮긴 고침 얹기 → 캐시 사용자 칸 바탕 +
* 카드 선택 · 암선 · 암 경사. `outside` = 노선 밖으로 버린 고침 수(측점 단위). `detail` 을 제자리에서 고침.
*/
export function routeCarryPatches(input: RouteCarryInput): {
patches: CrossSectionPatch[];
outside: number;
} {
const { detail } = input;
const sections = detail.cross_sections;
const book = pipeBook(input.pipe_ids);
const policy = (
detail.longitudinal?.profile_alignment as
{ policy?: { station_interval_m?: number } } | undefined
)?.policy;
const interval = policy?.station_interval_m ?? inferStationInterval(sections);
const from = input.from_keys ?? undefined;
const to = stationKeysFrom(sections, interval, input.owners, book);
layPipeOptions(detail, input.pipes_after);
const states = input.states;
const keyed = carryKeyedStates((name) => (states[name] as Raw | undefined) ?? {}, from, to);
const newAt = (id: string | null | undefined): number | null =>
input.pipes_after.find((pipe) => id != null && pipe.id === id)?.chainage_m ?? null;
const chain = carryChainageStates(
(name) => (states[name] as Record<string, unknown> | undefined) ?? null,
from,
to,
{
keyAt: (key) => {
const id = key.startsWith("pipe:") ? key.slice(5) : null;
if (!id) return null;
return id.startsWith("pipe-") ? book.chainageOf(id) : newAt(id);
},
movedAt: (chainageM) =>
newAt(input.pipes_before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id),
},
);
const cells = (name: string): Cells => chain.states[name] ?? {};
const read = <V>(name: KeyedState) => readKeyedIn<V>(keyed.states[name], to);
layDraftAdjusts(detail, cells);
layCarriedEdits(detail, cells, {
shoulder: byChainage(read<Sides>("shoulderext")),
berm: byChainage(read<Sides>("bermdepth")),
});
const kindChanged = Object.keys(chain.states.kindreset ?? {}).map(Number);
const savedAt = sections.map((section) => section.chainage_m);
const base = crossPatchesFromCache(detail, kindChanged, savedAt);
// 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 옛 `sessionOverlay` 와 같은 칸(445 H5)
const overlay: CrossSectionPatch[] = [];
byChainage(read<CardChoice>("crossdesign")).forEach((choice, chainage) => {
const patch: Record<string, unknown> = {
chainage_m: chainage,
ground_type: choice.ground_type,
section_mode: choice.section_mode,
};
if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side;
if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type;
if (choice.paved !== undefined) patch.paved = choice.paved;
if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope;
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
overlay.push(patch as unknown as CrossSectionPatch);
});
for (const [chainage, offset] of read<unknown>("rockb"))
if (typeof offset === "number")
overlay.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset });
for (const [chainage, ratio] of read<number>("cutslope"))
overlay.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio });
const patches = mergeCrossPatches(
base,
overlay.filter((patch) => Number.isFinite(patch.chainage_m)),
);
return { patches, outside: keyed.outside.size + chain.outside.size };
}
+15 -195
View File
@@ -13,93 +13,25 @@
* ========================================================================== */
import { readState } from "../A00_Common/b_page_state";
import type { CrossDesign, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom";
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { applySpanPatch, WALL_MIN_PURE_HEIGHT_M } from "./logic/B06_Section_UI_Cross_Culvert_Const";
import { tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeStoredLayouts } from "./B06_Section_Structure_Layouts";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { readBermDepths, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
import {
layCarriedEdits,
layDraftAdjusts,
layPipeOptions,
type Cells,
type OptionPipe,
} from "./B06_Section_Route_Carry_Patch";
type Cells = Record<string, unknown>;
type Obj = Record<string, unknown>;
const INLET = ["auto", "revet", "I", "L", "U"];
const isObj = (value: unknown): value is Obj => !!value && typeof value === "object";
const count = (value: unknown): number =>
Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0;
/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */
function wallAdjust(value: unknown): WallAdjust | null {
if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value };
if (!isObj(value)) return null;
const partial = value as Partial<WallAdjust>;
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
}
// 얹기 몸통 = `B06_Section_Route_Carry_Patch` 한 벌(서버 노선 바뀐 [저장] 넘김도 · 472-6) — 여기는 세션 겉.
export { layPipeOptions, type OptionPipe };
export function withDraftAdjusts(
detail: SectionDetailResponse,
projectId: string,
routeId: number,
): void {
const read = (name: Parameters<typeof readState>[0]): Cells =>
readState<Cells>(name, projectId, routeId) ?? {};
const revet = read("revetx");
const basin = read("basinadjust");
const inlet = read("inletstruct");
const extra = read("extrawall");
const spans = read("extraspan");
const ford = read("fordadjust");
const box = read("boxadjust");
const link = read("revetlink") as { detached?: Cells; grade?: Cells };
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
const own = (cells: Cells): Array<[string, unknown]> =>
Object.entries(cells)
.filter(([key]) => key.startsWith(`${at}:`))
.map(([key, value]) => [key.slice(at.length + 1), value]);
for (const [role, value] of own(revet)) {
const adjust = wallAdjust(value);
if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust };
else if (value === null && design.revet_adjust?.[role]) {
// null = 되돌림 — 저장분 그 벽을 뺌(445 M2)
const { [role]: _gone, ...rest } = design.revet_adjust;
design.revet_adjust = rest;
}
}
if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string))
design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"];
if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] };
// 세션에 있는 쪽만 — 없는 쪽은 저장분 그대로(445 H3)
const saved = design.extra_wall_counts;
if (at in extra || `b${at}` in extra)
design.extra_wall_counts = {
outlet: at in extra ? count(extra[at]) : (saved?.outlet ?? 0),
basin: `b${at}` in extra ? count(extra[`b${at}`]) : (saved?.basin ?? 0),
};
for (const [wall, value] of own(spans)) {
const span = value as { lengthM: number; beforeM: number; afterM: number } | null;
if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue;
design.extra_spans = {
...(design.extra_spans ?? {}),
[wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM },
};
}
if (isObj(link.detached) && at in link.detached)
design.revet_link_detached = !!link.detached[at];
if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at];
if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]);
const sides = box[at] as { left?: Obj; right?: Obj } | undefined;
if (isObj(sides))
design.box_adjust = {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right },
};
}
layDraftAdjusts(detail, (name) => readState<Cells>(name as never, projectId, routeId) ?? {});
}
/** 노선 저장 뒤 새 노선으로 옮긴 세션 고침(417) — 사면 쉬프트 · 소단 깊이 · 측점 폭 · 메모 · 상세 표를 설계 칸에 얹음 ·
@@ -109,29 +41,10 @@ export function withCarriedEdits(
projectId: string,
routeId: number,
): void {
const read = (name: Parameters<typeof readState>[0]): Cells =>
readState<Cells>(name, projectId, routeId) ?? {};
const cells: Array<[string, Cells]> = [
["display_half_width_m", read("crossw")],
["structure_note", read("structure-notes")],
["structure_note_cad", read("structure-note-cads")],
["detail_table", read("detail-table")],
];
const sides: Array<[string, ReturnType<typeof readShoulderExtends>]> = [
["shoulder_extend", readShoulderExtends(projectId, routeId)],
["berm_depth", readBermDepths(projectId, routeId)],
];
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
for (const [field, values] of cells) if (values[at] !== undefined) design[field] = values[at];
for (const [field, values] of sides) {
const own = values.get(Number(at));
for (const side of ["left", "right"] as const)
if (own?.[side] !== undefined) design[`${field}_${side}_m`] = own[side];
}
}
layCarriedEdits(detail, (name) => readState<Cells>(name as never, projectId, routeId) ?? {}, {
shoulder: readShoulderExtends(projectId, routeId),
berm: readBermDepths(projectId, routeId),
});
}
/** 관 옵션 조정값 몫 얹기(387-4b) — 관 초안 ?? 저장 관(페이지 자료 줄). 가게 · 자료 줄은 부를 때 늦게 불러옴 —
@@ -158,96 +71,3 @@ export async function withPipeOptions(
.catch(() => null);
if (pipes) layPipeOptions(detail, pipes as OptionPipe[]);
}
/** 관 하나 — 자리 · 폼 칸(관 초안 · 저장 관 `pipe_points` 같은 꼴). */
export interface OptionPipe {
chainage_m: number;
options?: Record<string, unknown> | null;
}
const isDefault = (a: WallAdjust): boolean =>
Math.abs(a.x) < 1e-9 && a.d == null && a.h == null && a.m == null;
const round1 = (value: number): number => Math.round(value * 10) / 10;
/** 조정창 `update` 와 같은 꼴 — x · d 는 그대로 · h · m 만(폼 한 칸이 주인 · 빈 칸 = 자동 null). */
function setWall(design: CrossDesign, role: string, h: number | null, m: string | null): void {
const current = { ...ZERO_ADJUST, ...(design.revet_adjust?.[role] ?? {}) } as WallAdjust;
const next: WallAdjust = { ...current, h: h === null ? null : round1(h), m };
const bucket = { ...(design.revet_adjust ?? {}) };
if (isDefault(next)) delete bucket[role];
else bucket[role] = next;
design.revet_adjust = bucket;
}
/** 관 옵션의 조정값 몫을 상세에 얹음(폼 칸 전부 = 되보내기와 같은 규칙) — 같은 값이면 그대로. */
export function layPipeOptions(
detail: SectionDetailResponse,
pipes: ReadonlyArray<OptionPipe>,
): void {
const sections = detail.cross_sections;
for (const pipe of pipes) {
const options = pipe.options;
if (!options) continue;
const near = (at: number): boolean => Math.abs(at - pipe.chainage_m) < PIPE_MATCH_M;
// 물넘이포장 = 제원만(66-2) · 벽 없음
if (sections.some((s) => s.ford_pavement && near(s.ford_pavement.chainage_m ?? s.chainage_m)))
continue;
const owner = sections.find((s) => near(s.chainage_m));
const culvert = owner?.culvert;
const design = owner?.design;
if (!owner || !culvert || !design) continue;
const num = (key: string): number | undefined => {
const value = Number(options[key]);
return Number.isFinite(value) ? value : undefined;
};
for (const role of ["inlet", "outlet"] as const) {
const form = options[`${role}_revet_form`];
setWall(
design,
role,
num(`${role}_revet_height_m`) ?? null,
typeof form === "string" ? form : null,
);
}
if (culvert.hidden_pipe && !owner.ford && !owner.box) continue;
// 흙막이 n 단 = 지형이 세운 유출 다단(`pipeExtraCount` 와 같은 셈)
const outlet = design.extra_wall_counts?.outlet ?? 0;
const probe = {
...owner,
design: { ...design, extra_wall_counts: { basin: 0, ...design.extra_wall_counts, outlet } },
};
const tiers = outlet
? (computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet)
: 0;
for (let n = 1; n <= tiers; n++) {
const role = `extra${n - 1}`;
const value = (item: string): number | undefined => {
const raw = options[`soil_add_${n}_${item}`];
return raw === undefined || raw === "" || !Number.isFinite(Number(raw))
? undefined
: Number(raw);
};
const form = options[`soil_add_${n}_form`];
const height = value("height");
setWall(
design,
role,
height === undefined ? null : Number(Math.max(height, WALL_MIN_PURE_HEIGHT_M).toFixed(1)),
typeof form === "string" && form ? form : null,
);
const span = { lengthM: value("length"), beforeM: value("before"), afterM: value("after") };
if (Object.values(span).every((v) => v === undefined)) continue;
const at = tierSpanOf(owner, role);
const current = {
lengthM: Math.round((at.beforeM + at.afterM) * 10) / 10,
beforeM: Math.round(at.beforeM * 10) / 10,
afterM: Math.round(at.afterM * 10) / 10,
};
const next = applySpanPatch(current, span);
design.extra_spans = {
...(design.extra_spans ?? {}),
[role]: { length_m: next.lengthM, before_m: next.beforeM, after_m: next.afterM },
};
}
}
}
+6 -54
View File
@@ -18,7 +18,6 @@
* ========================================================================== */
import { clearState, readState, readStateRaw, writeState } from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { setGeneralStations, setPlusStations } from "../common_util/common_util_structure_station";
import { loadSavedPlusStations, plusStationsOf } from "../B05_Profile/B05_Profile_Api_PlusStations";
import {
@@ -29,6 +28,10 @@ import {
} from "./B06_Section_Section_Drafts";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import { withDraftAdjusts, withPipeOptions } from "./B06_Section_Section_Adjusts";
import { crossPatchesFromCache } from "./B06_Section_Route_Carry_Patch";
// [저장] patch 바탕 = 한 벌(서버 노선 바뀐 [저장] 넘김도 · 472-6 6-B)
export { crossPatchesFromCache };
import type {
CrossSection,
CrossSectionPatch,
@@ -47,7 +50,7 @@ import {
} from "./B06_Section_Section_Canon";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh";
import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options";
import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options";
import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types";
import type { DesignStoreValues } from "../A00_Common/design/design_store";
import { useDesignStore } from "../A00_Common/design/design_store";
@@ -60,11 +63,7 @@ import {
type Keyed,
type KeyedState,
} from "./B06_Section_Station_Keys";
import {
ROW_CARRY_KEYS,
sameStationList,
swapCrossSections,
} from "./logic/B06_Section_Station_Swap";
import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap";
import { mergeCrossPatches } from "./logic/B06_Section_UI_Page_Patches";
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
import { readCutSlopeSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
@@ -455,53 +454,6 @@ export function peekSectionDetail(
return routeId === null ? null : (cache.get(keyOf(projectId, routeId)) ?? null);
}
/**
* 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름).
*
* 초기 계산값은 원복용으로 서버에 그대로 있고, 사용자가 만진 값은 이 캐시 한 세트가
* 최종본이다. B05 [임시저장]도 이 세트를 함께 보내야 B06에서 만진 구조물 조정이
* 영구저장소에 남는다 — 안 보내면 캐시만 비워져 편집이 사라진다.
*
* 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)를 측점마다 **통째로** 싣는다 — [저장] patch 의 바탕
* (445 H3 · 세션 고침은 그 위에 `mergeCrossPatches`). `savedAt`(서버 행 누가거리)을 주면 그 자리 행이 없는
* 측점(옮긴 관 · 구조물 · 새 측점 = 서버가 기본 설계로 새로 세움)은 지반 등 입력(`ROW_CARRY_KEYS`)도 실음(445 H2).
*/
export function crossPatchesFromCache(
detail: SectionDetailResponse,
kindChanged: readonly number[] = [],
savedAt?: readonly number[],
): CrossSectionPatch[] {
const patches: CrossSectionPatch[] = [];
// 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값은 서버가 비움 · 캐시에 남은 옛 값을 다시 안 실음
const structureKeys: readonly string[] = STRUCTURE_ADJUST_KEYS;
const reset = (at: number) => kindChanged.some((c) => Math.abs(c - at) < PIPE_MATCH_M);
const rowless = (at: number) => !!savedAt && savedAt.every((c) => Math.abs(c - at) >= 0.01);
for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true });
for (const section of detail.cross_sections) {
const design = section.design;
if (!design) continue;
const patch: CrossSectionPatch = { chainage_m: section.chainage_m };
const skip = reset(section.chainage_m);
let touched = false;
// 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고
// 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이
// 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
const source = design as unknown as Record<string, unknown>;
const target = patch as unknown as Record<string, unknown>;
const keys = rowless(section.chainage_m)
? [...USER_TOUCHED_KEYS, ...ROW_CARRY_KEYS]
: USER_TOUCHED_KEYS;
for (const key of keys) {
const value = source[key];
if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue;
target[key] = value;
touched = true;
}
if (touched) patches.push(patch);
}
return patches;
}
/**
* [저장] · [확정] 횡단 patch(445) — 바탕 = 캐시 설계의 사용자 칸(측점마다 통째 · 서버 행이 없는 측점은 지반 등
* 입력까지) 위에 세션 고침(`overlay` · 벽 · 단 dict 는 열쇠마다 합침 · 안쪽 null = 되돌림). 캐시가 없으면 세션 고침만.
+69 -225
View File
@@ -22,76 +22,33 @@ import { renameChainageKeys } from "./logic/B06_Section_Station_Swap";
import { afterAnySwap } from "./B06_Section_Swap_Bus";
import { pipeIdAt, pipeIdNear } from "../A00_Common/design/design_pipe_ids";
import { showToast } from "@ui/ui_template_elements";
import {
chainageKey,
curveMoves,
dedupeStationKeys,
looseChainage,
stationKey,
type StationKeyRole,
} from "@util/common_util_station_key";
import { chainageKey, looseChainage, type StationKeyRole } from "@util/common_util_station_key";
import { inferStationInterval } from "@util/common_util_svg";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import {
carryChainageStates as carryChainageCells,
carryKeyedStates,
chainageIn,
HINT,
keyIn,
KEYED_STATES,
NEAR_M,
nearestKeyed,
rawEntries,
ROUTE_CARRY_STATES,
stationKeysFrom,
type CarryFacilities,
type Keyed,
type KeyedState,
type Owner,
type Raw,
} from "./logic/B06_Section_Route_Carry";
/** 측점 열쇠로 담는 세션 칸. */
export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope";
export const KEYED_STATES: readonly KeyedState[] = [
"crossdesign",
"shoulderext",
"bermdepth",
"rockb",
"cutslope",
];
/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */
const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const;
/** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */
const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope", "shoulderext", "bermdepth"];
/**
* 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸.
* ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS`(측점 열쇠마다) + `STRUCTURE_ADJUST_KEYS`(관 · 구조물 열쇠만) ·
* 거울 시험 `resources/tester/test_417_route_carry_mirror.py`.
*/
export const ROUTE_CARRY_STATES = {
revetx: ["revet_adjust"],
basinadjust: ["basin_adjust"],
inletstruct: ["inlet_structure"],
extrawall: ["extra_wall_counts"],
extraspan: ["extra_spans"],
revetlink: ["revet_link_detached", "revet_follow_grade"],
fordadjust: ["ford_adjust"],
boxadjust: ["box_adjust"],
crossw: ["display_half_width_m"],
"structure-notes": ["structure_note"],
"structure-note-cads": ["structure_note_cad"],
"detail-table": ["detail_table"],
shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"],
bermdepth: ["berm_depth_left_m", "berm_depth_right_m"],
uphill: [], // 상단측(측구 방향) — 프로젝트 칸 · 설계 칸 없음 · 노선 바뀐 저장 때 새 측점으로(443-2)
} as const;
/** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */
const STRUCTURE_STATES = new Set<string>([
"revetx",
"basinadjust",
"inletstruct",
"extrawall",
"extraspan",
"revetlink",
"fordadjust",
"boxadjust",
]);
/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */
const NEAR_M = 0.01;
const HINT = "@at";
export type Keyed = { key: string; chainageM: number };
type Raw = Record<string, unknown> & { [HINT]?: Record<string, number> };
// 넘김 규칙 · 칸 목록 = logic 한 벌(서버 Node 입구도 · 472-6 6-B) — 부르는 쪽은 여기서 그대로 받음
export { KEYED_STATES, ROUTE_CARRY_STATES, type CarryFacilities, type Keyed, type KeyedState };
const lists = new Map<string, Keyed[]>();
/** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */
type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole };
const owners = new Map<string, Owner[]>();
let current: { projectId: string; routeId: number } | null = null;
@@ -145,39 +102,17 @@ export function useStructureIds(
owners.set(projectId, known);
}
const looksLikePipe = (section: CrossSection): boolean =>
Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? "");
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이
* 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */
/** 측점 목록 → 열쇠(규칙 = `stationKeysFrom` · 관 id = 관 세션 장부). */
export function stationKeysOf(
projectId: string | null,
sections: ReadonlyArray<CrossSection>,
intervalM: number | null,
): Keyed[] {
const known = projectId ? (owners.get(projectId) ?? []) : [];
const planned = known.some((owner) => owner.kind === "pipe");
const keys = sections.map((section, index) => {
const at = section.chainage_m;
let key: string;
if (index === 0) key = stationKey("bp", at, intervalM);
else if (index === sections.length - 1) key = stationKey("ep", at, intervalM);
else if (section.kind === "irregular") {
const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M);
if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role);
else if (looksLikePipe(section)) {
// 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E)
const id = planned ? pipeIdNear(at, projectId) : pipeIdAt(at, projectId);
key = id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM);
} else
key = section.grid
? stationKey("regular", at, intervalM)
: stationKey("irregular", at, intervalM);
} else key = stationKey(section.kind, at, intervalM);
return key;
return stationKeysFrom(sections, intervalM, known, {
near: (at) => pipeIdNear(at, projectId),
at: (at) => pipeIdAt(at, projectId),
});
const chainages = sections.map((section) => section.chainage_m);
return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i] }));
}
type KeyedDetail = Pick<SectionDetailResponse, "cross_sections" | "longitudinal">;
@@ -229,22 +164,12 @@ export function registeredKeys(
): string[] {
const list = lists.get(scopeOf(projectId, routeId));
const computed = stationKeysOf(projectId, sections, intervalM);
return sections.map((s, i) => (list && nearest(list, s.chainage_m)?.key) ?? computed[i].key);
}
function nearest(list: Keyed[], chainageM: number): Keyed | undefined {
let best: Keyed | undefined;
for (const entry of list) {
const gap = Math.abs(entry.chainageM - chainageM);
if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry;
}
return best;
return sections.map((s, i) => (list && nearestKeyed(list, s.chainage_m)?.key) ?? computed[i].key);
}
/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */
export function keyAt(projectId: string, routeId: number, chainageM: number): string {
const list = lists.get(scopeOf(projectId, routeId));
return (list && nearest(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`;
return keyIn(lists.get(scopeOf(projectId, routeId)), chainageM);
}
/**
@@ -257,21 +182,7 @@ export function chainageOfKey(
key: string,
hint?: number,
): number | null {
const list = lists.get(scopeOf(projectId, routeId));
const hit = list?.find((entry) => entry.key === key);
if (hit) return Number(chainageKey(hit.chainageM));
const at = looseChainage(key) ?? hint;
if (at === undefined) return null;
if (!list) return Number(chainageKey(at));
const near = nearest(list, at);
return near ? Number(chainageKey(near.chainageM)) : null;
}
/** 두 노선 목록 사이 MC 가 움직인 곡선 열쇠(432-3 · 서버 `carry_cross_inputs` 와 같음). */
function curveMovesBetween(projectId: string, fromRouteId: number, toRouteId: number) {
const pairs = (routeId: number) =>
(lists.get(scopeOf(projectId, routeId)) ?? []).map((e) => [e.key, e.chainageM] as const);
return curveMoves(pairs(fromRouteId), pairs(toRouteId));
return chainageIn(lists.get(scopeOf(projectId, routeId)), key, hint);
}
function readRaw(name: KeyedState, projectId: string, routeId: number): Raw {
@@ -279,13 +190,6 @@ function readRaw(name: KeyedState, projectId: string, routeId: number): Raw {
return raw && typeof raw === "object" ? raw : {};
}
function entries<V>(raw: Raw): Array<[string, V, number | undefined]> {
const hints = raw[HINT] ?? {};
return Object.entries(raw)
.filter(([key]) => key !== HINT)
.map(([key, value]) => [key, value as V, hints[key]]);
}
/**
* 칸 하나를 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌(지우지 않음).
* 옛 열쇠(누가거리 · at:)는 지금 측점 열쇠로 옮겨 다시 씀(목록이 있을 때).
@@ -294,7 +198,7 @@ export function readKeyed<V>(name: KeyedState, projectId: string, routeId: numbe
const raw = readRaw(name, projectId, routeId);
const out = new Map<string, V>();
let moved = false;
for (const [key, value, hint] of entries<V>(raw)) {
for (const [key, value, hint] of rawEntries<V>(raw)) {
const at = chainageOfKey(projectId, routeId, key, hint);
if (at === null) continue;
out.set(chainageKey(at), value);
@@ -319,7 +223,7 @@ export function writeKeyed<V>(
const raw = readRaw(name, projectId, routeId);
const next: Raw = { [HINT]: {} };
const replaced = new Set((only ?? [...byChainage.keys()]).map((at) => chainageKey(Number(at))));
for (const [key, value, hint] of entries<unknown>(raw)) {
for (const [key, value, hint] of rawEntries<unknown>(raw)) {
const at = chainageOfKey(projectId, routeId, key, hint);
if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue;
next[key] = value;
@@ -346,30 +250,12 @@ export function carryStationDrafts(
toRouteId: number,
{ quiet = false }: { quiet?: boolean } = {},
): number {
const outside = new Set<string>();
const curves = curveMovesBetween(projectId, fromRouteId, toRouteId);
for (const name of KEYED_STATES) {
const raw = readRaw(name, projectId, fromRouteId);
const next: Raw = { [HINT]: {} };
for (const [key, value] of entries<unknown>(raw)) {
// 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로
const fromAt =
looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key);
const was = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt);
const stable = curves.get(was) ?? was;
const toAt = chainageOfKey(projectId, toRouteId, stable, looseChainage(stable) ?? undefined);
const carried = carriedValue(name, value);
if (toAt === null || stable.startsWith("at:")) {
if (carried !== null) outside.add(stable);
continue;
}
if (carried === null) continue;
const toKey = keyAt(projectId, toRouteId, toAt);
next[toKey] = carried;
next[HINT]![toKey] = toAt;
}
writeState(name, next, projectId, toRouteId);
}
const { states, outside } = carryKeyedStates(
(name) => readRaw(name, projectId, fromRouteId),
lists.get(scopeOf(projectId, fromRouteId)),
lists.get(scopeOf(projectId, toRouteId)),
);
for (const name of KEYED_STATES) writeState(name, states[name], projectId, toRouteId);
if (outside.size && !quiet)
showToast(`노선 밖 고침 ${outside.size} 건 — 사라진 측점이라 버렸습니다.`, "info");
return outside.size;
@@ -394,28 +280,6 @@ export function carryStationKeys(projectId: string, routeId: number, next: Keyed
return outside;
}
/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */
function carriedValue(name: KeyedState, value: unknown): unknown {
if (CARRY_WHOLE.includes(name)) return value;
if (name !== "crossdesign" || !value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
const picked = Object.fromEntries(
CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]),
);
return Object.keys(picked).length ? picked : null;
}
/** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */
export interface CarryFacilities {
/** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */
movedAt?: (chainageM: number) => number | null | undefined;
/** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */
keyAt?: (key: string) => number | null | undefined;
}
/** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */
const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/;
/**
* 노선 저장 뒤(새 노선 측점 열쇠를 세운 뒤) 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고)을 옛 노선
* → 새 노선 측점으로 옮김 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 ·
@@ -429,62 +293,42 @@ export function carryChainageStates(
toRouteId: number,
facilities: CarryFacilities = {},
): number {
const facilityAt = facilities.keyAt ?? (() => null);
const outside = new Set<string>();
const curves = curveMovesBetween(projectId, fromRouteId, toRouteId);
const moveAt = (state: string, text: string): string | null => {
const moved = facilities.movedAt?.(Number(text));
if (moved !== null && moved !== undefined) return chainageKey(moved);
const was = keyAt(projectId, fromRouteId, Number(text));
const stable = curves.get(was) ?? was;
const structural = stable.startsWith("pipe:") || stable.startsWith("structure:");
// 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점)
const toAt =
stable.startsWith("at:") || (STRUCTURE_STATES.has(state) && !structural)
? null
: chainageOfKey(
projectId,
toRouteId,
stable,
(structural ? facilityAt(stable) : null) ?? Number(text),
);
if (toAt === null) outside.add(stable);
return toAt === null ? null : chainageKey(toAt);
};
const rekey = (state: string, cells: Record<string, unknown>): Record<string, unknown> => {
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cells)) {
const head = CHAINAGE_HEAD.exec(key);
const to = head && moveAt(state, head[2]);
if (head && to !== null) next[`${head[1]}${to}${head[3] ?? ""}`] = value;
}
return next;
};
for (const state of Object.keys(ROUTE_CARRY_STATES)) {
if ((KEYED_STATES as readonly string[]).includes(state)) continue; // 측점 열쇠 칸 = carryStationDrafts
const name = state as StateName;
const raw = readState<Record<string, unknown>>(name, projectId, fromRouteId);
if (!raw || typeof raw !== "object") continue;
// 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹
const next =
state === "revetlink"
? Object.fromEntries(
Object.entries(raw).map(([part, cells]) => [
part,
cells && typeof cells === "object"
? rekey(state, cells as Record<string, unknown>)
: cells,
]),
)
: rekey(state, raw);
writeState(name, next, projectId, toRouteId);
}
// 관 종류 바꿈 자리(318-4 · 프로젝트 칸) — 구조물 조절과 같은 규칙으로 옮김(445 M7)
const resets = readState<Record<string, unknown>>("kindreset", projectId);
if (resets) writeState("kindreset", rekey("revetx", resets), projectId);
const { states, outside } = carryChainageCells(
(name) =>
name === "kindreset"
? readState<Record<string, unknown>>("kindreset", projectId)
: readState<Record<string, unknown>>(name as StateName, projectId, fromRouteId),
lists.get(scopeOf(projectId, fromRouteId)),
lists.get(scopeOf(projectId, toRouteId)),
facilities,
);
for (const [name, next] of Object.entries(states))
if (name === "kindreset") writeState("kindreset", next, projectId);
else writeState(name as StateName, next, projectId, toRouteId);
return outside.size;
}
/** 노선 바뀐 [저장] 본문의 넘김 입력(472-6 6-B) — 서버 Node `stations.carry` 가 같은 규칙으로 새 노선에 얹음.
* 칸 = 측점 열쇠 칸 다섯 · 누가거리 칸(상단측 뺌 · 요구 274) · 관 종류 바꿈 자리 · 옛 노선 열쇠 목록 · 측점 주인 ·
* 관 세션 장부. */
export function routeCarryInputs(projectId: string, fromRouteId: number): Record<string, unknown> {
const states: Record<string, unknown> = {};
for (const name of KEYED_STATES) states[name] = readRaw(name, projectId, fromRouteId);
for (const state of Object.keys(ROUTE_CARRY_STATES)) {
if (state === "uphill" || (KEYED_STATES as readonly string[]).includes(state)) continue;
const raw = readState<Record<string, unknown>>(state as StateName, projectId, fromRouteId);
if (raw && typeof raw === "object") states[state] = raw;
}
const resets = readState<Record<string, unknown>>("kindreset", projectId);
if (resets) states.kindreset = resets;
return {
states,
from_keys: lists.get(scopeOf(projectId, fromRouteId)) ?? null,
owners: owners.get(projectId) ?? [],
pipe_ids: readState("pipe-ids", projectId) ?? null,
};
}
/**
* 측점이 움직임(갈아끼움 · 옛 0.01 m 글 → 새 글) — 조정창 맵이 안 선 누가거리 칸(B05 만 연 페이지 · 새로고침 ·
* 세월 · BOX · 연동)을 세션에서 바로 새 자리로(415-3). 칸 = `ROUTE_CARRY_STATES`(측점 열쇠 칸 빼고) · 조정창
@@ -0,0 +1,55 @@
/* =============================================================================
* B06_Section_Design_Keys.ts
* 횡단 설계 칸 목록 한 벌 — 사용자 값(`USER_TOUCHED_KEYS`) · 구조물 조정값(`STRUCTURE_ADJUST_KEYS`).
* 자료만(브라우저 · 서버 Node 입구 `stations.carry` 가 같은 목록 · 472-6 6-B) · 서버 짝 = 각 목록 주석.
* ========================================================================== */
/**
* 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로
* 실어 보낸다(`B06_Section_Section_Store` · `B06_Section_Route_Carry_Patch`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS`
* 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`).
*
* 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을
* 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
*/
export const USER_TOUCHED_KEYS = [
"display_half_width_m",
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
// 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9).
"berm",
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
"cut_slope_ratio_user",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
// 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님.
"structure_note_cad",
// [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈)
"detail_table",
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
"shoulder_extend_left_m",
"shoulder_extend_right_m",
// 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김
"berm_depth_left_m",
"berm_depth_right_m",
] as const;
/** 구조물(관 · 박스 · 세월교) 조정값 열쇠 — 종류를 바꾸면 버리는 몫 · 서버 짝. */
export const STRUCTURE_ADJUST_KEYS = [
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
] as const;
@@ -0,0 +1,300 @@
/* =============================================================================
* B06_Section_Route_Carry.ts
* 노선이 바뀔 때 횡단 고침 넘김 규칙 **한 벌**(PLAN 344-3 · 417 · 472-6 6-B) — 순수 계산(세션 · 화면 없음).
*
* · 측점 열쇠 목록 = `stationKeysFrom`(일반 grid:k · 관 pipe:<id> · 구조물 structure:<id> · 시종점 end:BP/EP ·
* id 모름 at:<0.01 m> · 규칙 = `common_util_station_key`).
* · 측점 열쇠 칸 다섯 = `carryKeyedStates` — 넘김 = 카드 선택 넷 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이 ·
* 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점 고침은 버림(수만 셈).
* · 누가거리 열쇠 칸(`ROUTE_CARRY_STATES`) = `carryChainageStates` — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리 ·
* 구조물 조절 = 관 · 구조물 열쇠만.
* 쓰는 곳 = 브라우저 세션(`B06_Section_Station_Keys` · 끌기 동안 같은 노선 안 목록 갈림) · 서버 Node 입구
* `stations.carry`(노선 바뀐 [저장] · `design_calc_node_carry.ts`).
* ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS` + `STRUCTURE_ADJUST_KEYS` · 거울 시험 `test_417_route_carry.py`.
* ========================================================================== */
import {
chainageKey,
curveMoves,
dedupeStationKeys,
looseChainage,
stationKey,
type StationKeyRole,
} from "../../common_util/common_util_station_key";
import type { CrossSection } from "../B06_Section_Api_Fetch";
/** 측점 열쇠로 담는 세션 칸. */
export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope";
export const KEYED_STATES: readonly KeyedState[] = [
"crossdesign",
"shoulderext",
"bermdepth",
"rockb",
"cutslope",
];
/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */
const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const;
/** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */
const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope", "shoulderext", "bermdepth"];
/**
* 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸.
* ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS`(측점 열쇠마다) + `STRUCTURE_ADJUST_KEYS`(관 · 구조물 열쇠만) ·
* 거울 시험 `resources/tester/test_417_route_carry.py`.
*/
export const ROUTE_CARRY_STATES = {
revetx: ["revet_adjust"],
basinadjust: ["basin_adjust"],
inletstruct: ["inlet_structure"],
extrawall: ["extra_wall_counts"],
extraspan: ["extra_spans"],
revetlink: ["revet_link_detached", "revet_follow_grade"],
fordadjust: ["ford_adjust"],
boxadjust: ["box_adjust"],
crossw: ["display_half_width_m"],
"structure-notes": ["structure_note"],
"structure-note-cads": ["structure_note_cad"],
"detail-table": ["detail_table"],
shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"],
bermdepth: ["berm_depth_left_m", "berm_depth_right_m"],
uphill: [], // 상단측(측구 방향) — 프로젝트 칸 · 설계 칸 없음 · 노선 바뀐 [저장]은 안 보냄(요구 274)
} as const;
/** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */
const STRUCTURE_STATES = new Set<string>([
"revetx",
"basinadjust",
"inletstruct",
"extrawall",
"extraspan",
"revetlink",
"fordadjust",
"boxadjust",
]);
/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */
export const NEAR_M = 0.01;
export const HINT = "@at";
export type Keyed = { key: string; chainageM: number };
export type Raw = Record<string, unknown> & { [HINT]?: Record<string, number> };
/** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */
export type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole };
/** 관 측점의 관 id — 관 장부(세션 `pipe-sN`) · 찾기만(`near`) · 없으면 지음(`at`). */
export interface PipeIdBook {
near: (chainageM: number) => string | null;
at: (chainageM: number) => string;
}
const looksLikePipe = (section: CrossSection): boolean =>
Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? "");
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이
* 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */
export function stationKeysFrom(
sections: ReadonlyArray<CrossSection>,
intervalM: number | null,
known: ReadonlyArray<Owner>,
pipes: PipeIdBook | null,
): Keyed[] {
const planned = known.some((owner) => owner.kind === "pipe");
const keys = sections.map((section, index) => {
const at = section.chainage_m;
if (index === 0) return stationKey("bp", at, intervalM);
if (index === sections.length - 1) return stationKey("ep", at, intervalM);
if (section.kind !== "irregular") return stationKey(section.kind, at, intervalM);
const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M);
if (owner) return stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role);
if (looksLikePipe(section)) {
// 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E)
const id = pipes ? (planned ? pipes.near(at) : pipes.at(at)) : null;
return id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM);
}
return section.grid
? stationKey("regular", at, intervalM)
: stationKey("irregular", at, intervalM);
});
const chainages = sections.map((section) => section.chainage_m);
return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i] }));
}
/** 목록에서 그 자리(0.01 m 안) 가장 가까운 측점. */
export function nearestKeyed(list: ReadonlyArray<Keyed>, chainageM: number): Keyed | undefined {
let best: Keyed | undefined;
for (const entry of list) {
const gap = Math.abs(entry.chainageM - chainageM);
if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry;
}
return best;
}
/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */
export function keyIn(list: ReadonlyArray<Keyed> | undefined, chainageM: number): string {
return (list && nearestKeyed(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`;
}
/**
* 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금
* 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖).
*/
export function chainageIn(
list: ReadonlyArray<Keyed> | undefined,
key: string,
hint?: number,
): number | null {
const hit = list?.find((entry) => entry.key === key);
if (hit) return Number(chainageKey(hit.chainageM));
const at = looseChainage(key) ?? hint;
if (at === undefined) return null;
if (!list) return Number(chainageKey(at));
const near = nearestKeyed(list, at);
return near ? Number(chainageKey(near.chainageM)) : null;
}
/** 두 목록 사이 MC 가 움직인 곡선 열쇠(432-3 · 서버 `carry_cross_inputs` 와 같음). */
export function curveMovesIn(
from: ReadonlyArray<Keyed> | undefined,
to: ReadonlyArray<Keyed> | undefined,
): Map<string, string> {
const pairs = (list: ReadonlyArray<Keyed> | undefined) =>
(list ?? []).map((e) => [e.key, e.chainageM] as const);
return curveMoves(pairs(from), pairs(to));
}
/** 칸 원본 → [열쇠, 값, 쓴 때 자리]. */
export function rawEntries<V>(raw: Raw): Array<[string, V, number | undefined]> {
const hints = raw[HINT] ?? {};
return Object.entries(raw)
.filter(([key]) => key !== HINT)
.map(([key, value]) => [key, value as V, hints[key]]);
}
/** 칸 원본을 목록 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌. */
export function readKeyedIn<V>(raw: Raw, list: ReadonlyArray<Keyed> | undefined): Map<string, V> {
const out = new Map<string, V>();
for (const [key, value, hint] of rawEntries<V>(raw)) {
const at = chainageIn(list, key, hint);
if (at !== null) out.set(chainageKey(at), value);
}
return out;
}
/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */
function carriedValue(name: KeyedState, value: unknown): unknown {
if (CARRY_WHOLE.includes(name)) return value;
if (name !== "crossdesign" || !value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
const picked = Object.fromEntries(
CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]),
);
return Object.keys(picked).length ? picked : null;
}
/**
* 측점 열쇠 칸 다섯을 옛 목록 → 새 목록으로(넘김 규칙 = 머리 주석) — 칸마다 새 원본(빈 칸도) · 노선 밖으로 나간
* 고침의 열쇠(측점 단위).
*/
export function carryKeyedStates(
read: (name: KeyedState) => Raw,
from: ReadonlyArray<Keyed> | undefined,
to: ReadonlyArray<Keyed> | undefined,
): { states: Record<KeyedState, Raw>; outside: Set<string> } {
const outside = new Set<string>();
const curves = curveMovesIn(from, to);
const states = {} as Record<KeyedState, Raw>;
for (const name of KEYED_STATES) {
const next: Raw = { [HINT]: {} };
for (const [key, value] of rawEntries<unknown>(read(name))) {
// 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로
const fromAt = looseChainage(key) === null ? null : chainageIn(from, key);
const was = fromAt === null ? key : keyIn(from, fromAt);
const stable = curves.get(was) ?? was;
const toAt = chainageIn(to, stable, looseChainage(stable) ?? undefined);
const carried = carriedValue(name, value);
if (toAt === null || stable.startsWith("at:")) {
if (carried !== null) outside.add(stable);
continue;
}
if (carried === null) continue;
const toKey = keyIn(to, toAt);
next[toKey] = carried;
next[HINT]![toKey] = toAt;
}
states[name] = next;
}
return { states, outside };
}
/** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */
export interface CarryFacilities {
/** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */
movedAt?: (chainageM: number) => number | null | undefined;
/** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */
keyAt?: (key: string) => number | null | undefined;
}
/** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */
const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/;
/**
* 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고 · 관 종류 바꿈 자리 `kindreset`)을 옛 목록 → 새 목록
* 측점으로 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 · 못 옮긴 고침은 버림.
* 돌려줌 = 원본이 있던 칸만 새 원본 · 버린 고침의 열쇠(측점 단위).
* 시설 풀이(`facilities`) = 옛 저장 시설 측점 → 새 노선 그 시설 측점(좌표 짝 · 초안으로 안 따라간 고침) · 관 ·
* 구조물 열쇠 → 새 누가거리(관 id 규칙이 갈림 — 세션 `pipe-s3` · 계산 줄 `pipe:p8017`).
*/
export function carryChainageStates(
read: (name: string) => Record<string, unknown> | null,
from: ReadonlyArray<Keyed> | undefined,
to: ReadonlyArray<Keyed> | undefined,
facilities: CarryFacilities = {},
): { states: Record<string, Record<string, unknown>>; outside: Set<string> } {
const facilityAt = facilities.keyAt ?? (() => null);
const outside = new Set<string>();
const curves = curveMovesIn(from, to);
const moveAt = (state: string, text: string): string | null => {
const moved = facilities.movedAt?.(Number(text));
if (moved !== null && moved !== undefined) return chainageKey(moved);
const was = keyIn(from, Number(text));
const stable = curves.get(was) ?? was;
const structural = stable.startsWith("pipe:") || stable.startsWith("structure:");
// 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점)
const toAt =
stable.startsWith("at:") || (STRUCTURE_STATES.has(state) && !structural)
? null
: chainageIn(to, stable, (structural ? facilityAt(stable) : null) ?? Number(text));
if (toAt === null) outside.add(stable);
return toAt === null ? null : chainageKey(toAt);
};
const rekey = (state: string, cells: Record<string, unknown>): Record<string, unknown> => {
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cells)) {
const head = CHAINAGE_HEAD.exec(key);
const to = head && moveAt(state, head[2]);
if (head && to !== null) next[`${head[1]}${to}${head[3] ?? ""}`] = value;
}
return next;
};
const states: Record<string, Record<string, unknown>> = {};
for (const state of Object.keys(ROUTE_CARRY_STATES)) {
if ((KEYED_STATES as readonly string[]).includes(state)) continue; // 측점 열쇠 칸 = carryKeyedStates
const raw = read(state);
if (!raw || typeof raw !== "object") continue;
// 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹
states[state] =
state === "revetlink"
? Object.fromEntries(
Object.entries(raw).map(([part, cells]) => [
part,
cells && typeof cells === "object"
? rekey(state, cells as Record<string, unknown>)
: cells,
]),
)
: rekey(state, raw);
}
// 관 종류 바꿈 자리(318-4 · 프로젝트 칸) — 구조물 조절과 같은 규칙으로 옮김(445 M7)
const resets = read("kindreset");
if (resets) states.kindreset = rekey("revetx", resets);
return { states, outside };
}
+7
View File
@@ -0,0 +1,7 @@
/* =============================================================================
* common_util/common_util_pipe_match.ts
* 관 · 구조물 찾는 거리 한 값 — 화면(`design_selection` 이 내줌) · 서버 Node 묶음(노선 바뀐 [저장] 넘김)이 같이 씀.
* ========================================================================== */
/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51;
@@ -51,6 +51,7 @@ function loadTs(file, stubs = {}) {
const surface = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Surface.ts"));
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": { readState: () => null, writeState: () => {} },
});
@@ -27,6 +27,7 @@ function loadTs(file, stubs = {}) {
// 세션(structure-pick) — 메모리 한 벌
const store = new Map();
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -27,6 +27,7 @@ global.requestAnimationFrame = (fn) => fn();
const store = new Map();
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -59,6 +59,7 @@ function fire(type, clientX, clientY) {
/* ── 진짜 모듈 ── */
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -22,6 +22,7 @@ function loadTs(file, stubs = {}) {
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -62,6 +62,7 @@ globalThis.window = { addEventListener() {}, removeEventListener() {} };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -39,6 +39,7 @@ const press = (key, { inInput = false, prevented = false } = {}) => {
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -26,6 +26,7 @@ globalThis.document = { addEventListener() {}, removeEventListener() {} };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -24,6 +24,7 @@ function loadTs(file, stubs = {}) {
}
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": { readState: () => null, writeState() {} },
});
const PREFIX = "irregular:";
@@ -488,8 +488,10 @@ const out = {};
const load = makeLoader(
[
B06("B06_Section_Api_Culvert_Options"),
B06("logic/B06_Section_Design_Keys"), // 칸 목록 한 벌(472-6)
path.join(ROOT, "A00_Common", "b_page_state.ts"),
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
path.join(ROOT, "common_util", "common_util_pipe_match.ts"), // 472-6
],
{
"./B06_Section_Section_Drafts": {
@@ -596,14 +598,22 @@ const loadDrafts = makeLoader(
const loadStore = makeLoader(
[
B06("B06_Section_Section_Store"),
B06("B06_Section_Route_Carry_Patch"), // patch 바탕 한 벌(472-6)
B06("B06_Section_Api_Culvert_Options"),
path.join(ROOT, "A00_Common", "b_page_state.ts"),
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
path.join(ROOT, "common_util", "common_util_pipe_match.ts"), // 472-6
],
{
"./B06_Section_Cross_Refresh": {
USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"],
},
"./logic/B06_Section_Design_Keys": {
USER_TOUCHED_KEYS: ["display_half_width_m", "ford_adjust", "revet_adjust", "berm"],
STRUCTURE_ADJUST_KEYS: makeLoader([B06("logic/B06_Section_Design_Keys")])(
B06("logic/B06_Section_Design_Keys"),
).STRUCTURE_ADJUST_KEYS,
},
},
);
@@ -89,8 +89,10 @@ const part1 = (async () => {
B05("B05_Profile_UI_Structures_Panel_Events"),
B05("B05_Profile_Api_Structures"),
B06("B06_Section_Api_Culvert_Options"),
B06("logic/B06_Section_Design_Keys"), // 칸 목록 · 찾는 거리 한 벌(472-6)
path.join(ROOT, "A00_Common", "b_page_state.ts"),
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
path.join(ROOT, "common_util", "common_util_pipe_match.ts"),
path.join(ROOT, "common_util", "common_util_summary_item.ts"),
],
{
@@ -15,7 +15,7 @@ function loadTs(file, real) {
const box = { exports: {} };
const localRequire = (request) =>
real.some((name) => request.endsWith(name))
? loadTs(path.join(path.dirname(file), `${request}.ts`), [])
? loadTs(path.join(path.dirname(file), `${request}.ts`), ["common_util_pipe_match"]) // 472-6
: stub;
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
@@ -25,6 +25,7 @@ globalThis.document = { querySelector: () => null };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../../common_util/common_util_pipe_match": { PIPE_MATCH_M: 0.51 }, // 찾는 거리 정의 자리(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
@@ -73,6 +73,10 @@ const load = makeLoader(
B06("B06_Section_UI_Cross_Culvert_Types"),
B06("logic/B06_Section_Station_Swap"),
B06("logic/B06_Section_UI_Page_Patches"),
B06("logic/B06_Section_Design_Keys"), // 칸 목록 · 얹기 · patch 꼴 한 벌(472-6)
B06("logic/B06_Section_Route_Carry"),
B06("B06_Section_Route_Carry_Patch"),
path.join(ROOT, "common_util", "common_util_pipe_match.ts"),
path.join(ROOT, "A00_Common", "b_page_state.ts"),
path.join(ROOT, "A00_Common", "design", "design_selection.ts"),
],
@@ -25,6 +25,9 @@ function loadTs(file, stubs = {}) {
// 세션(structure-pick) — 메모리 한 벌
const store = new Map();
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
"../../common_util/common_util_pipe_match": loadTs(
path.join(ROOT, "common_util", "common_util_pipe_match.ts"),
), // 찾는 거리 정의(472-6)
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
+1 -1
View File
@@ -246,7 +246,7 @@ def test_손질_없으면_도면_표_그대로():
def test_살림_목록_짝에_detail_table():
assert "detail_table" in USER_TOUCHED_KEYS
text = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
text = (ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts").read_text(encoding="utf-8") # 목록 한 벌(472-6)
block = re.search(r"export const USER_TOUCHED_KEYS = \[(.*?)\] as const;", text, re.S)
assert block and '"detail_table"' in block.group(1)
+1 -1
View File
@@ -202,7 +202,7 @@ def test_patch_칸_왕복() -> None:
with pytest.raises(ValidationError):
CrossSectionPatch(chainage_m=40, berm_depth_left_m=-0.1) # 도로 위 금지
assert {"berm_depth_left_m", "berm_depth_right_m"} <= set(USER_TOUCHED_KEYS)
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
refresh = (ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts").read_text(encoding="utf-8") # 목록 한 벌(472-6)
assert '"berm_depth_left_m",' in refresh and '"berm_depth_right_m",' in refresh
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "patch.berm_depth_left_m = sides.left" in persist
+1 -1
View File
@@ -77,7 +77,7 @@ def test_브라우저가_CAD_칸을_저장_patch_에_싣는다():
state = read("A00_Common/b_page_state.ts")
assert '"structure-note-cads": { bucket: "draft", scope: "route" }' in state
# 캐시 설계 → B05 [저장] 길(`crossPatchesFromCache`)도 같은 목록
assert '"structure_note_cad",' in read("B06_Section/B06_Section_Cross_Refresh.ts")
assert '"structure_note_cad",' in read("B06_Section/logic/B06_Section_Design_Keys.ts") # 472-6
# 모달 = 빈 CAD(캔버스만) · 글 상자 없음
detail = read("B06_Section/B06_Section_UI_Cross_Detail.ts")
assert "textarea" not in detail and "mountNoteCad(" in detail and "noteCad?.destroy()" in detail
+1 -1
View File
@@ -252,7 +252,7 @@ def test_wiring_sources() -> None:
def test_structure_keys_mirror() -> None:
"""서버 · 브라우저 열쇠 목록이 같음 · 모두 승계 목록 안."""
ts = _src("B06_Section", "B06_Section_Api_Culvert_Options.ts")
ts = _src("B06_Section", "logic", "B06_Section_Design_Keys.ts") # 목록 한 벌(472-6)
block = ts[ts.index("export const STRUCTURE_ADJUST_KEYS = [") :]
block = block[: block.index("] as const")]
assert re.findall(r'"([a-z_]+)"', block) == list(STRUCTURE_ADJUST_KEYS)
+1 -1
View File
@@ -41,7 +41,7 @@ from B06_Section.B06_Section_Router_Design import ( # noqa: E402
)
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
TS_KEYS = ROOT / "B06_Section" / "B06_Section_Station_Keys.ts"
TS_KEYS = ROOT / "B06_Section" / "logic" / "B06_Section_Route_Carry.ts" # 넘김 규칙 한 벌(472-6)
@pytest.fixture(scope="module")
+7 -2
View File
@@ -246,9 +246,14 @@ def test_H5_노선_저장_뒤_본문_M7_kindreset_옮김():
assert (
"cachedCrossPatches(projectId, routeId, edits.crossPatches)" in persist
) # 472-6 횡단 조각
# 넘김 규칙 = logic 한 벌(472-6 6-B) · 세션 겉이 kindreset 을 프로젝트 칸에 다시 씀
rules = (ROOT / "B06_Section" / "logic" / "B06_Section_Route_Carry.ts").read_text(
encoding="utf-8"
)
carry = rules[rules.index("export function carryChainageStates") :]
assert 'states.kindreset = rekey("revetx", resets)' in carry
keys = (ROOT / "B06_Section" / "B06_Section_Station_Keys.ts").read_text(encoding="utf-8")
carry = keys[keys.index("export function carryChainageStates") :]
assert 'writeState("kindreset", rekey("revetx", resets), projectId)' in carry
assert 'if (name === "kindreset") writeState("kindreset", next, projectId);' in keys
idle = keys[keys.index("function moveIdleStates") :]
assert 'writeState("kindreset", move(resets), projectId)' in idle
@@ -54,7 +54,12 @@ def test_거리_정의는_한_곳뿐이고_옛_0_05는_없다():
text = _read(os.path.relpath(os.path.join(current, name), ROOT))
if re.search(r"\b(const|let)\s+PIPE_MATCH_M\b", text):
definitions.append(name)
assert definitions == ["design_selection.ts"]
assert (
definitions == []
) # 정의 = common_util 한 곳(서버 Node 묶음도 끔 · 472-6) · 페이지는 내줌만
station = _read("common_util/common_util_pipe_match.ts")
assert re.search(r"export const PIPE_MATCH_M = 0\.51;", station)
assert "export { PIPE_MATCH_M }" in _read("A00_Common/design/design_selection.ts")
for name, folder in (
("B05_Profile_UI_Structures_Panel.ts", "B05_Profile"),
("design_view_profile.ts", "A00_Common/design"),
@@ -90,7 +90,9 @@ def test_0_은_표준값으로_되돌림이다():
def test_다시_계산해도_사용자_값이_남는다():
"""표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 (사용자 원문 끝줄)."""
assert "cut_slope_ratio_user" in USER_TOUCHED_KEYS
assert '"cut_slope_ratio_user"' in _REFRESH.read_text(encoding="utf-8")
assert '"cut_slope_ratio_user"' in (_B06 / "logic" / "B06_Section_Design_Keys.ts").read_text(
encoding="utf-8"
) # 목록 한 벌(472-6)
def test_계산_전에_넘어간다():
@@ -11,7 +11,8 @@ from pathlib import Path
from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS
ROOT = Path(__file__).resolve().parents[2]
TS_PATH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts"
TS_PATH = ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts" # 목록 한 벌(472-6)
REFRESH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts"
# 브라우저 목록에만 있고 서버 목록에는 없는 둘 — 부르는 쪽이 따로 붙인다.
_CALLER_ADDED = {"status", "pavement_suggested"}
@@ -36,7 +37,7 @@ def test_서버와_브라우저_목록이_같다():
def test_브라우저_목록도_한_벌이다():
"""보존 목록·저장 payload 가 같은 한 벌을 쓰는지 — 손나열이 되살아나면 깨진다."""
text = TS_PATH.read_text(encoding="utf-8")
text = REFRESH.read_text(encoding="utf-8")
assert "...USER_TOUCHED_KEYS" in text, "PRESERVED_KEYS 가 목록을 다시 적고 있음"
store = (ROOT / "B06_Section" / "B06_Section_Section_Store.ts").read_text(encoding="utf-8")
assert "USER_TOUCHED_KEYS" in store, "저장 payload 가 목록을 안 씀"
@@ -18,6 +18,7 @@ def _read(relative: str) -> str:
REPO = _read("B06_Section/B06_Section_Repository.py")
B06_ROUTER = _read("B06_Section/B06_Section_Router.py")
REFRESH = _read("B06_Section/B06_Section_Cross_Refresh.ts")
KEYS = _read("B06_Section/logic/B06_Section_Design_Keys.ts")
B05_PREVIEW = _read("B05_Profile/B05_Profile_UI_Profile_Preview.ts")
# 설계 재계산 자리는 700줄 제한으로 `_UI_Page_Design_Sync` 로 떨어져 나갔다(2026-09-13).
B06_PAGE = _read("A00_Common/design/design_view_cross.ts") + _read(
@@ -66,7 +67,7 @@ def test_cross_refresh_preserves_user_fields():
"revet_link_detached",
"revet_follow_grade",
):
assert f'"{field}"' in REFRESH # preserveUserFields 의 보존 필드 목록
assert f'"{field}"' in KEYS # preserveUserFields 의 보존 필드 목록(한 벌 · 472-6)
def test_cross_refresh_lays_structure_areas_once():