feat(측점열쇠): 346-2 덧 2 열쇠 한 벌 — 구간형 :start · :end · 묶음 측점 종류(grid · end) · 같은 열쇠 둘째 규칙 공용

- common_util_station_key(ts · py 짝): role 덧붙임(구간형 시작 · 끝 = structure:<id>:start · :end · 기준은 structure:<id> 그대로) · grid(원래 격자 번호) · end(BP/EP) 종류 · dedupeStationKeys(같은 열쇠 둘째부터 at:<누가거리>)
- B06 stationKeysOf 가 같은 규칙(측점 계획 · 구조물 목록의 시작 · 끝 자리 · 공용 dedupe)
- 거울 시험에 새 갈래(구간형 · grid · end · dedupe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-16 12:20:06 +09:00
co-authored by Claude Opus 5.5
parent f9c70757b4
commit 69d84e5ca9
4 changed files with 119 additions and 28 deletions
+33 -13
View File
@@ -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<string, unknown> & { [HINT]?: Record<string, number> };
const lists = new Map<string, Keyed[]>();
/** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */
const owners = new Map<string, Array<{ id: string; kind: "pipe" | "structure"; at: number }>>();
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;
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:<id> · 구간형 시작 · 끝 = :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<CrossSection>,
intervalM: number | null,
): Keyed[] {
const known = projectId ? (owners.get(projectId) ?? []) : [];
const seen = new Set<string>();
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<SectionDetailResponse, "cross_sections" | "longitudinal">;
+30 -4
View File
@@ -2,6 +2,8 @@
일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:<id>`` · 구조물 = ``structure:<id>`` ·
시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``.
구간형 구조물 시작 · 끝 측점 = ``structure:<id>:start`` · ``:end``(기준 측점은 ``structure:<id>``).
한 목록에 같은 열쇠가 둘이면 둘째부터 ``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:
+33 -8
View File
@@ -3,21 +3,29 @@
* 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름.
* 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:<id>` · 구조물 = `structure:<id>` ·
* 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫).
* 구간형 구조물의 시작 · 끝 측점 = `structure:<id>:start` · `:end`(기준 측점은 `structure:<id>` 그대로).
* 한 목록에 같은 열쇠가 둘이면 둘째부터 `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<string>,
chainages: ReadonlyArray<number>,
): string[] {
const seen = new Set<string>();
return keys.map((key, index) => {
const unique = seen.has(key) ? `at:${chainageKey(chainages[index])}` : key;
seen.add(unique);
return unique;
});
}
/** 옛 초안 열쇠(누가거리 글 `"85.05"`) · `at:` 열쇠의 누가거리 — 측점 열쇠면 null. */
@@ -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"]