diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index f8e5d8be3..1adb723e3 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -28,7 +28,6 @@ import { type StandardCrossSection, } from "../../B06_Section/B06_Section_Api_Fetch"; import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls"; -import { setStationAdjustReload } from "../../B06_Section/B06_Section_Api_Culvert_Options"; import { createCutSlopeStore, createRockBoundaryStore, @@ -298,7 +297,6 @@ export async function mountCrossView( wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120); }, }); - setStationAdjustReload(() => stationControls.load()); // 종류 바꿈이 세션을 비운 뒤(318-4) // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); // 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김 @@ -540,9 +538,9 @@ export async function mountCrossView( patchSources: () => ({ rockOffsets: rockStore.offsets, cutSlopeRatios: cutSlopeStore.ratios, - stationWidths: stationControls.widths, - inletStructures: stationControls.inletStructures, - basinAdjustments: stationControls.basinAdjustments, + stationWidths: stationControls.widths(), + inletStructures: stationControls.inletStructures(), + basinAdjustments: stationControls.basinAdjustments(), revetAdjusts: stationControls.revetAdjustsByChainage(), extraCounts: stationControls.extraCountsByChainage(), extraSpans: stationControls.extraSpansByChainage(), @@ -582,6 +580,17 @@ export async function mountCrossView( host.onDispose(shop.subscribe("structures", onShop)); host.onDispose(shop.subscribe("pipes", onShop)); host.onDispose(followSwaps(projectId, () => currentRouteId, host, refreshDetailForStructures)); + // ↶ ↷ 가 조정창 칸을 되씀 — 상세 가게 얹기(고치기 전 칸 + 지금 칸)로 캐시 · 카드 다시 · 면적 다시 셈(472-5 5-D) + host.onDispose( + stationControls.onRestore(() => { + if (!projectId || currentRouteId === null) return; + if (host.visible()) reloadPanel(); // 폼 [연동] · [경사] · 단 수도 되돌린 칸으로 + const again = host.visible() + ? refreshDetailForStructures() + : loadSectionDetail(projectId, currentRouteId); + void again.then(() => reconcileStaleDesigns({ force: true })); + }), + ); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); window.clearTimeout(wallAdjustTimer); diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 91956d0f2..c20dc378a 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -118,13 +118,6 @@ const STATION_ADJUST_STATES: readonly StateName[] = [ "boxadjust", ]; -let reloadStationAdjusts: (() => void) | null = null; - -/** 횡단 보기가 메모리 사본을 세션에서 다시 읽는 창구(한 페이지 하나 · 나중 것이 씀). */ -export function setStationAdjustReload(reload: () => void): void { - reloadStationAdjusts = reload; -} - const atChainage = (key: string, chainageM: number): boolean => { const head = /^b?(-?\d+(?:\.\d+)?)/.exec(key); return head !== null && Math.abs(Number(head[1]) - chainageM) < PIPE_MATCH_M; @@ -168,7 +161,6 @@ export function forgetStationAdjusts( if (hit !== undefined) delete marks[hit]; if (next !== first) marks[chainageM.toFixed(2)] = first; writeState("kindreset", Object.keys(marks).length ? marks : null, projectId); - reloadStationAdjusts?.(); } /** [저장] 때 옛 조정값을 정본에서 비울 측점 — 종류를 바꾼 관 자리(누가거리). */ diff --git a/B06_Section/B06_Section_Section_Adjusts.ts b/B06_Section/B06_Section_Section_Adjusts.ts index 7675413be..eed1cfbb7 100644 --- a/B06_Section/B06_Section_Section_Adjusts.ts +++ b/B06_Section/B06_Section_Section_Adjusts.ts @@ -10,10 +10,13 @@ * 흙막이 n 단(h · m · 구간). 예전에는 B06 보기가 열릴 때 폼이 되보내야만 얹혀 B05 만 연 면적이 달랐음. * 형상(관 세트 · 흙막이 세트 · 도수로 · 세월교 · BOX 제원)은 형상 얹기(`attachShapes`)가 이미 함. * 같은 규칙 = `B06_Section_UI_Page_Pipe_Options`(폼을 고친 그 자리 즉시 반영 몫). + * + * 조정창 값 주인 = 세션 칸(472-5 5-D) — 얹기 = 「조정창이 처음 고치기 전 설계 칸 + 지금 세션 칸」. ↶ ↷ 가 칸을 + * 지워도 캐시 설계(그림 · [저장] 바탕)에 옛 값이 안 남음. 상세가 새로 서면(받음 · [저장] · 떠남) 새 객체라 빔. * ========================================================================== */ import { readState } from "../A00_Common/b_page_state"; -import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { readBermDepths, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; import { layCarriedEdits, @@ -26,11 +29,31 @@ import { // 얹기 몸통 = `B06_Section_Route_Carry_Patch` 한 벌(서버 노선 바뀐 [저장] 넘김도 · 472-6) — 여기는 세션 겉. export { layPipeOptions, type OptionPipe }; +/** 조정창이 처음 고치기 전 설계 칸(측점 객체마다 · 칸 이름 → 값 · 다시 셈이 설계 객체를 갈아도 측점은 그대로). */ +const beforeAdjust = new WeakMap>(); + +/** 조정창이 캐시 설계 칸을 고치기 직전 — 그 칸의 처음 값을 한 번 떠 둠. */ +export function noteAdjustBase(section: CrossSection, keys: readonly string[]): void { + const design = section.design as unknown as Record | null | undefined; + if (!design) return; + const base = beforeAdjust.get(section) ?? {}; + for (const key of keys) if (!(key in base)) base[key] = structuredClone(design[key]); + beforeAdjust.set(section, base); +} + export function withDraftAdjusts( detail: SectionDetailResponse, projectId: string, routeId: number, ): void { + for (const section of detail.cross_sections) { + const base = beforeAdjust.get(section); + const design = section.design as unknown as Record | null | undefined; + if (!base || !design) continue; + for (const [key, value] of Object.entries(base)) + if (value === undefined) delete design[key]; + else design[key] = structuredClone(value); + } layDraftAdjusts(detail, (name) => readState(name as never, projectId, routeId) ?? {}); } diff --git a/B06_Section/B06_Section_Session_Cells.ts b/B06_Section/B06_Section_Session_Cells.ts new file mode 100644 index 000000000..16f945ec2 --- /dev/null +++ b/B06_Section/B06_Section_Session_Cells.ts @@ -0,0 +1,91 @@ +/* ============================================================================= + * B06_Section_Session_Cells.ts + * 조정창 값 창구(472-5 5-D) — 측점별 조작값(반폭 · 기슭막이 4축 · 유입 형식 · 집수정 · 단 수 · 단별 구간 · 연동 · + * 세월교 · BOX)의 주인은 **세션 칸 하나**. 메모리 사본 없음 — 읽을 때마다 칸을 풀고 쓸 때마다 칸에 씀(창구 = + * 등록부 `b_page_state` 열쇠 · 가게도 밑은 이것). 되돌리기 · 다시하기 · 측점 갈아끼움 이동(`Station_Keys`)이 칸을 + * 되써도 다음 조작이 옛 값으로 덮지 않음(N5). + * 가벼운 자리 — 등록부만 끎(세월교 · BOX 제어 · 시험 로더가 화면 줄기를 안 끌게). + * ========================================================================== */ + +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; + +/** 세션 칸 한 겹(열쇠 = 누가거리 0.01 m 글 · `:역할` · `b` 앞머리). */ +export interface SessionCells { + get(key: string): V | undefined; + has(key: string): boolean; + set(key: string, value: V): void; + delete(key: string): void; + clear(): void; + forEach(fn: (value: V, key: string) => void): void; + keys(): string[]; + /** 지금 칸 값(새 Map) — [저장] patch 재료. */ + entries(): Map; +} + +const isObject = (value: unknown): value is Record => + !!value && typeof value === "object"; + +function cellOf(key: string): Record { + try { + const parsed: unknown = JSON.parse(readByKey(key) ?? "null"); + return isObject(parsed) ? parsed : {}; + } catch { + return {}; // 손상된 세션 값 = 빈 칸(정본 · 기본값으로) + } +} + +/** + * 세션 칸 창구를 만듦. `accept` = 항목마다 검증 · 정규화(`undefined` = 버림 · 손상값 · 옛 꼴 정리 자리) · + * `part` = 칸 안 한 겹(연동 칸 `{ detached, grade }`). 빈 맵을 쓰면 칸 지움(272 · 연동은 두 겹 다 빌 때). + */ +export function sessionCells( + sessionKey: () => string | null, + accept: (value: unknown) => V | undefined, + part?: string, +): SessionCells { + const read = (): Map => { + const values = new Map(); + const key = sessionKey(); + if (!key) return values; + const whole = cellOf(key); + const cells = part ? whole[part] : whole; + if (!isObject(cells)) return values; + for (const [entry, value] of Object.entries(cells)) { + const taken = accept(value); + if (taken !== undefined) values.set(entry, taken); + } + return values; + }; + const write = (values: Map): void => { + const key = sessionKey(); + if (!key) return; + const cells = Object.fromEntries(values); + const whole = part ? { ...cellOf(key), [part]: cells } : cells; + const empty = part + ? Object.values(whole).every((v) => !isObject(v) || !Object.keys(v).length) + : !values.size; + try { + writeByKey(key, empty ? null : JSON.stringify(whole)); + } catch { + /* 세션 저장 실패(용량 초과 등) — 그 조작만 안 남음. */ + } + }; + const edit = (change: (values: Map) => void): void => { + const values = read(); + change(values); + write(values); + }; + return { + get: (key) => read().get(key), + has: (key) => read().has(key), + set: (key, value) => edit((values) => values.set(key, value)), + delete: (key) => { + const values = read(); + if (values.delete(key)) write(values); + }, + clear: () => write(new Map()), + forEach: (fn) => read().forEach(fn), + keys: () => [...read().keys()], + entries: read, + }; +} diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 333112dd5..29f550e10 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -330,11 +330,10 @@ export function routeCarryInputs(projectId: string, fromRouteId: number): Record } /** - * 측점이 움직임(갈아끼움 · 옛 0.01 m 글 → 새 글) — 조정창 맵이 안 선 누가거리 칸(B05 만 연 페이지 · 새로고침 · - * 세월 · BOX · 연동)을 세션에서 바로 새 자리로(415-3). 칸 = `ROUTE_CARRY_STATES`(측점 열쇠 칸 빼고) · 조정창 - * 맵이 이번에 옮긴 칸(`owned`)은 그대로. + * 측점이 움직임(갈아끼움 · 옛 0.01 m 글 → 새 글) — 누가거리 칸(조정창 값 · 메모 · 상세 표 등)을 세션에서 바로 + * 새 자리로(415-3 · 조정창 값 주인 = 세션 칸 · 472-5 5-D). 칸 = `ROUTE_CARRY_STATES`(측점 열쇠 칸 빼고). */ -function moveIdleStates(moves: ReadonlyMap, owned: ReadonlySet): void { +function moveIdleStates(moves: ReadonlyMap): void { if (!moves.size || !current) return; const { projectId, routeId } = current; const move = (cells: Record) => @@ -344,7 +343,7 @@ function moveIdleStates(moves: ReadonlyMap, owned: ReadonlySet>(name, projectId, routeId); if (!raw || typeof raw !== "object") continue; // 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹 @@ -363,4 +362,4 @@ function moveIdleStates(moves: ReadonlyMap, owned: ReadonlySet moveIdleStates(moves, owned)); +afterAnySwap((_detail, moves) => moveIdleStates(moves)); diff --git a/B06_Section/B06_Section_Swap_Bus.ts b/B06_Section/B06_Section_Swap_Bus.ts index 4255493a1..11eb8ab63 100644 --- a/B06_Section/B06_Section_Swap_Bus.ts +++ b/B06_Section/B06_Section_Swap_Bus.ts @@ -1,7 +1,7 @@ /* ============================================================================= * B06_Section_Swap_Bus.ts * 상세 갈아끼움 알림판(PLAN 346-2) — 측점 수가 바뀌어 `Section_Store` 가 상세 객체를 갈아끼우면 여기로 알림. - * 가져오는 것 없음 — 세션 조작값 맵(`_UI_Page_Link_Session`) · 암 경계 저장소(`_UI_Page_Persist`)가 모듈을 읽을 때 + * 가져오는 것 없음 — 측점 열쇠(`Station_Keys`) · 암 경계 저장소(`_UI_Page_Persist`)가 모듈을 읽을 때 * 붙어도 `Section_Store` 와 돌고 도는 가져오기가 안 생김. * ========================================================================== */ @@ -21,37 +21,16 @@ export function onAnySwap(fn: DetailListener): () => void { return () => anyListeners.delete(fn); } -/** 알림 한 번 동안 조정창 맵이 옮긴 세션 칸(열쇠 글) — 뒤 차례(`afterAnySwap`)는 그 칸을 다시 안 옮김(415-3). */ -const moved = new Set(); -const afterListeners = new Set< - ( - detail: SectionDetailResponse, - moves: ReadonlyMap, - owned: ReadonlySet, - ) => void ->(); +const afterListeners = new Set(); -/** 조정창 맵이 세션 칸을 옮겼다고 적음(`_UI_Page_Link_Session`). */ -export function markMovedStates(keys: Iterable): void { - for (const key of keys) moved.add(key); -} - -/** 갈아끼움 알림이 다 돈 뒤 부름 — `owned` = 그 사이 조정창 맵이 옮긴 세션 칸. */ -export function afterAnySwap( - fn: ( - detail: SectionDetailResponse, - moves: ReadonlyMap, - owned: ReadonlySet, - ) => void, -): () => void { +/** 갈아끼움 알림이 다 돈 뒤 부름(세션 누가거리 칸 옮김 · `Station_Keys`). */ +export function afterAnySwap(fn: DetailListener): () => void { afterListeners.add(fn); return () => afterListeners.delete(fn); } /** 갈아끼운 쪽(`Section_Store`)이 부름. */ export function emitSwap(detail: SectionDetailResponse, moves: ReadonlyMap): void { - moved.clear(); anyListeners.forEach((fn) => fn(detail, moves)); - const owned = new Set(moved); - afterListeners.forEach((fn) => fn(detail, moves, owned)); + afterListeners.forEach((fn) => fn(detail, moves)); } diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 495de821e..ff7c994eb 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -3,7 +3,7 @@ * 세월교 측벽·BOX암거 구체 조작값 제어 — 측점 제어기 * (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25). * - * 데이터 흐름은 집수정과 같다: 세션 사본 + 캐시 `design.ford_adjust`에 함께 실어 + * 데이터 흐름은 집수정과 같다: 세션 칸(값 주인 · 472-5 5-D) + 캐시 `design.ford_adjust`에 함께 실어 * 3D(코리도)가 같은 값으로 다시 그리게 한다. 관경·수량·날개벽은 폼이 관 초안(가게 pipes) * 한 벌로 냄 — 여기는 캐시만 고침(444 · 조정 예약 culvertopt 에 또 담으면 옮긴 관 옛 자리에 남음). * ========================================================================== */ @@ -21,7 +21,7 @@ import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box"; import type { BoxAdjust, BoxPick, BoxSideAdjust } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; -import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { sessionCells } from "./B06_Section_Session_Cells"; /** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */ type WingPatch = Partial<{ @@ -116,38 +116,18 @@ export interface FordControls { control: FordControl; /** 확정 payload용 — 측점별 조작값. */ byChainage: () => Map; - /** 노선이 바뀔 때 세션 값을 다시 읽는다. */ - load: () => void; } export function createFordControls(deps: FordControlDeps): FordControls { const { round1, clampMove, sectionAt, patchCachedDesign } = deps; /* ── 세월교 측벽 조작(2026-08-25 사용자) ─────────────────────────────── - * 축은 집수정과 같다(높이·좌우·상하 대각). 값은 세션 사본 + 캐시 design에 함께 + * 축은 집수정과 같다(높이·좌우·상하 대각). 값은 세션 칸 + 캐시 design에 함께 * 실어 3D가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본이 원천이라 * 배수관 구간값과 같은 저장기(`pipe_points` 되쓰기)로 보낸다. */ - const fordAdjustments = new Map(); + const fordAdjustments = sessionCells(deps.sessionKey, (value) => + fordAdjustOf(value as Partial | null), + ); const fordSelections = new Map(); - const fordSessionKey = (): string | null => deps.sessionKey(); - - function loadFordAdjustments(): void { - fordAdjustments.clear(); - const key = fordSessionKey(); - if (!key) return; - try { - const parsed = JSON.parse(readByKey(key) ?? "{}") as Record; - Object.entries(parsed).forEach(([chainage, value]) => - fordAdjustments.set(chainage, fordAdjustOf(value)), - ); - } catch { - /* 손상된 세션 값은 기본값으로 대체. */ - } - } - - function persistFordAdjustments(): void { - const key = fordSessionKey(); - if (key) writeByKey(key, JSON.stringify(Object.fromEntries(fordAdjustments))); - } // 옛 저장(측벽마다 높이 · 대각)은 읽을 때 바닥판 내림 하나로 옮김(139-3 · `fordDropOf`) const fordAdjustAt = (chainageM: number): FordAdjust => { @@ -165,7 +145,6 @@ export function createFordControls(deps: FordControlDeps): FordControls { const writeFord = (chainageM: number, next: FordAdjust): void => { fordAdjustments.set(chainageM.toFixed(2), next); - persistFordAdjustments(); patchCachedDesign(chainageM, { ford_adjust: next }); deps.refreshCard(chainageM); }; @@ -234,7 +213,6 @@ export function createFordControls(deps: FordControlDeps): FordControls { }); return result; }, - load: loadFordAdjustments, }; } @@ -313,40 +291,24 @@ export function applyBoxFormOptions( } /** - * BOX암거 구체 조작값 제어 — 세월교와 같은 흐름(세션 사본 + 캐시 `design.box_adjust`). + * BOX암거 구체 조작값 제어 — 세월교와 같은 흐름(세션 칸 + 캐시 `design.box_adjust`). * 좌·우 끝을 따로 잡으며 길이는 바깥으로만, 표고는 양방향으로 움직인다. */ export function createBoxControls(deps: FordControlDeps): { control: BoxControl; byChainage: () => Map; - load: () => void; } { const { round1, clampMove, sectionAt, patchCachedDesign } = deps; - const adjustments = new Map(); + const adjustments = sessionCells(deps.sessionKey, (value): BoxAdjust | undefined => { + if (!value || typeof value !== "object") return undefined; + const sides = value as Partial; + return { + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right }, + }; + }); const selections = new Map(); - function load(): void { - adjustments.clear(); - const key = deps.sessionKey(); - if (!key) return; - try { - const parsed = JSON.parse(readByKey(key) ?? "{}") as Record; - Object.entries(parsed).forEach(([chainage, value]) => - adjustments.set(chainage, { - left: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.left }, - right: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.right }, - }), - ); - } catch { - /* 손상된 세션 값은 기본값으로 대체. */ - } - } - - function persist(): void { - const key = deps.sessionKey(); - if (key) writeByKey(key, JSON.stringify(Object.fromEntries(adjustments))); - } - const adjustAt = (chainageM: number): BoxAdjust => { const stored = sectionAt(chainageM)?.design?.box_adjust; return ( @@ -359,7 +321,6 @@ export function createBoxControls(deps: FordControlDeps): { const write = (chainageM: number, next: BoxAdjust): void => { adjustments.set(chainageM.toFixed(2), next); - persist(); patchCachedDesign(chainageM, { box_adjust: next }); deps.refreshCard(chainageM); }; @@ -430,7 +391,6 @@ export function createBoxControls(deps: FordControlDeps): { }); return result; }, - load, }; } diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index ffefdcafb..9825a3885 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -1,12 +1,8 @@ /* ============================================================================= * B06_Section_UI_Page_Link_Session.ts - * 연동 기슭막이 옵션(연동 해제·종단경사 반영)의 **세션 보관** — 측점 제어기 - * (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25). - * 두 플래그는 한 세션 항목에 같이 담아 확정 전 리로드에도 살아남는다. - * - * 측점별 조작값을 담는 **한 겹 맵**도 여기서 낸다(`createSessionMap`, 2026-09-02) — - * 반폭·기슭막이 4축·유입 형식·집수정·추가 벽 수·단별 구간값이 같은 모양(읽기 → 항목 - * 검증 → 담기 / 쓰기 → 통째 직렬화)이라 제어기마다 짝을 두던 것을 하나로 모았다. + * 연동 기슭막이 거들기 — 측점 제어기(`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리(2026-08-25). + * 저장 묶음 접기(세션 칸 → 측점별 patch 재료) · 연동 풀 때 굳히기 · 좌측 관 보호공 [연동] · [경사] 다리. + * 조작값 칸 창구 = `B06_Section_Session_Cells`(메모리 맵 없음 · 472-5 5-D). * ========================================================================== */ import type { CrossSection, StoredWallSpan } from "./B06_Section_Api_Fetch"; @@ -27,138 +23,9 @@ import { } from "./B06_Section_UI_Cross_Culvert_Wire"; 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 { markMovedStates, onAnySwap } from "./B06_Section_Swap_Bus"; -import { readByKey, writeByKey } from "../A00_Common/b_page_state"; - -/** 세션에 담기는 측점별 조작값 한 겹 맵. */ -export interface SessionMap { - values: Map; - /** 세션값 읽기 — 맵을 비우고 다시 채운다. */ - load: () => void; - /** 맵을 통째로 세션에 쓴다 — 빈 맵이면 칸을 지운다. */ - persist: () => void; -} - -/** - * 세션 보관 맵을 만든다. `accept` 가 항목마다 값을 검증·정규화해 돌려주고, - * `undefined` 를 내면 그 항목은 버린다(손상값·옛 형식 정리 자리). - */ -/** 만든 조작값 맵 → 세션 열쇠 — 측점 목록을 갈아끼우면 누가거리 열쇠를 새 자리로 옮김(346-2 4파). */ -const alive = new Map, () => string | null>(); - -/** - * 측점이 움직임(이어 받은 측점의 누가거리가 바뀜 · `옛 0.01 m 글 → 새 글`) — 조정창 값(반폭 · 기슭막이 · - * 유입 형식 · 집수정 · 추가 벽 · 단별 구간값)을 새 자리로 옮겨 세션에 씀. 빠진 측점 값은 그대로 둠. - */ -export function moveSessionMaps(moves: ReadonlyMap): void { - if (!moves.size) return; - // 같은 세션 칸은 마지막에 만든 맵 하나만(떠난 보기의 옛 맵이 지금 값을 덮지 않게) - const latest = new Map>(); - alive.forEach((key, map) => { - const at = key(); - if (at) latest.set(at, map); - }); - for (const map of latest.values()) { - map.load(); - const next = renameChainageKeys(map.values, moves); - map.values.clear(); - next.forEach((value, key) => map.values.set(key, value)); - map.persist(); - } - markMovedStates(latest.keys()); // 나머지 누가거리 칸은 `Station_Keys` 가 옮김(415-3) -} - -// 상세 갈아끼움(어느 보기가 했든)마다 한 번 — 조정창 값을 옮긴 측점 자리로 -onAnySwap((_detail, moves) => moveSessionMaps(moves)); - -export function createSessionMap( - sessionKey: () => string | null, - accept: (value: unknown) => V | undefined, -): SessionMap { - const values = new Map(); - const map: SessionMap = { - values, - load(): void { - values.clear(); - const key = sessionKey(); - if (!key) return; - try { - const raw = readByKey(key); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - Object.entries(parsed).forEach(([entry, value]) => { - const taken = accept(value); - if (taken !== undefined) values.set(entry, taken); - }); - } catch { - /* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */ - } - }, - persist(): void { - const key = sessionKey(); - if (!key) return; - try { - // 빈 맵 = 칸 지움(272) — 「{}」 를 남기면 사진첩이 걸음으로 봄 - writeByKey(key, values.size ? JSON.stringify(Object.fromEntries(values)) : null); - } catch { - /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ - } - }, - }; - alive.set(map as SessionMap, sessionKey); - return map; -} - -export interface LinkFlagMaps { - detached: Map; - followGrade: Map; - load: () => void; - persist: () => void; -} - -export function createLinkFlagSession(sessionKey: () => string | null): LinkFlagMaps { - const detached = new Map(); - const followGrade = new Map(); - return { - detached, - followGrade, - load(): void { - detached.clear(); - followGrade.clear(); - const key = sessionKey(); - if (!key) return; - try { - const raw = readByKey(key); - if (!raw) return; - const parsed = JSON.parse(raw) as { - detached?: Record; - grade?: Record; - }; - Object.entries(parsed.detached ?? {}).forEach(([at, value]) => detached.set(at, !!value)); - Object.entries(parsed.grade ?? {}).forEach(([at, value]) => followGrade.set(at, !!value)); - } catch { - /* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */ - } - }, - persist(): void { - const key = sessionKey(); - if (!key) return; - writeByKey( - key, - detached.size || followGrade.size - ? JSON.stringify({ - detached: Object.fromEntries(detached), - grade: Object.fromEntries(followGrade), - }) - : null, // 둘 다 비면 칸 지움(272) - ); - }, - }; -} /* ── 확정 · 임시저장 payload — 세션 키(누가거리 · `:역할` · `b` 앞머리)를 측점별로 되접음 ── */ -type Each = Pick, "forEach">; +type Each = { forEach(fn: (value: V, key: string) => void): void }; /** 측점별 기슭막이 4축 조작값(역할 → 값 · null = 되돌림). */ export function foldRevetAdjusts( diff --git a/B06_Section/B06_Section_UI_Page_Span_Control.ts b/B06_Section/B06_Section_UI_Page_Span_Control.ts index 757789a8f..1f9117023 100644 --- a/B06_Section/B06_Section_UI_Page_Span_Control.ts +++ b/B06_Section/B06_Section_UI_Page_Span_Control.ts @@ -3,7 +3,7 @@ * 구조물 **구간값(길이·전/후)** 제어 — 다단 단별 구간값과 유입·유출·집수정 구간값. * * `B06_Section_UI_Page_Station_Controls` 에서 떼어냈다(700줄 제한, 2026-09-02). - * 세션 맵(`extraSpans`)과 캐시(design)를 함께 만지는 자리라 한 덩어리로 옮겼다. + * 세션 칸(`extraSpans` · 값 주인 · 472-5 5-D)과 캐시(design)를 함께 만지는 자리라 한 덩어리로 옮겼다. * 기준벽 · 집수정 구간값은 폼 칸(관 초안)이 정본 — 여기는 읽기만(433-19 · 444). * ========================================================================== */ @@ -14,6 +14,7 @@ import { type StructureSpanControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; import * as CulvertConst from "./logic/B06_Section_UI_Cross_Culvert_Const"; +import type { SessionCells } from "./B06_Section_Session_Cells"; /** 구간값 제어가 페이지·상위 제어기에서 받아 쓰는 창구. */ export interface SpanControlDeps { @@ -21,9 +22,8 @@ export interface SpanControlDeps { refreshCard: (chainageM: number) => void; /** 연동 구조물의 소유 측점 — 값은 소유 측점 하나에만 담는다. */ ownerOf: (section: CrossSection) => CrossSection | null; - /** 세션 맵(누가거리:벽키 → 구간값)과 저장. */ - extraSpans: Map; - persistExtraSpans: () => void; + /** 세션 칸(누가거리:벽키 → 구간값) — 읽고 쓰면 곧 칸. */ + extraSpans: SessionCells; patchCachedDesign: (chainageM: number, patch: Partial) => void; } @@ -34,7 +34,7 @@ export function createSpanControl(deps: SpanControlDeps): { tierSpansOf: (owner: CrossSection) => Record; spanKeyOf: (chainageM: number, wall: string) => string; } { - const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf } = deps; + const { extraSpans, patchCachedDesign, ownerOf } = deps; const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`; const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => { @@ -74,7 +74,6 @@ export function createSpanControl(deps: SpanControlDeps): { const current = extraSpans.get(mapKey) ?? storedTierSpan(owner, key); const next = CulvertConst.applySpanPatch(current, patch); extraSpans.set(mapKey, next); - persistExtraSpans(); // 캐시(design)에도 얹는다 — 링크 판정·3D가 순수 함수로 이 값을 읽는다. patchCachedDesign(owner.chainage_m, { extra_spans: tierSpansOf(owner) }); // 연장이 바뀌면 링크되는 옆 측점이 달라진다 — 옛 연장·새 연장을 합친 구간만. diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index d23e749c1..350f3e6cb 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -27,14 +27,16 @@ import { culvertOwnerFor, culvertReach, wallStandsAt } from "./B06_Section_UI_Cr import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options"; import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls"; import { - createLinkFlagSession, - createSessionMap, foldExtraCounts, foldExtraSpans, foldLinkFlags, foldRevetAdjusts, freezeLinkedWalls, } from "./B06_Section_UI_Page_Link_Session"; +import { sessionCells } from "./B06_Section_Session_Cells"; +import { noteAdjustBase } from "./B06_Section_Section_Adjusts"; +import { readByKey } from "../A00_Common/b_page_state"; +import { onPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History"; import { createSpanControl } from "./B06_Section_UI_Page_Span_Control"; import type { FordAdjust } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; @@ -75,7 +77,7 @@ export interface StationControlDeps { onWallAdjust?: () => void; } -/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ +/** 반폭·기슭막이·유입 구조물 제어 묶음. 값 = 세션 칸(읽을 때마다 · 472-5 5-D) · `load` = 경로가 바뀔 때 고름 풂. */ export interface StationControls { stationWidth: StationWidthControl; revetOffset: RevetOffsetControl; @@ -90,9 +92,10 @@ export interface StationControls { /** 확정 payload용 — 측점별 세월교·BOX암거 조작값. */ fordAdjustsByChainage: () => Map; boxAdjustsByChainage: () => Map; - widths: Map; - inletStructures: Map; - basinAdjustments: Map; + /** 확정 payload용 — 지금 세션 칸 값(새 Map). */ + widths: () => Map; + inletStructures: () => Map; + basinAdjustments: () => Map; /** 확정 payload용 — 측점별 기슭막이 4축 조작값(역할 → 값 · null = 되돌림). */ revetAdjustsByChainage: () => Map>; /** 확정 payload용 — 측점별 다단 단 수(세션에 있는 쪽만). */ @@ -106,6 +109,8 @@ export interface StationControls { /** 관 옵션 조각을 예약한다 — 좌측 폼의 [수정]도 이 경로로 정본에 간다(2026-08-29). */ queueCulvertOptions: (chainageM: number, patch: Record) => void; load: () => void; + /** ↶ ↷ 가 조정창 칸을 바꿨으면 부름(472-5 5-D · 캐시 · 카드 · 면적 다시) — 돌려준 함수로 끊음. */ + onRestore: (fn: () => void) => () => void; /** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */ applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void; } @@ -121,10 +126,13 @@ export function createStationControls(deps: StationControlDeps): StationControls .detail() ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01); - /** design이 있는 측점에만 얹는다 — 없는 측점은 아직 설계 전이라 실을 자리가 없다. */ + /** design이 있는 측점에만 얹는다 — 없는 측점은 아직 설계 전이라 실을 자리가 없다. 고치기 전 칸을 떠 둠 + * (↶ ↷ 뒤 얹기 = 그 값 + 지금 세션 칸 · 472-5 5-D). */ const patchCachedDesign = (chainageM: number, patch: Partial): void => { - const design = sectionAt(chainageM)?.design; - if (design) Object.assign(design, patch); + const section = sectionAt(chainageM); + if (!section?.design) return; + noteAdjustBase(section, Object.keys(patch)); + Object.assign(section.design, patch); }; /* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ────────────────────── @@ -132,14 +140,11 @@ export function createStationControls(deps: StationControlDeps): StationControls * cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다. * 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */ // 0 = 되돌림(빈 전체 폭 · 저장분 개별 폭을 지움 · 445 M2) - const widthSession = createSessionMap( + const stationWidths = sessionCells( () => deps.sessionKey("crossw"), (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined, ); - const stationWidths = widthSession.values; - const loadStationWidths = widthSession.load; - const persistStationWidths = widthSession.persist; const widthKey = (chainageM: number): string => chainageM.toFixed(2); const clampStationWidth = (value: number): number => Math.max(Math.round(value), 2); @@ -155,7 +160,6 @@ export function createStationControls(deps: StationControlDeps): StationControls const width = clampStationWidth(widthM); const apply = (): void => { stationWidths.set(widthKey(chainageM), width); - persistStationWidths(); deps.refreshCard(chainageM); }; // 보유 샘플 안이면 표시만 바꾸면 된다 — 캐시(계산 20m)를 그대로 쓴다. @@ -175,7 +179,7 @@ export function createStationControls(deps: StationControlDeps): StationControls * 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`. * 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */ // null = 되돌림(저장분 그 벽을 지움 · 다시 열어도 안 살아남 · 445 M2) - const revetSession = createSessionMap( + const revetShifts = sessionCells( () => deps.sessionKey("revetx"), (value) => { if (value === null) return null; @@ -188,18 +192,11 @@ export function createStationControls(deps: StationControlDeps): StationControls return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null }; }, ); - const revetShifts = revetSession.values; /** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편: * 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). `shape` = 「관」이 물넘이 콘크리트(307). */ let revetSelection: { at: string; key: RevetPick; shape?: boolean } | null = null; const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`; - function loadRevetShifts(): void { - revetSession.load(); - revetSelection = null; - } - const persistRevetShifts = revetSession.persist; - /** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */ const round1 = (value: number): number => Math.round(value * 10) / 10; const clampMove = (value: number): number => Math.min(Math.max(round1(value), -10), 10); @@ -209,7 +206,6 @@ export function createStationControls(deps: StationControlDeps): StationControls Math.abs(value.x) < 1e-9 && value.d == null && value.h == null && value.m == null; const storeAdjust = (key: string, value: WallAdjust): void => { revetShifts.set(key, isDefaultAdjust(value) ? null : value); - persistRevetShifts(); // 같은 값을 캐시(design.revet_adjust)에도 실어 3D가 바로 따라오게 한다. const [chainage, role] = key.split(":"); const at = Number(chainage); @@ -219,7 +215,7 @@ export function createStationControls(deps: StationControlDeps): StationControls const bucket = { ...(design.revet_adjust ?? {}) }; if (isDefaultAdjust(value)) delete bucket[role]; else bucket[role] = { ...value }; - design.revet_adjust = bucket; + patchCachedDesign(at, { revet_adjust: bucket }); deps.onWallAdjust?.(); }; const axisEq = (a: number | null, b: number | null): boolean => @@ -320,7 +316,7 @@ export function createStationControls(deps: StationControlDeps): StationControls }; /* ── 유입측 구조물 형식(2026-08-22 사용자 — 드롭다운) ──────────────── * auto(규칙)/revet(기슭막이+배관)/I/L/U(집수정 형식). 세션에만 담는다. */ - const basinSession = createSessionMap( + const basinAdjustments = sessionCells( () => deps.sessionKey("basinadjust"), (value) => { if (!value || typeof value !== "object") return undefined; @@ -331,30 +327,21 @@ export function createStationControls(deps: StationControlDeps): StationControls return adjust; }, ); - const structSession = createSessionMap( + const inletStructures = sessionCells( () => deps.sessionKey("inletstruct"), (value) => typeof value === "string" && ["auto", "revet", "I", "L", "U"].includes(value) ? (value as InletStructureChoice) : undefined, ); - const inletStructures = structSession.values; - const basinAdjustments = basinSession.values; - const loadBasinAdjustments = basinSession.load; - const persistBasinAdjustments = basinSession.persist; - const loadInletStructures = structSession.load; - const persistInletStructures = structSession.persist; /* ── 유출측 추가 기슭막이 개수(2026-08-22 사용자 — 성토부 5m 이상 계단식) ── * 측점별 개수만 세션에 담는다. 각 벽의 이동량은 revetShifts에 `extra{n}` 키로. */ // 0 = 되돌림(저장분 단 수를 지움 · 445 M2) - const extraSession = createSessionMap( + const extraCounts = sessionCells( () => deps.sessionKey("extrawall"), (value) => (Number.isInteger(value) && (value as number) >= 0 ? (value as number) : undefined), ); - const extraCounts = extraSession.values; - const loadExtraCounts = extraSession.load; - const persistExtraCounts = extraSession.persist; const pendingEqualize = new Set(); @@ -394,9 +381,6 @@ export function createStationControls(deps: StationControlDeps): StationControls revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey)); extraSpans.delete(spanKeyOf(chainageM, `${extraPrefix(side)}${i}`)); } - persistRevetShifts(); - persistExtraSpans(); - persistExtraCounts(); patchCachedDesign(chainageM, { extra_wall_counts: countsWith(chainageM, side, count) }); refreshWithLinked(chainageM); deps.onExtraCount?.(); @@ -412,9 +396,6 @@ export function createStationControls(deps: StationControlDeps): StationControls revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey)); extraSpans.delete(spanKeyOf(chainageM, `${extraPrefix(side)}${i}`)); } - persistRevetShifts(); - persistExtraSpans(); - persistExtraCounts(); // 캐시 patch 도 세운 만큼으로 — 세션 키를 지우면 `countFor` 가 옛 patch 수로 돌아감(54-1) patchCachedDesign(chainageM, { extra_wall_counts: countsWith(chainageM, side, Math.max(built, 0)), @@ -435,7 +416,6 @@ export function createStationControls(deps: StationControlDeps): StationControls }, set: (chainageM, value) => { inletStructures.set(chainageM.toFixed(2), value); - persistInletStructures(); patchCachedDesign(chainageM, { inlet_structure: value }); refreshWithLinked(chainageM); }, @@ -449,13 +429,11 @@ export function createStationControls(deps: StationControlDeps): StationControls lateralM: Math.max(0, clampMove(next.lateralM)), slopeM: Math.max(0, clampMove(next.slopeM)), }); - persistBasinAdjustments(); patchCachedDesign(chainageM, { basin_adjust: basinAdjustments.get(key) }); refreshWithLinked(chainageM); }, resetAdjust: (chainageM) => { basinAdjustments.set(chainageM.toFixed(2), { ...DEFAULT_BASIN_ADJUST }); - persistBasinAdjustments(); patchCachedDesign(chainageM, { basin_adjust: { ...DEFAULT_BASIN_ADJUST } }); refreshWithLinked(chainageM); }, @@ -471,12 +449,11 @@ export function createStationControls(deps: StationControlDeps): StationControls () => deps.sessionKey("culvertopt"), deps.onSaveError, ); - const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink")); - const linkDetached = linkSession.detached; - const followGrades = linkSession.followGrade; + // 연동 칸 = { detached: {누가거리: …}, grade: {…} } 두 겹 + const flag = (value: unknown): boolean => !!value; + const linkDetached = sessionCells(() => deps.sessionKey("revetlink"), flag, "detached"); + const followGrades = sessionCells(() => deps.sessionKey("revetlink"), flag, "grade"); const chainageKey = (chainageM: number): string => chainageM.toFixed(2); - const loadLinkFlags = linkSession.load; - const persistLinkFlags = linkSession.persist; /** 이 카드가 구간값을 만질 대상 — 링크면 소유 측점, 아니면 자기 자신. */ const ownerOf = (section: CrossSection): CrossSection | null => { @@ -489,7 +466,7 @@ export function createStationControls(deps: StationControlDeps): StationControls * 기준벽 연장에 종속시키지 않는다 — 계곡부·능선부에서 아래 단일수록 연장이 달라져 * 자동 규칙으로 못 잡는다. 제어는 세션이고 [저장]·[확정] 때 정본으로 간다 * (4축 조작값과 같은 경로). 키는 `누가거리:벽키`(예 `234.10:extra0`). */ - const extraSpanSession = createSessionMap( + const extraSpans = sessionCells( () => deps.sessionKey("extraspan"), (value) => { const span = value as SpanValues | null; @@ -498,16 +475,12 @@ export function createStationControls(deps: StationControlDeps): StationControls : undefined; }, ); - const extraSpans = extraSpanSession.values; - const loadExtraSpans = extraSpanSession.load; - const persistExtraSpans = extraSpanSession.persist; const spanControl = createSpanControl({ detail: () => deps.detail(), refreshCard: (chainageM) => deps.refreshCard(chainageM), ownerOf, extraSpans, - persistExtraSpans, patchCachedDesign, }); const structureSpanControl = spanControl.control; @@ -532,10 +505,9 @@ export function createStationControls(deps: StationControlDeps): StationControls for (const role of Object.keys(design?.revet_adjust ?? {})) roles.push(`${prefix}${role}`); // 저장분 굳힌 값도 되돌림 표시(null · 445 M2) for (const key of roles) if (key.slice(prefix.length) !== "own") revetShifts.set(key, null); - persistRevetShifts(); if (!design?.revet_adjust) return; const own = design.revet_adjust.own; - design.revet_adjust = own ? { own } : {}; + patchCachedDesign(chainageM, { revet_adjust: own ? { own } : {} }); }; const revetLinkControl: RevetLinkControl = { @@ -559,7 +531,6 @@ export function createStationControls(deps: StationControlDeps): StationControls revetLink: revetLinkControl, }); linkDetached.set(chainageKey(chainageM), !linked); - persistLinkFlags(); patchCachedDesign(chainageM, { revet_link_detached: !linked }); deps.refreshCard(chainageM); }, @@ -582,7 +553,6 @@ export function createStationControls(deps: StationControlDeps): StationControls } deps.refreshCard(other.chainage_m); } - persistLinkFlags(); }, }; @@ -614,9 +584,9 @@ export function createStationControls(deps: StationControlDeps): StationControls box: bodyControls.box.control, fordAdjustsByChainage: bodyControls.ford.byChainage, boxAdjustsByChainage: bodyControls.box.byChainage, - widths: stationWidths, - inletStructures, - basinAdjustments, + widths: stationWidths.entries, + inletStructures: inletStructures.entries, + basinAdjustments: basinAdjustments.entries, // 확정·임시저장 payload용 — 세션 키를 측점별로 되접음(`_Link_Session`) revetAdjustsByChainage: () => foldRevetAdjusts(revetShifts), linkFlagsByChainage: () => foldLinkFlags(linkDetached, followGrades), @@ -624,23 +594,37 @@ export function createStationControls(deps: StationControlDeps): StationControls extraSpansByChainage: () => foldExtraSpans(extraSpans), flushCulvertOptions: () => culvertOptions.flush(), queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch), - load: () => { - loadStationWidths(); - loadRevetShifts(); - loadInletStructures(); - loadBasinAdjustments(); - loadExtraCounts(); - loadExtraSpans(); - loadLinkFlags(); - bodyControls.ford.load(); - bodyControls.box.load(); + load: () => void (revetSelection = null), + onRestore: (fn) => { + const kinds = [ + "crossw", + "revetx", + "inletstruct", + "basinadjust", + "extrawall", + "extraspan", + "revetlink", + "fordadjust", + "boxadjust", + ] as const; + const print = (): string => + JSON.stringify( + kinds.map((kind) => { + const key = deps.sessionKey(kind); + return key ? readByKey(key) : null; + }), + ); + let seen = print(); // 마지막 걸음(찍음 · 되돌림) 때 칸 — 되돌림이 이 칸을 바꿨나만 가름 + return onPageHistory({ + restore: () => void (print() !== seen && fn()), + change: () => void (seen = print()), + }); }, applyGlobalWidth: (requested, chainages) => { stationWidths.clear(); // 빈 전체 폭 = 0(되돌림 — 저장분 개별 폭도 지움 · 445 M2) const width = requested === undefined ? 0 : clampStationWidth(requested); for (const chainageM of chainages) stationWidths.set(widthKey(chainageM), width); - persistStationWidths(); }, }; } diff --git a/docs/wiki/concepts/design_page/index.md b/docs/wiki/concepts/design_page/index.md index eb0684d90..42e930e0f 100644 --- a/docs/wiki/concepts/design_page/index.md +++ b/docs/wiki/concepts/design_page/index.md @@ -30,7 +30,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ ``` 사용자 조작(끌기 · 단추 · 칸) ▼ - ① 화면 메모리 — 편집기 · 배수판 · 보기 구조물 · B06 조정 맵 · 시설 줄 + ① 화면 메모리 — 편집기 · 배수판 · 보기 구조물 · 시설 줄 ② 상세 가게 — Section_Store cache(페이지 수명) · 정본 사본 canon(세션) ③ 설계 가게 design_store — stations · profile · footprint 는 메모리 우선(멈춘 뒤 1 s 세션) ④ 세션 sessionStorage — aislo:{통}:{이름}:{프로젝트}[:{노선}] · draft · result · pref(localStorage) @@ -47,7 +47,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 층 | 위치(파일:줄) | 역할 · 비우는 때 | |---|---|---| -| ① 화면 메모리 | `B05_Profile_UI_RouteEdit.ts:124-145` · `B05_Profile_UI_Drainage_Panel.ts:123 · 142` · `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175-176` · `B06_Section_UI_Page_Link_Session.ts:47` · `design_facility_lane.ts:95-105` | 조작 즉시 · 페이지 dispose 로 버림 | +| ① 화면 메모리 | `B05_Profile_UI_RouteEdit.ts:124-145` · `B05_Profile_UI_Drainage_Panel.ts:123 · 142` · `B05_Profile_UI_Page_Structures.ts:78` · `B06_Section_UI_Page_Structures_Panel.ts:175-176` · `design_facility_lane.ts:95-105` | 조작 즉시 · 페이지 dispose 로 버림 | | ② 상세 가게 | `B06_Section_Section_Store.ts:68` cache · `:69` pending · `:117` loadSectionDetail · `:431` peekSectionDetail | 상세 횡단(살아 있는 객체) · 떠남 `forgetSectionMemory`(`:424`) · 정본 바뀜 `invalidateSectionDetail`(`:403`) | | ② 정본 사본 · 상세 칸 | `B06_Section_Section_Canon.ts:36` keepCanon · `:59` sessionDetail · `:72` savedDesigns · `:84` forgetCanon · `:107-143` 칸 셋(저장 정본 · 초안 전 hold · 노선 초안 · 472-5) | 서버 정본 설계 · 자리 · 표본 · invalidate 때 비움 · 칸 셋 = 떠남 · invalidate 때 비움 | | ③ 설계 가게 | `design_store.ts:226` useDesignStore · `:151` 메모리 칸 · `:160` 1 s | 읽고 쓰는 창구 · 쓰면 듣는 쪽 부름 | diff --git a/docs/wiki/concepts/design_page/operations_b06.md b/docs/wiki/concepts/design_page/operations_b06.md index 9fe64906c..37580f494 100644 --- a/docs/wiki/concepts/design_page/operations_b06.md +++ b/docs/wiki/concepts/design_page/operations_b06.md @@ -21,7 +21,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 소단 구간(구조물) | 구조물 → 시설 줄 `syncBerm` 이 세션 `berm`(같으면 안 씀 · 쓰면 cross 한 번 · B05 만 열어도) | 횡단 다시 셈 · 사본 없으면 저장분 `design.berm` | — | 서버가 `structures.json` 에서(`load_berm_spans`) | 6 | `design_facility_lane.ts:151-166` · `B06_Section_Cross_Refresh.ts:362-368 · 521-522` · `B06_Section_Engine_Structures_Wall.py:124` | | 소단 깊이 ▲▼ | 세션 `bermdepth` | 횡단 다시 셈 | — | 횡단 패치 | 5 | `B06_Section_UI_Cross_Design_Pick.ts:408` · `B06_Section_UI_Page_Design_Sync.ts:78-83` · `B06_Section_Cross_Refresh.ts:513-519` | | 암선 · 암 경사 | 메모리 맵 + 세션 `rockb` · `cutslope` | 횡단 다시 셈(암 측점 2단 무릎 · 면적) | — | 횡단 패치 · 파이썬 설계선 | 5 | `B06_Section_UI_Cross_Line_Cell.ts:161 · 166` · `B06_Section_UI_Cross_CutSlope.ts:94 · 114` · `B06_Section_Cross_Refresh.ts:229-243 · 306` | -| 조정창(흙막이 · 집수정 · 유입 · 경간 · 세월교 · BOX · 연동) | 세션 맵(`revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust`) · 빈 맵 = 칸 지움 · 관 옵션 = `culvertopt` | 카드 다시 · 구조물 면적 · 누가토량 | — | 횡단 패치 · Node 구조물 면적 · 관 옵션 = 관 옵션 차례 `pipe_points` PUT | 6 | `B06_Section_UI_Page_Station_Controls.ts:121-220 · 294-582` · `B06_Section_UI_Page_Link_Session.ts:97-105 · 143-155` · `B06_Section_Section_Adjusts.ts:42-103` · `logic/B06_Section_UI_Page_Patches.ts:41-136` | +| 조정창(흙막이 · 집수정 · 유입 · 경간 · 세월교 · BOX · 연동) | 세션 칸(`revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` · 값 주인 · 메모리 사본 없음 · 창구 = b_page_state 열쇠 `B06_Section_Session_Cells`) · 빈 칸 = 칸 지움 · 관 옵션 = `culvertopt` | 카드 다시 · 구조물 면적 · 누가토량 · ↶ ↷ 가 칸을 바꾸면 상세 얹기(고치기 전 칸 + 지금 칸) · 카드 · 면적 다시 | — | 횡단 패치 · Node 구조물 면적 · 관 옵션 = 관 옵션 차례 `pipe_points` PUT | 6 | `B06_Section_UI_Page_Station_Controls.ts` · `B06_Section_Session_Cells.ts` · `B06_Section_Section_Adjusts.ts` noteAdjustBase · withDraftAdjusts · `design_view_cross.ts` onRestore · `logic/B06_Section_UI_Page_Patches.ts:41-136` | | 반폭 ◀▶ | 측점 = 세션 `crossw` · 전역 = `cross-display` | 표시만 · 횡단 전체 강제 다시 셈(헛일) | — | 측점 반폭만 패치 · `cross-display` 안 감 | 3 | `B06_Section_UI_Cross_View.ts:664-670` · `design_view_cross.ts:251-263 · 448-457` · `B06_Section_Display_Width.ts:20-23` | | 표준횡단 · [전체 반영] | 세션 `std-cross`([폐기]에도 안 지움) | 전 측점 다시 셈(지문에 표준 포함) | — | `standard_cross_section` 으로 · 서버 설계선 다시 | 3 | `B06_Section_UI_Standard_Panel.ts:303-325 · 450-458` · `B06_Section_Api_Fetch.ts:101-111` · `B06_Section_Router_Confirm.py:157-162` | diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index b75363c00..28d6ab47c 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -21,7 +21,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | 다시 셈 입력 물러섬 = 세션 고름 → 저장 정본 설계 → config 기본값 · 고름 단추는 메모리 상세 설계를 안 덮음 | `B06_Section_Cross_Refresh.ts:402-408` · `B06_Section_UI_Page_Design_Sync.ts:59-104` | `test_272_choice_undo` | | 소단 사본 쓰는 자리 = 시설 줄 하나(B05 만 열어도) · 같으면 안 씀 · 사본 없으면 저장분 `design.berm` | `design_facility_lane.ts:151-166` · `B06_Section_Cross_Refresh.ts:362-368` | `test_272_berm_source` | | 두 보기가 보는 파생값은 페이지 한 벌이 숨은 동안도 셈 · 보기는 그리기만 · 접힘 = 그리기만 건너뜀 | `design_facility_lane.ts:1-12` · `B05_Profile_UI_Profile_Render.ts:211-226` | `test_272_hidden_summary` · `test_271_facility_lane` | -| 되돌리기 걸음 = 사용자 조작으로 값이 실제로 바뀐 때만 · 빈 칸(「""」「{}」「null」) = 칸 없음 · 빈 맵 persist = 칸 지움 | `B05_Profile_UI_Profile_History.ts:50 · 235-236` · `B06_Section_UI_Page_Link_Session.ts:97-105 · 153` | `test_272_history_empty_cells` | +| 되돌리기 걸음 = 사용자 조작으로 값이 실제로 바뀐 때만 · 빈 칸(「""」「{}」「null」) = 칸 없음 · 빈 칸 쓰기 = 칸 지움 | `B05_Profile_UI_Profile_History.ts:50 · 235-236` · `B06_Section_Session_Cells.ts` | `test_272_history_empty_cells` | | 시설 줄 측점 = 종류 표 · 구조물 · 관 알림이 다 온 뒤만 심음 · 그 전 종단도 = 저장 측점 · 저장 기준 그대로(모름 ≠ 빈 목록) | `design_facility_lane.ts` known · `design_profile.ts` planted · `design_profile_base.ts` listedBase · `design_view_profile_sections.ts` stationsReady · `B05_Profile_UI_Profile_Render.ts` deriveProfile | `test_473_lane_ready` | | 짝 없는 새 측점(구조물 · 추가 측점 · 새 관) = 계획선 꼭짓점 안 세움 · 짝 있는 옮긴 측점 = 꼭짓점 따라감 | `common_util_structure_station.ts:356` · `B05_Profile_Router_Confirm.py:306-400` | `test_427_1_follow_seat` · `test_459_route_seat_mirror` | | 구조물 측점 다시 뜸([저장] 상단측 차례 · 측점 맞춤) = 같은 자리 옛 줄의 상단측 출처(`user` 면 값도) · 포장 제안 표기 들고 감 · 새 자리 포장 표기 = 지금 계획선 | `B05_Profile_Router_Confirm.py:426-465` | `test_472_station_user_fields` | @@ -53,7 +53,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함 | 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 | | `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 | | 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠(누가거리 · 관이 따라 옮긴 새 자리와 안 맞으면 안 얹힘) | `design_save.ts:145` · `B06_Section_Router_DesignSave.py` `_patched_points` | 추정 · 옛 길과 같음 | -| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정 메모리 맵 · B05 상단측 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore | 관 · 구조물 = `test_472_5_shop_owner` · 나머지 추정 | +| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정창 = 세션 칸이 주인(사본 없음) · 칸이 바뀐 걸음만 상세 얹기 · 카드 · 면적 다시(472-5 5-D) · B05 상단측 맵 · 암선 · 암 경사 맵은 아직 옛 값일 수 있음 | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore · `B06_Section_UI_Page_Station_Controls.ts` onRestore | 관 · 구조물 = `test_472_5_shop_owner` · 조정창 = `test_472_5d_adjust_owner` · 나머지 추정 | | 스냅 켬 · 간격 = 되돌리기 밖 | `B05_Profile_UI_RouteEdit_SnapStep.ts:74-78` | 추정 | | `flushDesignStore` 부름 없음 — 멈춘 뒤 1 s 안 새로고침 = 가게 세션 사본 잃음(다시 셈) | `design_store.ts:179` | 코드 확인 | | 「[]」 — 되돌리기 = 값 · 떠남 확인 = 빈 칸 | `B05_Profile_UI_Profile_History.ts:50` · `design_leave.ts:41-42` | 코드 확인 | diff --git a/docs/wiki/concepts/design_page/session_keys.md b/docs/wiki/concepts/design_page/session_keys.md index f8f142074..ab4667731 100644 --- a/docs/wiki/concepts/design_page/session_keys.md +++ b/docs/wiki/concepts/design_page/session_keys.md @@ -24,7 +24,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "A00_Common | `uphill` | `:131` | 상단측 | uphill(노선 바뀌면 cross 뒤) | | `crossdesign` · `shoulderext` · `bermdepth` | `:196` · `:198` · `:200` | 카드 단추 · 설계선 쉬프트 · 소단 깊이 | cross | | `rockb` · `cutslope` · `crossw` | `:158` · `:162` · `:184` | 암선 · 암 경사 · 측점 반폭 | cross | -| `revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` | `:185-192` | 조정창 맵(빈 맵 = 칸 지움) | cross | +| `revetx` · `revetlink` · `basinadjust` · `inletstruct` · `extrawall` · `extraspan` · `fordadjust` · `boxadjust` | `:185-192` | 조정창 칸(값 주인 · 창구 = b_page_state 열쇠 `B06_Section_Session_Cells` · 메모리 사본 없음 · 빈 칸 = 칸 지움) | cross | | `structure-notes` · `structure-note-cads` · `detail-table` | `:121` · `:123` · `:125` | [상세] 모달 | cross | | `std-cross` | `:175` | 표준횡단 패널([폐기] · [저장]에도 안 지움 · [초기화]만) | cross(`standard_cross_section`) | | `berm` | `:166` | 시설 줄 `syncBerm` 만(구조물 파생) | 안 감 — 서버가 `structures.json` 에서 | diff --git a/docs/wiki/concepts/design_page/values.md b/docs/wiki/concepts/design_page/values.md index 303c1fe93..2892738cc 100644 --- a/docs/wiki/concepts/design_page/values.md +++ b/docs/wiki/concepts/design_page/values.md @@ -20,7 +20,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/ | 계획고 편집(▲▼ · 직선화 · 자유 · 쉬프트 · 시종점) | 종단 파일 `profile_alignment.edits` | ★ 가게 `alignmentDraft`(세션 `alignment-draft`) · 가게 `alignmentSaved`(서버 저장분 · 메모리 · `B05_Profile_UI_Profile_Edit.ts:127 · 235`)(노선 · 초안 노선 0) · 묶음 입력 · 가게 `profile` → `design-profile` · 상세 `longitudinal` | TS `logic/B05_Profile_UI_Profile_Alignment.ts:401` ↔ 파이썬 `B05_Profile_Engine_Grade_Alignment.py:494` · `test_426_profile_straight` · `test_432_1_curve_law` | 커밋마다 세션 · [저장] = 서버 다시 셈 | | 종단 R | 같은 `edits.curve_radii` | 위와 같음 · 칸은 L · 저장은 R | TS `logic/B05_Profile_Profile_CurveLaw.ts` ↔ `B05_Profile_Engine_Grade_CurveLaw.py` · `test_432_1_curve_law` | 위와 같음 · R = L/Δ 로 적어 Δ 바뀌면 L 흐름 | | 계획선 꼭짓점(시설 측점) | 종단 파일 base PVI | 묶음 planline · 종단도 | TS `common_util_structure_station.ts:356` followMovedNodes ↔ 파이썬 `B05_Profile_Router_Confirm.py:306` `_follow_moved_stations` · `:220` `_seat_born_nodes` · `test_427_1_follow_seat` · `test_459_route_seat_mirror` | 짝 있는 옮긴 측점 = 꼭짓점 따라감 · 짝 없는 새 측점 = 안 세움 | -| 횡단 입력(카드 · 조정) | DB `cross_sections.design` 패치 칸 | 세션 칸(`crossdesign` · 조정 맵 등) · B06 조정 메모리 맵(`B06_Section_UI_Page_Link_Session.ts:47`) | 입력 도구(셈 없음) | 조작마다 세션 · [저장] 횡단 패치 | +| 횡단 입력(카드 · 조정) | DB `cross_sections.design` 패치 칸 | ★ 세션 칸(`crossdesign` · 조정창 칸 등 · 조정 칸 창구 = b_page_state 열쇠 `B06_Section_Session_Cells` · 메모리 사본 없음 · 472-5 5-D) · cache 설계 칸(조작 즉시 얹음 · 얹기 = 고치기 전 칸 + 지금 세션 칸) | 입력 도구(셈 없음) | 조작마다 세션 · [저장] 횡단 패치 | | 횡단 설계 결과(설계선) | DB `cross_sections.design` | ★ 상세 가게 — cache(살아 있는 객체 · 제자리 고침) · 칸 셋(`B06_Section_Section_Canon.ts:93-143` 저장 정본 상세 · 초안 전 hold 상세 · 노선 초안 상세 = `pipelineDetail` · 472-5) · 세션 `section-detail`(서버 사본만) · canon 설계 사본 · 묶음 cross — 계산 묶음 페이지는 지문(노선 · 표본)만 | TS `common_util_cross_design.ts:267` ↔ 파이썬 `B06_Section_Engine_Design.py:195` · `test_b06_cross_design_mirror` | 다시 셈마다 cache 제자리 · 입력 물러섬 = 저장 정본(`B06_Section_Cross_Refresh.ts:402-408`) · 페이지 수명 · [저장]·[확정]·[초기화] 뒤 비움 | | 측점 면적 | DB 면적 — 표준 측점 = 파이썬 · 구조물 측점 = Node | cache 면적 · 묶음 cross | TS `common_util_cross_design_areas.ts` ↔ 파이썬 `B06_Section_Engine_Areas.py` · 구조물 = `B06_Section_Structure_Layouts.ts`(Node 한 벌) | [저장] 때 서버가 브라우저 면적 버리고 다시 셈 | | 누가토량 | 종단 파일 `mass_haul` | 그릴 때 셈 · 배지 · `_derived/haul` · `B05_Profile_Api_HaulPlan.ts:28` kept | TS `common_util_mass_haul.ts:426` 한 벌(브라우저 · Node) | 그릴 때(접혀도 셈) · [저장] | diff --git a/resources/tester/helper_272_entry.ts b/resources/tester/helper_272_entry.ts index 8808c4f29..2baa1b4d9 100644 --- a/resources/tester/helper_272_entry.ts +++ b/resources/tester/helper_272_entry.ts @@ -1,9 +1,6 @@ /* 272 시험 묶음 진입점(468 · 466-C) — 사진첩 빈 칸 · 세션 맵 빈 쓰기 · 숨은 종단도 파생값 · 배지 자릿수(진짜 모듈 · vite ssr). */ export { createProfileHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History"; -export { - createSessionMap, - moveSessionMaps, -} from "../../B06_Section/B06_Section_UI_Page_Link_Session"; +export { sessionCells } from "../../B06_Section/B06_Section_Session_Cells"; export { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render"; export { createRouteMassHaulPanel } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul"; export { createMassHaulBadge } from "../../common_util/common_util_mass_haul_badge"; diff --git a/resources/tester/helper_272_value_hold.mjs b/resources/tester/helper_272_value_hold.mjs index 820064ab2..cd08d897d 100644 --- a/resources/tester/helper_272_value_hold.mjs +++ b/resources/tester/helper_272_value_hold.mjs @@ -101,38 +101,41 @@ const touch = () => win.dispatchEvent(new Event("keydown")); localStorage.setItem("frd_current_project_id", "p1"); } -/* ── H4 · 세션 맵 — 빈 맵 쓰기 = 칸 지움 · 값 있으면 씀 · 빈 맵 이동은 칸을 안 만듦 ── */ +/* ── H4 · 세션 칸 창구(472-5 5-D · 메모리 맵 없음) — 빈 쓰기 = 칸 지움 · 값 있으면 씀 · 연동 두 겹 ── */ { - const width = m.createSessionMap( + const width = m.sessionCells( () => key("crossw"), (v) => (typeof v === "number" ? v : undefined), ); - width.persist(); + width.clear(); const emptyWrite = S.getItem(key("crossw")); - width.values.set("100.00", 6); - width.persist(); + width.set("100.00", 6); const filledWrite = S.getItem(key("crossw")); - width.values.clear(); - width.persist(); + width.delete("100.00"); const clearedWrite = S.getItem(key("crossw")); - const others = SIX.filter((n) => n !== "crossw").map((n) => - m.createSessionMap( - () => key(n), - (v) => v, - ), + const detached = m.sessionCells( + () => key("revetlink"), + (v) => !!v, + "detached", ); - m.moveSessionMaps(new Map([["100.00", "120.00"]])); - const moved = SIX.map((n) => S.getItem(key(n))); - // 값 있는 맵 이동 = 새 자리로 옮겨 씀 - S.setItem(key("extrawall"), JSON.stringify({ "100.00": 2 })); - m.moveSessionMaps(new Map([["100.00", "120.00"]])); + const grade = m.sessionCells( + () => key("revetlink"), + (v) => !!v, + "grade", + ); + detached.set("100.00", true); + grade.set("100.00", false); + const linkBoth = S.getItem(key("revetlink")); + detached.clear(); + const linkGrade = S.getItem(key("revetlink")); + grade.clear(); out.h4 = { emptyWrite, filledWrite, clearedWrite, - moved, - movedFilled: S.getItem(key("extrawall")), - made: others.length, + linkBoth, + linkGrade, + linkCleared: S.getItem(key("revetlink")), }; S.clear(); localStorage.setItem("frd_current_project_id", "p1"); diff --git a/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index 72a1a3744..b79b29dd7 100644 --- a/resources/tester/helper_318_cross_card.cjs +++ b/resources/tester/helper_318_cross_card.cjs @@ -522,8 +522,6 @@ const out = {}; put("aislo:draft:basinadjust:P:7", { "100.00": { d: 1 } }); put("aislo:draft:inletstruct:P:7", { "100.00": "basin" }); put("aislo:draft:crossw:P:7", { "100.00": 5 }); - let reloads = 0; - mod.setStationAdjustReload(() => (reloads += 1)); mod.forgetStationAdjusts("P", 100, "pipe", "box_culvert"); out.forget = { culvertopt: get("aislo:draft:culvertopt:P:7"), @@ -541,7 +539,7 @@ const out = {}; kindreset: get("aislo:draft:kindreset:P"), changed: mod.kindChangedChainages("P"), preview_calls: previewCalls, - reloads, + reload_hook: typeof mod.setStationAdjustReload, }; mod.forgetStationAdjusts("P", 100, "box_culvert", "ford_bridge"); out.forget.second = get("aislo:draft:kindreset:P"); diff --git a/resources/tester/helper_346_2_station_swap.mjs b/resources/tester/helper_346_2_station_swap.mjs index 4d60c78a2..c69385b1f 100644 --- a/resources/tester/helper_346_2_station_swap.mjs +++ b/resources/tester/helper_346_2_station_swap.mjs @@ -140,14 +140,8 @@ const NEW = (dy) => [ m.writeCrossDesignChoice(P, R, 20, choice("soil")); m.writeCrossDesignChoice(P, R, 40, choice("soil")); m.writeCrossDesignChoice(P, R, 45.054, choice("blasting_rock")); - const widths = m.createSessionMap( - () => "aislo:test:crossw", - (v) => v, - ); - widths.values.set("45.05", 12); - widths.values.set("45.05:outlet", 3); - widths.values.set("20.00", 8); - widths.persist(); // 조정창은 바꿀 때마다 세션에 씀(제어기와 같은 차례) + // 조정창 칸 = 세션 칸이 주인(472-5 5-D) — 갈아끼움 이동은 세션 칸을 바로 옮김(`Station_Keys`) + m.writeState("crossw", { 45.05: 12, "45.05:outlet": 3, "20.00": 8 }, P, R); const heard = []; // 관 · 구조물 자리 장부 — 새 목록에서 관은 47 m · 구조물 id 는 측점 계획이 줌 m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P); @@ -181,7 +175,7 @@ const NEW = (dy) => [ rawKeys: Object.keys(m.readState("crossdesign", P, R) ?? {}) .filter((k) => k !== "@at") .sort(), - widths: Object.fromEntries(widths.values), + widths: m.readState("crossw", P, R), redraws, }; // 같은 목록을 다시 내면 갈아끼우지 않음 diff --git a/resources/tester/helper_346_2_swap_entry.ts b/resources/tester/helper_346_2_swap_entry.ts index 56d0b2bbe..878716d8d 100644 --- a/resources/tester/helper_346_2_swap_entry.ts +++ b/resources/tester/helper_346_2_swap_entry.ts @@ -17,10 +17,6 @@ export { writeCrossDesignChoice, } from "../../B06_Section/B06_Section_Cross_Design_Session"; export { useStructureIds } from "../../B06_Section/B06_Section_Station_Keys"; -export { - createSessionMap, - moveSessionMaps, -} 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/helper_445_save_patch.cjs b/resources/tester/helper_445_save_patch.cjs index 6fcc1f069..6ebdeb7f5 100644 --- a/resources/tester/helper_445_save_patch.cjs +++ b/resources/tester/helper_445_save_patch.cjs @@ -70,6 +70,7 @@ const load = makeLoader( B06("B06_Section_Api_Culvert_Options"), B06("B06_Section_UI_Page_Station_Controls"), B06("B06_Section_UI_Page_Link_Session"), + B06("B06_Section_Session_Cells"), // 조정창 칸 창구(472-5 5-D · 메모리 맵 없음) B06("B06_Section_UI_Cross_Culvert_Types"), B06("logic/B06_Section_Station_Swap"), B06("logic/B06_Section_UI_Page_Patches"), @@ -143,9 +144,9 @@ function savePatches(controls) { const overlay = buildCrossPatches({ rockOffsets: new Map(), cutSlopeRatios: new Map(), - stationWidths: controls.widths, - inletStructures: controls.inletStructures, - basinAdjustments: controls.basinAdjustments, + stationWidths: controls.widths(), + inletStructures: controls.inletStructures(), + basinAdjustments: controls.basinAdjustments(), revetAdjusts: controls.revetAdjustsByChainage(), extraCounts: controls.extraCountsByChainage(), extraSpans: controls.extraSpansByChainage(), diff --git a/resources/tester/helper_472_5d_adjust.mjs b/resources/tester/helper_472_5d_adjust.mjs new file mode 100644 index 000000000..1300df657 --- /dev/null +++ b/resources/tester/helper_472_5d_adjust.mjs @@ -0,0 +1,105 @@ +/* 472-5 5-D 헬퍼 — node helper_472_5d_adjust.mjs <묶음.mjs> · 표준출력 JSON. + * 조정창(반폭 · 흙막이 4축 · 유입 형식 · 연동 · 단 수 · 세월교)을 고침 → ↶ → 다른 측점을 고침 → ↶ ↷ — + * 세션 칸 · 캐시 설계 칸 · 조정창 읽기 · 저장 묶음이 되돌린 값을 따르는가(옛 코드 = 메모리 맵이 옛 값을 쥠 · N5). */ +import { pathToFileURL } from "node:url"; + +const storage = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.window = { sessionStorage: storage(), localStorage: storage() }; // 손길 듣기 없음 = 열 때 규칙 꺼짐 +globalThis.sessionStorage = window.sessionStorage; +globalThis.localStorage = window.localStorage; +globalThis.document = any; + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p472fived"; +const R = 7; +localStorage.setItem("frd_current_project_id", P); +const NAMES = ["crossw", "revetx", "inletstruct", "revetlink", "extrawall", "fordadjust"]; +const SAVED_OUTLET = { x: 2, d: null, h: null, m: null }; +const sections = [100, 120, 140].map((chainage_m) => ({ + chainage_m, + station_id: `s${chainage_m}`, + design: chainage_m === 100 ? { revet_adjust: { outlet: { ...SAVED_OUTLET } } } : {}, +})); +const detail = { cross_sections: sections }; +const c = m.createStationControls({ + sessionKey: (kind) => m.stateKey(kind, P, R), + refreshCard: () => undefined, + detail: () => detail, + crossHalfWidth: () => undefined, + sampledHalfWidth: () => 20, + ensureSampledWidth: async () => true, + projectId: () => P, +}); +c.load(); +const cells = () => Object.fromEntries(NAMES.map((n) => [n, m.readState(n, P, R)])); +const designs = () => sections.map((s) => JSON.parse(JSON.stringify(s.design))); +const reads = () => ({ + inlet: c.revetOffset.adjustFor(sections[0], "inlet"), + outlet: c.revetOffset.adjustFor(sections[0], "outlet"), + width100: c.stationWidth.widthFor(sections[0]) ?? null, + struct100: c.inletStructure.valueFor(sections[0]), + linked120: c.revetLink.linkedFor(sections[1]), + extra120: c.extraWalls.countFor(sections[1], "outlet"), + ford140: c.ford.adjustFor(140).inlet.lateralM, +}); +const out = {}; + +const history = m.createProfileHistory(() => undefined, 100); +// 조작 A — 측점 100 · 120 · 140 +c.revetOffset.update(100, "inlet", { x: 3 }); +c.stationWidth.request(100, 6); +c.inletStructure.set(100, "L"); +c.revetLink.setLinked(120, false); +c.extraWalls.setCount(120, 2, "outlet"); +c.ford.update(140, "inlet", { lateralM: 2 }); +history.record(); +out.afterA = cells(); +out.readsA = reads(); + +history.undo(); // ↶ = 세션 칸을 조작 전으로 되씀(가게 · 조정창을 안 거침) +out.afterUndo = cells(); +m.withDraftAdjusts(detail, P, R); // 상세 가게가 읽을 때 얹기(↶ 훅 · 보기 전환 · 다시 그림 길) +out.designsUndo = designs(); +out.readsUndo = reads(); + +// 조작 B — 다른 측점 · 다른 역할(옛 코드 = 메모리 맵이 A 값을 쥐고 통째로 다시 씀) +c.revetOffset.update(100, "outlet", { x: 1 }); +c.stationWidth.request(120, 8); +c.inletStructure.set(120, "I"); +c.revetLink.setLinked(140, false); +c.extraWalls.setCount(140, 1, "outlet"); +c.ford.update(100, "outlet", { lateralM: 1 }); +out.afterB = cells(); +out.payloadB = { + revet: Object.fromEntries(c.revetAdjustsByChainage()), + link: Object.fromEntries(c.linkFlagsByChainage()), + extra: Object.fromEntries(c.extraCountsByChainage()), + ford: [...c.fordAdjustsByChainage().keys()], +}; +history.record(); +history.undo(); +out.afterUndo2 = cells(); +history.redo(); // ↷ = B 그대로 +out.afterRedo = cells(); +m.withDraftAdjusts(detail, P, R); +out.designsRedo = designs(); +out.readsRedo = reads(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_472_5d_adjust_entry.ts b/resources/tester/helper_472_5d_adjust_entry.ts new file mode 100644 index 000000000..95d94f85d --- /dev/null +++ b/resources/tester/helper_472_5d_adjust_entry.ts @@ -0,0 +1,5 @@ +/* 472-5 5-D 헬퍼 — B06 조정창 값 주인 = 세션 칸 하나(vite ssr) · test_472_5d_adjust_owner.py 가 부름. */ +export { createProfileHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History"; +export { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls"; +export { withDraftAdjusts } from "../../B06_Section/B06_Section_Section_Adjusts"; +export { readState, stateKey } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/test_272_history_empty_cells.py b/resources/tester/test_272_history_empty_cells.py index bf8b843eb..f192dd759 100644 --- a/resources/tester/test_272_history_empty_cells.py +++ b/resources/tester/test_272_history_empty_cells.py @@ -99,6 +99,8 @@ def test_세션_맵_빈_쓰기는_칸_지움(out): assert h4["emptyWrite"] is None assert h4["filledWrite"] == '{"100.00":6}' assert h4["clearedWrite"] is None - # 빈 맵 이동은 칸을 안 만듦 · 값 있는 맵은 새 자리로 옮겨 씀 - assert h4["moved"] == [None] * 6 - assert h4["movedFilled"] == '{"120.00":2}' + # 472-5 5-D — 메모리 맵 · 맵 이동(moveSessionMaps)을 걷음 · 측점 이동은 세션 칸을 바로(`Station_Keys`) · + # 연동 두 겹은 둘 다 빌 때 칸 지움 + assert h4["linkBoth"] == '{"detached":{"100.00":true},"grade":{"100.00":false}}' + assert h4["linkGrade"] == '{"detached":{},"grade":{"100.00":false}}' + assert h4["linkCleared"] is None diff --git a/resources/tester/test_318_cross_card.py b/resources/tester/test_318_cross_card.py index 22056e53a..65d491dde 100644 --- a/resources/tester/test_318_cross_card.py +++ b/resources/tester/test_318_cross_card.py @@ -210,7 +210,8 @@ def test_forget_session_adjusts_at_station(result: dict) -> None: assert forget["fordadjust"] == {} and forget["boxadjust"] == {} # 100.30 = 같은 관(0.51 안) assert forget["basinadjust"] == {} and forget["inletstruct"] == {} assert forget["crossw"] == {"100.00": 5} # 구조물 아닌 값(표시 반폭)은 그대로 - assert forget["reloads"] == 1 # 횡단 보기 메모리 사본 다시 읽음 + # 472-5 5-D — 조정창 메모리 사본 없음(세션 칸이 주인) = 다시 읽을 창구도 없음(옛 단언 reloads == 1) + assert forget["reload_hook"] == "undefined" assert forget["preview_calls"] == [] # 415 M4 — 미리 뜬 횡단 사본 없음 @@ -247,7 +248,7 @@ def test_wiring_sources() -> None: store = _src("B06_Section", "B06_Section_Section_Store.ts") assert "crossPatchesFromCache(detail, kindChangedChainages(projectId), savedAt)" in store view = _src("A00_Common", "design", "design_view_cross.ts") - assert "setStationAdjustReload(() => stationControls.load())" in view + assert "setStationAdjustReload" not in view # 472-5 5-D — 다시 읽을 메모리 사본 없음 def test_structure_keys_mirror() -> None: diff --git a/resources/tester/test_472_5d_adjust_owner.py b/resources/tester/test_472_5d_adjust_owner.py new file mode 100644 index 000000000..d7d18c62d --- /dev/null +++ b/resources/tester/test_472_5d_adjust_owner.py @@ -0,0 +1,127 @@ +"""PLAN 472-5 5-D (요구 273) — B06 조정창 값 주인 = 세션 칸 하나 · 메모리 맵 걷음(N5). + +못박는 것 + - 조정창(반폭 · 흙막이 4축 · 유입 형식 · 연동 · 단 수 · 세월교)을 고친 뒤 ↶ 하고 다른 측점을 고쳐도 되돌린 값이 + 되살아나지 않음 — 옛 코드는 메모리 맵이 옛 값을 쥐고 다음 조작이 칸을 통째로 다시 써 ↶ 를 덮었음(옛 코드 실패). + - ↶ 뒤 조정창 읽기 · 캐시 설계 칸(상세 가게 얹기 뒤) = 조작 전 값(저장분은 그대로) · ↷ = 다시 그 값. + - 저장 묶음(횡단 patch 재료)도 세션 칸 그대로. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_5d_adjust_owner.py -q +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +SAVED_OUTLET = {"x": 2, "d": None, "h": None, "m": None} + + +@pytest.fixture(scope="module") +def out(tmp_path_factory) -> dict: + work = tmp_path_factory.mktemp("s4725d") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_472_5d_adjust_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + check=True, + capture_output=True, + timeout=300, + ) + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_472_5d_adjust_entry.js", bundle) + proc = subprocess.run( # noqa: S603 + ["node", str(HERE / "helper_472_5d_adjust.mjs"), str(bundle)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_조작_A_는_세션_칸에(out) -> None: + a = out["afterA"] + assert a["revetx"]["100.00:inlet"]["x"] == 3 + assert a["crossw"] == {"100.00": 6} + assert a["inletstruct"] == {"100.00": "L"} + assert a["revetlink"]["detached"] == {"120.00": True} + assert a["extrawall"] == {"120.00": 2} + assert a["fordadjust"]["140.00"]["inlet"]["lateralM"] == 2 + + +def test_되돌리면_칸이_비고_조정창도_조작_전(out) -> None: + assert out["afterUndo"] == {name: None for name in out["afterUndo"]} + r = out["readsUndo"] + assert r["inlet"]["x"] == 0 + assert r["outlet"] == SAVED_OUTLET # 저장분 그대로 + assert r["width100"] is None + assert r["struct100"] == "auto" + assert r["linked120"] is True + assert r["extra120"] == 0 + assert r["ford140"] == 0 + + +def test_되돌린_뒤_캐시_설계_칸도_조작_전(out) -> None: + """옛 코드 = 조정창이 캐시(section.design)에 실은 값이 ↶ 뒤에도 남아 그림 · [저장] 바탕으로 나감.""" + d100, d120, d140 = out["designsUndo"] + assert d100 == {"revet_adjust": {"outlet": SAVED_OUTLET}} + assert d120 == {} + assert d140 == {} + + +def test_되돌린_뒤_다른_조작이_되돌림을_안_덮음(out) -> None: + """N5 — 옛 코드는 메모리 맵이 A 값을 쥐고 B 를 쓸 때 통째로 다시 씀(옛 코드 실패).""" + b = out["afterB"] + assert set(b["revetx"]) == {"100.00:outlet"} + assert b["revetx"]["100.00:outlet"]["x"] == 1 + assert b["crossw"] == {"120.00": 8} + assert b["inletstruct"] == {"120.00": "I"} + assert b["revetlink"]["detached"] == {"140.00": True} + assert b["extrawall"] == {"140.00": 1} + assert set(b["fordadjust"]) == {"100.00"} + p = out["payloadB"] + assert set(p["revet"]) == {"100"} and set(p["revet"]["100"]) == {"outlet"} + assert set(p["link"]) == {"140"} + assert p["extra"] == {"140": {"outlet": 1}} + assert p["ford"] == [100] + + +def test_다시하기_뒤_값_같음(out) -> None: + assert out["afterUndo2"] == {name: None for name in out["afterUndo2"]} + assert out["afterRedo"] == out["afterB"] + d100, d120, d140 = out["designsRedo"] + assert d100["revet_adjust"] == {"outlet": {"x": 1, "d": None, "h": None, "m": None}} + assert "inlet_structure" not in d100 + assert d120["inlet_structure"] == "I" + assert d140["revet_link_detached"] is True + assert d140["extra_wall_counts"] == {"outlet": 1, "basin": 0} + assert d100["ford_adjust"]["outlet"]["lateralM"] == 1 + r = out["readsRedo"] + assert r["outlet"]["x"] == 1 and r["inlet"]["x"] == 0 + assert r["width100"] is None and r["ford140"] == 0