From 69d84e5ca9d4d0cbeeaf0ae73fa431ca36372dfe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 12:20:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(=EC=B8=A1=EC=A0=90=EC=97=B4=EC=87=A0):?= =?UTF-8?q?=20346-2=20=EB=8D=A7=202=20=EC=97=B4=EC=87=A0=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C=20=E2=80=94=20=EA=B5=AC=EA=B0=84=ED=98=95=20:start=20?= =?UTF-8?q?=C2=B7=20:end=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=A2=85=EB=A5=98(grid=20=C2=B7=20end)=20=C2=B7=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=97=B4=EC=87=A0=20=EB=91=98=EC=A7=B8=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EA=B3=B5=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_station_key(ts · py 짝): role 덧붙임(구간형 시작 · 끝 = structure::start · :end · 기준은 structure: 그대로) · grid(원래 격자 번호) · end(BP/EP) 종류 · dedupeStationKeys(같은 열쇠 둘째부터 at:<누가거리>) - B06 stationKeysOf 가 같은 규칙(측점 계획 · 구조물 목록의 시작 · 끝 자리 · 공용 dedupe) - 거울 시험에 새 갈래(구간형 · grid · end · dedupe) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B06_Section/B06_Section_Station_Keys.ts | 46 +++++++++++++------ common_util/common_util_station_key.py | 34 ++++++++++++-- common_util/common_util_station_key.ts | 41 +++++++++++++---- .../tester/test_344_3_station_key_mirror.py | 26 +++++++++-- 4 files changed, 119 insertions(+), 28 deletions(-) diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index ffe3cbf28..6710c8db1 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -18,7 +18,13 @@ import { readState, writeState } from "../A00_Common/b_page_state"; import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; import { showToast } from "@ui/ui_template_elements"; -import { chainageKey, looseChainage, stationKey } from "@util/common_util_station_key"; +import { + chainageKey, + dedupeStationKeys, + looseChainage, + stationKey, + type StationKeyRole, +} from "@util/common_util_station_key"; import { inferStationInterval } from "@util/common_util_svg"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; @@ -46,7 +52,8 @@ type Raw = Record & { [HINT]?: Record }; const lists = new Map(); /** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */ -const owners = new Map>(); +type Owner = { id: string; kind: "pipe" | "structure"; at: number; role: StationKeyRole }; +const owners = new Map(); let current: { projectId: string; routeId: number } | null = null; const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`; @@ -54,7 +61,7 @@ const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${ /** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */ export function useStationOwners( projectId: string, - planned: ReadonlyArray<{ chainage_m: number; owners: string[] }>, + planned: ReadonlyArray<{ chainage_m: number; owners: string[]; role?: StationKeyRole }>, ): void { owners.set( projectId, @@ -63,6 +70,7 @@ export function useStationOwners( id, kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const), at: station.chainage_m, + role: station.role ?? "anchor", })), ), ); @@ -71,12 +79,25 @@ export function useStationOwners( /** 구조물 목록(횡단 보기) — 한 자리 구조물의 id. 관 자리는 관 장부가 정함. */ export function useStructureIds( projectId: string, - structures: ReadonlyArray<{ structure_id?: string | null; chainage_m?: number | null }>, + structures: ReadonlyArray<{ + structure_id?: string | null; + chainage_m?: number | null; + start_m?: number | null; + end_m?: number | null; + }>, ): void { const known = (owners.get(projectId) ?? []).filter((owner) => owner.kind === "pipe"); for (const item of structures) { - if (item.structure_id && typeof item.chainage_m === "number") - known.push({ id: item.structure_id, kind: "structure", at: item.chainage_m }); + const id = item.structure_id; + if (!id) continue; + // 기준 측점 = structure: · 구간형 시작 · 끝 = :start · :end(열쇠 한 벌 · common_util_station_key) + const marks: Array<[StationKeyRole, number | null | undefined]> = [ + ["anchor", item.chainage_m], + ["start", item.start_m], + ["end", item.end_m], + ]; + for (const [role, at] of marks) + if (typeof at === "number") known.push({ id, kind: "structure", at, role }); } owners.set(projectId, known); } @@ -84,20 +105,19 @@ export function useStructureIds( const looksLikePipe = (section: CrossSection): boolean => Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); -/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:`). */ +/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). */ export function stationKeysOf( projectId: string | null, sections: ReadonlyArray, intervalM: number | null, ): Keyed[] { const known = projectId ? (owners.get(projectId) ?? []) : []; - const seen = new Set(); - return sections.map((section) => { + const keys = sections.map((section) => { const at = section.chainage_m; let key: string; 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); + if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role); else if (looksLikePipe(section)) key = stationKey("pipe", at, intervalM, pipeIdAt(at, projectId)); else @@ -105,10 +125,10 @@ export function stationKeysOf( ? stationKey("regular", at, intervalM) : stationKey("irregular", at, intervalM); } else key = stationKey(section.kind, at, intervalM); - if (seen.has(key)) key = `at:${chainageKey(at)}`; - seen.add(key); - return { key, chainageM: at }; + return key; }); + const chainages = sections.map((section) => section.chainage_m); + return dedupeStationKeys(keys, chainages).map((key, i) => ({ key, chainageM: chainages[i] })); } type KeyedDetail = Pick; diff --git a/common_util/common_util_station_key.py b/common_util/common_util_station_key.py index de9c02d68..101121e31 100644 --- a/common_util/common_util_station_key.py +++ b/common_util/common_util_station_key.py @@ -2,6 +2,8 @@ 일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:`` · 구조물 = ``structure:`` · 시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``. +구간형 구조물 시작 · 끝 측점 = ``structure::start`` · ``:end``(기준 측점은 ``structure:``). +한 목록에 같은 열쇠가 둘이면 둘째부터 ``at:<누가거리>``(``dedupe_station_keys``). ⚠ 파이썬·TS 짝 — ``common_util_station_key.ts`` ``stationKey`` ↔ ``station_key``. 한쪽을 고치면 다른 쪽도 고치고 거울 시험 ``resources/tester/test_344_3_station_key_mirror.py``. @@ -31,19 +33,43 @@ def station_key( interval_m: float | None, station_id: str | None = None, id_kind: str = "structure", + role: str = "anchor", ) -> str: - """측점 열쇠 — irregular 는 id 가 있으면 pipe: · structure:(``id_kind``) · 없으면 at:.""" + """측점 열쇠 — irregular 는 id 가 있으면 pipe: · structure:(``id_kind``) · 없으면 at:. + + 구간형 시작 · 끝은 ``role`` 로 ``:start`` · ``:end``. ``grid`` · ``end`` 는 ``station_id`` 에 + 원래 격자 번호 · BP/EP(없으면 누가거리 ÷ 간격 반올림 · 0 이면 BP). + """ + loose = f"at:{chainage_key(chainage_m)}" + suffix = "" if role == "anchor" else f":{role}" if kind == "bp": return "end:BP" if kind == "ep": return "end:EP" + if kind == "end": + return ( + f"end:{station_id if station_id is not None else ('BP' if chainage_m <= 0 else 'EP')}" + ) if kind in ("pipe", "structure"): - return f"{kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}" + return f"{kind}:{station_id}{suffix}" if station_id else loose if kind == "irregular": - return f"{id_kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}" + return f"{id_kind}:{station_id}{suffix}" if station_id else loose + if kind == "grid" and station_id is not None: + return f"grid:{station_id}" if interval_m and interval_m > 0: return f"grid:{_half_up(chainage_m / interval_m)}" - return f"at:{chainage_key(chainage_m)}" + return loose + + +def dedupe_station_keys(keys: list[str], chainages: list[float]) -> list[str]: + """한 목록에 같은 열쇠가 둘이면 둘째부터 ``at:<누가거리>`` — 열쇠마다 측점 하나.""" + seen: set[str] = set() + out: list[str] = [] + for key, chainage in zip(keys, chainages, strict=True): + unique = f"at:{chainage_key(chainage)}" if key in seen else key + seen.add(unique) + out.append(unique) + return out def loose_chainage(key: str) -> float | None: diff --git a/common_util/common_util_station_key.ts b/common_util/common_util_station_key.ts index db398ac99..ad6809f2f 100644 --- a/common_util/common_util_station_key.ts +++ b/common_util/common_util_station_key.ts @@ -3,21 +3,29 @@ * 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. * 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:` · 구조물 = `structure:` · * 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫). + * 구간형 구조물의 시작 · 끝 측점 = `structure::start` · `:end`(기준 측점은 `structure:` 그대로). + * 한 목록에 같은 열쇠가 둘이면 둘째부터 `at:<누가거리>`(`dedupeStationKeys`). + * 쓰는 곳 = 서버 `B05_Profile_Route_Save_Parts` · 묶음 stations 단계 · B06 세션 횡단 초안(`stationKeysOf`). * * ⚠ 파이썬·TS 짝 — `common_util_station_key.py` `station_key` ↔ `stationKey`. 한쪽을 고치면 다른 쪽도 * 고치고 거울 시험 `resources/tester/test_344_3_station_key_mirror.py`. * ========================================================================== */ -/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular) 그대로 받음. */ -export type StationKeyKind = "bp" | "ep" | "regular" | "irregular" | "pipe" | "structure"; +/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular) · 묶음 측점 `kind`(grid · end · pipe · structure). */ +export type StationKeyKind = + "bp" | "ep" | "regular" | "irregular" | "grid" | "end" | "pipe" | "structure"; + +/** 구간형 구조물 측점 자리 — 기준(anchor) · 시작 · 끝. */ +export type StationKeyRole = "anchor" | "start" | "end"; /** 누가거리 0.01 m — 옛 초안 열쇠(`toFixed(2)`)와 같은 자릿수. */ export const chainageKey = (chainageM: number): string => (Math.round(chainageM * 100) / 100).toFixed(2); /** - * 측점 열쇠. irregular 는 id 가 있으면 `pipe:` · `structure:`(종류는 `idKind`) · 없으면 `at:`. - * 간격이 없거나 0 이하면 일반 측점도 `at:`. + * 측점 열쇠. irregular 는 id 가 있으면 `pipe:` · `structure:`(종류는 `idKind`) · 없으면 `at:` · 구간형 시작 · 끝은 + * `role` 로 `:start` · `:end` 덧붙임. `grid` · `end` 는 `id` 에 원래 격자 번호 · `BP`/`EP` 를 줄 수 있음(없으면 + * 누가거리 ÷ 간격 반올림 · 0 이면 BP). 간격이 없거나 0 이하면 일반 측점도 `at:`. */ export function stationKey( kind: StationKeyKind, @@ -25,14 +33,31 @@ export function stationKey( intervalM: number | null, id: string | null = null, idKind: "pipe" | "structure" = "structure", + role: StationKeyRole = "anchor", ): string { + const loose = `at:${chainageKey(chainageM)}`; + const suffix = role === "anchor" ? "" : `:${role}`; if (kind === "bp") return "end:BP"; if (kind === "ep") return "end:EP"; - if (kind === "pipe" || kind === "structure") - return id ? `${kind}:${id}` : `at:${chainageKey(chainageM)}`; - if (kind === "irregular") return id ? `${idKind}:${id}` : `at:${chainageKey(chainageM)}`; + if (kind === "end") return `end:${id ?? (chainageM <= 0 ? "BP" : "EP")}`; + if (kind === "pipe" || kind === "structure") return id ? `${kind}:${id}${suffix}` : loose; + if (kind === "irregular") return id ? `${idKind}:${id}${suffix}` : loose; + if (kind === "grid" && id !== null) return `grid:${id}`; if (intervalM && intervalM > 0) return `grid:${Math.round(chainageM / intervalM)}`; - return `at:${chainageKey(chainageM)}`; + return loose; +} + +/** 한 목록에 같은 열쇠가 둘이면 둘째부터 `at:<누가거리>` — 열쇠마다 측점 하나. */ +export function dedupeStationKeys( + keys: ReadonlyArray, + chainages: ReadonlyArray, +): string[] { + const seen = new Set(); + return keys.map((key, index) => { + const unique = seen.has(key) ? `at:${chainageKey(chainages[index])}` : key; + seen.add(unique); + return unique; + }); } /** 옛 초안 열쇠(누가거리 글 `"85.05"`) · `at:` 열쇠의 누가거리 — 측점 열쇠면 null. */ diff --git a/resources/tester/test_344_3_station_key_mirror.py b/resources/tester/test_344_3_station_key_mirror.py index 66e3af00d..3b2587fb2 100644 --- a/resources/tester/test_344_3_station_key_mirror.py +++ b/resources/tester/test_344_3_station_key_mirror.py @@ -16,6 +16,7 @@ if str(PROJECT_ROOT) not in sys.path: from common_util.common_util_station_key import ( # noqa: E402 chainage_key, + dedupe_station_keys, loose_chainage, station_key, ) @@ -24,7 +25,17 @@ TS = PROJECT_ROOT / "common_util" / "common_util_station_key.ts" TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript" CASES = [ - ("bp", 0.0, 20.0, None, "structure"), + ("bp", 0.0, 20.0, None, "structure", "anchor"), + # 5파 — 묶음 측점 종류(grid · end) · 구간형 시작 · 끝(:start · :end) + ("grid", 41.3, 20.0, "2", "structure", "anchor"), + ("grid", 41.3, 20.0, None, "structure", "anchor"), + ("end", 0.0, 20.0, None, "structure", "anchor"), + ("end", 99.0, 20.0, None, "structure", "anchor"), + ("end", 99.0, 20.0, "EP", "structure", "anchor"), + ("structure", 30.0, 20.0, "st-9", "structure", "start"), + ("structure", 36.0, 20.0, "st-9", "structure", "end"), + ("irregular", 30.0, 20.0, "st-9", "structure", "start"), + ("pipe", 30.0, 20.0, None, "structure", "end"), ("ep", 812.345, 20.0, None, "structure"), ("regular", 40.0, 20.0, None, "structure"), ("regular", 50.0, 20.0, None, "structure"), # 2.5 → 3(위로) @@ -37,6 +48,7 @@ CASES = [ ("pipe", 12.344, 20.0, "pipe-s1", "structure"), ("structure", 12.345, 20.0, None, "structure"), ] +DEDUPE = (["grid:1", "structure:st-1", "grid:1", "structure:st-1"], [20.0, 25.0, 20.004, 25.006]) LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"] SCRIPT = r""" @@ -50,7 +62,8 @@ new Function("exports", "module", js)(box.exports, box); const m = box.exports; const input = JSON.parse(process.argv[3]); process.stdout.write(JSON.stringify({ - keys: input.cases.map((c) => m.stationKey(c[0], c[1], c[2], c[3], c[4])), + keys: input.cases.map((c) => m.stationKey(c[0], c[1], c[2], c[3], c[4], c[5] ?? "anchor")), + dedupe: m.dedupeStationKeys(input.dedupe[0], input.dedupe[1]), chainage: input.cases.map((c) => m.chainageKey(c[1])), loose: input.loose.map((k) => m.looseChainage(k)), })); @@ -65,7 +78,7 @@ def test_파이썬과_TS_열쇠_같음(): SCRIPT, str(TYPESCRIPT), str(TS), - json.dumps({"cases": CASES, "loose": LOOSE}), + json.dumps({"cases": CASES, "loose": LOOSE, "dedupe": DEDUPE}), ], capture_output=True, text=True, @@ -77,6 +90,7 @@ def test_파이썬과_TS_열쇠_같음(): assert out["keys"] == [station_key(*case) for case in CASES] assert out["chainage"] == [chainage_key(case[1]) for case in CASES] assert out["loose"] == [loose_chainage(key) for key in LOOSE] + assert out["dedupe"] == dedupe_station_keys(*DEDUPE) def test_열쇠_규칙(): @@ -86,3 +100,9 @@ def test_열쇠_규칙(): assert station_key("irregular", 85.054, 20.0, "pipe-s3", "pipe") == "pipe:pipe-s3" assert station_key("irregular", 85.054, 20.0) == "at:85.05" assert loose_chainage("85.05") == 85.05 and loose_chainage("grid:4") is None + # 5파 — 구간형 시작 · 끝 · 기준은 그대로 · 원래 격자 번호 · 같은 열쇠 둘째는 at: + assert station_key("structure", 30.0, 20.0, "st-9", role="start") == "structure:st-9:start" + assert station_key("structure", 33.0, 20.0, "st-9") == "structure:st-9" + assert station_key("grid", 41.3, 20.0, "2") == "grid:2" + assert station_key("end", 99.0, 20.0) == "end:EP" + assert dedupe_station_keys(*DEDUPE) == ["grid:1", "structure:st-1", "at:20.00", "at:25.01"] From da3dfc1ca89078fa5d9384e42441d67df3a41ec4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 12:28:11 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8=EB=B3=B4=EA=B8=B0):?= =?UTF-8?q?=20346-2=20=EB=8D=A7=202=20B05=20=EC=A2=85=EB=8B=A8=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=203D=20=EC=B8=A1=EC=A0=90=EC=84=A0=EB=8F=84?= =?UTF-8?q?=20=EA=B0=88=EC=95=84=EB=81=BC=EC=9A=B4=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EB=B3=B4=EC=9D=BC=20=EB=95=8C=EB=A7=8C?= =?UTF-8?q?=20=C2=B7=20=EC=88=A8=EC=97=88=EC=9C=BC=EB=A9=B4=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=B3=B4=EC=9D=BC=20=EB=95=8C=20=C2=B7=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EB=B3=B4=EA=B8=B0=20=EC=97=86=EC=9D=B4=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_view_profile_sections: 노선마다 상세 갈아끼움을 듣고 종단도 · 표 · 3D 측점선 다시(보일 때만 · 숨으면 flushSwap 이 다시 보일 때) · 가게 stations 도 들어 횡단 보기를 안 열어도 갈아끼움(먼저 부른 쪽만 · 다음은 「같은 목록」) - design_view_profile: 받는 줄만(보임 · 떠날 때 끊음 · 다시 보일 때 flushSwap) - 갈아끼움 알림판 B06_Section_Swap_Bus(가져오기 없음 · 돌고 도는 가져오기 막음) — 조정창 값 · 암 경계 옮김을 어느 보기가 갈아끼웠든 한 번 · 횡단 보기는 알림으로 다시 그림 - 시험: 종단 보기 보임 7 → 8 · 숨음 = 안 그림 · 다시 보임 = 6 · 3D 측점선 같은 차례 · 옛 시험 둘(342-1 화면 선 = listedBase · 323-9 열쇠 모듈 진짜로) 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/design/design_view_profile.ts | 3 ++ .../design/design_view_profile_sections.ts | 44 ++++++++++++++- B06_Section/B06_Section_Section_Store.ts | 6 ++- B06_Section/B06_Section_Swap_Bus.ts | 27 ++++++++++ .../B06_Section_UI_Page_Link_Session.ts | 4 ++ B06_Section/B06_Section_UI_Page_Persist.ts | 4 ++ .../B06_Section_UI_Page_Station_Swap.ts | 25 +++++---- resources/tester/helper_323_9_save_redraw.cjs | 4 ++ resources/tester/helper_342_1_entry.ts | 1 + .../tester/helper_342_1_station_line.mjs | 25 +-------- .../tester/helper_346_2_station_swap.mjs | 54 +++++++++++++++++++ resources/tester/helper_346_2_swap_entry.ts | 1 + resources/tester/test_346_2_station_swap.py | 9 ++++ 13 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 B06_Section/B06_Section_Swap_Bus.ts diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 920f92c0e..61525c4bf 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -259,7 +259,9 @@ export async function mountProfileView( panel: () => panel, profilePanel: () => profilePanel, bridge: () => bridge, + visible: () => host.visible(), // 숨은 동안 갈아끼운 상세(346-2)는 다시 보일 때 그림 }); + host.onDispose(sections.stopSwap); const { restorePanel, renderLatest, restoreSections } = sections; const view3d = createProfile3d({ projectId: activeProjectId, @@ -390,6 +392,7 @@ export async function mountProfileView( ready: () => !state.restoring, // 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다. reload: async () => { + sections.flushSwap(); // 숨은 동안 측점 목록이 바뀌었으면 종단도 · 3D 측점선 다시(346-2) await bridge.load(); if (lastPipes) applyPipes(lastPipes); }, diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts index dc9702ea6..47787fe6a 100644 --- a/A00_Common/design/design_view_profile_sections.ts +++ b/A00_Common/design/design_view_profile_sections.ts @@ -13,7 +13,12 @@ import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_P import type { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures"; import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; -import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; +import { + loadSectionDetail, + subscribeSectionDetail, + swapSectionStations, +} from "../../B06_Section/B06_Section_Section_Store"; +import { useDesignStore } from "./design_store"; import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures"; import { loadCorridorIfFresh } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations"; @@ -42,6 +47,8 @@ export interface ProfileSectionsDeps { panel: () => RoutePanel; profilePanel: () => ReturnType; bridge: () => ReturnType; + /** 종단 보기가 보이나 — 숨은 동안 갈아끼운 상세는 다시 보일 때 그림(346-2 · 없으면 늘 보임). */ + visible?: () => boolean; } /** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ @@ -114,6 +121,7 @@ export function createProfileSections(deps: ProfileSectionsDeps) { return; } profilePanel.setLoading(null); + followSwap(routeId); try { renderSections(detail, routeId); // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만 @@ -154,6 +162,38 @@ export function createProfileSections(deps: ProfileSectionsDeps) { } } + /* 측점 수가 바뀜(346-2 · 묶음 측점 → 횡단 쪽이 상세를 갈아끼움) — 종단도 · 표 · 3D 측점선을 새 목록으로 + * 다시(보일 때만 · 숨었으면 다시 보일 때 `flushSwap`). 노선마다 한 번 듣고, 노선이 바뀌면 옛 것을 끊음. */ + let swap: { routeId: number; stop: () => void } | null = null; + let swapPending = false; + function followSwap(routeId: number): void { + if (swap?.routeId === routeId) return; + swap?.stop(); + const stopListen = subscribeSectionDetail(projectId, routeId, (next) => { + state.detail = next; + swapPending = !(deps.visible?.() ?? true); + if (!swapPending) renderSections(next, routeId); + }); + // 묶음 측점(가게)이 바뀌면 갈아끼움 — 횡단 보기를 안 열었어도(그쪽도 부르면 둘째는 「같은 목록」) + const stopStore = useDesignStore(projectId, null).subscribe("stations", (stations) => { + if (stations?.length) void swapSectionStations(projectId, routeId, stations); + }); + swap = { + routeId, + stop: () => { + stopListen(); + stopStore(); + }, + }; + } + + /** 숨은 동안 받은 새 상세를 그림 — 종단 보기가 다시 보일 때. */ + function flushSwap(): void { + if (!swapPending || !state.detail || !swap) return; + swapPending = false; + renderSections(state.detail, swap.routeId); + } + /** 「3D 늦음」 — [3D 업데이트] 표시(setCorridorPending 그대로)와 상태 한 칸을 같이 맞춘다. */ function setCorridorPending(pending: boolean): void { state.corridorPending = pending; @@ -184,5 +224,7 @@ export function createProfileSections(deps: ProfileSectionsDeps) { restoreSections, renderLatest, setCorridorPending, + flushSwap, + stopSwap: () => swap?.stop(), }; } diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 43e57226f..9df278c0e 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -38,6 +38,9 @@ import { useStationKeyList, } from "./B06_Section_Station_Keys"; import { sameStationList, swapCrossSections } from "./logic/B06_Section_Station_Swap"; +import { emitSwap, type DetailListener } from "./B06_Section_Swap_Bus"; + +export { onAnySwap } from "./B06_Section_Swap_Bus"; const cache = new Map(); const pending = new Map>(); @@ -111,8 +114,6 @@ export async function loadSectionDetail( return request; } -/** 새 상세 · 이어 받은 측점의 누가거리 옮김(옛 0.01 m 글 → 새 글 · 조작값 맵을 옮길 때). */ -type DetailListener = (detail: SectionDetailResponse, moves: ReadonlyMap) => void; const listeners = new Map>(); /** 상세 객체가 갈아끼워짐(측점 수가 바뀜 · 346-2 4파) — 쥔 옛 객체를 버리고 새것으로 다시 그릴 것. */ @@ -154,6 +155,7 @@ export async function swapSectionStations( await withDraftShapes(swap.detail, projectId); // 새 측점에도 구조물 그림 자료(323-4) withStructureAreas(swap.detail); if (cache.get(key) !== swap.detail) return null; // 그 사이 다른 갈아끼움 · 새로 받음 + emitSwap(swap.detail, swap.moves); // 세션 값 옮김 · 다시 그림(어느 보기든) listeners.get(key)?.forEach((fn) => fn(swap.detail, swap.moves)); return { detail: swap.detail, moves: swap.moves }; } diff --git a/B06_Section/B06_Section_Swap_Bus.ts b/B06_Section/B06_Section_Swap_Bus.ts new file mode 100644 index 000000000..277ea26e8 --- /dev/null +++ b/B06_Section/B06_Section_Swap_Bus.ts @@ -0,0 +1,27 @@ +/* ============================================================================= + * B06_Section_Swap_Bus.ts + * 상세 갈아끼움 알림판(PLAN 346-2) — 측점 수가 바뀌어 `Section_Store` 가 상세 객체를 갈아끼우면 여기로 알림. + * 가져오는 것 없음 — 세션 조작값 맵(`_UI_Page_Link_Session`) · 암 경계 저장소(`_UI_Page_Persist`)가 모듈을 읽을 때 + * 붙어도 `Section_Store` 와 돌고 도는 가져오기가 안 생김. + * ========================================================================== */ + +import type { SectionDetailResponse } from "./B06_Section_Api_Types"; + +/** 새 상세 · 이어 받은 측점의 누가거리 옮김(옛 0.01 m 글 → 새 글 · 조작값 맵을 옮길 때). */ +export type DetailListener = ( + detail: SectionDetailResponse, + moves: ReadonlyMap, +) => void; + +const anyListeners = new Set(); + +/** 어느 노선이든 상세가 갈아끼워지면 부름(조정창 값 · 암 경계 옮김 · 다시 그림) — 돌려준 함수로 끊음. */ +export function onAnySwap(fn: DetailListener): () => void { + anyListeners.add(fn); + return () => anyListeners.delete(fn); +} + +/** 갈아끼운 쪽(`Section_Store`)이 부름. */ +export function emitSwap(detail: SectionDetailResponse, moves: ReadonlyMap): void { + anyListeners.forEach((fn) => fn(detail, moves)); +} diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index 3544a19d6..18fe380ad 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -27,6 +27,7 @@ import { import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc"; import { renameChainageKeys } from "./logic/B06_Section_Station_Swap"; +import { onAnySwap } from "./B06_Section_Swap_Bus"; /** 세션에 담기는 측점별 조작값 한 겹 맵. */ export interface SessionMap { @@ -65,6 +66,9 @@ export function moveSessionMaps(moves: ReadonlyMap): void { } } +// 상세 갈아끼움(어느 보기가 했든)마다 한 번 — 조정창 값을 옮긴 측점 자리로 +onAnySwap((_detail, moves) => moveSessionMaps(moves)); + export function createSessionMap( sessionKey: () => string | null, accept: (value: unknown) => V | undefined, diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 3580cdb6f..398fe1520 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -15,6 +15,7 @@ import { type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import { invalidateSectionDetail } from "./B06_Section_Section_Store"; +import { onAnySwap } from "./B06_Section_Swap_Bus"; import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { @@ -87,6 +88,9 @@ export function reloadKeyedStores(): void { keyedStores.forEach((store) => store.load()); } +// 상세 갈아끼움(어느 보기가 했든)마다 한 번 — 암 경계 · 암 경사를 새 목록 누가거리로 다시 읽음 +onAnySwap(() => reloadKeyedStores()); + /** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */ function keyedScope(storageKey: string): { projectId: string; routeId: number } | null { const [projectId, route] = storageKey.split(":").slice(-2); diff --git a/B06_Section/B06_Section_UI_Page_Station_Swap.ts b/B06_Section/B06_Section_UI_Page_Station_Swap.ts index a53ff4132..6a2c0db26 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Swap.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Swap.ts @@ -1,14 +1,13 @@ /* ============================================================================= * B06_Section_UI_Page_Station_Swap.ts * 횡단 보기가 계산 묶음 측점을 따라감(PLAN 346-2 4파) — 가게 `stations` 가 측점 목록을 바꾸면(늘고 줄면) - * 상세 객체를 갈아끼우고(`swapSectionStations`) 조정창 값 · 암 경계 · 암 경사를 새 자리로 옮긴 뒤 보일 때 카드 · - * 종단도를 다시(카드는 보이는 것 먼저 · 다시 셈은 그 길이 맡음 · 안 보이면 다시 보일 때 그림). + * 상세 객체를 갈아끼우고(`swapSectionStations` · 조정창 값 · 암 경계 · 암 경사 옮김은 `onAnySwap` 이 맡음) 보일 때 + * 카드 · 종단도를 다시(카드는 보이는 것 먼저 · 다시 셈은 그 길이 맡음 · 안 보이면 다시 보일 때 그림). + * 종단 보기도 같은 가게를 듣고 같은 함수를 부름 — 먼저 부른 쪽이 갈아끼우고 다음 쪽은 「같은 목록」. * ========================================================================== */ import { useDesignStore } from "../A00_Common/design/design_store"; -import { swapSectionStations } from "./B06_Section_Section_Store"; -import { moveSessionMaps } from "./B06_Section_UI_Page_Link_Session"; -import { reloadKeyedStores } from "./B06_Section_UI_Page_Persist"; +import { onAnySwap, swapSectionStations } from "./B06_Section_Section_Store"; /** 묶음 측점을 따라감 — 돌려준 함수로 끊음. `redraw` = 캐시에서 상세를 다시 읽어 카드 · 그림 다시. */ export function followSwaps( @@ -18,14 +17,14 @@ export function followSwaps( redraw: () => Promise, ): () => void { if (!projectId) return () => undefined; - return useDesignStore(projectId, null).subscribe("stations", (stations) => { + // 누가 갈아끼웠든(종단 보기가 먼저 불렀어도) 보이면 다시 그림 — 한 페이지 한 노선 + const stopRedraw = onAnySwap(() => void (host.visible() && redraw())); + const stopStore = useDesignStore(projectId, null).subscribe("stations", (stations) => { const route = routeId(); - if (route === null || !stations?.length) return; - void swapSectionStations(projectId, route, stations).then((swap) => { - if (!swap) return; // 같은 목록 — 제자리 갱신 길이 맡음 - moveSessionMaps(swap.moves); // 조정창 값(반폭 · 기슭막이 · 유입 · 집수정 · 추가 벽) - reloadKeyedStores(); // 암 경계 · 암 경사 = 측점 열쇠 세션 → 새 목록 누가거리로 - if (host.visible()) void redraw(); - }); + if (route !== null && stations?.length) void swapSectionStations(projectId, route, stations); }); + return () => { + stopRedraw(); + stopStore(); + }; } diff --git a/resources/tester/helper_323_9_save_redraw.cjs b/resources/tester/helper_323_9_save_redraw.cjs index 858520282..5408af51a 100644 --- a/resources/tester/helper_323_9_save_redraw.cjs +++ b/resources/tester/helper_323_9_save_redraw.cjs @@ -68,6 +68,10 @@ function loadTs(file) { return box.exports; } +// 측점 열쇠 한 벌(344-3 · 346-2)은 진짜로 — 빈 대역은 열쇠 목록을 못 냄 +STUBS["@util/common_util_station_key"] = loadTs( + path.join(ROOT, "common_util", "common_util_station_key.ts"), +); const { saveDesign, savingDesign } = loadTs( path.join(ROOT, "A00_Common", "design", "design_save.ts"), ); diff --git a/resources/tester/helper_342_1_entry.ts b/resources/tester/helper_342_1_entry.ts index c9ec78270..c1a660fff 100644 --- a/resources/tester/helper_342_1_entry.ts +++ b/resources/tester/helper_342_1_entry.ts @@ -26,3 +26,4 @@ export { createCrossPreview } from "../../B05_Profile/B05_Profile_UI_Profile_Pre export { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; export { createDesignSync } from "../../B06_Section/B06_Section_UI_Page_Design_Sync"; export { writeState } from "../../A00_Common/b_page_state"; +export { listedBase } from "../../A00_Common/design/design_profile_base"; diff --git a/resources/tester/helper_342_1_station_line.mjs b/resources/tester/helper_342_1_station_line.mjs index e336a91a2..953785df9 100644 --- a/resources/tester/helper_342_1_station_line.mjs +++ b/resources/tester/helper_342_1_station_line.mjs @@ -114,29 +114,8 @@ const irregularStations = [ { id: "pipe-1", station: 4, remainder: 5.05, chainage_m: PIPE, structure: "파형강관 D800" }, ]; -/* 화면 선 = design_profile.ts 의 진짜 listed() */ -const dpSrc = readFileSync(join(ROOT, "A00_Common", "design", "design_profile.ts"), "utf8"); -const listedSrc = dpSrc.match(/\r?\n function listed\(\)[\s\S]*?\r?\n \}\r?\n/)[0]; -const listedOf = new Function( - "storedBase", - "detail", - "irregularStations", - "oneStationList", - "irregularGraphStations", - "maxChainageOf", - "followMovedNodes", - `${transpile(listedSrc)}\nreturn listed();`, -); -const screenBase = (detail) => - listedOf( - m.toAlignmentBase(stored), - detail, - irregularStations, - m.oneStationList, - m.irregularGraphStations, - m.maxChainageOf, - m.followMovedNodes, - ); +/* 화면 선 = 종단도가 쓰는 진짜 `listedBase`(design_profile_base · 옛 design_profile 안 listed()) */ +const screenBase = (detail) => m.listedBase(m.toAlignmentBase(stored), detail, irregularStations); const staleOf = (detail, line) => m.staleKey({ longitudinal: { design_profiles: [m.toDesignProfile(line, undefined)] }, diff --git a/resources/tester/helper_346_2_station_swap.mjs b/resources/tester/helper_346_2_station_swap.mjs index 9458927f0..08fc7ef39 100644 --- a/resources/tester/helper_346_2_station_swap.mjs +++ b/resources/tester/helper_346_2_station_swap.mjs @@ -190,6 +190,60 @@ const NEW = (dy) => [ stopListen(); } +/* 2-2. 종단 보기(B05) · 3D 측점선 — 횡단 보기 없이도 가게 측점으로 갈아끼우고 보일 때만 다시 그림 */ +{ + const R2 = 11; + m.writeState("section-detail", oldDetail(), P, R2); + const renders = []; + const lines = []; + let shown = true; + const sections = m.createProfileSections({ + projectId: P, + state: { + confirmedSurface: null, + latest: { route_points: [] }, + roadWidths: { g: 5 }, + detail: null, + corridorVisible: false, + corridorPending: false, + routeReady: true, + restoring: false, + }, + viewer: { renderStationLines: (list) => lines.push(list.length), markers: {} }, + panel: () => ({ + values: () => ({ gradeClass: "g", stationInterval: 20, enforcePipeClearance: false }), + gradeLimitPct: () => 12, + stationDisplayOffset: () => ({ station: 0, cumulative: 0 }), + setCorridorPending: () => undefined, + }), + profilePanel: () => ({ + render: (detail) => renders.push(detail.cross_sections.length), + setGradeLimit: () => undefined, + setEnforceMinCover: () => undefined, + setStationDisplay: () => undefined, + setIrregularStations: () => undefined, + alignmentSamples: () => null, + setLoading: () => undefined, + clear: () => undefined, + }), + bridge: () => ({ irregularStations: () => [] }), + visible: () => shown, + }); + await sections.restoreSections(R2); + const shop = m.useDesignStore(P, null); + shop.set("stations", NEW(0)); // 보임 — 바로 다시 + await new Promise((resolve) => setTimeout(resolve, 30)); + const afterShown = [...renders]; + shown = false; + shop.set("stations", NEW(0).slice(0, 6)); // 숨음 — 안 그림 + await new Promise((resolve) => setTimeout(resolve, 30)); + const whileHidden = [...renders]; + shown = true; + sections.flushSwap(); // 다시 보임 + out.profile = { afterShown, whileHidden, afterShow: [...renders], lines: lines.length }; + sections.stopSwap(); +} + /* 3. 보이는 카드 먼저 — 바로 그릴 수 */ out.eager = [ m.eagerCardCount({ clientHeight: 600, scrollTop: 0 }, 3, 250, 12), diff --git a/resources/tester/helper_346_2_swap_entry.ts b/resources/tester/helper_346_2_swap_entry.ts index 29be9449b..56d0b2bbe 100644 --- a/resources/tester/helper_346_2_swap_entry.ts +++ b/resources/tester/helper_346_2_swap_entry.ts @@ -23,3 +23,4 @@ export { } from "../../B06_Section/B06_Section_UI_Page_Link_Session"; export { useDesignStore } from "../../A00_Common/design/design_store"; export { readState, writeState } from "../../A00_Common/b_page_state"; +export { createProfileSections } from "../../A00_Common/design/design_view_profile_sections"; diff --git a/resources/tester/test_346_2_station_swap.py b/resources/tester/test_346_2_station_swap.py index df0af3dbc..d03def1d7 100644 --- a/resources/tester/test_346_2_station_swap.py +++ b/resources/tester/test_346_2_station_swap.py @@ -7,6 +7,7 @@ - 가게 `stations` → 캐시 상세 갈아끼움 · 듣는 쪽에 새 상세 + 누가거리 옮김 · 카드 선택은 측점 열쇠로 새 자리(관 45.054 → 47 m) · 노선 모양이 바뀌면 넘김 규칙 (단면 유형 버림) · 사라진 측점 고침 버림 · 조정창 값도 옮긴 자리로. + - 종단 보기(B05) · 3D 측점선 — 횡단 보기를 안 열어도 가게 측점으로 갈아끼움 · 보일 때만 다시 그림. - 보이는 카드 먼저 — 보이는 줄 + 한 줄만 바로. - `formatStation` ↔ 파이썬 `format_station` 같은 글. @@ -116,6 +117,14 @@ def test_가게_측점으로_상세_갈아끼움_열쇠로_이어_받기(out): assert store["again"] is True # 같은 목록 = 안 갈아끼움 +def test_종단_보기_3D_측점선도_새_목록_보일_때만(out): + profile = out["profile"] + assert profile["afterShown"] == [7, 8] # 처음 7 → 가게 측점으로 갈아끼움 8(횡단 보기 없이) + assert profile["whileHidden"] == [7, 8] # 숨은 동안은 안 그림 + assert profile["afterShow"] == [7, 8, 6] # 다시 보일 때 새 목록(6) + assert profile["lines"] == 3 # 3D 측점선도 같은 차례로 다시 + + def test_보이는_카드_먼저(out): assert out["eager"] == [12, 27, 4] # 3열 · 600 px · (250+12) px 줄 = 3 줄 + 1 assert out["rename"] == {"47.00": 1, "b47.00": 2, "47.00:outlet": 3, "60.00": 4}