From 1c090c0f5e85ad4d2a8ed33c8ca3232d4fb679d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:45:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8=EC=B4=88=EC=95=88):=203?= =?UTF-8?q?44-3=20=EC=B8=A1=EC=A0=90=20=EC=97=B4=EC=87=A0=20=E2=80=94=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=ED=9A=A1=EB=8B=A8=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=EC=9D=84=20=EC=B8=A1=EC=A0=90=20=EC=97=B4=EC=87=A0=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EC=98=9B=20=EC=B4=88=EC=95=88=20=EC=98=AE=EA=B9=80=20?= =?UTF-8?q?=C2=B7=20=EB=85=B8=EC=84=A0=20=EB=B0=94=EB=80=9C=20=EB=84=98?= =?UTF-8?q?=EA=B9=80=20=C2=B7=20=EB=85=B8=EC=84=A0=20=EB=B0=96=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 열쇠 규칙 한 벌 = common_util_station_key(ts · py 짝 · 거울 시험) — 일반 grid:k · 관 pipe: · 구조물 structure: · 시종점 end:BP/EP · id 모름 at:<0.01 m> - 세션 칸 다섯(카드 선택 · 노견 확장 · 소단 깊이 · 암 경계 · 암 경사) = 측점 열쇠로 담음 · 부르는 쪽은 그대로 누가거리로 읽고 씀 · 칸마다 쓴 때 자리를 함께 담아 구조물 id 를 아직 모를 때도 그 측점 고침을 읽음 - 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 고침은 지우지 않고 남김 - 측점 목록 = 상세가 초안 측점을 얹을 때 · 다시 셈 때 세움(관 id = 관 장부 · 구조물 id = 측점 계획 · 구조물 목록) - 노선 바뀜 넘김 carryStationDrafts — 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사만 · 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점 = 「노선 밖 고침 N 건」 한 줄(부르는 자리 = 3파) - 옛 시험 셋 새 창구로 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- .../B06_Section_Cross_Design_Session.ts | 38 +-- B06_Section/B06_Section_Cross_Refresh.ts | 16 +- B06_Section/B06_Section_Section_Drafts.ts | 3 + B06_Section/B06_Section_Station_Keys.ts | 273 ++++++++++++++++++ .../B06_Section_UI_Page_Design_Sync.ts | 2 + B06_Section/B06_Section_UI_Page_Persist.ts | 46 ++- common_util/common_util_station_key.py | 52 ++++ common_util/common_util_station_key.ts | 43 +++ .../tester/helper_273_2_preview_stations.cjs | 2 + .../tester/test_344_3_station_key_mirror.py | 88 ++++++ .../tester/test_session_keys_registered.py | 3 +- resources/tester/test_user_values_survive.py | 3 +- 12 files changed, 527 insertions(+), 42 deletions(-) create mode 100644 B06_Section/B06_Section_Station_Keys.ts create mode 100644 common_util/common_util_station_key.py create mode 100644 common_util/common_util_station_key.ts create mode 100644 resources/tester/test_344_3_station_key_mirror.py diff --git a/B06_Section/B06_Section_Cross_Design_Session.ts b/B06_Section/B06_Section_Cross_Design_Session.ts index d351a1f6b..451456bf7 100644 --- a/B06_Section/B06_Section_Cross_Design_Session.ts +++ b/B06_Section/B06_Section_Cross_Design_Session.ts @@ -8,9 +8,11 @@ * 계산은 브라우저가 하고(`common_util_cross_design.ts`), 선택값은 여기 담긴다. * * 담는 것은 **사용자가 고른 것만**이다 — 계산 결과(면적·설계선)는 담지 않는다. + * 세션 열쇠 = 측점 열쇠(344-3 · `B06_Section_Station_Keys`) · 부르는 쪽에는 누가거리로 내줌. * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; +import { chainageKey } from "@util/common_util_station_key"; +import { readKeyed, writeKeyed } from "./B06_Section_Station_Keys"; /** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */ export interface CrossDesignChoice { @@ -25,14 +27,9 @@ export interface CrossDesignChoice { ditch_choice?: boolean | null; } -type ChoiceMap = Record; - -/** 측점 키 — 암 경계선 저장소와 같은 규칙(0.01m 단위). */ -const keyOf = (chainageM: number): string => chainageM.toFixed(2); - -function readAll(projectId: string, routeId: number): ChoiceMap { - return readState("crossdesign", projectId, routeId) ?? {}; -} +/** 누가거리(0.01 m 글) → 선택값 — 지금 측점에 있는 것만. */ +const readAll = (projectId: string, routeId: number) => + readKeyed("crossdesign", projectId, routeId); /** 이 측점의 선택값(없으면 null). */ export function readCrossDesignChoice( @@ -40,7 +37,7 @@ export function readCrossDesignChoice( routeId: number, chainageM: number, ): CrossDesignChoice | null { - return readAll(projectId, routeId)[keyOf(chainageM)] ?? null; + return readAll(projectId, routeId).get(chainageKey(chainageM)) ?? null; } /** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */ @@ -50,9 +47,8 @@ export function writeCrossDesignChoice( chainageM: number, choice: CrossDesignChoice, ): void { - const all = readAll(projectId, routeId); - all[keyOf(chainageM)] = choice; - writeState("crossdesign", all, projectId, routeId); + const at = chainageKey(chainageM); + writeKeyed("crossdesign", projectId, routeId, new Map([[at, choice]]), [at]); } /** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */ @@ -62,7 +58,7 @@ export function crossDesignChoices( ): Map { const choices = new Map(); if (!projectId || routeId === null) return choices; - for (const [key, value] of Object.entries(readAll(projectId, routeId))) { + for (const [key, value] of readAll(projectId, routeId)) { const chainage = Number(key); if (Number.isFinite(chainage) && value) choices.set(chainage, value); } @@ -80,12 +76,11 @@ type SideState = "shoulderext" | "bermdepth"; function readSides(name: SideState, projectId: string | null, routeId: number | null) { const out: ShoulderExtends = new Map(); if (!projectId || routeId === null) return out; - const raw = readState>>>( + for (const [key, value] of readKeyed>>( name, projectId, routeId, - ); - for (const [key, value] of Object.entries(raw ?? {})) { + )) { const chainage = Number(key); if (Number.isFinite(chainage) && value) out.set(chainage, value); } @@ -100,11 +95,10 @@ function writeSide( side: ShoulderSide, valueM: number, ): void { - const all = - readState>>>(name, projectId, routeId) ?? - {}; - all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) }; - writeState(name, all, projectId, routeId); + const at = chainageKey(chainageM); + const own = readKeyed>>(name, projectId, routeId).get(at); + const next = { ...(own ?? {}), [side]: Math.max(valueM, 0) }; + writeKeyed(name, projectId, routeId, new Map([[at, next]]), [at]); } /** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */ diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index c4b1da7a5..07233dac9 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -28,7 +28,12 @@ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { + bermSpecAt, + readBermSpans, + readCutSlopeSession, + readRockBoundarySession, +} from "./B06_Section_UI_Page_Persist"; import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; import { @@ -48,7 +53,7 @@ import { } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; -import { readState } from "../A00_Common/b_page_state"; +import { useStationKeys } from "./B06_Section_Station_Keys"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; @@ -211,6 +216,7 @@ export async function refreshStaleDesigns( * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3) const sections = input.detail.cross_sections; const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); const updated = refreshLocally(input) ?? (await refreshFromServer(input)); @@ -301,8 +307,7 @@ function bermDepthReader(projectId: string, routeId: number) { */ function readCutSlopeRatios(projectId: string, routeId: number): Map { const out = new Map(); - const raw = readState>("cutslope", projectId, routeId); - if (!raw) return out; + const raw = readCutSlopeSession(projectId, routeId); for (const [chainage, ratio] of Object.entries(raw)) { const at = Number(chainage); if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio); @@ -516,8 +521,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { berms: bermPayload(projectId, routeId, detail), // 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만 // 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07). - cutSlopeRatios: - readState>("cutslope", projectId, routeId) ?? undefined, + cutSlopeRatios: readCutSlopeSession(projectId, routeId), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 633c9d7a9..64d6f1927 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -30,6 +30,7 @@ import { structureStationMarks, type PlannedStation, } from "../common_util/common_util_structure_station"; +import { useStationKeys, useStationOwners } from "./B06_Section_Station_Keys"; import { previewStructureSections, type CrossSection, @@ -188,6 +189,7 @@ export async function withDraftStations( if (inputs) { const [pipes, structures, types] = inputs; const planned = planStations(pipes, structures, types, registryDefaults(types)); + useStationOwners(projectId, planned); // 구조물 측점 열쇠 = 덮는 구조물 id(344-3) const planted = planned.map((station) => station.chainage_m); // 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고 // 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 @@ -246,4 +248,5 @@ export async function withDraftStations( ...[...live, ...fresh].sort(byChainage), ); parked.set(detail, stale); + useStationKeys(projectId, routeId, detail); // 세션 횡단 초안 = 이 측점 목록의 열쇠(344-3) } diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts new file mode 100644 index 000000000..3962048f3 --- /dev/null +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -0,0 +1,273 @@ +/* ============================================================================= + * B06_Section_Station_Keys.ts + * 세션 횡단 초안의 **측점 열쇠**(PLAN 344-3) — 노선이 바뀌어 누가거리가 밀려도 같은 측점의 고침이 남게. + * + * 열쇠 규칙 = `common_util_station_key`(일반 grid:k · 관 pipe: · 구조물 structure: · 시종점 end:BP/EP · + * id 모름 at:<0.01 m>). 담는 칸 다섯(crossdesign · shoulderext · bermdepth · rockb · cutslope)은 측점 열쇠로 + * 쓰고, 부르는 쪽에는 지금처럼 **누가거리**로 내줌(읽기 · 쓰기 창구가 그대로). + * + * · 지금 측점 목록 = 상세가 초안 측점을 얹을 때마다(`useStationKeys` · Section_Drafts) — 관 id 는 관 세션 + * 장부 · 구조물 id 는 측점 계획(초안) · 구조물 목록(횡단 보기)에서. + * · 칸마다 열쇠 → 쓴 때 누가거리 `@at` 을 함께 담음 — 구조물 id 를 아직 모를 때(첫 열기)도 그 자리 측점이면 읽음. + * · 옛 초안(누가거리 열쇠 `"85.05"`) · `at:` 열쇠는 읽을 때 지금 측점 열쇠로 옮겨 다시 씀. + * · 노선이 바뀜 = `carryStationDrafts` — 넘기는 칸 = 서버 `CARRY_INPUT_KEYS` 일곱(지반 유형 · 측구 종류 · + * 측구 선택 · 2단 경사 · 암 경계 · 암 경사 · 소단 구간은 구간 그대로) · 단면 유형 · 측구 방향은 새 지형이 + * 정함 · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」 한 줄. + * ========================================================================== */ + +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 { inferStationInterval } from "@util/common_util_svg"; +import type { CrossSection, SectionDetailResponse } 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; +/** 노선이 바뀔 때 통째로 넘기는 칸 · 버리는 칸(노견 확장 · 소단 깊이 = 그 측점 기하 손질). */ +const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"]; + +/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */ +const NEAR_M = 0.01; +const HINT = "@at"; + +type Keyed = { key: string; chainageM: number }; +type Raw = Record & { [HINT]?: Record }; + +const lists = new Map(); +/** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */ +const owners = new Map>(); +let current: { projectId: string; routeId: number } | null = null; + +const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`; + +/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */ +export function useStationOwners( + projectId: string, + planned: ReadonlyArray<{ chainage_m: number; owners: string[] }>, +): void { + owners.set( + projectId, + planned.flatMap((station) => + station.owners.slice(0, 1).map((id) => ({ + id, + kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const), + at: station.chainage_m, + })), + ), + ); +} + +/** 구조물 목록(횡단 보기) — 한 자리 구조물의 id. 관 자리는 관 장부가 정함. */ +export function useStructureIds( + projectId: string, + structures: ReadonlyArray<{ structure_id?: string | null; chainage_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 }); + } + owners.set(projectId, known); +} + +const looksLikePipe = (section: CrossSection): boolean => + Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); + +/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:`). */ +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 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); + else if (looksLikePipe(section)) + key = stationKey("pipe", at, intervalM, pipeIdAt(at, projectId)); + else + key = section.grid + ? 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 }; + }); +} + +/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */ +export function useStationKeys( + projectId: string, + routeId: number, + detail: Pick, +): void { + const interval = + ( + detail.longitudinal?.profile_alignment as + { policy?: { station_interval_m?: number } } | undefined + )?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections); + lists.set(scopeOf(projectId, routeId), stationKeysOf(projectId, detail.cross_sections, interval)); + current = { projectId, routeId }; +} + +/** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */ +export const currentStationScope = () => current; + +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; +} + +/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `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)}`; +} + +/** + * 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금 + * 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖). + */ +export function chainageOfKey( + projectId: string, + routeId: number, + 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; +} + +function readRaw(name: KeyedState, projectId: string, routeId: number): Raw { + const raw = readState(name, projectId, routeId); + return raw && typeof raw === "object" ? raw : {}; +} + +function entries(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:)는 지금 측점 열쇠로 옮겨 다시 씀(목록이 있을 때). + */ +export function readKeyed(name: KeyedState, projectId: string, routeId: number): Map { + const raw = readRaw(name, projectId, routeId); + const out = new Map(); + let moved = false; + for (const [key, value, hint] of entries(raw)) { + const at = chainageOfKey(projectId, routeId, key, hint); + if (at === null) continue; + out.set(chainageKey(at), value); + if (looseChainage(key) !== null && !keyAt(projectId, routeId, at).startsWith("at:")) + moved = true; + } + if (moved) writeKeyed(name, projectId, routeId, out); + return out; +} + +/** + * 누가거리(0.01 m 글) → 값 묶음을 측점 열쇠로 씀. 지금 측점이 아닌 옛 고침(노선 밖 · 아직 못 읽는 것)은 그대로 둠. + * `only` 를 주면 그 누가거리들만 바꾸고 나머지 지금 측점 고침도 그대로. + */ +export function writeKeyed( + name: KeyedState, + projectId: string, + routeId: number, + byChainage: ReadonlyMap, + only?: ReadonlyArray, +): void { + 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(raw)) { + const at = chainageOfKey(projectId, routeId, key, hint); + if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue; + next[key] = value; + if (hint !== undefined) next[HINT]![key] = hint; + } + for (const [at, value] of byChainage) { + if (only !== undefined && !replaced.has(chainageKey(Number(at)))) continue; + const key = keyAt(projectId, routeId, Number(at)); + next[key] = value; + next[HINT]![key] = Number(chainageKey(Number(at))); + } + writeState(name, next, projectId, routeId); +} + +/** + * 노선이 바뀜(새 측점 목록을 `useStationKeys` 로 세운 뒤) — 옛 노선 고침을 측점 열쇠로 새 노선에 넘김. + * 넘김 = 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사 · 단면 유형 · 측구 + * 방향은 새 지형이 정함 · 노견 확장 · 소단 깊이는 안 넘김. 돌려주는 값 = 노선 밖으로 나간 고침 수(측점 단위). + */ +export function carryStationDrafts( + projectId: string, + fromRouteId: number, + toRouteId: number, +): number { + const outside = new Set(); + for (const name of KEYED_STATES) { + const raw = readRaw(name, projectId, fromRouteId); + const next: Raw = { [HINT]: {} }; + for (const [key, value] of entries(raw)) { + // 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로 + const fromAt = + looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key); + const stable = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt); + 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); + } + if (outside.size) + showToast(`노선 밖 고침 ${outside.size} 건 — 사라진 측점이라 버렸습니다.`, "info"); + return outside.size; +} + +/** 칸마다 넘기는 값 — 넘길 것이 없으면 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; + const picked = Object.fromEntries( + CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]), + ); + return Object.keys(picked).length ? picked : null; +} diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 804307026..fa04f3326 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -27,6 +27,7 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail"; +import { useStructureIds } from "./B06_Section_Station_Keys"; export interface DesignSyncContext { projectId: string | null; @@ -194,6 +195,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { function syncBermSpans(structures: ReadonlyArray): void { const currentRouteId = ctx.routeId(); if (!projectId || currentRouteId === null) return; + useStructureIds(projectId, structures); // 구조물 측점 열쇠 = 구조물 id(344-3) const next = bermSpansFromStructures(structures); if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; writeBermSpans(projectId, currentRouteId, next); diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 3e870bbbc..8c0c1b2e1 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -49,6 +49,7 @@ import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { catalogRow } from "../common_util/common_util_structure_catalog"; +import { readKeyed, writeKeyed, type KeyedState } from "./B06_Section_Station_Keys"; /** * 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. @@ -65,8 +66,35 @@ export function readRockBoundarySession( projectId: string, routeId: number, ): Record { - const stored = readState>("rockb", projectId, routeId); - return stored && typeof stored === "object" ? stored : {}; + // 세션 열쇠 = 측점 열쇠(344-3) — 부르는 쪽(다시 셈 · 서버 폴백)에는 누가거리(0.01 m 글)로 + return Object.fromEntries(readKeyed("rockb", projectId, routeId)); +} + +/** 측점별 암 절토 경사 세션값 — 누가거리(0.01 m 글) → 경사비(0 = 표준으로 되돌림 · 344-3). */ +export function readCutSlopeSession(projectId: string, routeId: number): Record { + return Object.fromEntries(readKeyed("cutslope", projectId, routeId)); +} + +/** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */ +function keyedScope(storageKey: string): { projectId: string; routeId: number } | null { + const [projectId, route] = storageKey.split(":").slice(-2); + const routeId = Number(route); + return projectId && Number.isInteger(routeId) ? { projectId, routeId } : null; +} + +/** 저장소 값 읽기 — 측점 열쇠 칸이면 누가거리로 옮겨 · 아니면 옛 꼴 그대로. */ +function loadKeyed(name: KeyedState, storageKey: string): Array<[string, number]> { + const scope = keyedScope(storageKey); + if (scope) return [...readKeyed(name, scope.projectId, scope.routeId)]; + const raw = readByKey(storageKey); + return raw ? Object.entries(JSON.parse(raw) as Record) : []; +} + +/** 저장소 값 쓰기 — 측점 열쇠로(노선 밖 옛 고침은 그대로 둠). */ +function persistKeyed(name: KeyedState, storageKey: string, values: Map): void { + const scope = keyedScope(storageKey); + if (scope) writeKeyed(name, scope.projectId, scope.routeId, values); + else writeByKey(storageKey, JSON.stringify(Object.fromEntries(values))); } /** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ @@ -215,7 +243,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets))); + persistKeyed("rockb", storageKey, offsets); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -228,10 +256,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = readByKey(storageKey); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - Object.entries(parsed).forEach(([chainage, offset]) => { + loadKeyed("rockb", storageKey).forEach(([chainage, offset]) => { if (Number.isFinite(offset)) offsets.set(chainage, offset); }); } catch { @@ -318,7 +343,7 @@ export function createCutSlopeStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios))); + persistKeyed("cutslope", storageKey, ratios); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -331,10 +356,7 @@ export function createCutSlopeStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = readByKey(storageKey); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - Object.entries(parsed).forEach(([chainage, ratio]) => { + loadKeyed("cutslope", storageKey).forEach(([chainage, ratio]) => { // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio); }); diff --git a/common_util/common_util_station_key.py b/common_util/common_util_station_key.py new file mode 100644 index 000000000..de9c02d68 --- /dev/null +++ b/common_util/common_util_station_key.py @@ -0,0 +1,52 @@ +"""측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. + +일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:`` · 구조물 = ``structure:`` · +시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``. + +⚠ 파이썬·TS 짝 — ``common_util_station_key.ts`` ``stationKey`` ↔ ``station_key``. +한쪽을 고치면 다른 쪽도 고치고 거울 시험 ``resources/tester/test_344_3_station_key_mirror.py``. +""" + +from __future__ import annotations + +import math +import re + +_LOOSE = re.compile(r"^-?\d+(\.\d+)?$") + + +def _half_up(value: float) -> int: + """JS ``Math.round`` 와 같은 반올림(0.5 는 위로).""" + return math.floor(value + 0.5) + + +def chainage_key(chainage_m: float) -> str: + """누가거리 0.01 m — 옛 초안 열쇠(``toFixed(2)``)와 같은 자릿수.""" + return f"{_half_up(chainage_m * 100) / 100:.2f}" + + +def station_key( + kind: str, + chainage_m: float, + interval_m: float | None, + station_id: str | None = None, + id_kind: str = "structure", +) -> str: + """측점 열쇠 — irregular 는 id 가 있으면 pipe: · structure:(``id_kind``) · 없으면 at:.""" + if kind == "bp": + return "end:BP" + if kind == "ep": + return "end:EP" + if kind in ("pipe", "structure"): + return f"{kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}" + if kind == "irregular": + return f"{id_kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}" + if interval_m and interval_m > 0: + return f"grid:{_half_up(chainage_m / interval_m)}" + return f"at:{chainage_key(chainage_m)}" + + +def loose_chainage(key: str) -> float | None: + """옛 초안 열쇠(누가거리 글) · ``at:`` 열쇠의 누가거리 — 측점 열쇠면 None.""" + text = key[3:] if key.startswith("at:") else key + return float(text) if _LOOSE.match(text) else None diff --git a/common_util/common_util_station_key.ts b/common_util/common_util_station_key.ts new file mode 100644 index 000000000..db398ac99 --- /dev/null +++ b/common_util/common_util_station_key.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * common_util_station_key.ts + * 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. + * 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:` · 구조물 = `structure:` · + * 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫). + * + * ⚠ 파이썬·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"; + +/** 누가거리 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:`. + */ +export function stationKey( + kind: StationKeyKind, + chainageM: number, + intervalM: number | null, + id: string | null = null, + idKind: "pipe" | "structure" = "structure", +): string { + 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 (intervalM && intervalM > 0) return `grid:${Math.round(chainageM / intervalM)}`; + return `at:${chainageKey(chainageM)}`; +} + +/** 옛 초안 열쇠(누가거리 글 `"85.05"`) · `at:` 열쇠의 누가거리 — 측점 열쇠면 null. */ +export function looseChainage(key: string): number | null { + const text = key.startsWith("at:") ? key.slice(3) : key; + if (!/^-?\d+(\.\d+)?$/.test(text)) return null; + return Number(text); +} diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 7f3b15bce..759e05c8f 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -65,6 +65,8 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { } }, }, + // 측점 열쇠 세우기(344-3) — 이 시험 몫 아님 + "./B06_Section_Station_Keys": { useStationKeys: () => {}, useStationOwners: () => {} }, "./B06_Section_Api_Fetch": { previewStructureSections: async (_projectId, chainages) => { asked.push(chainages); diff --git a/resources/tester/test_344_3_station_key_mirror.py b/resources/tester/test_344_3_station_key_mirror.py new file mode 100644 index 000000000..66e3af00d --- /dev/null +++ b/resources/tester/test_344_3_station_key_mirror.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""344-3 측점 열쇠 거울 — `common_util_station_key.py` ↔ `common_util_station_key.ts` 같은 열쇠. + +일반 grid:k(누가거리 ÷ 간격 반올림 · 0.5 는 위로) · 관 pipe: · 구조물 structure: · +시종점 end:BP/EP · id 모름 at:<0.01 m> · 옛 열쇠(누가거리 글) · at: 의 누가거리. +""" + +import json +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from common_util.common_util_station_key import ( # noqa: E402 + chainage_key, + loose_chainage, + station_key, +) + +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"), + ("ep", 812.345, 20.0, None, "structure"), + ("regular", 40.0, 20.0, None, "structure"), + ("regular", 50.0, 20.0, None, "structure"), # 2.5 → 3(위로) + ("regular", 69.996, 20.0, None, "structure"), + ("regular", 40.0, None, None, "structure"), + ("irregular", 85.054, 20.0, "pipe-s3", "pipe"), + ("irregular", 85.054, 20.0, "st-7", "structure"), + ("irregular", 85.054, 20.0, None, "structure"), + ("irregular", 1.005, 20.0, None, "structure"), + ("pipe", 12.344, 20.0, "pipe-s1", "structure"), + ("structure", 12.345, 20.0, None, "structure"), +] +LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"] + +SCRIPT = r""" +const ts = require(process.argv[1]); +const fs = require("fs"); +const src = fs.readFileSync(process.argv[2], "utf8"); +const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } }; +const js = ts.transpileModule(src, opts).outputText; +const box = { exports: {} }; +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])), + chainage: input.cases.map((c) => m.chainageKey(c[1])), + loose: input.loose.map((k) => m.looseChainage(k)), +})); +""" + + +def test_파이썬과_TS_열쇠_같음(): + proc = subprocess.run( + [ + "node", + "-e", + SCRIPT, + str(TYPESCRIPT), + str(TS), + json.dumps({"cases": CASES, "loose": LOOSE}), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + 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] + + +def test_열쇠_규칙(): + assert station_key("bp", 0.0, 20.0) == "end:BP" + assert station_key("ep", 812.3, 20.0) == "end:EP" + assert station_key("regular", 50.0, 20.0) == "grid:3" + 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 diff --git a/resources/tester/test_session_keys_registered.py b/resources/tester/test_session_keys_registered.py index c49dbd168..b45cc4461 100644 --- a/resources/tester/test_session_keys_registered.py +++ b/resources/tester/test_session_keys_registered.py @@ -64,7 +64,8 @@ def test_등록표_밖에서_날문자열_세션_키를_안_쓴다(): def test_옛_암경계_키가_코드에_안_남아_있다(): """실사고 자리 못박기 — 읽는 쪽이 등록표를 거치는지.""" persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") - assert 'readState>("rockb"' in persist, "등록표를 안 거침" + # 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음 + assert 'readKeyed("rockb", projectId, routeId)' in persist, "등록표를 안 거침" # 옛 키가 **코드로** 되살아나는 것만 막는다 — 주석에 사연으로 적힌 것은 남겨 둔다. code = "\n".join( line for line in persist.splitlines() if not line.lstrip().startswith(("*", "//", "/*")) diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 04ef0fefe..ff3053c52 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -202,7 +202,8 @@ def test_암경계선은_등록표_키로_읽는다(): persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text( encoding="utf-8" ) - assert 'readState>("rockb"' in persist + # 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음 + assert 'readKeyed("rockb", projectId, routeId)' in persist # ── ⑦ 저장된 표준 횡단면이 새 탭에서 안 서던 것 (`72e50b3e`) ───────────────────