/* ============================================================================= * B06_Section_UI_Page_Station_Controls.ts * 측점 단위 표시 제어 두 가지 — **개별 표시 반폭**(2026-08-06)과 **기슭막이 X 자리** * (2026-08-21). 둘 다 세션에만 담고 카드 재렌더로 반영한다. 페이지 본체 * (`_UI_Page.ts`)에서 700줄 제한으로 분리했다. * ========================================================================== */ import type { CrossDesign, CrossSection, SectionDetailResponse, StoredWallSpan, } 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, RevetLinkControl, RevetOffsetControl, SpanValues, StationWidthControl, StructureSpanControl, } from "./B06_Section_UI_Cross_View"; import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const"; import { culvertOwnerFor, culvertReach, tierSpanOf, wallStandsAt, } from "./B06_Section_UI_Cross_Culvert_Wire"; import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options"; import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls"; import { createLinkFlagSession } from "./B06_Section_UI_Page_Link_Session"; import type { FordAdjust } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import type { BoxAdjust } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; /** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */ export interface StationControlDeps { sessionKey: ( kind: | "crossw" | "revetx" | "inletstruct" | "basinadjust" | "extrawall" | "extraspan" | "culvertopt" | "culvertmove" | "revetlink" | "fordadjust" | "boxadjust", ) => string | null; refreshCard: (chainageM: number) => void; detail: () => SectionDetailResponse | null; crossHalfWidth: () => number | undefined; sampledHalfWidth: () => number; /** * 요청 반폭까지 지반 샘플을 넓힌다(백엔드 재생성). 이미 충분하면 즉시 true, * 재생성에 실패하면 false. 페이지가 구현한다(2026-08-23 사용자 지시). */ ensureSampledWidth: (widthM: number) => Promise; /** 구간값(길이·전/후)을 되돌려 쓸 대상 프로젝트. 없으면 저장하지 않는다. */ projectId: () => string | null; /** 구간값 저장 실패 알림 — 화면 토스트는 페이지가 띄운다. */ onSaveError?: (message: string) => void; } /** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ export interface StationControls { stationWidth: StationWidthControl; revetOffset: RevetOffsetControl; inletStructure: InletStructureControl; extraWalls: ExtraWallControl; structureSpan: StructureSpanControl; revetLink: RevetLinkControl; /** 세월교 측벽 조작(2026-08-25). */ ford: FordControl; /** BOX암거 구체 조작(2026-08-25). */ box: BoxControl; /** 확정 payload용 — 측점별 세월교·BOX암거 조작값. */ fordAdjustsByChainage: () => Map; boxAdjustsByChainage: () => Map; widths: Map; inletStructures: Map; basinAdjustments: Map; /** 확정 payload용 — 측점별 기슭막이 4축 조작값(역할 → 값). */ revetAdjustsByChainage: () => Map>; /** 확정 payload용 — 측점별 다단 단 수. */ extraCountsByChainage: () => Map; /** 확정 payload용 — 측점별 연동 해제·종단경사 반영(2026-08-24). */ linkFlagsByChainage: () => Map; /** 다단 단별 구간값(2026-08-29) — 확정·임시저장 payload용. */ extraSpansByChainage: () => Map>; /** 예약된 구간값 저장을 즉시 내보낸다 — 확정·임시저장 직전에 부른다. */ flushCulvertOptions: () => Promise; /** 관 옵션 조각을 예약한다 — 좌측 폼의 [수정]도 이 경로로 정본에 간다(2026-08-29). */ queueCulvertOptions: (chainageM: number, patch: Record) => void; /** 기준 측점 이동을 예약한다 — B06 구조물 배치 폼의 측점 칸이 쓴다(2026-08-29). */ queueCulvertMove: (fromChainageM: number, toChainageM: number) => void; load: () => void; /** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */ applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void; } export function createStationControls(deps: StationControlDeps): StationControls { /* ── 조작값 캐시 write-through(2026-08-24 사용자 확정 데이터 흐름) ──────── * 영구저장소 → 브라우저 캐시 → 3D는 **캐시로 플롯**. 편집하면 캐시를 갱신하고 그 * 캐시로 다시 그린다. 그래서 조정창 조작값은 세션에만 담지 않고 그 자리에서 * `section.design`(B05·B06 공유 객체)에 실어야 한다 — 세션은 리로드 복원용 사본이고, * 코리도 해시가 design을 읽으므로 캐시가 바뀌면 3D가 다시 빌드된다. */ const sectionAt = (chainageM: number): CrossSection | undefined => deps .detail() ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01); /** design이 있는 측점에만 얹는다 — 없는 측점은 아직 설계 전이라 실을 자리가 없다. */ const patchCachedDesign = (chainageM: number, patch: Partial): void => { const design = sectionAt(chainageM)?.design; if (design) Object.assign(design, patch); }; /* ── 측점 개별 표시 반폭(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(); /** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편: * 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */ let revetSelection: { at: string; key: RevetKey } | null = null; const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`; const revetSessionKey = (): string | null => deps.sessionKey("revetx"); function loadRevetShifts(): void { revetShifts.clear(); revetSelection = null; 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") { // 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로 // 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다. revetShifts.set(entry, { ...ZERO_ADJUST, ...value, d: value.d ? value.d : null }); } }); } 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 && value.d == null && 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(); // 같은 값을 캐시(design.revet_adjust)에도 실어 3D가 바로 따라오게 한다. const [chainage, role] = key.split(":"); const at = Number(chainage); if (!Number.isFinite(at) || !role) return; const design = sectionAt(at)?.design; if (!design) return; const bucket = { ...(design.revet_adjust ?? {}) }; if (isDefaultAdjust(value)) delete bucket[role]; else bucket[role] = { ...value }; design.revet_adjust = bucket; }; const axisEq = (a: number | null, b: number | null): boolean => a == null || b == null ? a === b : Math.abs(a - b) < 1e-9; const sameAdjust = (a: WallAdjust, b: WallAdjust): boolean => Math.abs(a.x - b.x) < 1e-9 && axisEq(a.d, b.d) && (a.h ?? null) === (b.h ?? null) && (a.m ?? null) === (b.m ?? null); /** 그 측점과 **그 구조물이 덮는 측점**을 함께 다시 그린다 — 연동된 옆 측점이 * 같이 움직여야 한다(2026-08-24 사용자). 구조물이 없는 측점이면 자기 카드만. */ const refreshWithLinked = (chainageM: number): void => { deps.refreshCard(chainageM); const sections = deps.detail()?.cross_sections ?? []; const owner = sections.find((s) => Math.abs(s.chainage_m - chainageM) < 0.01); if (!owner) return; // 연장은 그리기·링크 판정과 **같은 함수**로 잰다 — 다단 단별 구간값까지 본다. const span = owner.culvert ? culvertReach(owner) : null; if (!span) return; const reach = Math.max(span.beforeM, span.afterM); for (const other of sections) { if (other === owner) continue; if (Math.abs(other.chainage_m - chainageM) <= reach + 1e-9) deps.refreshCard(other.chainage_m); } }; const revetOffsetControl: RevetOffsetControl = { // 세션 → 정본(design.revet_adjust) → 자동. 확정된 조작값이 재접근·3D에서 살아난다 // (2026-08-24 사용자: 3D는 종단·횡단 확정 뒤의 최종 산출물). adjustFor: (section, role) => revetShifts.get(revetKey(section.chainage_m, role)) ?? (section.design?.revet_adjust?.[role] ? { ...ZERO_ADJUST, ...section.design.revet_adjust[role] } : { ...ZERO_ADJUST }), storedAdjustFor: (section, role) => { const session = revetShifts.get(revetKey(section.chainage_m, role)); if (session) return session; const stored = section.design?.revet_adjust?.[role]; return stored ? { ...ZERO_ADJUST, ...stored } : null; }, selectedFor: (section) => revetSelection?.at === section.chainage_m.toFixed(2) ? revetSelection.key : null, // 강조는 **같은 구조물**을 그리는 카드 전부. 고른 카드의 소유 측점과 이 카드의 // 소유 측점이 같고, 그 벽이 이 측점에도 서면 켠다(2026-08-30 사용자). highlightFor: (section) => { if (!revetSelection) return null; if (revetSelection.at === section.chainage_m.toFixed(2)) return revetSelection.key; const picked = sectionAt(Number(revetSelection.at)); if (!picked) return null; const owner = picked.culvert ? picked : ownerOf(picked); const here = section.culvert ? section : ownerOf(section); if (!owner || !here || Math.abs(here.chainage_m - owner.chainage_m) > 0.01) return null; return wallStandsAt(owner, section, revetSelection.key) ? revetSelection.key : null; }, select: (chainageM, key) => { revetSelection = key ? { at: chainageM.toFixed(2), key } : null; revetOffsetControl.onSelectionChange?.(); }, 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: null = 기본 자리(배관 벽 — 경사길이 4m 지점) 유지. d: patch.d === undefined ? current.d : patch.d === null ? null : clampMove(patch.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); refreshWithLinked(chainageM); }, reset: (chainageM, role) => { revetShifts.delete(revetKey(chainageM, role)); persistRevetShifts(); refreshWithLinked(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)) ?? section.design?.extra_wall_counts?.[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)); extraSpans.delete(spanKeyOf(chainageM, `${extraPrefix(side)}${i}`)); } persistRevetShifts(); persistExtraSpans(); persistExtraCounts(); patchCachedDesign(chainageM, { extra_wall_counts: { outlet: extraCounts.get(extraKey(chainageM, "outlet")) ?? 0, basin: extraCounts.get(extraKey(chainageM, "basin")) ?? 0, }, }); refreshWithLinked(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)); extraSpans.delete(spanKeyOf(chainageM, `${extraPrefix(side)}${i}`)); } persistRevetShifts(); persistExtraSpans(); 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(); patchCachedDesign(chainageM, { inlet_structure: value }); refreshWithLinked(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(); 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); }, }; /* ── 구조물 구간값(길이·전/후)과 연동 옵션 ───────────────────────────── * 2026-08-24 사용자. 구간값의 원천은 배수관 옵션(`pipe_points`) 하나다 — 여기서는 * 캐시(detail의 culvert 스펙)를 고쳐 즉시 보여주고, 저장은 페이지가 예약해 늦게 * 한 번 내보낸다. 링크 측점에서 만져도 바뀌는 것은 **소유 측점** 값이다. * 연동은 측점별(세션 → 정본 → 기본 켬), 종단경사 반영은 소유 측점에 하나. */ const culvertOptions = createCulvertOptionWriter( deps.projectId, () => deps.sessionKey("culvertopt"), deps.onSaveError, () => deps.sessionKey("culvertmove"), ); const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink")); const linkDetached = linkSession.detached; const followGrades = linkSession.followGrade; const chainageKey = (chainageM: number): string => chainageM.toFixed(2); const loadLinkFlags = linkSession.load; const persistLinkFlags = linkSession.persist; /** 이 카드가 구간값을 만질 대상 — 링크면 소유 측점, 아니면 자기 자신. */ const ownerOf = (section: CrossSection): CrossSection | null => { if (section.culvert) return section; const sections = deps.detail()?.cross_sections ?? []; return culvertOwnerFor(section, sections) ?? null; }; /* ── 다단(추가) 기슭막이 **단별** 구간값(2026-08-29 사용자) ─────────────── * 기준벽 연장에 종속시키지 않는다 — 계곡부·능선부에서 아래 단일수록 연장이 달라져 * 자동 규칙으로 못 잡는다. 제어는 세션이고 [저장]·[확정] 때 정본으로 간다 * (4축 조작값과 같은 경로). 키는 `누가거리:벽키`(예 `234.10:extra0`). */ const extraSpans = new Map(); const extraSpanSessionKey = (): string | null => deps.sessionKey("extraspan"); const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`; function loadExtraSpans(): void { extraSpans.clear(); const key = extraSpanSessionKey(); if (!key) return; try { const raw = window.sessionStorage.getItem(key); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([mapKey, value]) => { if (value && Number.isFinite(value.beforeM) && Number.isFinite(value.afterM)) { extraSpans.set(mapKey, value); } }); } catch { /* 손상된 세션 값은 무시 — 기본 구간값으로 재시작. */ } } function persistExtraSpans(): void { const key = extraSpanSessionKey(); if (!key) return; try { window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(extraSpans))); } catch { /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ } } /** * 세션에 없을 때의 단별 구간값 — 정본(`design.extra_spans`) → **소유 벽 연장 상속** * 순서다(2026-08-30 사용자: 손대기 전에는 기준벽과 같이 옆 측점에 서야 한다). * 상속 규칙은 그리기·링크 판정이 쓰는 `tierSpanOf`와 같은 함수 하나로 맞춘다. */ const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => { const span = tierSpanOf(owner, wall); return { lengthM: Math.round((span.beforeM + span.afterM) * 10) / 10, beforeM: Math.round(span.beforeM * 10) / 10, afterM: Math.round(span.afterM * 10) / 10, }; }; /** 이 측점의 단별 구간값 전부(세션 우선) — 캐시·payload에 실을 모양으로. */ const tierSpansOf = (owner: CrossSection): Record => { const prefix = `${owner.chainage_m.toFixed(2)}:`; const result: Record = { ...(owner.design?.extra_spans ?? {}) }; extraSpans.forEach((value, key) => { if (!key.startsWith(prefix)) return; result[key.slice(prefix.length)] = { length_m: value.lengthM, before_m: value.beforeM, after_m: value.afterM, }; }); return result; }; const structureSpanControl: StructureSpanControl = { ownerOf, tierValuesFor: (section, key) => { const owner = ownerOf(section) ?? section; return extraSpans.get(spanKeyOf(owner.chainage_m, key)) ?? storedTierSpan(owner, key); }, updateTier: (section, key, patch) => { const owner = ownerOf(section); if (!owner) return; const mapKey = spanKeyOf(owner.chainage_m, key); 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) }); // 연장이 바뀌면 링크되는 옆 측점이 달라진다 — 옛 연장·새 연장을 합친 구간만. const reach = Math.max(current.beforeM, current.afterM, next.beforeM, next.afterM); for (const other of deps.detail()?.cross_sections ?? []) { if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) { deps.refreshCard(other.chainage_m); } } }, valuesFor: (section, role) => { const owner = ownerOf(section); return owner ? CulvertConst.spanValuesOf(owner, role) : null; }, update: (section, role, patch) => { const owner = ownerOf(section); if (!owner?.culvert) return; const current = CulvertConst.spanValuesOf(owner, role); if (!current) return; // 길이↔전/후 산식은 다단과 공용(`applySpanPatch`). const { lengthM, beforeM, afterM } = CulvertConst.applySpanPatch(current, patch); const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet; const keys = CulvertConst.SPAN_OPTION_KEYS[role]; if (role === "basin") { spec.basin_length_m = lengthM; spec.basin_before_m = beforeM; spec.basin_after_m = afterM; } else { spec.revet_length_m = lengthM; spec.revet_before_m = beforeM; spec.revet_after_m = afterM; } culvertOptions.queue(owner.chainage_m, { [keys.length]: lengthM, [keys.before]: beforeM, [keys.after]: afterM, }); // 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과 // 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번 // 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러 // 2번만 반영). const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM); for (const other of deps.detail()?.cross_sections ?? []) { if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) { deps.refreshCard(other.chainage_m); } } }, }; /** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */ const followGradeOf = (section: CrossSection): boolean => { const session = followGrades.get(chainageKey(section.chainage_m)); if (session !== undefined) return session; return section.design?.revet_follow_grade !== false; }; /** * 연동 해제로 **그 측점에만** 붙어 있던 4축 값을 지운다 — 경사 해제(수평)로 넘어갈 때 * 쓴다(2026-08-30 사용자: 해제해 둔 값은 되살리지 않는다). 옛 D경로 독립 벽(`own`)은 * 이 구조물 것이 아니라 남긴다. */ const dropLinkedAdjusts = (chainageM: number): void => { const prefix = `${chainageM.toFixed(2)}:`; for (const key of [...revetShifts.keys()]) { if (!key.startsWith(prefix) || key.slice(prefix.length) === "own") continue; revetShifts.delete(key); } persistRevetShifts(); const design = sectionAt(chainageM)?.design; if (!design?.revet_adjust) return; const own = design.revet_adjust.own; design.revet_adjust = own ? { own } : {}; }; const revetLinkControl: RevetLinkControl = { linkedFor: (section) => { // 경사 해제(수평)면 연동은 잠긴다 — 측점마다 자리를 따로 잡으면 기슭막이 Z를 // 하나로 묶을 수 없다(2026-08-30 사용자). if (!followGradeOf(ownerOf(section) ?? section)) return true; const session = linkDetached.get(chainageKey(section.chainage_m)); if (session !== undefined) return !session; return section.design?.revet_link_detached !== true; }, setLinked: (chainageM, linked) => { linkDetached.set(chainageKey(chainageM), !linked); persistLinkFlags(); patchCachedDesign(chainageM, { revet_link_detached: !linked }); deps.refreshCard(chainageM); }, followGradeFor: followGradeOf, setFollowGrade: (section, follow) => { followGrades.set(chainageKey(section.chainage_m), follow); patchCachedDesign(section.chainage_m, { revet_follow_grade: follow }); // 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그 // 연장 안의 측점만 다시 그린다. const span = section.culvert ? culvertReach(section) : null; const reach = span ? Math.max(span.beforeM, span.afterM) : 0; for (const other of deps.detail()?.cross_sections ?? []) { if (Math.abs(other.chainage_m - section.chainage_m) > reach + 1e-9) continue; // 수평으로 넘어가면 연동 해제를 되돌리고 그 측점만의 자리도 지운다 // (2026-08-30 사용자: 경사를 다시 켜도 되살아나지 않는다). if (!follow && Math.abs(other.chainage_m - section.chainage_m) > 0.01) { linkDetached.set(chainageKey(other.chainage_m), false); patchCachedDesign(other.chainage_m, { revet_link_detached: false }); dropLinkedAdjusts(other.chainage_m); } deps.refreshCard(other.chainage_m); } persistLinkFlags(); }, }; // 세월교·BOX암거 조작은 별도 모듈(700줄 제한) — 저장 흐름은 집수정과 같다. const bodyControls = createBodyControls( { sectionAt, patchCachedDesign, refreshCard: deps.refreshCard, queuePipeOptions: culvertOptions.queue, round1, clampMove, }, deps.sessionKey, ); return { stationWidth: stationWidthControl, revetOffset: revetOffsetControl, inletStructure: inletStructureControl, extraWalls: extraWallControl, structureSpan: structureSpanControl, revetLink: revetLinkControl, ford: bodyControls.ford.control, box: bodyControls.box.control, fordAdjustsByChainage: bodyControls.ford.byChainage, boxAdjustsByChainage: bodyControls.box.byChainage, widths: stationWidths, inletStructures, basinAdjustments, // 확정·임시저장 payload용 — 세션 키(누가거리:역할)를 측점별로 되접는다. revetAdjustsByChainage: () => { const result = new Map>(); revetShifts.forEach((adjust, key) => { const [chainage, role] = key.split(":"); const value = Number(chainage); if (!Number.isFinite(value) || !role) return; const bucket = result.get(value) ?? {}; bucket[role] = adjust; result.set(value, bucket); }); return result; }, // 확정 payload용 — 측점별 연동 해제·종단경사 반영 플래그. linkFlagsByChainage: () => { const result = new Map(); const put = (key: string, patch: { detached?: boolean; followGrade?: boolean }): void => { const value = Number(key); if (!Number.isFinite(value)) return; result.set(value, { ...(result.get(value) ?? {}), ...patch }); }; linkDetached.forEach((detached, key) => put(key, { detached })); followGrades.forEach((followGrade, key) => put(key, { followGrade })); return result; }, extraCountsByChainage: () => { const result = new Map(); extraCounts.forEach((count, key) => { const basin = key.startsWith("b"); const value = Number(basin ? key.slice(1) : key); if (!Number.isFinite(value)) return; const bucket = result.get(value) ?? { outlet: 0, basin: 0 }; bucket[basin ? "basin" : "outlet"] = count; result.set(value, bucket); }); return result; }, extraSpansByChainage: () => { const result = new Map>(); extraSpans.forEach((value, key) => { const [chainage, wall] = key.split(":"); const chainageM = Number(chainage); if (!Number.isFinite(chainageM) || !wall) return; const bucket = result.get(chainageM) ?? {}; bucket[wall] = { length_m: value.lengthM, before_m: value.beforeM, after_m: value.afterM }; result.set(chainageM, bucket); }); return result; }, flushCulvertOptions: () => culvertOptions.flush(), queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch), queueCulvertMove: (fromChainageM, toChainageM) => culvertOptions.queueMove(fromChainageM, toChainageM), load: () => { loadStationWidths(); loadRevetShifts(); loadInletStructures(); loadBasinAdjustments(); loadExtraCounts(); loadExtraSpans(); loadLinkFlags(); bodyControls.ford.load(); bodyControls.box.load(); }, applyGlobalWidth: (requested, chainages) => { stationWidths.clear(); if (requested !== undefined) { for (const chainageM of chainages) { stationWidths.set(widthKey(chainageM), clampStationWidth(requested)); } } persistStationWidths(); }, }; }