diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index 0aaebb7e..a014cc93 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -3,8 +3,60 @@ * 연동 기슭막이 옵션(연동 해제·종단경사 반영)의 **세션 보관** — 측점 제어기 * (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25). * 두 플래그는 한 세션 항목에 같이 담아 확정 전 리로드에도 살아남는다. + * + * 측점별 조작값을 담는 **한 겹 맵**도 여기서 낸다(`createSessionMap`, 2026-09-02) — + * 반폭·기슭막이 4축·유입 형식·집수정·추가 벽 수·단별 구간값이 같은 모양(읽기 → 항목 + * 검증 → 담기 / 쓰기 → 통째 직렬화)이라 제어기마다 짝을 두던 것을 하나로 모았다. * ========================================================================== */ +/** 세션에 담기는 측점별 조작값 한 겹 맵. */ +export interface SessionMap { + values: Map; + /** 세션값 읽기 — 맵을 비우고 다시 채운다. */ + load: () => void; + /** 맵을 통째로 세션에 쓴다. */ + persist: () => void; +} + +/** + * 세션 보관 맵을 만든다. `accept` 가 항목마다 값을 검증·정규화해 돌려주고, + * `undefined` 를 내면 그 항목은 버린다(손상값·옛 형식 정리 자리). + */ +export function createSessionMap( + sessionKey: () => string | null, + accept: (value: unknown) => V | undefined, +): SessionMap { + const values = new Map(); + return { + values, + load(): void { + values.clear(); + const key = sessionKey(); + 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]) => { + const taken = accept(value); + if (taken !== undefined) values.set(entry, taken); + }); + } catch { + /* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */ + } + }, + persist(): void { + const key = sessionKey(); + if (!key) return; + try { + window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(values))); + } catch { + /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ + } + }, + }; +} + export interface LinkFlagMaps { detached: Map; followGrade: Map; diff --git a/B06_Section/B06_Section_UI_Page_Span_Control.ts b/B06_Section/B06_Section_UI_Page_Span_Control.ts new file mode 100644 index 00000000..2a9ffb30 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Span_Control.ts @@ -0,0 +1,131 @@ +/* ============================================================================= + * B06_Section_UI_Page_Span_Control.ts + * 구조물 **구간값(길이·전/후)** 제어 — 다단 단별 구간값과 유입·유출·집수정 구간값. + * + * `B06_Section_UI_Page_Station_Controls` 에서 떼어냈다(700줄 제한, 2026-09-02). + * 세션 맵(`extraSpans`)과 캐시(design)·정본 큐(`culvertOptions`)를 함께 만지는 + * 자리라 한 덩어리로 옮겼다. 판정 규칙은 종전대로 `_Cross_Culvert_Const` 몫이다. + * ========================================================================== */ + +import type { CrossDesign, CrossSection, StoredWallSpan } from "./B06_Section_Api_Fetch"; +import { + tierSpanOf, + type SpanValues, + type StructureSpanControl, +} from "./B06_Section_UI_Cross_Culvert_Wire"; +import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const"; + +/** 구간값 제어가 페이지·상위 제어기에서 받아 쓰는 창구. */ +export interface SpanControlDeps { + detail: () => { cross_sections: CrossSection[] } | null; + refreshCard: (chainageM: number) => void; + /** 연동 구조물의 소유 측점 — 값은 소유 측점 하나에만 담는다. */ + ownerOf: (section: CrossSection) => CrossSection | null; + /** 세션 맵(누가거리:벽키 → 구간값)과 저장. */ + extraSpans: Map; + persistExtraSpans: () => void; + patchCachedDesign: (chainageM: number, patch: Partial) => void; + /** 정본 반영 큐(관 옵션). */ + culvertOptions: { queue: (chainageM: number, values: Record) => void }; +} + +/** 구간값 제어기와 단별 구간값 조회를 만든다. */ +export function createSpanControl(deps: SpanControlDeps): { + control: StructureSpanControl; + /** 이 측점의 단별 구간값 전부(세션 우선) — 상위 제어기가 payload에 실을 때 쓴다. */ + tierSpansOf: (owner: CrossSection) => Record; + spanKeyOf: (chainageM: number, wall: string) => string; +} { + const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf, culvertOptions } = deps; + const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`; + + 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 control: 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); + } + } + }, + }; + + return { control, tierSpansOf, spanKeyOf }; +} diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index ac8c0db3..095e6be5 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -23,16 +23,11 @@ import type { 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 { culvertOwnerFor, culvertReach, 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 { createLinkFlagSession, createSessionMap } from "./B06_Section_UI_Page_Link_Session"; +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"; import type { BoxAdjust } from "./B06_Section_UI_Cross_Box"; @@ -127,40 +122,16 @@ export function createStationControls(deps: StationControlDeps): StationControls * 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때 * cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다. * 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */ - const stationWidths = new Map(); + const widthSession = createSessionMap( + () => 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 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 = { @@ -193,45 +164,29 @@ export function createStationControls(deps: StationControlDeps): StationControls * 값은 세션에만 담는다 — 자동 자리가 지형·계획고를 따라 다시 풀리므로, 손으로 * 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`. * 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */ - const revetShifts = new Map(); + const revetSession = createSessionMap( + () => deps.sessionKey("revetx"), + (value) => { + // 구 형식(숫자 = x 이동량)도 읽어 준다. + if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value }; + if (!value || typeof value !== "object") return undefined; + // 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로 + // 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다. + const partial = value as Partial; + return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null }; + }, + ); + const revetShifts = revetSession.values; /** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(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(); + revetSession.load(); 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 { - /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ - } } + const persistRevetShifts = revetSession.persist; /** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */ const round1 = (value: number): number => Math.round(value * 10) / 10; @@ -340,92 +295,37 @@ export function createStationControls(deps: StationControlDeps): StationControls }; /* ── 유입측 구조물 형식(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 { - /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ - } - } + const basinSession = createSessionMap( + () => deps.sessionKey("basinadjust"), + (value) => + value && typeof value === "object" + ? { ...DEFAULT_BASIN_ADJUST, ...(value as Partial) } + : undefined, + ); + const structSession = createSessionMap( + () => 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}` 키로. */ - const extraCounts = new Map(); - const extraSessionKey = (): string | null => deps.sessionKey("extrawall"); + const extraSession = createSessionMap( + () => 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; - 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) 두 갈래라 키에 쪽을 담는다. @@ -551,130 +451,30 @@ export function createStationControls(deps: StationControlDeps): StationControls * 기준벽 연장에 종속시키지 않는다 — 계곡부·능선부에서 아래 단일수록 연장이 달라져 * 자동 규칙으로 못 잡는다. 제어는 세션이고 [저장]·[확정] 때 정본으로 간다 * (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}`; + const extraSpanSession = createSessionMap( + () => deps.sessionKey("extraspan"), + (value) => { + const span = value as SpanValues | null; + return span && Number.isFinite(span.beforeM) && Number.isFinite(span.afterM) + ? span + : undefined; + }, + ); + const extraSpans = extraSpanSession.values; + const loadExtraSpans = extraSpanSession.load; + const persistExtraSpans = extraSpanSession.persist; - 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 = { + const spanControl = createSpanControl({ + detail: () => deps.detail(), + refreshCard: (chainageM) => deps.refreshCard(chainageM), 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); - } - } - }, - }; + extraSpans, + persistExtraSpans, + patchCachedDesign, + culvertOptions, + }); + const structureSpanControl = spanControl.control; + const spanKeyOf = spanControl.spanKeyOf; /** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */ const followGradeOf = (section: CrossSection): boolean => {