diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 90a08089..1dd57e58 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -21,7 +21,7 @@ import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_A import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; -import { restrictToSide } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; +import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { @@ -538,13 +538,16 @@ export function buildCorridorStructures( if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM); else pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore); } - // 다단(성토부) 벽은 유출측 연장을, 집수정 계류측 다단은 집수정 연장을 따른다. - for (const wall of layout.extraWalls) { - pushSwept("revet", wall.points, outletSpan.beforeM, outletSpan.afterM); - } - for (const wall of layout.basinExtras) { - pushSwept("revet", wall.points, basinSpan.beforeM, basinSpan.afterM); - } + // 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다). + // 값이 없는 단은 고정 기본 10m(5/5)로 선다(`tierSpanOf`). + layout.extraWalls.forEach((wall, i) => { + const span = tierSpanOf(section, `extra${i}`); + pushSwept("revet", wall.points, span.beforeM, span.afterM); + }); + layout.basinExtras.forEach((wall, i) => { + const span = tierSpanOf(section, `bextra${i}`); + pushSwept("revet", wall.points, span.beforeM, span.afterM); + }); if (layout.basin) { // 집수정 = 부재 외곽을 감싼 **직육면체**(2026-08-23 사용자). diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 7b8384e1..c88ce07d 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -323,6 +323,13 @@ export interface StoredWallAdjust { m: string | null; } +/** 다단 기슭막이 한 단의 종방향 구간값(길이·기준측점 전/후 m — 2026-08-29). */ +export interface StoredWallSpan { + length_m: number; + before_m: number; + after_m: number; +} + /** 다단 기슭막이 단 수(유출 성토부 / 집수정 계류측). */ export interface StoredExtraWallCounts { outlet: number; @@ -372,6 +379,9 @@ export interface CrossDesign { revet_adjust?: Record; /** 다단 기슭막이 단 수 — 유출 성토부·집수정 계류측. */ extra_wall_counts?: StoredExtraWallCounts; + /** 다단 기슭막이 **단별** 구간값 — 키는 벽 키("extra0"…/"bextra0"…). 기준벽 연장에 + * 종속되지 않고 단마다 따로 잡는다(2026-08-29 사용자). 없으면 기본 10m(5/5). */ + extra_spans?: Record; /** 연동 해제(측점별 — 2026-08-24 사용자). 옆 측점에서 연장돼 온 기슭막이의 위치 * 4축을 이 측점에서 따로 잡는다. 구조물 추가가 아니라 3D 위치의 개별 지정이다. */ revet_link_detached?: boolean; @@ -478,6 +488,7 @@ export interface CrossSectionPatch { ford_adjust?: StoredFordAdjust; box_adjust?: StoredBoxAdjust; extra_wall_counts?: StoredExtraWallCounts; + extra_spans?: Record; /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */ revet_link_detached?: boolean; revet_follow_grade?: boolean; diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index a66540ff..418f1039 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -132,6 +132,10 @@ async def _apply_section_edits( patch["box_adjust"] = patch_item.box_adjust.model_dump() if patch_item.extra_wall_counts is not None: patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump() + if patch_item.extra_spans is not None: + patch["extra_spans"] = { + wall: span.model_dump() for wall, span in patch_item.extra_spans.items() + } if patch_item.revet_link_detached is not None: patch["revet_link_detached"] = patch_item.revet_link_detached if patch_item.revet_follow_grade is not None: diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 70afb901..f4d80979 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -84,6 +84,14 @@ class WallAdjustPatch(BaseModel): m: str | None = None +class WallSpanPatch(BaseModel): + """다단 기슭막이 한 단의 종방향 구간값(길이·기준측점 전/후 m — 2026-08-29).""" + + length_m: float = Field(default=10.0, ge=0.0, le=200.0) + before_m: float = Field(default=5.0, ge=0.0, le=100.0) + after_m: float = Field(default=5.0, ge=0.0, le=100.0) + + class ExtraWallCountsPatch(BaseModel): """다단 기슭막이 단 수 — 유출 성토부(outlet)·집수정 계류측(basin).""" @@ -138,6 +146,9 @@ class CrossSectionPatch(BaseModel): # BOX암거 구체 조작값(좌·우 끝 길이·표고) — 2026-08-25 사용자. box_adjust: BoxAdjustPatch | None = None extra_wall_counts: ExtraWallCountsPatch | None = None + # 다단 기슭막이 단별 구간값 — 키는 벽 키("extra0"…/"bextra0"…). 기준벽 연장에 + # 종속되지 않고 사용자가 단마다 넣는다(2026-08-29 사용자 확정). + extra_spans: dict[str, WallSpanPatch] | None = None # 연동 기슭막이 옵션(2026-08-24 사용자). 연동 해제는 측점별, 종단경사 반영은 # 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다. revet_link_detached: bool | None = None diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index b24f6aa0..e12be186 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -311,6 +311,33 @@ export const SPAN_OPTION_KEYS: Record): SpanValues { + let { beforeM, afterM } = current; + if (patch.lengthM !== undefined) { + const total = Math.max(patch.lengthM, 0); + const delta = total - current.lengthM; + const ratio = current.lengthM > 1e-9 ? current.beforeM / current.lengthM : 0.5; + beforeM = Math.max(Math.round((current.beforeM + delta * ratio) * 10) / 10, 0); + afterM = Math.max(Math.round((total - beforeM) * 10) / 10, 0); + } + if (patch.beforeM !== undefined) beforeM = Math.max(patch.beforeM, 0); + if (patch.afterM !== undefined) afterM = Math.max(patch.afterM, 0); + return { lengthM: Math.round((beforeM + afterM) * 10) / 10, beforeM, afterM }; +} + /** 소유 측점의 구간값(길이·전/후) — 저장 옵션과 기본값을 함께 푼 결과. */ export const spanValuesOf = (owner: CrossSection, role: SpanRole): SpanValues | null => { const spec = role === "outlet" ? owner.culvert?.outlet : owner.culvert?.inlet; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index adb56b74..40c8c103 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -14,6 +14,7 @@ import { materialLimit, revetSpanOfSpec, } from "./B06_Section_UI_Cross_Culvert_Const"; +import { EXTRA_SPAN_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; import type { StructureSpan } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { @@ -57,6 +58,10 @@ export interface StructureSpanControl { ownerOf: (section: CrossSection) => CrossSection | null; valuesFor: (section: CrossSection, role: SpanRole) => SpanValues | null; update: (section: CrossSection, role: SpanRole, patch: Partial) => void; + /** 다단 한 단의 구간값 — 기준벽과 달리 **단마다 따로** 잡는다(2026-08-29 사용자). + * 값은 세션에 담기고 [저장]·[확정] 때 정본으로 간다. 없으면 기본 10m(5/5). */ + tierValuesFor: (section: CrossSection, key: RevetKey) => SpanValues; + updateTier: (section: CrossSection, key: RevetKey, patch: Partial) => void; } /** @@ -132,7 +137,17 @@ export interface CulvertLink { deltaM: number; } -/** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이 연장·집수정 연장 중 큰 값. */ +/** + * 다단 한 단의 구간값 — 단별로 따로 잡은 값(`design.extra_spans`)이 있으면 그것, + * 없으면 고정 기본 10m(5/5). 기준벽 연장을 상속하지 않는다(2026-08-29 사용자). + */ +export function tierSpanOf(section: CrossSection, key: string): StructureSpan { + const stored = section.design?.extra_spans?.[key]; + if (!stored) return { ...EXTRA_SPAN_DEFAULT }; + return { beforeM: Math.max(stored.before_m, 0), afterM: Math.max(stored.after_m, 0) }; +} + +/** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이·집수정·다단 중 큰 값. */ function culvertReach(section: CrossSection): StructureSpan | null { const culvert = section.culvert; if (!culvert) return null; @@ -146,6 +161,12 @@ function culvertReach(section: CrossSection): StructureSpan | null { beforeM = Math.max(beforeM, span.beforeM); afterM = Math.max(afterM, span.afterM); } + // 다단은 기준벽보다 길 수 있다(단별 수동값 — 2026-08-29). 그 몫까지 닿아야 먼 + // 측점 카드에도 그 단이 선다. + for (const span of Object.values(section.design?.extra_spans ?? {})) { + beforeM = Math.max(beforeM, Math.max(span.before_m, 0)); + afterM = Math.max(afterM, Math.max(span.after_m, 0)); + } return beforeM > 0 || afterM > 0 ? { beforeM, afterM } : null; } @@ -247,16 +268,28 @@ function trimLinkedLayout(layout: CulvertLayout, link: CulvertLink): CulvertLayo return { ...layout, walls: layout.walls.filter((wall) => (wall.role === "inlet" ? inletIn : outletIn)), - // 다단(성토부)은 유출 벽에 딸린다. - extraWalls: outletIn ? layout.extraWalls : [], - outletFill: outletIn ? layout.outletFill : empty, + // 다단은 **자기 구간값**으로 판정한다(2026-08-29 사용자 — 단마다 연장이 다르다). + // 성토부선은 단이 서는 카드에서만 그린다(끝 단이 빠지면 그 아래 선도 뺀다). + extraWalls: layout.extraWalls.filter((_wall, i) => + spanCovers(tierSpanOf(link.source, `extra${i}`), link.deltaM), + ), + outletFill: spanCovers(tierSpanOf(link.source, "extra0"), link.deltaM) + ? layout.outletFill + : empty, // 집수정은 **소유 측점 횡단도 하나에만** 선다(2026-08-24 사용자 확정) — 기슭막이와 // 달리 옆 측점으로 이어지는 구조물이 아니다. 계류측 다단·성토부선도 집수정에 딸린 // 것이라 함께 뺀다. 3D에서는 소유 측점 하나가 자기 길이(기본 2m)만큼 스윕한다. // 단 독립 기슭막이(관 숨김)는 집수정이 없고 이 채널이 **유입측 벽의 다단·성토부선** // 이라, 유출측과 같은 규칙으로 그 벽의 연장을 따른다(2026-08-29 좌우 통일). - basinExtras: spec.hidden_pipe && inletIn ? layout.basinExtras : [], - basinFill: spec.hidden_pipe && inletIn ? layout.basinFill : empty, + basinExtras: spec.hidden_pipe + ? layout.basinExtras.filter((_wall, i) => + spanCovers(tierSpanOf(link.source, `bextra${i}`), link.deltaM), + ) + : [], + basinFill: + spec.hidden_pipe && spanCovers(tierSpanOf(link.source, "bextra0"), link.deltaM) + ? layout.basinFill + : empty, basin: null, designTrim: keepTrimSides( layout.designTrim, diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index a8567474..9fdffd0c 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -564,8 +564,8 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan ); currentCount = tiers.count; countValue.textContent = `${tiers.count}단`; - // 구간값 — 있는 요소(유입·유출 기슭막이, 유입 집수정)에서만 세 행이 뜬다. - // 다단(extra·bextra)은 소유 벽 연장을 그대로 따르는 파생물이라 대상이 아니다. + // 구간값 — 유입·유출 기슭막이, 유입 집수정, 그리고 **다단**(2026-08-29 사용자: + // 계곡부·능선부에서 단마다 연장이 달라 단별로 손수 넣는다)에서 세 행이 뜬다. currentSpan = deps.spanFor(key); for (const entry of spanRows) { entry.row.classList.toggle("is-hidden", currentSpan === null); diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index 31a06389..041ccfd5 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -22,9 +22,13 @@ import type { StructureSpanControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; +/** 다단(추가) 벽 키인가 — 유출측 `extra0…`·유입측 `bextra0…`. */ +export const isTierKey = (key: RevetKey): boolean => + key.startsWith("extra") || key.startsWith("bextra"); + /** - * 선택된 벽이 구간값을 갖는 요소인지 — 유입·유출 기슭막이와 유입 집수정만 자기 - * 길이·전/후를 갖는다. 다단(extra·bextra)은 소유 벽 연장을 그대로 따른다. + * 선택된 벽이 **기준벽 구간값**을 갖는 요소인지 — 유입·유출 기슭막이와 유입 집수정. + * 다단(extra·bextra)은 자기 단별 구간값을 따로 갖는다(`isTierKey` — 2026-08-29). */ export function spanRoleOf(key: RevetKey, inletIsBasin = false): SpanRole | null { if (key === "inlet") return inletIsBasin ? "basin" : "inlet"; @@ -163,13 +167,19 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe ctx.extraWalls?.setCount(ctx.adjustChainage(), count, "basin"); }, equalizeExtras: () => ctx.extraWalls?.equalize(ctx.adjustChainage()), - // 구간값(길이·전·후) — 다단은 소유 벽 연장을 따르는 파생물이라 대상이 아니다. + // 구간값(길이·전·후). 기준벽은 배관 옵션 정본, **다단은 단별 세션 값**이다 + // (2026-08-29 사용자 — 계곡부·능선부에서 단마다 연장이 달라 수동으로 넣는다). // 링크 카드에서 만져도 제어기가 소유 측점 값을 고친다(원천은 하나). spanFor: (key) => { + if (isTierKey(key)) return ctx.structureSpan?.tierValuesFor(ctx.section, key) ?? null; const role = spanRoleOf(key, ctx.inletIsBasin()); return role && ctx.structureSpan ? ctx.structureSpan.valuesFor(ctx.section, role) : null; }, setSpan: (key, patch) => { + if (isTierKey(key)) { + ctx.structureSpan?.updateTier(ctx.section, key, patch); + return; + } const role = spanRoleOf(key, ctx.inletIsBasin()); if (role) ctx.structureSpan?.update(ctx.section, role, patch); }, diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 6a7571f9..24835730 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -516,6 +516,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { basinAdjustments, revetAdjusts: stationControls.revetAdjustsByChainage(), extraCounts: stationControls.extraCountsByChainage(), + extraSpans: stationControls.extraSpansByChainage(), fordAdjusts: stationControls.fordAdjustsByChainage(), boxAdjusts: stationControls.boxAdjustsByChainage(), linkFlags: stationControls.linkFlagsByChainage(), diff --git a/B06_Section/B06_Section_UI_Page_Patches.ts b/B06_Section/B06_Section_UI_Page_Patches.ts index 50fa38a4..cdb5025f 100644 --- a/B06_Section/B06_Section_UI_Page_Patches.ts +++ b/B06_Section/B06_Section_UI_Page_Patches.ts @@ -7,7 +7,7 @@ * 모으기만 한다 — 판정·한계는 각 제어기가 이미 끝냈다. * ========================================================================== */ -import type { CrossSectionPatch, StoredWallAdjust } from "./B06_Section_Api_Fetch"; +import type { CrossSectionPatch, StoredWallAdjust, StoredWallSpan } from "./B06_Section_Api_Fetch"; import type { BasinAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { FordAdjust } from "./B06_Section_UI_Cross_Ford"; import type { BoxAdjust } from "./B06_Section_UI_Cross_Box"; @@ -22,6 +22,8 @@ export interface CrossPatchSources { basinAdjustments: Map; revetAdjusts: Map>; extraCounts: Map; + /** 다단 단별 구간값(2026-08-29) — 키는 벽 키("extra0"…/"bextra0"…). */ + extraSpans: Map>; fordAdjusts: Map; boxAdjusts: Map; linkFlags: Map; @@ -57,6 +59,10 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch sources.extraCounts.forEach((counts, chainage) => { patchFor(chainage).extra_wall_counts = counts; }); + // 다단 단별 구간값 — 세션에서 만진 값을 정본에 싣는다(2026-08-29 사용자). + sources.extraSpans.forEach((spans, chainage) => { + patchFor(chainage).extra_spans = spans; + }); // 세월교 측벽 조작값(2026-08-25) — 배수관 값과 같은 자리에 실어 3D·재계산이 잇는다. sources.fordAdjusts.forEach((adjust, chainage) => { patchFor(chainage).ford_adjust = adjust; diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 70a7e681..45f6927b 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -5,7 +5,12 @@ * (`_UI_Page.ts`)에서 700줄 제한으로 분리했다. * ========================================================================== */ -import type { CrossDesign, CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; +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"; @@ -14,6 +19,7 @@ import type { InletStructureControl, RevetLinkControl, RevetOffsetControl, + SpanValues, StationWidthControl, StructureSpanControl, } from "./B06_Section_UI_Cross_View"; @@ -36,6 +42,7 @@ export interface StationControlDeps { | "inletstruct" | "basinadjust" | "extrawall" + | "extraspan" | "revetlink" | "fordadjust" | "boxadjust", @@ -79,6 +86,8 @@ export interface StationControls { extraCountsByChainage: () => Map; /** 확정 payload용 — 측점별 연동 해제·종단경사 반영(2026-08-24). */ linkFlagsByChainage: () => Map; + /** 다단 단별 구간값(2026-08-29) — 확정·임시저장 payload용. */ + extraSpansByChainage: () => Map>; /** 예약된 구간값 저장을 즉시 내보낸다 — 확정·임시저장 직전에 부른다. */ flushCulvertOptions: () => Promise; load: () => void; @@ -416,11 +425,13 @@ export function createStationControls(deps: StationControlDeps): StationControls 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: { @@ -439,8 +450,10 @@ export function createStationControls(deps: StationControlDeps): StationControls 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(); }, }; @@ -503,8 +516,97 @@ export function createStationControls(deps: StationControlDeps): StationControls 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)에 남은 단별 구간값 — 세션에 없을 때의 다음 후보. */ + const storedTierSpan = (owner: CrossSection, wall: string): SpanValues | null => { + const stored = owner.design?.extra_spans?.[wall]; + if (!stored) return null; + return { + lengthM: stored.length_m, + beforeM: stored.before_m, + afterM: stored.after_m, + }; + }; + + /** 이 측점의 단별 구간값 전부(세션 우선) — 캐시·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) ?? + CulvertConst.EXTRA_SPAN_DEFAULT + ); + }, + 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) ?? CulvertConst.EXTRA_SPAN_DEFAULT; + 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; @@ -514,20 +616,8 @@ export function createStationControls(deps: StationControlDeps): StationControls if (!owner?.culvert) return; const current = CulvertConst.spanValuesOf(owner, role); if (!current) return; - // 길이를 바꾸면 **늘어난 몫만** 지금 비율대로 나눠 담는다 — 매번 총길이에서 - // 비율로 다시 계산하면 0.1m 반올림이 쌓여 5.0/5.0이 5.6/5.4로 어긋난다 - // (2026-08-24 화면 실측). 전·후를 바꾸면 길이는 둘의 합이다. - let { beforeM, afterM } = current; - if (patch.lengthM !== undefined) { - const total = Math.max(patch.lengthM, 0); - const delta = total - current.lengthM; - const ratio = current.lengthM > 1e-9 ? current.beforeM / current.lengthM : 0.5; - beforeM = Math.max(Math.round((current.beforeM + delta * ratio) * 10) / 10, 0); - afterM = Math.max(Math.round((total - beforeM) * 10) / 10, 0); - } - if (patch.beforeM !== undefined) beforeM = Math.max(patch.beforeM, 0); - if (patch.afterM !== undefined) afterM = Math.max(patch.afterM, 0); - const lengthM = Math.round((beforeM + afterM) * 10) / 10; + // 길이↔전/후 산식은 다단과 공용(`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") { @@ -664,6 +754,18 @@ export function createStationControls(deps: StationControlDeps): StationControls }); 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(), load: () => { loadStationWidths(); @@ -671,6 +773,7 @@ export function createStationControls(deps: StationControlDeps): StationControls loadInletStructures(); loadBasinAdjustments(); loadExtraCounts(); + loadExtraSpans(); loadLinkFlags(); bodyControls.ford.load(); bodyControls.box.load();