/* ============================================================================= * B06_Section_UI_Page_Station_Controls.ts * 측점 단위 표시 제어 두 가지 — **개별 표시 반폭**(2026-08-06)과 **기슭막이 X 자리** * (2026-08-21). 둘 다 세션에만 담고 카드 재렌더로 반영한다. 페이지 본체 * (`_UI_Page.ts`)에서 700줄 제한으로 분리했다. * ========================================================================== */ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { ExtraWallControl, InletStructureControl, RevetOffsetControl, StationWidthControl, } from "./B06_Section_UI_Cross_View"; /** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */ export interface StationControlDeps { sessionKey: ( kind: "crossw" | "revetx" | "inletstruct" | "basinadjust" | "extrawall", ) => string | null; refreshCard: (chainageM: number) => void; detail: () => SectionDetailResponse | null; crossHalfWidth: () => number | undefined; sampledHalfWidth: () => number; /** * 요청 반폭까지 지반 샘플을 넓힌다(백엔드 재생성). 이미 충분하면 즉시 true, * 재생성에 실패하면 false. 페이지가 구현한다(2026-08-23 사용자 지시). */ ensureSampledWidth: (widthM: number) => Promise; } /** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ export interface StationControls { stationWidth: StationWidthControl; revetOffset: RevetOffsetControl; inletStructure: InletStructureControl; extraWalls: ExtraWallControl; widths: Map; inletStructures: Map; basinAdjustments: Map; load: () => void; /** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */ applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void; } export function createStationControls(deps: StationControlDeps): StationControls { /* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ────────────────────── * 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때 * cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다. * 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */ const stationWidths = new Map(); const widthKey = (chainageM: number): string => chainageM.toFixed(2); const widthSessionKey = (): string | null => deps.sessionKey("crossw"); function loadStationWidths(): void { stationWidths.clear(); const key = widthSessionKey(); if (!key) return; try { const raw = window.sessionStorage.getItem(key); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, width]) => { if (Number.isFinite(width) && width > 0) stationWidths.set(chainage, width); }); } catch { /* 손상된 세션 값은 무시 — 저장값·전역 반폭으로 재시작. */ } } function persistStationWidths(): void { const key = widthSessionKey(); if (!key) return; try { window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(stationWidths))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } } /** * 개별 반폭 하한 2m. **상한은 두지 않는다**(2026-08-23 개편) — 계산 반폭(20m)을 * 넘는 값은 `deps.ensureSampledWidth`가 백엔드 재생성으로 샘플을 넓힌 뒤 적용된다. */ const clampStationWidth = (value: number): number => Math.max(Math.round(value), 2); const stationWidthControl: StationWidthControl = { widthFor: (section) => { const session = stationWidths.get(widthKey(section.chainage_m)); if (session !== undefined) return session; const stored = section.design?.display_half_width_m; return typeof stored === "number" && stored > 0 ? stored : undefined; }, request: (chainageM, widthM) => { const width = clampStationWidth(widthM); const apply = (): void => { stationWidths.set(widthKey(chainageM), width); persistStationWidths(); deps.refreshCard(chainageM); }; // 보유 샘플 안이면 표시만 바꾸면 된다 — 캐시(계산 20m)를 그대로 쓴다. if (width <= deps.sampledHalfWidth() + 1e-6) { apply(); return; } // 샘플 밖 = 지반이 없는 폭. 백엔드에 그 폭으로 재계산을 맡기고, 끝나면 적용한다. void deps.ensureSampledWidth(width).then((ready) => { if (ready) apply(); }); }, }; /* ── 기슭막이 4축 조작값(2026-08-22 확정: 좌우 x·상하 d·높이 h·재질 m) ── * 값은 세션에만 담는다 — 자동 자리가 지형·계획고를 따라 다시 풀리므로, 손으로 * 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`. * 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */ const revetShifts = new Map(); const revetSelected = new Map(); const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`; const revetSessionKey = (): string | null => deps.sessionKey("revetx"); function loadRevetShifts(): void { revetShifts.clear(); revetSelected.clear(); const key = revetSessionKey(); if (!key) return; try { const raw = window.sessionStorage.getItem(key); if (!raw) return; const parsed = JSON.parse(raw) as Record>; Object.entries(parsed).forEach(([entry, value]) => { if (typeof value === "number" && Number.isFinite(value)) { revetShifts.set(entry, { ...ZERO_ADJUST, x: value }); } else if (value && typeof value === "object") { revetShifts.set(entry, { ...ZERO_ADJUST, ...value }); } }); } catch { /* 손상된 세션 값은 무시 — 자동 자리로 재시작. */ } } function persistRevetShifts(): void { const key = revetSessionKey(); if (!key) return; try { window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(revetShifts))); } catch { /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ } } /** 손으로 미는 범위 한계(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); const isDefaultAdjust = (value: WallAdjust): boolean => Math.abs(value.x) < 1e-9 && Math.abs(value.d) < 1e-9 && value.h == null && value.m == null; const storeAdjust = (key: string, value: WallAdjust): void => { if (isDefaultAdjust(value)) revetShifts.delete(key); else revetShifts.set(key, value); persistRevetShifts(); }; const sameAdjust = (a: WallAdjust, b: WallAdjust): boolean => Math.abs(a.x - b.x) < 1e-9 && Math.abs(a.d - b.d) < 1e-9 && (a.h ?? null) === (b.h ?? null) && (a.m ?? null) === (b.m ?? null); const revetOffsetControl: RevetOffsetControl = { adjustFor: (section, role) => revetShifts.get(revetKey(section.chainage_m, role)) ?? { ...ZERO_ADJUST }, selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null, select: (chainageM, key) => { if (key) revetSelected.set(chainageM.toFixed(2), key); else revetSelected.delete(chainageM.toFixed(2)); }, syncApplied: (chainageM, role, applied) => { // 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. const key = revetKey(chainageM, role); const stored = revetShifts.get(key) ?? { ...ZERO_ADJUST }; if (sameAdjust(stored, applied)) return; storeAdjust(key, { ...applied }); }, update: (chainageM, role, patch) => { const key = revetKey(chainageM, role); const current = revetShifts.get(key) ?? { ...ZERO_ADJUST }; const next: WallAdjust = { x: clampMove(patch.x ?? current.x), d: clampMove(patch.d ?? current.d), // 높이는 0.1 눈금 반올림만 — 재질 한계 절삭은 기하가 하고 되받는다. h: patch.h === undefined ? current.h : patch.h === null ? null : round1(patch.h), m: patch.m === undefined ? current.m : patch.m, }; storeAdjust(key, next); deps.refreshCard(chainageM); }, reset: (chainageM, role) => { revetShifts.delete(revetKey(chainageM, role)); persistRevetShifts(); deps.refreshCard(chainageM); }, }; /* ── 유입측 구조물 형식(2026-08-22 사용자 — 드롭다운) ──────────────── * auto(규칙)/revet(기슭막이+배관)/I/L/U(집수정 형식). 세션에만 담는다. */ const inletStructures = new Map(); const basinAdjustments = new Map(); const structSessionKey = (): string | null => deps.sessionKey("inletstruct"); const basinSessionKey = (): string | null => deps.sessionKey("basinadjust"); function loadBasinAdjustments(): void { basinAdjustments.clear(); const key = basinSessionKey(); if (!key) return; try { const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record< string, BasinAdjust >; Object.entries(parsed).forEach(([chainage, value]) => basinAdjustments.set(chainage, { ...DEFAULT_BASIN_ADJUST, ...value }), ); } catch { /* 손상된 세션 값은 기본값으로 대체. */ } } function persistBasinAdjustments(): void { const key = basinSessionKey(); if (key) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(basinAdjustments))); } function loadInletStructures(): void { inletStructures.clear(); const key = structSessionKey(); if (!key) return; try { const raw = window.sessionStorage.getItem(key); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, value]) => { if (["auto", "revet", "I", "L", "U"].includes(value)) inletStructures.set(chainage, value); }); } catch { /* 손상된 세션 값은 무시 — auto(규칙)로 재시작. */ } } function persistInletStructures(): void { const key = structSessionKey(); if (!key) return; try { window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(inletStructures))); } catch { /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ } } /* ── 유출측 추가 기슭막이 개수(2026-08-22 사용자 — 성토부 5m 이상 계단식) ── * 측점별 개수만 세션에 담는다. 각 벽의 이동량은 revetShifts에 `extra{n}` 키로. */ const extraCounts = new Map(); const extraSessionKey = (): string | null => deps.sessionKey("extrawall"); function loadExtraCounts(): void { extraCounts.clear(); const key = extraSessionKey(); if (!key) return; try { const raw = window.sessionStorage.getItem(key); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, count]) => { if (Number.isInteger(count) && count > 0) extraCounts.set(chainage, count); }); } catch { /* 손상된 세션 값은 무시 — 추가 벽 없음으로 재시작. */ } } function persistExtraCounts(): void { const key = extraSessionKey(); if (!key) return; try { window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(extraCounts))); } catch { /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ } } /** 등간격 배치 1회성 요청(2026-08-22 ①) — 다음 카드 계산에서 소비된다. */ const pendingEqualize = new Set(); // 다단은 유출 성토부(outlet)·집수정 계류측(basin) 두 갈래라 키에 쪽을 담는다. const extraKey = (chainageM: number, side: "outlet" | "basin"): string => side === "basin" ? `b${chainageM.toFixed(2)}` : chainageM.toFixed(2); const extraPrefix = (side: "outlet" | "basin"): string => (side === "basin" ? "bextra" : "extra"); const extraWallControl: ExtraWallControl = { countFor: (section, side = "outlet") => extraCounts.get(extraKey(section.chainage_m, side)) ?? 0, equalize: (chainageM) => { if ((extraCounts.get(chainageM.toFixed(2)) ?? 0) <= 0) return; // 단이 없으면 무의미 pendingEqualize.add(chainageM.toFixed(2)); deps.refreshCard(chainageM); }, consumeEqualize: (section) => pendingEqualize.delete(section.chainage_m.toFixed(2)), setCount: (chainageM, requested, side = "outlet") => { const key = extraKey(chainageM, side); const previous = extraCounts.get(key) ?? 0; const count = Math.max(0, Math.min(9, Math.round(requested))); if (count === previous) return; if (count === 0) extraCounts.delete(key); else extraCounts.set(key, count); // 줄어든 단의 이동량은 지운다 — 다시 늘리면 자동 자리에서 시작한다. for (let i = count; i < previous; i += 1) { revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey)); } persistRevetShifts(); persistExtraCounts(); deps.refreshCard(chainageM); }, syncCount: (chainageM, built, side = "outlet") => { const key = extraKey(chainageM, side); const count = extraCounts.get(key) ?? 0; if (built >= count) return; // 성토부가 없어져 못 세운 벽 — 개수·이동량을 세워진 만큼으로 자른다(고아 방지). if (built <= 0) extraCounts.delete(key); else extraCounts.set(key, built); for (let i = built; i < count; i += 1) { revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey)); } persistRevetShifts(); persistExtraCounts(); }, }; const inletStructureControl: InletStructureControl = { valueFor: (section) => inletStructures.get(section.chainage_m.toFixed(2)) ?? section.design?.inlet_structure ?? "auto", adjustFor: (section) => basinAdjustments.get(section.chainage_m.toFixed(2)) ?? { ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}), }, set: (chainageM, value) => { inletStructures.set(chainageM.toFixed(2), value); persistInletStructures(); deps.refreshCard(chainageM); }, updateAdjust: (chainageM, patch) => { const key = chainageM.toFixed(2); const current = basinAdjustments.get(key) ?? { ...DEFAULT_BASIN_ADJUST }; const next = { ...current, ...patch }; basinAdjustments.set(key, { innerWidthM: Math.min(2, Math.max(1, round1(next.innerWidthM))), innerHeightM: Math.min(2, Math.max(1, round1(next.innerHeightM))), lateralM: Math.max(0, clampMove(next.lateralM)), slopeM: Math.max(0, clampMove(next.slopeM)), }); persistBasinAdjustments(); deps.refreshCard(chainageM); }, resetAdjust: (chainageM) => { basinAdjustments.set(chainageM.toFixed(2), { ...DEFAULT_BASIN_ADJUST }); persistBasinAdjustments(); deps.refreshCard(chainageM); }, }; return { stationWidth: stationWidthControl, revetOffset: revetOffsetControl, inletStructure: inletStructureControl, extraWalls: extraWallControl, widths: stationWidths, inletStructures, basinAdjustments, load: () => { loadStationWidths(); loadRevetShifts(); loadInletStructures(); loadBasinAdjustments(); loadExtraCounts(); }, applyGlobalWidth: (requested, chainages) => { stationWidths.clear(); if (requested !== undefined) { for (const chainageM of chainages) { stationWidths.set(widthKey(chainageM), clampStationWidth(requested)); } } persistStationWidths(); }, }; }