diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 446bff53..ac712a64 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -35,6 +35,7 @@ import { splitByBand, } from "./B05_Profile_UI_Corridor_Structures_Box"; import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box"; +import { DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -335,7 +336,16 @@ export function buildCorridorStructures( } }; - if (section.box) solids.push(...boxSolids(section, frameAt, stationFrame)); + if (section.box) { + // 3D는 **정본만** 읽는다(2026-08-24 규칙) — 확정 전 세션 값은 반영하지 않는다. + const stored = section.design?.box_adjust; + solids.push( + ...boxSolids(section, frameAt, stationFrame, { + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.left ?? {}) }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.right ?? {}) }, + }), + ); + } if (fordLayout) { // 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다. @@ -457,6 +467,11 @@ export function structureHashParts(section: CrossSection): Array { + const value = section.design?.box_adjust?.[side]; + return value ? `${side}:${value.lengthM},${value.riseM}` : ""; + }), ]; } const ford = section.ford; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts index 3da33ab3..6427f480 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts @@ -13,6 +13,7 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { computeBoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box"; +import type { BoxAdjust } from "../B06_Section/B06_Section_UI_Cross_Box"; import type { CorridorStructure, RouteFrame, @@ -108,78 +109,110 @@ export function boreRingChainages( return [...values].sort((a, b) => a - b); } -/** BOX암거 3D 솔리드(구체 셸 + 날개벽 4매). 기하가 부족하면 빈 배열. */ +/** 링 누가거리 목록 → 프레임(실패하면 측점 프레임으로 대체). */ +function ringsAt( + chainages: number[], + frameAt: (chainageM: number) => RouteFrame | null, + fallback: StructureFrame, +): StructureFrame[] { + return chainages.map((at) => { + const frame = frameAt(at); + return frame + ? { cx: frame.cx, cy: frame.cy, leftX: frame.leftX, leftY: frame.leftY, dz: 0 } + : fallback; + }); +} + +/** 단면 폴리곤을 링 수만큼 복제한다(구체는 링마다 단면이 같다). */ +function repeat( + points: Array<{ offset: number; elevation: number }>, + count: number, +): SectionPolygon[] { + const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]); + return Array.from({ length: count }, () => polygon); +} + +/** + * BOX암거 3D 솔리드 — 상판·구체 저판·측벽 2매·에이프런 2매 + 날개벽 4매. + * + * 구체 밖(날개벽 구간)에는 **벽을 세우지 않는다**(2026-08-25 사용자) — 천장 없는 + * 통로가 생기던 자리는 날개벽이 맡는다. 그래서 측벽은 구체 폭 구간에만 선다. + */ export function boxSolids( section: CrossSection, frameAt: (chainageM: number) => RouteFrame | null, stationFrame: StructureFrame, + adjust?: BoxAdjust, ): CorridorStructure[] { const box = section.box; - const layout = computeBoxLayout(section, section.samples); + const layout = computeBoxLayout(section, section.samples, adjust); if (!box || !layout) return []; const chainage = section.chainage_m; const halfSpan = box.span_m / 2; const innerHalf = box.inner_width_m / 2; - const barrelTop = layout.topElevation - box.top_thickness_m; - const barrelBottom = barrelTop - box.inner_height_m; - const leftEdge = layout.topSlab[0].offset; - const rightEdge = layout.topSlab[1].offset; - const slabLeft = layout.bottomSlab[0].offset; - const slabRight = layout.bottomSlab[1].offset; - // 측벽 자리는 틈 0(속 참), 내공 구간만 벌린다. 벽면을 수직으로 세우려고 경계 - // 누가거리마다 링을 두 장 겹쳐 둔다(길이 0 구간 = 벽 안쪽 면). - const ringPlan: Array<{ at: number; open: boolean }> = [ - { at: chainage - halfSpan, open: false }, - { at: chainage - innerHalf, open: false }, - { at: chainage - innerHalf, open: true }, - { at: chainage + innerHalf, open: true }, - { at: chainage + innerHalf, open: false }, - { at: chainage + halfSpan, open: false }, + const bodyChainages = [ + chainage - halfSpan, + chainage - innerHalf, + chainage + innerHalf, + chainage + halfSpan, ]; - const rings = ringPlan.map(({ at }) => { - const frame = frameAt(at); - return frame - ? { cx: frame.cx, cy: frame.cy, leftX: frame.leftX, leftY: frame.leftY, dz: 0 } - : stationFrame; - }); - const upper: SectionPolygon[] = []; - const lower: SectionPolygon[] = []; - for (const { open } of ringPlan) { - const band: VoidBand = open - ? { bottom: barrelBottom, top: barrelTop } - : { bottom: barrelTop, top: barrelTop }; - upper.push([ - [leftEdge, layout.topElevation], - [rightEdge, layout.topElevation], - [rightEdge, band.top], - [leftEdge, band.top], - ]); - lower.push([ - [slabLeft, band.bottom], - [slabRight, band.bottom], - [slabRight, layout.bottomElevation], - [slabLeft, layout.bottomElevation], - ]); - } + const bodyRings = ringsAt(bodyChainages, frameAt, stationFrame); const solids: CorridorStructure[] = [ - { chainage_m: chainage, kind: "basin", polygons: upper, rings }, - { chainage_m: chainage, kind: "basin", polygons: lower, rings }, + // 상판·저판은 구체 폭 전체에 걸친다. 좌·우 표고가 다르면 폴리곤째 기울어 있다. + { + chainage_m: chainage, + kind: "basin", + polygons: repeat(layout.topSlab, bodyRings.length), + rings: bodyRings, + }, + { + chainage_m: chainage, + kind: "basin", + polygons: repeat(layout.bottomSlab, bodyRings.length), + rings: bodyRings, + }, ]; - solids.push(...wingSolids(section, layout, frameAt, stationFrame)); + + // 측벽 2매 — 내공 단면을 구체 폭의 바깥 구간에만 세운다(천장 아래 벽). + for (const sign of [-1, 1]) { + const wallRings = ringsAt( + [chainage + sign * halfSpan, chainage + sign * innerHalf], + frameAt, + stationFrame, + ); + solids.push({ + chainage_m: chainage, + kind: "basin", + polygons: repeat(layout.barrel, wallRings.length), + rings: wallRings, + }); + } + + // 에이프런 2매 — 날개벽 구간 바닥, 각 끝 표고에서 수평. + for (const side of layout.sides) { + if (!side.apron.length) continue; + solids.push({ + chainage_m: chainage, + kind: "basin", + polygons: repeat(side.apron, bodyRings.length), + rings: bodyRings, + }); + } + + solids.push(...wingSolids(section, layout, frameAt)); return solids; } /** * 날개벽 4매 — 박스 네 모서리에서 각도만큼 벌어져 나간다(2026-08-25 사용자: 3D에 * 날개벽과 바닥이 있어야 한다). 노선이 아니라 **날개 축**을 따라 프레임을 만들어 - * 같은 로프트에 태우고, 높이는 구체 높이에서 짧은쪽 높이로 체감시킨다. + * 같은 로프트에 태우고, 높이는 그쪽 구체 높이에서 짧은쪽 높이로 체감시킨다. */ function wingSolids( section: CrossSection, layout: NonNullable>, frameAt: (chainageM: number) => RouteFrame | null, - stationFrame: StructureFrame, ): CorridorStructure[] { const box = section.box; if (!box) return []; @@ -187,7 +220,7 @@ function wingSolids( const here = frameAt(chainage); const ahead = frameAt(chainage + 1) ?? here; if (!here || !ahead) return []; - // 노선 진행 방향 — 앞 측점 프레임과의 차이. 실패하면 좌향의 법선으로 대체한다. + // 노선 진행 방향 — 앞 지점 프레임과의 차이. 실패하면 좌향의 법선으로 대체한다. let dirX = ahead.cx - here.cx; let dirY = ahead.cy - here.cy; const norm = Math.hypot(dirX, dirY); @@ -203,20 +236,18 @@ function wingSolids( const thickness = box.wall_thickness_m; const solids: CorridorStructure[] = []; - for (const side of ["left", "right"] as const) { - const wing = (side === "left") === inletOnLeft ? box.wing_in : box.wing_out; + for (const side of layout.sides) { + const wing = (side.role === "left") === inletOnLeft ? box.wing_in : box.wing_out; if (!wing.installed) continue; const length = Math.max(wing.length_m ?? 0, 0); if (length <= 0.05) continue; const angle = ((wing.angle_deg ?? 45) * Math.PI) / 180; - // 구체 축(= 좌향) 기준으로 벌어진다. 박스 양 끝에서 도로 앞·뒤로 한 장씩. - const axisSign = side === "left" ? 1 : -1; - const edgeOffset = side === "left" ? layout.topSlab[0].offset : layout.topSlab[1].offset; - const height0 = layout.topElevation - layout.bottomElevation; + const axisSign = side.role === "left" ? 1 : -1; + const height0 = side.topElevation - side.bottomElevation; const height1 = Math.max(wing.height_m ?? height0, 0.3); for (const along of [1, -1]) { - const startX = here.cx + here.leftX * edgeOffset + dirX * along * halfSpan; - const startY = here.cy + here.leftY * edgeOffset + dirY * along * halfSpan; + const startX = here.cx + here.leftX * side.offset + dirX * along * halfSpan; + const startY = here.cy + here.leftY * side.offset + dirY * along * halfSpan; // 날개 방향 = 구체 축을 도로 방향으로 각도만큼 튼 단위벡터. const wingX = here.leftX * axisSign * Math.cos(angle) + dirX * along * Math.sin(angle); const wingY = here.leftY * axisSign * Math.cos(angle) + dirY * along * Math.sin(angle); @@ -228,25 +259,18 @@ function wingSolids( const steps = 4; for (let i = 0; i <= steps; i += 1) { const t = (length * i) / steps; - rings.push({ - cx: startX + ux * t, - cy: startY + uy * t, - // 패널 두께 방향 = 날개 축의 법선. - leftX: -uy, - leftY: ux, - dz: 0, - }); - const top = layout.bottomElevation + height0 + ((height1 - height0) * i) / steps; + // 패널 두께 방향 = 날개 축의 법선. + rings.push({ cx: startX + ux * t, cy: startY + uy * t, leftX: -uy, leftY: ux, dz: 0 }); + const top = side.bottomElevation + height0 + ((height1 - height0) * i) / steps; polygons.push([ [-thickness / 2, top], [thickness / 2, top], - [thickness / 2, layout.bottomElevation], - [-thickness / 2, layout.bottomElevation], + [thickness / 2, side.bottomElevation], + [-thickness / 2, side.bottomElevation], ]); } solids.push({ chainage_m: chainage, kind: "revet", polygons, rings }); } } - if (!solids.length && stationFrame) return []; return solids; } diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 356f29fe..8d2d1d48 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -326,6 +326,18 @@ export interface StoredFordAdjust { outlet: StoredFordWallAdjust; } +/** BOX암거 한쪽 끝의 저장형 조작값(2026-08-25). */ +export interface StoredBoxSideAdjust { + lengthM: number; + riseM: number; +} + +/** BOX암거 측점의 저장형 조작값 — 좌·우 끝을 따로 담는다. */ +export interface StoredBoxAdjust { + left: StoredBoxSideAdjust; + right: StoredBoxSideAdjust; +} + /** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */ export interface CrossDesign { inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; @@ -337,6 +349,8 @@ export interface CrossDesign { }; /** 세월교 측벽 조작값(유입·유출) — 높이·좌우·상하(2026-08-25). */ ford_adjust?: StoredFordAdjust; + /** BOX암거 구체 조작값(좌·우 끝) — 길이·표고(2026-08-25). */ + box_adjust?: StoredBoxAdjust; /** 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…). * 세션 전용이던 값을 정본에 남긴다(2026-08-24: 3D는 확정 결과물). */ revet_adjust?: Record; @@ -446,6 +460,7 @@ export interface CrossSectionPatch { }; revet_adjust?: Record; ford_adjust?: StoredFordAdjust; + box_adjust?: StoredBoxAdjust; extra_wall_counts?: StoredExtraWallCounts; /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */ revet_link_detached?: boolean; diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 59ca42ba..9d7bd852 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -664,6 +664,7 @@ async def compute_cross_section_design( "inlet_structure", "basin_adjust", "ford_adjust", + "box_adjust", "revet_adjust", "extra_wall_counts", "revet_link_detached", diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index ef8a713e..a66540ff 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -128,6 +128,8 @@ async def _apply_section_edits( } if patch_item.ford_adjust is not None: patch["ford_adjust"] = patch_item.ford_adjust.model_dump() + if patch_item.box_adjust is not None: + 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.revet_link_detached is not None: diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 7923cb70..dc9a5eaf 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -142,4 +142,6 @@ def recompute_designs_for_alignment( design["basin_adjust"] = stored["basin_adjust"] if stored.get("ford_adjust") is not None: design["ford_adjust"] = stored["ford_adjust"] + if stored.get("box_adjust") is not None: + design["box_adjust"] = stored["box_adjust"] section["design"] = design diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 06d71c1a..70afb901 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -106,6 +106,20 @@ class FordAdjustPatch(BaseModel): outlet: FordWallAdjustPatch = Field(default_factory=FordWallAdjustPatch) +class BoxSideAdjustPatch(BaseModel): + """BOX암거 한쪽 끝의 조작값 — 길이 연장·표고(2026-08-25).""" + + lengthM: float = Field(default=0, ge=0, le=50) + riseM: float = Field(default=0, ge=-20, le=20) + + +class BoxAdjustPatch(BaseModel): + """BOX암거 측점의 좌·우 끝 조작값.""" + + left: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch) + right: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch) + + class CrossSectionPatch(BaseModel): """확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" @@ -121,6 +135,8 @@ class CrossSectionPatch(BaseModel): revet_adjust: dict[str, WallAdjustPatch] | None = None # 세월교 측벽 조작값(유입·유출) — 2026-08-25 사용자. ford_adjust: FordAdjustPatch | None = None + # BOX암거 구체 조작값(좌·우 끝 길이·표고) — 2026-08-25 사용자. + box_adjust: BoxAdjustPatch | None = None extra_wall_counts: ExtraWallCountsPatch | None = None # 연동 기슭막이 옵션(2026-08-24 사용자). 연동 해제는 측점별, 종단경사 반영은 # 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다. diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index f79113c8..1f498914 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -103,6 +103,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti put("basin_adjust", design.basin_adjust); put("revet_adjust", design.revet_adjust); put("ford_adjust", design.ford_adjust); + put("box_adjust", design.box_adjust); put("extra_wall_counts", design.extra_wall_counts); put("revet_link_detached", design.revet_link_detached); put("revet_follow_grade", design.revet_follow_grade); diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index d0fc587c..95fd3b8b 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -1,29 +1,46 @@ /* ============================================================================= * B06_Section_UI_Cross_Box.ts - * BOX암거 측점 횡단 카드의 구체(상판·저판·내공) **오버레이 그리기**. + * BOX암거 측점 횡단 카드의 구체(에이프런·저판·상판·내공·성토선) **오버레이 그리기**. * * 기하 계산은 `B06_Section_UI_Cross_Box_Geom.ts`가 맡는다(700줄 제한 분리). * 여기서는 계산 결과(`BoxLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지. * - * 그리는 순서 = 시공 순서: ① 저판 → ② 상판 → ③ 내공 윤곽 → ④ 성토선 → 라벨. + * 그리는 순서 = 시공 순서: ① 에이프런 → ② 저판 → ③ 상판 → ④ 내공 윤곽 → ⑤ 성토선. * 콘크리트 색은 세월교 바닥판 클래스를 그대로 쓴다. * ========================================================================== */ -import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom"; +import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; -export { computeBoxLayout } from "./B06_Section_UI_Cross_Box_Geom"; -export type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom"; +export { + computeBoxLayout, + DEFAULT_BOX_ADJUST, + DEFAULT_BOX_SIDE_ADJUST, +} from "./B06_Section_UI_Cross_Box_Geom"; +export type { + BoxAdjust, + BoxLayout, + BoxSideAdjust, + BoxSideLayout, + BoxSideRole, +} from "./B06_Section_UI_Cross_Box_Geom"; const SVG_NS = "http://www.w3.org/2000/svg"; -/** BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다. */ +/** 선택 강조를 카드 재생성 없이 갈아 끼우는 setter. */ +export type BoxHighlightSetter = (role: BoxSideRole | null) => void; + +/** + * BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다. + * `onSelectSide`가 오면 좌·우 절반이 선택 대상이 된다(조정창이 그 측을 잡는다). + */ export function appendBoxOverlay( layer: SVGElement, layout: BoxLayout, x: (offset: number) => number, toDisplayY: (elevation: number) => number, -): void { + onSelectSide?: (role: BoxSideRole) => void, +): BoxHighlightSetter { const { box } = layout; const toXY = (point: OffsetPoint): [number, number] => [ x(point.offset), @@ -42,57 +59,67 @@ export function appendBoxOverlay( shape.append(title); return shape; }; - const wingText = (wing: { installed: boolean; length_m: number | null; angle_deg: number | null; }): string => (wing.installed ? `${wing.length_m ?? 0}m·${wing.angle_deg ?? 0}°` : "없음"); + const slopeText = layout.slopeRatio + ? ` · 구체 물매 1:${layout.slopeRatio.toFixed(1)}` + : " · 구체 수평"; - // ① 저판 — 날개벽 투영만큼 계류 상·하류로 길어진다. + // ① 에이프런 — 날개벽 구간 바닥. 각 끝 표고에서 수평이다(2026-08-25 사용자). + for (const side of layout.sides) { + if (!side.apron.length) continue; + const mark = side.role === "left" ? "좌" : "우"; + layer.append( + polygon( + side.apron, + "b06-chart__ford-slab", + `${mark}측 날개벽 구간 바닥 — 수평 · 두께 ${box.slab_thickness_m.toFixed(2)}m` + + ` (날개벽 ${wingText(side.role === "left" ? box.wing_in : box.wing_out)} 투영 연장)`, + ), + ); + } + + // ② 저판 · ③ 상판 — 좌·우 표고가 다르면 함께 기운다. layer.append( polygon( layout.bottomSlab, "b06-chart__ford-slab", - `BOX암거 저판 — 길이 ${layout.slabLengthM.toFixed(2)}m · 두께 ${box.slab_thickness_m.toFixed(2)}m` + - ` (날개벽 유입 ${wingText(box.wing_in)} / 유출 ${wingText(box.wing_out)} 투영 연장 반영)` + - (layout.bedGapM > 0.05 - ? ` · 계류 하상보다 ${layout.bedGapM.toFixed(2)}m 높음` - : layout.bedGapM < -0.05 - ? ` · 계류 하상보다 ${Math.abs(layout.bedGapM).toFixed(2)}m 낮음(터파기)` + `BOX암거 저판 — 두께 ${box.slab_thickness_m.toFixed(2)}m${slopeText}` + + (layout.bedGapM < -0.05 + ? ` · 계류 하상보다 ${Math.abs(layout.bedGapM).toFixed(2)}m 낮음(터파기)` + : layout.bedGapM > 0.05 + ? ` · 계류 하상보다 ${layout.bedGapM.toFixed(2)}m 높음` : ""), ), - ); - - // ② 상판 — 윗면이 노면 아래 복토 두께만큼 내려온다. - layer.append( polygon( layout.topSlab, "b06-chart__ford-slab", - `BOX암거 상판 — 두께 ${box.top_thickness_m.toFixed(2)}m · 윗면이 노면 −${box.cover_m.toFixed(2)}m(복토)`, + `BOX암거 상판 — 두께 ${box.top_thickness_m.toFixed(2)}m · 윗면이 노면 −${box.cover_m.toFixed(2)}m(복토)${slopeText}`, ), ); - // ③ 내공(유로) — 면은 비우고 윤곽만 점선으로 두른다. - const barrel = polygon( - layout.barrel, - "b06-chart__box-barrel", - `BOX암거 내공(유로) ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}m` + - ` · 측벽 두께 ${box.wall_thickness_m.toFixed(2)}m`, + // ④ 내공(유로) — 면은 비우고 윤곽만 점선으로 두른다. + layer.append( + polygon( + layout.barrel, + "b06-chart__box-barrel", + `BOX암거 내공(유로) ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}m` + + ` · 측벽 두께 ${box.wall_thickness_m.toFixed(2)}m · 구체 길이 ${layout.bodyLengthM.toFixed(2)}m`, + ), ); - layer.append(barrel); - // ④ 성토선 — 노견에서 (연장 후) 박스 최상단 모서리를 지나 원지반까지, 물매 1:1.2. - for (const line of layout.fillLines) { - if (line.length < 2) continue; + // ⑤ 성토선 — 노견에서 (연장 후) 구체 최상단 모서리까지, 물매 1:1.2. + for (const side of layout.sides) { + if (side.fillLine.length < 2) continue; const polyline = document.createElementNS(SVG_NS, "polyline"); - polyline.setAttribute("points", line.map((point) => toXY(point).join(",")).join(" ")); + polyline.setAttribute("points", side.fillLine.map((point) => toXY(point).join(",")).join(" ")); polyline.setAttribute("class", "b06-chart__design-cross"); const title = document.createElementNS(SVG_NS, "title"); - const height = line[0].elevation - line[line.length - 1].elevation; - title.textContent = - `BOX암거 성토선 — 물매 1:1.2 · 성토고 ${height.toFixed(2)}m` + - " (박스 최상단 모서리를 지난다)"; + const drop = side.fillLine[0].elevation - side.fillLine[side.fillLine.length - 1].elevation; + title.textContent = `BOX암거 성토선 — 물매 1:1.2 · 성토고 ${drop.toFixed(2)}m (구체 최상단에서 끝)`; polyline.append(title); layer.append(polyline); } @@ -105,4 +132,35 @@ export function appendBoxOverlay( label.setAttribute("class", "b06-chart__culvert-label"); label.textContent = layout.label.text; layer.append(label); + + // 좌·우 절반을 덮는 투명 겹면 — 어느 끝을 조정할지 클릭으로 고른다. + const hits = new Map(); + if (onSelectSide) { + const midOffset = (layout.sides[0].offset + layout.sides[1].offset) / 2; + const top = Math.max(...layout.topSlab.map((point) => point.elevation)); + const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation)); + for (const side of layout.sides) { + const outer = side.apron.length ? side.apron[1].offset : side.offset; + const hit = polygon( + [ + { offset: midOffset, elevation: top }, + { offset: outer, elevation: top }, + { offset: outer, elevation: bottom }, + { offset: midOffset, elevation: bottom }, + ], + "b06-chart__culvert-revet-hit", + "", + ); + hit.addEventListener("click", (event) => { + event.stopPropagation(); + onSelectSide(side.role); + }); + hits.set(side.role, hit); + layer.append(hit); + } + } + + return (role: BoxSideRole | null): void => { + for (const [side, hit] of hits) hit.classList.toggle("is-active", side === role); + }; } diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts index 9f47aeae..66808640 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -5,13 +5,13 @@ * * 구성(2026-08-25 사용자 확정): * · 박스 축 = 계류 방향이라 횡단도에는 **종단 절단면**(상판·내공·저판)이 보인다. - * · 상판 윗면 = **노면 − 복토 0.5m**(별표2 교차 참조), 구체는 수평 설치(교본 3장). - * · 저판은 **날개벽 투영**(길이×cos각)만큼 계류 상·하류로 길어진다 — 세월교와 같은 산식. - * · 내공(유로)은 비워 둔다 — 관 위 성토 채움처럼 면을 칠하지 않는다. - * · **성토선은 박스 최상단 모서리에서 끝난다**(2026-08-25 사용자). 물매 1:1.2 고정이라 - * 박스가 노견 밖으로 나간 만큼 노견을 수평 연장하고 거기서 사면을 시작한다 — - * 기슭막이 "벽이 밖으로 나간 만큼 노폭 연장" 규칙과 같다. 그래서 박스 길이를 - * 늘리면 노견부가 저절로 길어지고 사면 각도는 그대로다. + * · 상판 윗면 = 노면 − 복토 0.5m(별표2 교차 참조)가 기본이고, 조정창에서 **좌·우 끝 + * 표고를 따로** 올리고 내린다 — 값이 다르면 구체가 기울어진다(물매). + * · **성토선은 그쪽 구체 최상단 모서리에서 끝난다.** 물매 1:1.2 고정이라 구체가 길수록 + * 노견이 수평으로 늘어난다(기슭막이 "벽이 밖으로 나간 만큼 노폭 연장"과 같은 규칙). + * · **날개벽 구간 바닥(에이프런)은 각 끝 표고에서 수평**이고 구체 저판만 기울기를 + * 따라간다. 에이프런 길이 = 날개벽 투영(길이×cos각). + * · 내공(유로)은 비워 둔다 — 면을 칠하지 않는다. * 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다. * ========================================================================== */ @@ -20,29 +20,66 @@ import type { CulvertDesignTrim, OffsetPoint } from "./B06_Section_UI_Cross_Culv import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const"; import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; +/** 구체 한쪽 끝의 조작값 — 좌·우를 따로 잡는다(2026-08-25 사용자). */ +export interface BoxSideAdjust { + /** 기본 길이(성토선 규칙)에서 더 내보낸 양(m, + = 바깥). 음수는 물매가 깨져 막는다. */ + lengthM: number; + /** 그쪽 끝의 구체 표고 조정(m, + = 위). 좌·우가 다르면 구체가 기운다. */ + riseM: number; +} + +/** 측점별 BOX암거 조정값. */ +export interface BoxAdjust { + left: BoxSideAdjust; + right: BoxSideAdjust; +} + +export type BoxSideRole = "left" | "right"; + +export const DEFAULT_BOX_SIDE_ADJUST: BoxSideAdjust = { lengthM: 0, riseM: 0 }; +export const DEFAULT_BOX_ADJUST: BoxAdjust = { + left: DEFAULT_BOX_SIDE_ADJUST, + right: DEFAULT_BOX_SIDE_ADJUST, +}; + +export interface BoxSideLayout { + role: BoxSideRole; + /** 구체 끝 offset(+ = 좌측). */ + offset: number; + /** 그 끝의 상판 윗면 표고. */ + topElevation: number; + /** 그 끝의 저판 밑면 표고. */ + bottomElevation: number; + /** 날개벽 구간 바닥(에이프런) — 이 끝에서 수평으로 뻗는다. 연장이 0이면 빈 배열. */ + apron: OffsetPoint[]; + /** 성토선 — 노견 → (수평 연장) → 구체 최상단 모서리에서 끝. */ + fillLine: OffsetPoint[]; + /** 적용된 조작값(한계에 걸린 뒤). */ + adjust: BoxSideAdjust; +} + export interface BoxLayout { box: BoxSet; - /** 상판 폴리곤(노견~노견). */ + sides: BoxSideLayout[]; + /** 상판 폴리곤(좌·우 표고가 다르면 기울어진다). */ topSlab: OffsetPoint[]; - /** 저판 폴리곤 — 날개벽 투영만큼 각 측 연장. */ + /** 구체 저판 폴리곤 — 상판과 같은 기울기. */ bottomSlab: OffsetPoint[]; /** 내공(유로) 사각 — 비워 두고 윤곽선·라벨만 쓴다. */ barrel: OffsetPoint[]; - /** 상판 윗면·저판 밑면 표고. */ - topElevation: number; - bottomElevation: number; - /** 저판 전체 길이(m) — 노폭 + 유입·유출 날개벽 투영. */ + /** 구체 길이(m, 좌우 끝 사이). */ + bodyLengthM: number; + /** 바닥 전체 길이(m) — 구체 + 양측 에이프런. */ slabLengthM: number; + /** 구체 물매(1:n). 수평이면 null. */ + slopeRatio: number | null; /** 구체 바닥과 계류 하상(원지반 최저)의 고저차(m). +면 구체가 하상 위로 떠 있다. */ bedGapM: number; - /** - * 양측 성토선 — 노견 → (박스가 나간 만큼 수평 연장) → 박스 최상단 모서리에서 끝. - * 물매 1:1.2 고정이며, 모서리 밖은 구체 개구부라 선을 잇지 않는다. - */ - fillLines: OffsetPoint[][]; label: { at: OffsetPoint; text: string }; - /** 노견 밖 설계선은 이 성토선이 대신한다 — 노견에서 끊는다. */ + /** 노견 밖 설계선은 성토선이 대신한다 — 노견에서 끊는다. */ designTrim: CulvertDesignTrim; + /** 한계에 걸린 뒤 **실제 적용된** 조작값 — 조정창이 되받는다. */ + adjust: BoxAdjust; } /** 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선(세월교와 같은 규칙). */ @@ -61,20 +98,11 @@ function roadTopFactory( }; } -/** 사각 폴리곤(좌상 → 우상 → 우하 → 좌하). */ -function rect(left: number, right: number, top: number, bottom: number): OffsetPoint[] { - return [ - { offset: left, elevation: top }, - { offset: right, elevation: top }, - { offset: right, elevation: bottom }, - { offset: left, elevation: bottom }, - ]; -} - /** BOX암거 구체 기하. 제원·설계선·지반이 부족하면 null. */ export function computeBoxLayout( section: CrossSection, groundSamples: SectionSample[], + adjust: BoxAdjust = DEFAULT_BOX_ADJUST, ): BoxLayout | null { const box = section.box; if (!box) return null; @@ -82,36 +110,65 @@ export function computeBoxLayout( if (!edges) return null; const groundAt = groundInterpolator(groundSamples); if (!groundAt) return null; - - // 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백). - const inletOnLeft = (section.uphill_side ?? "left") === "left"; const roadTopAt = roadTopFactory(section, edges.left, edges.right); const leftEdge = edges.left.offset_m; const rightEdge = edges.right.offset_m; if (!(leftEdge > rightEdge)) return null; - // 구체는 수평 설치(교본 3장) — 노면이 가장 낮은 자리에서도 복토를 확보한다. - const roadLow = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)); - const topElevation = roadLow - box.cover_m; - const barrelTop = topElevation - box.top_thickness_m; - const barrelBottom = barrelTop - box.inner_height_m; - const bottomElevation = barrelBottom - box.slab_thickness_m; + // 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백). + const inletOnLeft = (section.uphill_side ?? "left") === "left"; + const baseTop = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)) - box.cover_m; + const bodyHeight = box.top_thickness_m + box.inner_height_m + box.slab_thickness_m; - // 구체 길이 — 성토선이 박스 최상단을 지나려면 (노면 − 박스 상단) × 물매만큼은 노견 - // 밖으로 나가야 한다. 노면이 크라운이라 좌·우 필요량이 다르므로 **큰 쪽에 맞춰** - // 대칭으로 잡고, 남는 쪽은 노견을 수평 연장해 물매 1:1.2를 지킨다(2026-08-25 사용자). - const reach = - Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation > 0 - ? (Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation) * FILL_SLOPE_RATIO_MIN - : box.cover_m * FILL_SLOPE_RATIO_MIN; - const bodyLeft = leftEdge + reach; - const bodyRight = rightEdge - reach; + const sideOf = (role: BoxSideRole): BoxSideLayout => { + const outward = role === "left" ? 1 : -1; + const edgeOffset = role === "left" ? leftEdge : rightEdge; + const wanted = role === "left" ? adjust.left : adjust.right; + const lengthM = Math.max(wanted.lengthM, 0); + const topElevation = baseTop + wanted.riseM; + // 성토선이 구체 최상단을 지나려면 (노면 − 상단) × 물매만큼은 노견 밖으로 나가야 한다. + const reach = Math.max((roadTopAt(edgeOffset) - topElevation) * FILL_SLOPE_RATIO_MIN, 0); + const offset = edgeOffset + outward * (reach + lengthM); + const bottomElevation = topElevation - bodyHeight; + // 날개벽 구간 바닥 — 각 끝 표고에서 **수평**(2026-08-25 사용자). + const wing = (role === "left") === inletOnLeft ? box.wing_in : box.wing_out; + const extend = Math.max(wing.slab_extend_m, 0); + const outer = offset + outward * extend; + const apron: OffsetPoint[] = extend + ? [ + { offset, elevation: bottomElevation + box.slab_thickness_m }, + { offset: outer, elevation: bottomElevation + box.slab_thickness_m }, + { offset: outer, elevation: bottomElevation }, + { offset, elevation: bottomElevation }, + ] + : []; + // 성토선 — 노견 표고에서 수평으로 나간 뒤 구체 최상단 모서리에서 끝난다. + const roadZ = roadTopAt(edgeOffset); + const start = offset - outward * (roadZ - topElevation) * FILL_SLOPE_RATIO_MIN; + const fillLine: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }]; + if ((start - edgeOffset) * outward > 0.01) fillLine.push({ offset: start, elevation: roadZ }); + fillLine.push({ offset, elevation: topElevation }); + return { + role, + offset, + topElevation, + bottomElevation, + apron, + fillLine, + adjust: { lengthM, riseM: wanted.riseM }, + }; + }; - // 저판 연장 — 유입·유출 날개벽 각도가 만든 투영량(백엔드 계산값). - const leftExtend = Math.max((inletOnLeft ? box.wing_in : box.wing_out).slab_extend_m, 0); - const rightExtend = Math.max((inletOnLeft ? box.wing_out : box.wing_in).slab_extend_m, 0); - const slabLeft = bodyLeft + leftExtend; - const slabRight = bodyRight - rightExtend; + const left = sideOf("left"); + const right = sideOf("right"); + const quad = (topDrop: number, bottomDrop: number): OffsetPoint[] => [ + { offset: left.offset, elevation: left.topElevation - topDrop }, + { offset: right.offset, elevation: right.topElevation - topDrop }, + { offset: right.offset, elevation: right.topElevation - bottomDrop }, + { offset: left.offset, elevation: left.topElevation - bottomDrop }, + ]; + const barrelTopDrop = box.top_thickness_m; + const barrelBottomDrop = barrelTopDrop + box.inner_height_m; // 계류 하상과의 고저차 — 터파기·성토 판정은 후속이라 값만 넘긴다. let bed = Math.min(groundAt(leftEdge), groundAt(rightEdge)); @@ -119,40 +176,34 @@ export function computeBoxLayout( bed = Math.min(bed, groundAt(offset)); } - // 성토선 — 노견 표고에서 수평으로 나간 뒤 박스 최상단 모서리를 지나 지반까지. - const fillLine = (edgeOffset: number, bodyOffset: number, outward: number): OffsetPoint[] => { - const roadZ = roadTopAt(edgeOffset); - // 사면이 박스 모서리를 지나려면 시작점이 모서리에서 (높이차 × 물매)만큼 안쪽이다. - const start = bodyOffset - outward * (roadZ - topElevation) * FILL_SLOPE_RATIO_MIN; - const corner: OffsetPoint = { offset: bodyOffset, elevation: topElevation }; - const points: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }]; - // 박스가 더 길면 노견을 수평으로 늘린다(각도 불변 — 2026-08-25 사용자). - if ((start - edgeOffset) * outward > 0.01) points.push({ offset: start, elevation: roadZ }); - // 모서리에서 끝난다 — 그 밖은 구체 개구부·날개벽이라 성토가 아니다 - // (2026-08-25 사용자: 박스와 만난 뒤의 성토선은 필요 없다). - points.push(corner); - return points; - }; - + const rise = left.topElevation - right.topElevation; + const run = left.offset - right.offset; + const apronLengthOf = (side: BoxSideLayout): number => + side.apron.length ? Math.abs(side.apron[1].offset - side.apron[0].offset) : 0; return { box, - topSlab: rect(bodyLeft, bodyRight, topElevation, barrelTop), - bottomSlab: rect(slabLeft, slabRight, barrelBottom, bottomElevation), - barrel: rect(bodyLeft, bodyRight, barrelTop, barrelBottom), - fillLines: [fillLine(leftEdge, bodyLeft, 1), fillLine(rightEdge, bodyRight, -1)], + sides: [left, right], + topSlab: quad(0, barrelTopDrop), + bottomSlab: quad(barrelBottomDrop, barrelBottomDrop + box.slab_thickness_m), + barrel: quad(barrelTopDrop, barrelBottomDrop), + bodyLengthM: run, + slabLengthM: run + apronLengthOf(left) + apronLengthOf(right), + slopeRatio: Math.abs(rise) > 1e-6 ? Math.abs(run / rise) : null, + bedGapM: Number((Math.min(left.bottomElevation, right.bottomElevation) - bed).toFixed(3)), + label: { + at: { + offset: (left.offset + right.offset) / 2, + elevation: + (left.topElevation + right.topElevation) / 2 - (barrelTopDrop + barrelBottomDrop) / 2, + }, + text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`, + }, designTrim: { minOffset: rightEdge, maxOffset: leftEdge, minElevation: roadTopAt(rightEdge), maxElevation: roadTopAt(leftEdge), }, - topElevation, - bottomElevation, - slabLengthM: Math.abs(slabLeft - slabRight), - bedGapM: Number((bottomElevation - bed).toFixed(3)), - label: { - at: { offset: (bodyLeft + bodyRight) / 2, elevation: (barrelTop + barrelBottom) / 2 }, - text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`, - }, + adjust: { left: left.adjust, right: right.adjust }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts new file mode 100644 index 00000000..a8281f77 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -0,0 +1,162 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Box_Panel.ts + * BOX암거 **조정 오버레이 창**(2026-08-25 사용자 확정). + * + * 좌·우 끝을 따로 잡는다 — 도면에서 그쪽 절반을 누르면 그 측이 선택되고, + * ◀▶는 **구체 길이**를, ▲▼는 **그 끝의 구체 표고**를 0.1m씩 움직인다. 좌·우 표고가 + * 달라지면 구체가 기울고, 날개벽 구간 바닥(에이프런)은 각 끝에서 수평을 유지한다. + * CSS 클래스와 조작 관례는 세월교·배수관 조정창과 같다. + * ========================================================================== */ + +import type { BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom"; + +/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ +export interface BoxPanelDeps { + /** 지금 조작값(한계에 걸린 뒤 실제 적용값). */ + adjustFor: (role: BoxSideRole) => BoxSideAdjust; + /** 구체 길이 ±0.1m — 화면 좌(◀)/우(▶) 부호 환산은 카드가 한다. */ + nudgeLength: (role: BoxSideRole, screenDeltaM: number) => void; + /** 그 끝의 구체 표고 ±0.1m. */ + nudgeRise: (role: BoxSideRole, deltaM: number) => void; + /** 그 측을 자동 자리로 되돌린다. */ + reset: (role: BoxSideRole) => void; + /** 지금 그려진 구체 길이(m)와 물매(1:n, 수평이면 null). */ + bodyLengthM: () => number; + slopeRatio: () => number | null; + /** 창을 닫는다 = 선택 해제. */ + close: () => void; +} + +export interface BoxPanelHandle { + root: HTMLElement; + /** null이면 숨긴다. 값이 오면 그 측 기준으로 다시 그린다. */ + show: (role: BoxSideRole | null) => void; +} + +/** 한 걸음(m) — 사용자 지정 0.1m. */ +const STEP_M = 0.1; + +function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b06-structure-panel__btn"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + // 카드 선택·팬으로 번지면 도면이 다시 그려져 맞춰 둔 배율이 날아간다. + event.stopPropagation(); + onClick(); + }); + return button; +} + +/** 항목 행 — 1행 이름 라벨 + 2행 값 조작(다른 조정창과 같은 2행 구조). */ +function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } { + const row = document.createElement("div"); + row.className = "b06-structure-panel__struct"; + const label = document.createElement("span"); + label.className = "b06-structure-panel__label"; + label.textContent = labelText; + const controls = document.createElement("div"); + controls.className = "b06-structure-panel__controls"; + row.append(label, controls); + return { row, controls }; +} + +/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ +export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle { + const root = document.createElement("div"); + root.className = "b06-structure-panel is-hidden"; + root.addEventListener("click", (event) => event.stopPropagation()); + + const title = document.createElement("span"); + title.className = "b06-structure-panel__title"; + + const value = document.createElement("div"); + value.className = "b06-structure-panel__value"; + const lengthLine = document.createElement("span"); + const riseLine = document.createElement("span"); + const slopeLine = document.createElement("span"); + value.append(lengthLine, riseLine, slopeLine); + + let current: BoxSideRole | null = null; + const act = (run: (role: BoxSideRole) => void) => () => { + if (current) run(current); + }; + + const moveRow = makeRow("길이 ◀▶ · 표고 ▲▼"); + moveRow.controls.classList.add("b06-structure-panel__buttons"); + const dpad = ( + label: string, + slot: "up" | "down" | "left" | "right" | "reset", + tip: string, + onClick: () => void, + ): HTMLButtonElement => { + const button = makeButton(label, tip, onClick); + button.classList.add(`b06-structure-panel__btn--${slot}`); + return button; + }; + moveRow.controls.append( + dpad( + "▲", + "up", + "이쪽 끝 구체 표고 0.1m 올리기", + act((role) => deps.nudgeRise(role, STEP_M)), + ), + dpad( + "◀", + "left", + "화면 왼쪽으로 구체 0.1m 늘리기", + act((role) => deps.nudgeLength(role, STEP_M)), + ), + dpad("↺", "reset", "이 측 조정값 초기화", act(deps.reset)), + dpad( + "▶", + "right", + "화면 오른쪽으로 구체 0.1m 늘리기", + act((role) => deps.nudgeLength(role, -STEP_M)), + ), + dpad( + "▼", + "down", + "이쪽 끝 구체 표고 0.1m 내리기", + act((role) => deps.nudgeRise(role, -STEP_M)), + ), + ); + + const closeButton = makeButton("✕", "닫기", () => deps.close()); + closeButton.classList.add("b06-structure-panel__close"); + + root.append(title, closeButton, value, moveRow.row); + + function render(): void { + if (!current) return; + const side = current === "left" ? "좌" : "우"; + title.textContent = `BOX암거 ${side}측 끝`; + const adjust = deps.adjustFor(current); + lengthLine.textContent = + `구체 길이 ${deps.bodyLengthM().toFixed(2)}m` + + (adjust.lengthM > 0 ? ` (이 측 +${adjust.lengthM.toFixed(1)}m)` : " (이 측 자동)"); + riseLine.textContent = `표고 ${adjust.riseM >= 0 ? "+" : ""}${adjust.riseM.toFixed(1)}m`; + const ratio = deps.slopeRatio(); + slopeLine.textContent = ratio ? `구체 물매 1:${ratio.toFixed(1)}` : "구체 수평"; + } + + return { + root, + show(role) { + current = role; + root.classList.toggle("is-hidden", role === null); + render(); + }, + }; +} + +/** 측점별 BOX암거 조작값 제어 — 페이지(세션·정본)가 구현한다. */ +export interface BoxControl { + adjustFor: (chainageM: number) => { left: BoxSideAdjust; right: BoxSideAdjust }; + update: (chainageM: number, role: BoxSideRole, patch: Partial) => void; + reset: (chainageM: number, role: BoxSideRole) => void; + selectedFor: (chainageM: number) => BoxSideRole | null; + select: (chainageM: number, role: BoxSideRole | null) => void; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 6618ea6a..bd60d90d 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -25,11 +25,10 @@ import { } from "./B06_Section_UI_Cross_Design"; import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert"; import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box"; +import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; +import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; -import { buildFordPanel } from "./B06_Section_UI_Cross_Ford_Panel"; -import { fordCardState, fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; -import type { FordHighlightSetter, FordWallRole } from "./B06_Section_UI_Cross_Ford"; import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { @@ -137,8 +136,9 @@ export function createCrossSectionCard( structureSpan?: StructureSpanControl, /** 연동·종단경사 반영 제어(2026-08-24). */ revetLink?: RevetLinkControl, - /** 세월교 측벽 조작 제어(2026-08-25). */ + /** 세월교 측벽·BOX암거 구체 조작 제어(2026-08-25). */ ford?: FordControl, + box?: BoxControl, ): CrossCardElement { // 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고 // 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다 @@ -178,10 +178,6 @@ export function createCrossSectionCard( let setBandActive: AreaHighlightSetter = () => undefined; let setChipActive: AreaHighlightSetter = () => undefined; let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null; - // 세월교 측벽 선택 — 배수관 기슭막이와 한 측점에 같이 서지 않는다. - let activeFordWall = section.ford ? (ford?.selectedFor(section.chainage_m) ?? null) : null; - let setFordActive: FordHighlightSetter = () => {}; - let showFordPanel = (_visible: boolean): void => {}; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용 @@ -202,28 +198,21 @@ export function createCrossSectionCard( * 같이 선택**된다 — 이미 선택된 카드 안에서 다른 구조물을 고를 때는 선택이 옮겨만 * 가고 카드 선택은 건드리지 않는다. */ - /** 마지막 계산의 세월교 바닥판 길이(m)·측벽 높이(m) — 조정창 표시·조작 시작값. */ - let fordSlabLengthM = 0; - let fordWallHeights = new Map< - FordWallRole, - number - >(); /** 세월교 측벽을 고른다 — 기슭막이 선택과 같은 규칙(면적 강조와 배타). */ - const toggleFordWall = (role: FordWallRole): void => { - const wasSelected = isSelected; - activeFordWall = activeFordWall === role ? null : role; - setFordActive(activeFordWall); - showFordPanel(activeFordWall !== null); - ford?.select(section.chainage_m, activeFordWall); - if (activeFordWall !== null && activeArea !== null) { + // 구체 구조물(세월교·BOX암거) 선택·조정창 배선 — 한 모듈로 묶었다(2026-08-25). + const bodies = createBodyWiring({ + section, + ford, + box, + isCardSelected: () => isSelected, + clearArea: () => { + if (activeArea === null) return; activeArea = null; setBandActive(null); setChipActive(null); - } - if (activeFordWall !== null) { - if (wasSelected) onAreaSelect?.(section.station_id, null); - else onSelect(section.station_id); - } - }; + onAreaSelect?.(section.station_id, null); + }, + selectCard: () => onSelect(section.station_id), + }); const toggleRevet = (key: RevetKey): void => { const wasSelected = isSelected; activeRevet = activeRevet === key ? null : key; @@ -257,12 +246,7 @@ export function createCrossSectionCard( showRevetControl(false); revetOffset?.select(section.chainage_m, null); } - if ((activeArea !== null || !nextSelected) && activeFordWall !== null) { - activeFordWall = null; - setFordActive(null); - showFordPanel(false); - ford?.select(section.chainage_m, null); - } + if (activeArea !== null || !nextSelected) bodies.clearSelection(); }; const toggleArea = (key: CrossAreaKey): void => { if (!isSelected) { @@ -443,9 +427,8 @@ export function createCrossSectionCard( revetLink, ); // 세월교·BOX암거 구체 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25). - const boxLayout = computeBoxLayout(section, section.samples); - const adjust = ford?.adjustFor(section.chainage_m); - const fordLayout = computeFordLayout(section, section.samples, adjust); + const boxLayout = computeBoxLayout(section, section.samples, bodies.boxAdjust()); + const fordLayout = computeFordLayout(section, section.samples, bodies.fordAdjust()); // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. appendPavementOverlay(plotLayer, section.design, x, toDisplayY); appendCrossDesignOverlay( @@ -478,17 +461,23 @@ export function createCrossSectionCard( culvertWallSpecs = state.wallSpecs; culvertAppliedD = state.appliedD; } - if (boxLayout) appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY); + if (boxLayout) { + bodies.attachBox( + appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY, box ? bodies.toggleBox : undefined), + boxLayout, + ); + } if (fordLayout) { - setFordActive = appendFordOverlay( - plotLayer, + bodies.attachFord( + appendFordOverlay( + plotLayer, + fordLayout, + x, + toDisplayY, + ford ? bodies.toggleFord : undefined, + ), fordLayout, - x, - toDisplayY, - ford ? toggleFordWall : undefined, ); - if (activeFordWall) setFordActive(activeFordWall); - ({ slabLengthM: fordSlabLengthM, heights: fordWallHeights } = fordCardState(fordLayout)); } if (culvertLayout) { setRevetActive = appendCulvertOverlay( @@ -642,24 +631,6 @@ export function createCrossSectionCard( showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl(activeRevet !== null); // 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자). - const fordPanel = - section.ford && ford - ? buildFordPanel( - fordPanelDeps({ - section, - ford, - heightFor: (role) => fordWallHeights.get(role) ?? 0, - slabLengthM: () => fordSlabLengthM, - close: () => { - if (activeFordWall) toggleFordWall(activeFordWall); - }, - }), - ) - : null; - if (fordPanel) { - showFordPanel = (visible) => fordPanel.show(visible ? activeFordWall : null); - showFordPanel(activeFordWall !== null); - } // 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23). const widthActions: CrossWidthActions | undefined = stationWidth && { step: (deltaM) => @@ -677,7 +648,7 @@ export function createCrossSectionCard( }, }; chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root); - if (fordPanel) chartWrap.append(fordPanel.root); + for (const panel of bodies.panels) chartWrap.append(panel.root); if (activeArea) { setBandActive(activeArea); setChipActive(activeArea); diff --git a/B06_Section/B06_Section_UI_Cross_View_Bodies.ts b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts new file mode 100644 index 00000000..b09845c5 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts @@ -0,0 +1,143 @@ +/* ============================================================================= + * B06_Section_UI_Cross_View_Bodies.ts + * 횡단 카드 ↔ **구체 구조물(세월교·BOX암거)** 배선 한 벌 — 선택 토글·강조 setter· + * 조정창을 묶어 카드 렌더러(`_UI_Cross_View.ts`)에서 700줄 제한으로 분리했다 + * (2026-08-25). 기하나 그리기는 하지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import type { + FordAdjust, + FordHighlightSetter, + FordLayout, + FordWallRole, +} from "./B06_Section_UI_Cross_Ford"; +import { buildFordPanel } from "./B06_Section_UI_Cross_Ford_Panel"; +import type { FordControl, FordPanelHandle } from "./B06_Section_UI_Cross_Ford_Panel"; +import { fordCardState, fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford"; +import type { + BoxAdjust, + BoxHighlightSetter, + BoxLayout, + BoxSideRole, +} from "./B06_Section_UI_Cross_Box"; +import { buildBoxPanel } from "./B06_Section_UI_Cross_Box_Panel"; +import type { BoxControl, BoxPanelHandle } from "./B06_Section_UI_Cross_Box_Panel"; +import { boxPanelDeps, structureToggle } from "./B06_Section_UI_Cross_View_Box"; + +export interface BodyWiringDeps { + section: CrossSection; + ford?: FordControl; + box?: BoxControl; + /** 카드가 지금 선택 상태인가 — 구조물을 고르면 카드도 함께 선택된다. */ + isCardSelected: () => boolean; + /** 면적 강조 해제(구조물 선택과 배타). */ + clearArea: () => void; + selectCard: () => void; +} + +export interface BodyWiring { + fordAdjust: () => FordAdjust | undefined; + boxAdjust: () => BoxAdjust | undefined; + toggleFord: (role: FordWallRole) => void; + toggleBox: (role: BoxSideRole) => void; + /** 오버레이가 만들어 준 강조 setter를 등록하고 현재 선택을 다시 칠한다. */ + attachFord: (setter: FordHighlightSetter, layout: FordLayout) => void; + attachBox: (setter: BoxHighlightSetter, layout: BoxLayout) => void; + /** 카드 선택이 풀리거나 면적이 켜지면 구조물 선택도 푼다. */ + clearSelection: () => void; + panels: Array; +} + +export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { + const { section, ford, box } = deps; + const chainage = section.chainage_m; + let fordRole = section.ford ? (ford?.selectedFor(chainage) ?? null) : null; + let boxRole = section.box ? (box?.selectedFor(chainage) ?? null) : null; + let setFordActive: FordHighlightSetter = () => {}; + let setBoxActive: BoxHighlightSetter = () => {}; + let fordSlabLengthM = 0; + let fordHeights = new Map(); + let boxLayout: BoxLayout | null = null; + + const fordPanel = + section.ford && ford + ? buildFordPanel( + fordPanelDeps({ + section, + ford, + heightFor: (role) => fordHeights.get(role) ?? 0, + slabLengthM: () => fordSlabLengthM, + close: () => { + if (fordRole) toggleFord(fordRole); + }, + }), + ) + : null; + const boxPanel = + section.box && box + ? buildBoxPanel( + boxPanelDeps({ + chainageM: chainage, + box, + layout: () => boxLayout, + close: () => { + if (boxRole) toggleBox(boxRole); + }, + }), + ) + : null; + + const toggleFord = structureToggle({ + current: () => fordRole, + setCurrent: (value) => (fordRole = value), + setActive: (value) => setFordActive(value), + showPanel: (visible) => fordPanel?.show(visible ? fordRole : null), + persist: (value) => ford?.select(chainage, value), + isCardSelected: deps.isCardSelected, + clearArea: deps.clearArea, + selectCard: deps.selectCard, + }); + const toggleBox = structureToggle({ + current: () => boxRole, + setCurrent: (value) => (boxRole = value), + setActive: (value) => setBoxActive(value), + showPanel: (visible) => boxPanel?.show(visible ? boxRole : null), + persist: (value) => box?.select(chainage, value), + isCardSelected: deps.isCardSelected, + clearArea: deps.clearArea, + selectCard: deps.selectCard, + }); + + fordPanel?.show(fordRole); + boxPanel?.show(boxRole); + + return { + fordAdjust: () => ford?.adjustFor(chainage), + boxAdjust: () => box?.adjustFor(chainage), + toggleFord, + toggleBox, + attachFord: (setter, layout) => { + setFordActive = setter; + const state = fordCardState(layout); + fordSlabLengthM = state.slabLengthM; + fordHeights = state.heights; + if (fordRole) setter(fordRole); + // 창은 그리기 전에 만들어져 값이 비어 있다 — 계산 결과가 들어온 뒤 다시 렌더한다. + fordPanel?.show(fordRole); + }, + attachBox: (setter, layout) => { + setBoxActive = setter; + boxLayout = layout; + if (boxRole) setter(boxRole); + boxPanel?.show(boxRole); + }, + clearSelection: () => { + if (fordRole !== null) toggleFord(fordRole); + if (boxRole !== null) toggleBox(boxRole); + }, + panels: [fordPanel, boxPanel].filter( + (panel): panel is FordPanelHandle | BoxPanelHandle => !!panel, + ), + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View_Box.ts b/B06_Section/B06_Section_UI_Cross_View_Box.ts new file mode 100644 index 00000000..00ae694b --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Box.ts @@ -0,0 +1,63 @@ +/* ============================================================================= + * B06_Section_UI_Cross_View_Box.ts + * 횡단 카드 ↔ **BOX암거 조정창** 배선과 구조물 선택 토글 — 카드 렌더러 + * (`_UI_Cross_View.ts`)에서 700줄 제한으로 분리했다(2026-08-25). + * 기하나 그리기는 하지 않는다. + * ========================================================================== */ + +import type { BoxLayout } from "./B06_Section_UI_Cross_Box"; +import type { BoxControl, BoxPanelDeps } from "./B06_Section_UI_Cross_Box_Panel"; + +export interface BoxPanelContext { + chainageM: number; + box: BoxControl; + /** 마지막 계산 결과 — 창이 길이·물매를 읽는다. */ + layout: () => BoxLayout | null; + close: () => void; +} + +export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps { + const { chainageM, box } = context; + return { + adjustFor: (role) => box.adjustFor(chainageM)[role], + // 화면 좌(◀) = offset 증가. 좌측 끝은 그대로, 우측 끝은 부호를 뒤집어야 바깥이 된다. + nudgeLength: (role, screenDeltaM) => + box.update(chainageM, role, { + lengthM: box.adjustFor(chainageM)[role].lengthM + screenDeltaM * (role === "left" ? 1 : -1), + }), + nudgeRise: (role, deltaM) => + box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }), + reset: (role) => box.reset(chainageM, role), + bodyLengthM: () => context.layout()?.bodyLengthM ?? 0, + slopeRatio: () => context.layout()?.slopeRatio ?? null, + close: context.close, + }; +} + +/** + * 구조물 선택 토글 한 벌 — 세월교 측벽·BOX암거 끝이 같은 규칙을 쓴다(2026-08-21 확정 + * 규칙: 구조물 선택과 면적 강조는 배타, 고르면 그 측점 카드도 함께 선택된다). + */ +export function structureToggle(deps: { + current: () => T | null; + setCurrent: (value: T | null) => void; + setActive: (value: T | null) => void; + showPanel: (visible: boolean) => void; + persist: (value: T | null) => void; + isCardSelected: () => boolean; + clearArea: () => void; + selectCard: () => void; +}): (value: T) => void { + return (value: T): void => { + const wasSelected = deps.isCardSelected(); + const next = deps.current() === value ? null : value; + deps.setCurrent(next); + deps.setActive(next); + deps.showPanel(next !== null); + deps.persist(next); + if (next === null) return; + deps.clearArea(); + // 미선택 카드는 카드 선택만 부른다 — 그 경로가 부모의 면적 강조를 이미 비운다. + if (!wasSelected) deps.selectCard(); + }; +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index dd7c52f7..6a7571f9 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -415,11 +415,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { projectId: () => projectId, onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"), }); - const stationWidthControl = stationControls.stationWidth; - const revetOffsetControl = stationControls.revetOffset; - const stationWidths = stationControls.widths; - const inletStructures = stationControls.inletStructures; - const basinAdjustments = stationControls.basinAdjustments; + const { stationWidth: stationWidthControl, revetOffset: revetOffsetControl } = stationControls; + const { widths: stationWidths, inletStructures, basinAdjustments } = stationControls; const sectionView = createSectionView( (chainageM, change) => { @@ -433,6 +430,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.structureSpan, stationControls.revetLink, stationControls.ford, + stationControls.box, ); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. @@ -519,6 +517,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { revetAdjusts: stationControls.revetAdjustsByChainage(), extraCounts: stationControls.extraCountsByChainage(), fordAdjusts: stationControls.fordAdjustsByChainage(), + boxAdjusts: stationControls.boxAdjustsByChainage(), linkFlags: stationControls.linkFlagsByChainage(), }); // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다. diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index ed63e270..08e42e25 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -1,7 +1,7 @@ /* ============================================================================= * B06_Section_UI_Page_Ford_Controls.ts - * 세월교 측벽 조작값 제어 — 측점 제어기(`_UI_Page_Station_Controls.ts`)에서 700줄 - * 제한으로 분리했다(2026-08-25). + * 세월교 측벽·BOX암거 구체 조작값 제어 — 측점 제어기 + * (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25). * * 데이터 흐름은 집수정과 같다: 세션 사본 + 캐시 `design.ford_adjust`에 함께 실어 * 3D(코리도)가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본(`pipe_points`)이 @@ -12,6 +12,9 @@ import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford"; import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; +import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box"; +import type { BoxAdjust, BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box"; +import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; export interface FordControlDeps { /** 세션 보관 키(프로젝트·노선별). 없으면 세션에 담지 않는다. */ @@ -134,3 +137,110 @@ export function createFordControls(deps: FordControlDeps): FordControls { load: loadFordAdjustments, }; } + +/** + * 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 selections = new Map(); + + function load(): void { + adjustments.clear(); + const key = deps.sessionKey(); + if (!key) return; + try { + const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record< + string, + BoxAdjust + >; + 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) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(adjustments))); + } + + const adjustAt = (chainageM: number): BoxAdjust => { + const stored = sectionAt(chainageM)?.design?.box_adjust; + return ( + adjustments.get(chainageM.toFixed(2)) ?? { + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.left ?? {}) }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.right ?? {}) }, + } + ); + }; + + const write = (chainageM: number, next: BoxAdjust): void => { + adjustments.set(chainageM.toFixed(2), next); + persist(); + patchCachedDesign(chainageM, { box_adjust: next }); + deps.refreshCard(chainageM); + }; + + const control: BoxControl = { + adjustFor: adjustAt, + update: (chainageM, role, patch) => { + const current = adjustAt(chainageM); + const side: BoxSideAdjust = { ...current[role], ...patch }; + write(chainageM, { + ...current, + [role]: { + // 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다. + lengthM: Math.max(0, round1(side.lengthM)), + riseM: clampMove(side.riseM), + }, + }); + }, + reset: (chainageM, role) => { + const current = adjustAt(chainageM); + write(chainageM, { ...current, [role]: { ...DEFAULT_BOX_SIDE_ADJUST } }); + }, + selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null, + select: (chainageM, role) => { + selections.set(chainageM.toFixed(2), role); + }, + }; + + return { + control, + byChainage: () => { + const result = new Map(); + adjustments.forEach((adjust, key) => { + const value = Number(key); + if (Number.isFinite(value)) result.set(value, adjust); + }); + return result; + }, + load, + }; +} + +/** 세월교·BOX암거 제어를 한 번에 만든다 — 측점 제어기 쪽 배선을 줄인다(700줄 제한). */ +export function createBodyControls( + common: Omit, + sessionKey: (kind: "fordadjust" | "boxadjust") => string | null, +): { + ford: ReturnType; + box: ReturnType; +} { + return { + ford: createFordControls({ ...common, sessionKey: () => sessionKey("fordadjust") }), + box: createBoxControls({ ...common, sessionKey: () => sessionKey("boxadjust") }), + }; +} diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts new file mode 100644 index 00000000..0aaebb7e --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -0,0 +1,51 @@ +/* ============================================================================= + * B06_Section_UI_Page_Link_Session.ts + * 연동 기슭막이 옵션(연동 해제·종단경사 반영)의 **세션 보관** — 측점 제어기 + * (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25). + * 두 플래그는 한 세션 항목에 같이 담아 확정 전 리로드에도 살아남는다. + * ========================================================================== */ + +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 = window.sessionStorage.getItem(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; + window.sessionStorage.setItem( + key, + JSON.stringify({ + detached: Object.fromEntries(detached), + grade: Object.fromEntries(followGrade), + }), + ); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Page_Patches.ts b/B06_Section/B06_Section_UI_Page_Patches.ts index e8708193..50fa38a4 100644 --- a/B06_Section/B06_Section_UI_Page_Patches.ts +++ b/B06_Section/B06_Section_UI_Page_Patches.ts @@ -10,6 +10,7 @@ import type { CrossSectionPatch, StoredWallAdjust } 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"; import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert"; export interface CrossPatchSources { @@ -22,6 +23,7 @@ export interface CrossPatchSources { revetAdjusts: Map>; extraCounts: Map; fordAdjusts: Map; + boxAdjusts: Map; linkFlags: Map; } @@ -59,6 +61,9 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch sources.fordAdjusts.forEach((adjust, chainage) => { patchFor(chainage).ford_adjust = adjust; }); + sources.boxAdjusts.forEach((adjust, chainage) => { + patchFor(chainage).box_adjust = adjust; + }); // 연동 해제(측점별)·종단경사 반영(전체 공통) — 같은 체계로 정본에 싣는다. sources.linkFlags.forEach((flags, chainage) => { if (flags.detached !== undefined) patchFor(chainage).revet_link_detached = flags.detached; diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 026d09e0..70a7e681 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -20,9 +20,12 @@ import type { import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const"; import { culvertOwnerFor } from "./B06_Section_UI_Cross_Culvert_Wire"; import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options"; -import { createFordControls } from "./B06_Section_UI_Page_Ford_Controls"; +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 { @@ -34,7 +37,8 @@ export interface StationControlDeps { | "basinadjust" | "extrawall" | "revetlink" - | "fordadjust", + | "fordadjust" + | "boxadjust", ) => string | null; refreshCard: (chainageM: number) => void; detail: () => SectionDetailResponse | null; @@ -61,8 +65,11 @@ export interface StationControls { revetLink: RevetLinkControl; /** 세월교 측벽 조작(2026-08-25). */ ford: FordControl; - /** 확정 payload용 — 측점별 세월교 조작값. */ + /** BOX암거 구체 조작(2026-08-25). */ + box: BoxControl; + /** 확정 payload용 — 측점별 세월교·BOX암거 조작값. */ fordAdjustsByChainage: () => Map; + boxAdjustsByChainage: () => Map; widths: Map; inletStructures: Map; basinAdjustments: Map; @@ -482,41 +489,12 @@ export function createStationControls(deps: StationControlDeps): StationControls * 한 번 내보낸다. 링크 측점에서 만져도 바뀌는 것은 **소유 측점** 값이다. * 연동은 측점별(세션 → 정본 → 기본 켬), 종단경사 반영은 소유 측점에 하나. */ const culvertOptions = createCulvertOptionWriter(deps.projectId, deps.onSaveError); - const linkDetached = new Map(); - const followGrades = new Map(); + const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink")); + const linkDetached = linkSession.detached; + const followGrades = linkSession.followGrade; const chainageKey = (chainageM: number): string => chainageM.toFixed(2); - - /** 두 체크박스는 한 세션 항목에 같이 담는다 — 확정 전 리로드에도 살아남는다. */ - function loadLinkFlags(): void { - linkDetached.clear(); - followGrades.clear(); - const key = deps.sessionKey("revetlink"); - if (!key) return; - try { - const raw = window.sessionStorage.getItem(key); - if (!raw) return; - const parsed = JSON.parse(raw) as { - detached?: Record; - grade?: Record; - }; - Object.entries(parsed.detached ?? {}).forEach(([at, value]) => linkDetached.set(at, !!value)); - Object.entries(parsed.grade ?? {}).forEach(([at, value]) => followGrades.set(at, !!value)); - } catch { - /* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */ - } - } - - function persistLinkFlags(): void { - const key = deps.sessionKey("revetlink"); - if (!key) return; - window.sessionStorage.setItem( - key, - JSON.stringify({ - detached: Object.fromEntries(linkDetached), - grade: Object.fromEntries(followGrades), - }), - ); - } + const loadLinkFlags = linkSession.load; + const persistLinkFlags = linkSession.persist; /** 이 카드가 구간값을 만질 대상 — 링크면 소유 측점, 아니면 자기 자신. */ const ownerOf = (section: CrossSection): CrossSection | null => { @@ -622,16 +600,18 @@ export function createStationControls(deps: StationControlDeps): StationControls }, }; - // 세월교 측벽 조작은 별도 모듈(700줄 제한) — 축·저장 흐름은 집수정과 같다. - const fordControls = createFordControls({ - sessionKey: () => deps.sessionKey("fordadjust"), - sectionAt, - patchCachedDesign, - refreshCard: deps.refreshCard, - queuePipeOptions: culvertOptions.queue, - round1, - clampMove, - }); + // 세월교·BOX암거 조작은 별도 모듈(700줄 제한) — 저장 흐름은 집수정과 같다. + const bodyControls = createBodyControls( + { + sectionAt, + patchCachedDesign, + refreshCard: deps.refreshCard, + queuePipeOptions: culvertOptions.queue, + round1, + clampMove, + }, + deps.sessionKey, + ); return { stationWidth: stationWidthControl, @@ -640,8 +620,10 @@ export function createStationControls(deps: StationControlDeps): StationControls extraWalls: extraWallControl, structureSpan: structureSpanControl, revetLink: revetLinkControl, - ford: fordControls.control, - fordAdjustsByChainage: fordControls.byChainage, + ford: bodyControls.ford.control, + box: bodyControls.box.control, + fordAdjustsByChainage: bodyControls.ford.byChainage, + boxAdjustsByChainage: bodyControls.box.byChainage, widths: stationWidths, inletStructures, basinAdjustments, @@ -690,7 +672,8 @@ export function createStationControls(deps: StationControlDeps): StationControls loadBasinAdjustments(); loadExtraCounts(); loadLinkFlags(); - fordControls.load(); + bodyControls.ford.load(); + bodyControls.box.load(); }, applyGlobalWidth: (requested, chainages) => { stationWidths.clear(); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index edd25dd9..e9ebc848 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -26,6 +26,7 @@ import type { import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; +import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; import { createCrossSectionCard, type ExtraWallControl, @@ -118,18 +119,16 @@ export interface SectionViewController { export function createSectionView( onDesignChange?: DesignChangeHandler, rockBoundary?: RockBoundaryControl, - /** 측점 개별 표시 반폭 제어(2026-08-06) — 카드 하단 ◀/▶/↺과 행 높이 계산이 쓴다. */ + /** 측점 개별 표시 반폭(2026-08-06)·기슭막이 X 자리(2026-08-21) 제어. */ stationWidth?: StationWidthControl, - /** 기슭막이 X 자리 제어(2026-08-21) — 벽을 골라 0.1m씩 민다. */ revetOffset?: RevetOffsetControl, - /** 유입측 구조물 형식 선택(2026-08-22) — 조정창 드롭다운. */ - inletStructure?: InletStructureControl, - /** 유출측 추가 기슭막이 개수 제어(2026-08-22). */ - extraWalls?: ExtraWallControl, - /** 기슭막이·집수정 구간값(길이·전·후)·연동 제어(2026-08-24), 세월교 측벽(2026-08-25). */ + inletStructure?: InletStructureControl, // 유입측 구조물 형식(2026-08-22) + extraWalls?: ExtraWallControl, // 유출측 추가 기슭막이 단 수(2026-08-22) + /** 구간값·연동(2026-08-24), 세월교 측벽·BOX암거 구체(2026-08-25) 제어. */ structureSpan?: StructureSpanControl, revetLink?: RevetLinkControl, ford?: FordControl, + box?: BoxControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -407,6 +406,7 @@ export function createSectionView( structureSpan, revetLink, ford, + box, ); /**