From 69d84e5ca9d4d0cbeeaf0ae73fa431ca36372dfe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 12:20:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=B8=A1=EC=A0=90=EC=97=B4=EC=87=A0):=203?= =?UTF-8?q?46-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"]