From 0d4e4c758db7a1ddd68d9628ae2595387a5ad8e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 12:01:22 +0900 Subject: [PATCH 01/42] =?UTF-8?q?feat(B06,B05):=20=EA=B8=B0=EC=8A=AD?= =?UTF-8?q?=EB=A7=89=EC=9D=B4=20=EC=97=B0=EB=8F=99=C2=B7=EA=B2=BD=EC=82=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=AC=EC=A0=95=EC=9D=98=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=B9=B4=EB=93=9C=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-08-30) 세 가지를 반영한다. ① 링크 강조 — 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다. 선택 (조정창)과 강조를 분리해 `highlightFor`/`applyRevetHighlight`로 카드 재생성 없이 클래스만 갈아 끼운다(줌·팬 보존). 다단은 단 번호까지 같은 키로 짝짓는다. ② 연동 = 분리가 아니다 — 길이·전/후로 이미 하나의 기슭막이다. 연동을 푼 측점은 그 자리에 서므로 3D에서 벽이 노선과 평행이 아니라 자기 경로선을 따라간다. 링마다 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 해제 시 단 수가 0이 되던 것(벽 4→2)도 고쳤다 — 소유 측점 4축에서 출발해 손댄 벽만 갈아 끼운다. ③ 경사 = 수평(Z 일원화) — 3D 링만 눕히던 것을 횡단도까지 넓혔다. 링크 카드 벽 상단 표고를 소유 측점과 맞추고(d = 소유 d + 1.2×dz) 차이는 노견 확장이 흡수한다. 그래서 수평 중에는 연동 해제 불가 — 버튼을 잠그고, 해제해 둔 값은 지운다. 곁다리: 3D 재빌드 해시에 extra_spans·revet_link_detached 추가(안 넣으면 저장 코리도가 안 만료된다), 캐시 저장 payload에 extra_spans 누락 보완, 3D 머리 주석을 "정본만 읽는다" → "캐시를 읽는다"로 정정. Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_UI_Corridor_Structures.ts | 117 +++++----- ...B05_Profile_UI_Corridor_Structures_Path.ts | 203 ++++++++++++++++++ B06_Section/B06_Section_Section_Store.ts | 1 + .../B06_Section_UI_Cross_Culvert_Wire.ts | 114 +++++++++- .../B06_Section_UI_Cross_Structure_Panel.ts | 17 +- B06_Section/B06_Section_UI_Cross_View.ts | 35 ++- .../B06_Section_UI_Page_Station_Controls.ts | 114 ++++++---- B06_Section/B06_Section_UI_Section_View.ts | 16 ++ 8 files changed, 502 insertions(+), 115 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Structures_Path.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 1dd57e58..dc079a94 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -11,15 +11,16 @@ * · 집수정: 부재 폴리곤 대신 **전체 직육면체**(부재 외곽 박스 — 2026-08-23 * 사용자). 연장 = basin_length/before/after_m(기본 2m·1/1 — 2026-08-24). * · 배관: 관 하단 시·끝점(횡단면 안 대각선)을 축으로 하는 원통, 지름 = 관경. - * 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 확정 시 정본(design.revet_adjust· - * extra_wall_counts)에 실린다 — 3D는 **정본만** 읽는다(2026-08-24 사용자 확정: 3D는 - * 종단·횡단 확정 뒤의 최종 산출물이며 3D 쪽 편집은 없다). 확정 전 세션 값은 반영하지 - * 않는다. + * 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 **캐시**(`section.design`)에 그 자리에서 + * 실린다 — 3D는 그 **캐시를 읽는다**(2026-08-30 사용자 확정. 영구저장소 정본이 아니라 + * 캐시가 지금 화면의 값이다). B06 조정창이 캐시를 고치면 코리도 해시가 바뀌어 다시 빌드된다. + * + * 연동(2026-08-30 사용자 확정): 기슭막이는 길이·전/후로 **이미 하나**다. 연동을 푼 측점은 + * 그 측점 자리에 서므로 벽이 노선과 평행이 아니라 **자기 경로선**을 따라간다 — 링마다 + * 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다. * ========================================================================== */ import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch"; -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, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const"; @@ -39,6 +40,13 @@ import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box"; import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box"; import type { BoxAdjust, BoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box"; import { buildWingSolids } from "./B05_Profile_UI_Corridor_Structures_Wing"; +import { + buildWallPath, + detachedControlsOf, + polygonAt, + storedAdjusts, + type WallPath, +} from "./B05_Profile_UI_Corridor_Structures_Path"; import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ @@ -163,27 +171,6 @@ function splitOf(spec: CulvertSideSpec | undefined, fallbackLength: number): [nu */ const layoutCache = new WeakMap(); -/** 정본(design.revet_adjust·extra_wall_counts)을 기하 입력 형태로 되접는다. */ -function storedAdjusts( - section: CrossSection, -): - | { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] } - | undefined { - const stored = section.design?.revet_adjust; - const counts = section.design?.extra_wall_counts; - if (!stored && !counts) return undefined; - const at = (role: string): WallAdjust => - stored?.[role] - ? { ...ZERO_ADJUST, ...(stored[role] as Partial) } - : { ...ZERO_ADJUST }; - return { - inlet: at("inlet"), - outlet: at("outlet"), - extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) => at(`extra${i}`)), - basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) => at(`bextra${i}`)), - }; -} - /** BOX암거 정본 조작값 — 확정 전 세션 값은 안 읽는다(2026-08-24 규칙). */ export function boxAdjustOf(section: CrossSection): BoxAdjust { const stored = section.design?.box_adjust; @@ -301,41 +288,38 @@ export function buildCorridorStructures( // 본다. 이 스위치는 3D 전용이다 — 횡단도는 언제나 그 측점 계획고로 그린다. const followGrade = section.design?.revet_follow_grade !== false; const baseZ = followGrade ? (frameAt(chainage)?.designZ ?? null) : null; - const ringsOf = (beforeM: number, afterM: number): StructureFrame[] => { + /** 스윕 링이 설 누가거리 열 — 프레임과 폴리곤 보간이 같은 자리를 쓴다. */ + const ringChainagesOf = (beforeM: number, afterM: number): number[] => { const from = chainage - beforeM; const to = chainage + afterM; const count = Math.max(2, Math.ceil((to - from) / SWEEP_STEP_M) + 1); - const rings: StructureFrame[] = []; - for (let i = 0; i < count; i += 1) { - const at = from + ((to - from) * i) / (count - 1); - const frame = frameAt(at); - if (!frame) { - rings.push(stationFrame); - continue; - } - rings.push({ - cx: frame.cx, - cy: frame.cy, - leftX: frame.leftX, - leftY: frame.leftY, - dz: baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0, - }); - } - return rings; + return Array.from({ length: count }, (_unused, i) => from + ((to - from) * i) / (count - 1)); }; - const pushSwept = ( kind: "revet" | "basin", points: Array<{ offset: number; elevation: number }>, beforeM: number, afterM: number, + /** 벽 경로선 — 연동을 푼 측점이 있으면 링마다 단면을 갈아 끼운다(2026-08-30). */ + path?: WallPath | null, ): void => { if (points.length < 3) return; + const chainages = ringChainagesOf(beforeM, afterM); + const rings = ringsAt(chainages); + if (!path) { + solids.push({ + chainage_m: chainage, + kind, + polygon: points.map((p) => [p.offset, p.elevation]), + rings, + }); + return; + } solids.push({ chainage_m: chainage, kind, - polygon: points.map((p) => [p.offset, p.elevation]), - rings: ringsOf(beforeM, afterM), + polygons: chainages.map((at) => polygonAt(path, at)), + rings, }); }; @@ -378,6 +362,8 @@ export function buildCorridorStructures( }, /** 보어(구멍) 중심 누가거리 목록 — 세월교는 련수만큼(2026-08-25 사용자 ⑥). */ boreChainages: number[] = [chainage], + /** 벽 경로선(2026-08-30) — 링마다 그 자리 단면을 잘라 구멍을 낸다. */ + path?: WallPath | null, ): void => { if (points.length < 3) return; const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]); @@ -385,7 +371,7 @@ export function buildCorridorStructures( const right = Math.min(...polygon.map(([offset]) => offset)); // 관 진행 구간과 겹치지 않는 벽(다단 등)은 손대지 않는다. if (left < bore.minOffset || right > bore.maxOffset) { - pushSwept(kind, points, beforeM, afterM); + pushSwept(kind, points, beforeM, afterM, path); return; } // 보어마다 촘촘한 링을 깐 뒤 합친다 — 링이 성기면 원형이 사라진다. @@ -423,7 +409,7 @@ export function buildCorridorStructures( clamped.bottom = center; clamped.top = center; } - const [top, bottom] = splitByBand(polygon, clamped); + const [top, bottom] = splitByBand(path ? polygonAt(path, at) : polygon, clamped); upper.push(top); lower.push(bottom); } @@ -532,21 +518,37 @@ export function buildCorridorStructures( const basinSpan = basinSpanOf(section); const hiddenPipe = section.culvert?.hidden_pipe === true; const culvertBore = pipeBore(layout.culvert.diameter_m, layout.pipe.inlet, layout.pipe.outlet); + // 연동을 푼 측점이 있으면 벽은 노선과 평행이 아니라 **자기 경로선**을 따라간다 + // (2026-08-30 사용자). 벽 하나에 솔리드 하나인 것은 그대로다 — 링 단면만 바뀐다. + const detachedControls = detachedControlsOf(section, crossSections); + const pathOf = ( + key: string, + points: Array<{ offset: number; elevation: number }>, + ): WallPath | null => + detachedControls.length + ? buildWallPath( + { at: chainage, polygon: points.map((p) => [p.offset, p.elevation]) }, + detachedControls, + key, + ) + : null; for (const wall of layout.walls) { const span = wall.role === "inlet" ? inletSpan : outletSpan; + const path = pathOf(wall.role, wall.points); // 독립 기슭막이는 관이 없다 — 벽을 관통 컷 없이 그대로 스윕한다. - if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM); - else pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore); + if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM, path); + else + pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore, [chainage], path); } // 다단은 **단별 구간값**을 따른다(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); + pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`extra${i}`, wall.points)); }); layout.basinExtras.forEach((wall, i) => { const span = tierSpanOf(section, `bextra${i}`); - pushSwept("revet", wall.points, span.beforeM, span.afterM); + pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`bextra${i}`, wall.points)); }); if (layout.basin) { @@ -701,5 +703,12 @@ export function structureHashParts(section: CrossSection): Array (a < b ? -1 : 1)) + .map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`) + .join("|"), + section.design?.revet_link_detached === true ? "detached" : "", ]; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Path.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Path.ts new file mode 100644 index 00000000..e1d71a76 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Path.ts @@ -0,0 +1,203 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Structures_Path.ts + * 기슭막이 **벽 경로선** — 연동을 푼 측점이 있을 때 벽이 노선과 평행하지 않고 자기 + * 경로를 따라가도록, 측점별 단면 폴리곤을 링마다 보간한다(2026-08-30 사용자 확정: + * "연동은 분리가 아니다 — 하나의 기슭막이가 스플라인 형태의 경로를 따라간다"). + * + * 순수 기하만 둔다(Three.js·레이아웃 계산 무의존). 측점 폴리곤을 만드는 쪽은 + * `_Corridor_Structures.ts`이고, 여기서는 **꼭짓점별 Catmull-Rom**으로 잇기만 한다. + * 꼭짓점 수가 다른 측점은 로프트 토폴로지가 깨지므로 제어점에서 뺀다 — 그런 벽은 + * 소유 측점 폴리곤 하나로 고정(종전 동작)된다. + * ========================================================================== */ + +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; +import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; +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 { + culvertOwnerFor, + culvertReach, + restrictToSide, +} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; + +/** 단면 폴리곤 [offset, 표고][] — 코리도 솔리드가 쓰는 그 형식. */ +export type SectionPolygon = Array<[number, number]>; + +/** 경로선 제어점 하나 — 그 측점 누가거리와 거기서 잰 벽 단면. */ +export interface WallPathControl { + at: number; + polygon: SectionPolygon; +} + +/** 누가거리 오름차순 제어점 열. 2개 이상이라야 경로선이 생긴다. */ +export type WallPath = WallPathControl[]; + +/** 조정창 키(`inlet`·`outlet`·`extra0`·`bextra0`…)로 그 벽 단면을 꺼낸다. */ +export function wallPolygonOf(layout: CulvertLayout, key: string): SectionPolygon | null { + const wall = key.startsWith("bextra") + ? layout.basinExtras[Number(key.slice(6))] + : key.startsWith("extra") + ? layout.extraWalls[Number(key.slice(5))] + : layout.walls.find((candidate) => candidate.role === key); + if (!wall || wall.points.length < 3) return null; + return wall.points.map((point) => [point.offset, point.elevation]); +} + +/** + * 소유 측점 단면 + 연동을 푼 옆 측점 단면으로 경로선을 만든다. 제어점이 소유 측점 + * 하나뿐이거나 꼭짓점 수가 어긋나면 null — 호출부는 종전대로 단일 폴리곤을 스윕한다. + */ +export function buildWallPath( + base: WallPathControl, + others: ReadonlyArray<{ at: number; layout: CulvertLayout }>, + key: string, +): WallPath | null { + const path: WallPath = [base]; + for (const other of others) { + const polygon = wallPolygonOf(other.layout, key); + // 꼭짓점 수가 다르면 로프트 링 토폴로지가 깨진다 — 그 측점은 제어점에서 뺀다. + if (!polygon || polygon.length !== base.polygon.length) continue; + path.push({ at: other.at, polygon }); + } + if (path.length < 2) return null; + return path.sort((a, b) => a.at - b.at); +} + +/** 제어값 열의 Catmull-Rom 보간(끝 밖은 끝값 유지). 제어점 2개면 사실상 선형. */ +function splineValue(values: number[], ats: number[], at: number): number { + const last = values.length - 1; + if (last < 1) return values[0]; + if (at <= ats[0]) return values[0]; + if (at >= ats[last]) return values[last]; + let i = 0; + while (i < last - 1 && ats[i + 1] < at) i += 1; + const span = ats[i + 1] - ats[i]; + const t = span <= 1e-9 ? 0 : (at - ats[i]) / span; + const p0 = i > 0 ? values[i - 1] : values[i]; + const p1 = values[i]; + const p2 = values[i + 1]; + const p3 = i + 2 <= last ? values[i + 2] : values[i + 1]; + const t2 = t * t; + const t3 = t2 * t; + return ( + 0.5 * + (2 * p1 + + (-p0 + p2) * t + + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + + (-p0 + 3 * p1 - 3 * p2 + p3) * t3) + ); +} + +/** 그 누가거리의 단면 — 꼭짓점마다 offset·표고를 따로 보간한다. */ +export function polygonAt(path: WallPath, at: number): SectionPolygon { + const ats = path.map((control) => control.at); + return path[0].polygon.map((_vertex, index) => [ + splineValue( + path.map((control) => control.polygon[index][0]), + ats, + at, + ), + splineValue( + path.map((control) => control.polygon[index][1]), + ats, + at, + ), + ]); +} + +/** 정본(design.revet_adjust·extra_wall_counts)을 기하 입력 형태로 되접는다. */ +export function storedAdjusts( + section: CrossSection, +): + | { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] } + | undefined { + const stored = section.design?.revet_adjust; + const counts = section.design?.extra_wall_counts; + if (!stored && !counts) return undefined; + const at = (role: string): WallAdjust => + stored?.[role] + ? { ...ZERO_ADJUST, ...(stored[role] as Partial) } + : { ...ZERO_ADJUST }; + return { + inlet: at("inlet"), + outlet: at("outlet"), + extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) => at(`extra${i}`)), + basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) => at(`bextra${i}`)), + }; +} + +/** + * 연동을 푼 측점의 4축 — **소유 측점 값에서 출발**해 이 측점이 손댄 벽만 갈아 끼운다. + * 단 수는 소유 측점 것이다(2026-08-30 사용자: 연동 해제는 분리가 아니다). 횡단도 + * `detachedAdjusts`와 같은 규칙 — 3D가 2D와 다른 벽을 세우면 안 된다. + */ +function detachedAdjusts( + owner: CrossSection, + section: CrossSection, +): ReturnType { + const base = storedAdjusts(owner); + const mine = section.design?.revet_adjust; + const counts = owner.design?.extra_wall_counts; + const pick = (role: string, fallback?: WallAdjust): WallAdjust => + mine?.[role] + ? { ...ZERO_ADJUST, ...(mine[role] as Partial) } + : (fallback ?? { ...ZERO_ADJUST }); + return { + inlet: pick("inlet", base?.inlet), + outlet: pick("outlet", base?.outlet), + extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) => + pick(`extra${i}`, base?.extras[i]), + ), + basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) => + pick(`bextra${i}`, base?.basinExtras[i]), + ), + }; +} + +/** + * **연동을 푼** 옆 측점의 기하 — 소유 측점 **스펙**을 그 측점 지형·조작값 위에 세운다. + * 횡단도 링크 카드(`computeCardCulvert`)와 같은 규칙이다: 구조물 형식은 언제나 소유 + * 측점 것, 4축은 이 측점 것. 벽 경로선의 제어점으로 쓴다(2026-08-30 사용자). + */ +export function detachedLayoutOf(owner: CrossSection, section: CrossSection): CulvertLayout | null { + if (!owner.culvert || !section.design) return null; + const hosted: CrossSection = { ...section, culvert: owner.culvert }; + try { + const layout = computeCulvertLayout( + hosted, + section.samples, + detachedAdjusts(owner, section), + owner.culvert.hidden_pipe ? "revet" : (owner.design?.inlet_structure ?? "auto"), + section.design.basin_adjust, + ); + if (layout && owner.culvert.hidden_pipe) return restrictToSide(layout, owner.culvert.side); + return layout; + } catch { + return null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다. + } +} + +/** + * 이 구조물의 연장 안에서 **연동을 푼** 측점들의 기하. 경사 해제(수평)면 연동을 풀 수 + * 없으므로 빈 배열이다 — 그때는 소유 측점 단면 하나가 Z 그대로 눕는다(2026-08-30). + */ +export function detachedControlsOf( + owner: CrossSection, + sections: ReadonlyArray, +): Array<{ at: number; layout: CulvertLayout }> { + if (owner.design?.revet_follow_grade === false) return []; + const reach = culvertReach(owner); + if (!reach) return []; + const controls: Array<{ at: number; layout: CulvertLayout }> = []; + for (const section of sections) { + if (section === owner || section.design?.revet_link_detached !== true) continue; + const deltaM = section.chainage_m - owner.chainage_m; + if (deltaM < -reach.beforeM - 1e-9 || deltaM > reach.afterM + 1e-9) continue; + // 겹치는 구조물이 있으면 가장 가까운 측점이 주인이다 — 남의 벽을 끌어오지 않는다. + if (culvertOwnerFor(section, sections) !== owner) continue; + const layout = detachedLayoutOf(owner, section); + if (layout) controls.push({ at: section.chainage_m, layout }); + } + return controls; +} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 1f498914..6464073f 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -105,6 +105,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti put("ford_adjust", design.ford_adjust); put("box_adjust", design.box_adjust); put("extra_wall_counts", design.extra_wall_counts); + put("extra_spans", design.extra_spans); put("revet_link_detached", design.revet_link_detached); put("revet_follow_grade", design.revet_follow_grade); if (touched) patches.push(patch); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 35b1261b..7c66da84 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -10,6 +10,7 @@ import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Sectio import type { BasinAdjust, CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert_Types"; import { basinSpanOfSpec, + FILL_SLOPE_RATIO_MIN, materialLabel, materialLimit, revetSpanOfSpec, @@ -29,8 +30,16 @@ import { */ export interface RevetOffsetControl { adjustFor: (section: CrossSection, role: RevetKey) => WallAdjust; + /** 이 측점이 **실제로 손댄** 값만 — 없으면 null(자동·상속과 구분해야 한다). */ + storedAdjustFor: (section: CrossSection, role: RevetKey) => WallAdjust | null; selectedFor: (section: CrossSection) => RevetKey | null; + /** 이 카드에서 **강조**할 벽 — 연동으로 이어진 옆 측점이 고른 벽도 켠다 + * (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다). + * 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */ + highlightFor: (section: CrossSection) => RevetKey | null; select: (chainageM: number, key: RevetKey | null) => void; + /** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */ + onSelectionChange?: () => void; /** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */ syncApplied: (chainageM: number, role: RevetKey, applied: WallAdjust) => void; /** 일부 축만 바꾼다 — 나머지는 유지. h: null = 기본값 복귀. */ @@ -154,7 +163,7 @@ export function tierSpanOf(section: CrossSection, key: string): StructureSpan { } /** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이·집수정·다단 중 큰 값. */ -function culvertReach(section: CrossSection): StructureSpan | null { +export function culvertReach(section: CrossSection): StructureSpan | null { const culvert = section.culvert; if (!culvert) return null; let beforeM = 0; @@ -205,6 +214,26 @@ export function culvertOwnerFor( return best; } +/** + * 그 벽(기준벽·다단)이 **이 측점 카드에도 서는가** — 연동 하이라이트 짝짓기용 + * (2026-08-30 사용자). 판정은 그리기(`trimLinkedLayout`)와 같은 구간값을 쓴다: + * 기준벽은 자기 길이·전/후, 다단은 단별 구간값(`tierSpanOf`). + */ +export function wallStandsAt(owner: CrossSection, section: CrossSection, key: string): boolean { + const deltaM = section.chainage_m - owner.chainage_m; + if (Math.abs(deltaM) < 0.01) return true; + const spec = owner.culvert; + if (!spec) return false; + if (key.startsWith("extra") || key.startsWith("bextra")) { + return spanCovers(tierSpanOf(owner, key), deltaM); + } + const side = key === "inlet" ? spec.inlet : key === "outlet" ? spec.outlet : null; + if (!side) return false; + // 집수정은 소유 측점 하나에만 선다(그리기와 같은 규칙) — 링크 카드에는 없다. + if (side.structure === "집수정" && !spec.hidden_pipe) return false; + return spanCovers(revetSpanOfSpec(side), deltaM); +} + export function culvertLinkFor( section: CrossSection, sections: readonly CrossSection[], @@ -352,23 +381,41 @@ export function computeCardCulvert( // 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다. // 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시 // 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자). - const ownerAdjusts = detached - ? adjustsInput(section, revetOffset, extraWalls) - : (computeCulvertLayout( - link.source, - link.source.samples, - adjustsInput(link.source, revetOffset, extraWalls), - link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), - inletStructure?.adjustFor(link.source), - )?.revetShift ?? adjustsInput(link.source, revetOffset, extraWalls)); + const ownerInput = adjustsInput(link.source, revetOffset, extraWalls); + const ownerApplied = + computeCulvertLayout( + link.source, + link.source.samples, + ownerInput, + link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), + inletStructure?.adjustFor(link.source), + )?.revetShift ?? ownerInput; + // 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄 + // 벽만 자기 값으로 선다(2026-08-30 사용자). + const ownerAdjusts = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied; + // 경사 해제 = **수평(Z 일원화)**. 3D 링만 눕히는 게 아니라 횡단도에서도 기준벽 + // 상단 표고를 소유 측점과 같게 맞춘다(2026-08-30 사용자 확정 — 종전 "3D 전용" + // 규정을 갈아엎는다). 차이는 노견 확장 구간이 흡수하므로 성토선은 이 측점 + // 노견에서 그대로 출발한다(2026-08-24 "성토선이 노견에서 떨어진다" 지적 회피). + const followGrade = + revetLink?.followGradeFor(link.source) ?? link.source.design?.revet_follow_grade !== false; const layout = computeCulvertLayout( hosted, sourceSamples, - ownerAdjusts, + followGrade ? ownerAdjusts : levelToOwner(ownerAdjusts, link.dz), link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), inletStructure?.adjustFor(detached ? section : link.source), ); if (!layout) return null; + // 노견 위로는 못 올라간다(기하가 d를 0에서 자른다) — 조작 중인 벽만 이유를 알린다. + if (!followGrade && (activeRevet === "inlet" || activeRevet === "outlet")) { + const requested = levelToOwner(ownerAdjusts, link.dz); + const applied = activeRevet === "inlet" ? layout.revetShift.inlet : layout.revetShift.outlet; + const asked = activeRevet === "inlet" ? requested?.inlet : requested?.outlet; + if (asked?.d != null && applied.d != null && Math.abs(asked.d - applied.d) > 0.05) { + showToast(L("B06_Cross_Revet_Limit_Up"), "info"); + } + } // 종단경사 반영은 **3D 전용** 스위치다(2026-08-24 사용자 확인). 횡단도는 언제나 // 그 측점 계획고로 그린다 — 레이아웃 전체를 −dz로 밀면 벽뿐 아니라 노견에서 // 출발하는 성토선까지 같이 내려가 **성토선이 노견에서 떨어진다**(사용자 지적). @@ -492,6 +539,51 @@ export function computeCardCulvert( return section.culvert?.hidden_pipe ? restrictToSide(layout, section.culvert.side) : layout; } +/** + * **수평(Z 일원화) 보정**(2026-08-30 사용자 확정) — 경사를 풀면 기슭막이는 종단을 타지 + * 않고 눕는다. 배관 벽 자리 규칙이 `상단 = 노견 − d/1.2`(`placePipeWall`)이므로 이 측점 + * d를 `소유 d + 1.2 × dz`로 밀면 상단 표고가 소유 측점 값 그대로가 된다. 계획고 차 dz는 + * **노견 확장 구간**이 흡수하고 성토선 각도(1:1.2)는 그대로다. + * + * 다단은 손대지 않는다 — 시작점이 기준벽 하단이라 기준벽을 따라 통째로 딸려 온다. + * d가 음수로 내려가면(벽이 이 측점 노견보다 위) 기하가 0에서 자른다. + */ +function levelToOwner(adjusts: WallAdjustSet, dz: number): WallAdjustSet { + if (!adjusts || Math.abs(dz) < 1e-9) return adjusts; + const shift = (adjust: WallAdjust): WallAdjust => ({ + ...adjust, + d: (adjust.d ?? 0) + FILL_SLOPE_RATIO_MIN * dz, + }); + return { ...adjusts, inlet: shift(adjusts.inlet), outlet: shift(adjusts.outlet) }; +} + +/** 한 측점의 4축 조작값 한 벌(기준벽 2 + 다단). */ +type WallAdjustSet = + | { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] } + | undefined; + +/** + * **연동을 푼 측점**의 4축(2026-08-30 사용자 확정: "연동 해제는 분리가 아니다"). + * 단 수·형식은 소유 측점 것 그대로이고, 이 측점이 **손댄 벽만** 자기 값으로 바뀐다. + * 종전에는 이 측점 값(대개 빈 값)으로 통째로 다시 풀어 다단이 통째로 사라졌다 + * (2026-08-30 화면 실측: 벽 4 → 2). + */ +function detachedAdjusts( + section: CrossSection, + base: WallAdjustSet, + revetOffset?: RevetOffsetControl, +): WallAdjustSet { + if (!base) return base; + const pick = (role: string, fallback: WallAdjust): WallAdjust => + revetOffset?.storedAdjustFor(section, role as RevetKey) ?? fallback; + return { + inlet: pick("inlet", base.inlet), + outlet: pick("outlet", base.outlet), + extras: base.extras.map((adjust, i) => pick(`extra${i}`, adjust)), + basinExtras: base.basinExtras.map((adjust, i) => pick(`bextra${i}`, adjust)), + }; +} + function adjustsInput( section: CrossSection, revetOffset?: RevetOffsetControl, diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 9fdffd0c..dc00bb8b 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -408,11 +408,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan optionParts.controls.append(button); return { button, set }; }; - const linkToggle = makeToggle( - "연동", - "옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.", - (on) => deps.setLinked(on), - ); + const LINK_TITLE = + "옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다."; + /** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */ + const LINK_LOCKED_TITLE = + "경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다."; + const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on)); const gradeToggle = makeToggle( "경사", "종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.", @@ -575,8 +576,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan const linkState = deps.linkState(); linkToggle.button.classList.toggle("is-hidden", linkState === null); linkToggle.set(linkState?.linked ?? true); + // 경사 해제(수평) = 연동 잠김(2026-08-30 사용자). 버튼은 눌리지 않고 이유를 단다. + const levelled = !deps.followGrade(); + linkToggle.button.disabled = levelled; + linkToggle.button.title = levelled ? LINK_LOCKED_TITLE : LINK_TITLE; gradeToggle.button.classList.toggle("is-hidden", currentSpan === null); - gradeToggle.set(deps.followGrade()); + gradeToggle.set(!levelled); optionParts.row.classList.toggle("is-hidden", linkState === null && currentSpan === null); const adjust = deps.adjustFor(key); // 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥, diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index e4b20ca1..df4f6938 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -70,6 +70,9 @@ export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics"; */ export interface CrossCardElement extends HTMLElement { applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void; + /** 벽 강조만 갈아 끼운다 — 연동으로 이어진 카드도 같이 켜진다(2026-08-30 사용자). + * 카드를 다시 만들지 않으므로 줌·팬이 살아남는다. */ + applyRevetHighlight?: (key: RevetKey | null) => void; } /** @@ -181,6 +184,9 @@ export function createCrossSectionCard( let setBandActive: AreaHighlightSetter = () => undefined; let setChipActive: AreaHighlightSetter = () => undefined; let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null; + /** 이 카드에서 **강조**할 벽. 연동으로 이어진 옆 측점이 고른 벽도 켠다 — + * 조정창(`activeRevet`)은 고른 카드 하나에만 뜬다(2026-08-30 사용자). */ + let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용 @@ -222,9 +228,11 @@ export function createCrossSectionCard( const toggleRevet = (key: RevetKey): void => { const wasSelected = isSelected; activeRevet = activeRevet === key ? null : key; - setRevetActive(activeRevet); showRevetControl(activeRevet !== null); + // 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를 + // 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다. revetOffset?.select(section.chainage_m, activeRevet); + card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet); if (activeRevet !== null && activeArea !== null) { activeArea = null; setBandActive(null); @@ -247,13 +255,20 @@ export function createCrossSectionCard( setChipActive(activeArea); // 면적이 켜졌거나 카드가 선택에서 빠지면 구조물 선택은 풀린다(같은 레벨). if ((activeArea !== null || !nextSelected) && activeRevet !== null) { + // 전역 선택이 **아직 이 카드 것일 때만** 비운다 — 옆 카드에서 방금 고른 벽까지 + // 지우면 그리로 옮겨 간 조정창이 곧바로 닫힌다(2026-08-30 링크 강조). + const owns = revetOffset?.selectedFor(section) === activeRevet; activeRevet = null; - setRevetActive(null); showRevetControl(false); - revetOffset?.select(section.chainage_m, null); + if (owns) revetOffset?.select(section.chainage_m, null); + else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null); } if (activeArea !== null || !nextSelected) bodies.clearSelection(); }; + card.applyRevetHighlight = (key) => { + highlightRevet = key; + setRevetActive(key); + }; const toggleArea = (key: CrossAreaKey): void => { if (!isSelected) { // 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다. @@ -479,7 +494,7 @@ export function createCrossSectionCard( baseSetActive(key); setOwnActive(key === "own"); }; - if (activeRevet === "own") setOwnActive(true); + if (highlightRevet === "own") setOwnActive(true); } } appendCrossDesignOverlay( @@ -548,7 +563,7 @@ export function createCrossSectionCard( // 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙. isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true, ); - if (activeRevet) setRevetActive(activeRevet); + if (highlightRevet) setRevetActive(highlightRevet); } } @@ -662,8 +677,14 @@ export function createCrossSectionCard( isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true) ? culvertLink.source : section; - const adjustOf = (key: RevetKey): WallAdjust => - revetOffset?.adjustFor(adjustTarget(), key) ?? { x: 0, d: null, h: null, m: null }; + // 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이 + // 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30). + const adjustOf = (key: RevetKey): WallAdjust => { + const stored = revetOffset?.storedAdjustFor(adjustTarget(), key); + if (stored) return stored; + const source = isLinkedCulvert && culvertLink ? culvertLink.source : section; + return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null }; + }; const panel = buildStructurePanel( structurePanelDeps({ section, diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 94a527ab..2decf785 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -24,7 +24,12 @@ import type { StructureSpanControl, } from "./B06_Section_UI_Cross_View"; import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const"; -import { culvertOwnerFor, tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { + culvertOwnerFor, + culvertReach, + tierSpanOf, + wallStandsAt, +} from "./B06_Section_UI_Cross_Culvert_Wire"; import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options"; import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls"; import { createLinkFlagSession } from "./B06_Section_UI_Page_Link_Session"; @@ -184,13 +189,15 @@ export function createStationControls(deps: StationControlDeps): StationControls * 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`. * 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */ const revetShifts = new Map(); - const revetSelected = new Map(); + /** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편: + * 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */ + let revetSelection: { at: string; key: RevetKey } | null = null; const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`; const revetSessionKey = (): string | null => deps.sessionKey("revetx"); function loadRevetShifts(): void { revetShifts.clear(); - revetSelected.clear(); + revetSelection = null; const key = revetSessionKey(); if (!key) return; try { @@ -255,17 +262,11 @@ export function createStationControls(deps: StationControlDeps): StationControls deps.refreshCard(chainageM); const sections = deps.detail()?.cross_sections ?? []; const owner = sections.find((s) => Math.abs(s.chainage_m - chainageM) < 0.01); - const spec = owner?.culvert; - if (!spec) return; - const reach = Math.max( - ...[spec.inlet, spec.outlet].flatMap((side) => { - const span = - side?.structure === "집수정" - ? CulvertConst.basinSpanOfSpec(side) - : CulvertConst.revetSpanOfSpec(side); - return [span.beforeM, span.afterM]; - }), - ); + if (!owner) return; + // 연장은 그리기·링크 판정과 **같은 함수**로 잰다 — 다단 단별 구간값까지 본다. + const span = owner.culvert ? culvertReach(owner) : null; + if (!span) return; + const reach = Math.max(span.beforeM, span.afterM); for (const other of sections) { if (other === owner) continue; if (Math.abs(other.chainage_m - chainageM) <= reach + 1e-9) @@ -281,10 +282,29 @@ export function createStationControls(deps: StationControlDeps): StationControls (section.design?.revet_adjust?.[role] ? { ...ZERO_ADJUST, ...section.design.revet_adjust[role] } : { ...ZERO_ADJUST }), - selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null, + storedAdjustFor: (section, role) => { + const session = revetShifts.get(revetKey(section.chainage_m, role)); + if (session) return session; + const stored = section.design?.revet_adjust?.[role]; + return stored ? { ...ZERO_ADJUST, ...stored } : null; + }, + selectedFor: (section) => + revetSelection?.at === section.chainage_m.toFixed(2) ? revetSelection.key : null, + // 강조는 **같은 구조물**을 그리는 카드 전부. 고른 카드의 소유 측점과 이 카드의 + // 소유 측점이 같고, 그 벽이 이 측점에도 서면 켠다(2026-08-30 사용자). + highlightFor: (section) => { + if (!revetSelection) return null; + if (revetSelection.at === section.chainage_m.toFixed(2)) return revetSelection.key; + const picked = sectionAt(Number(revetSelection.at)); + if (!picked) return null; + const owner = picked.culvert ? picked : ownerOf(picked); + const here = section.culvert ? section : ownerOf(section); + if (!owner || !here || Math.abs(here.chainage_m - owner.chainage_m) > 0.01) return null; + return wallStandsAt(owner, section, revetSelection.key) ? revetSelection.key : null; + }, select: (chainageM, key) => { - if (key) revetSelected.set(chainageM.toFixed(2), key); - else revetSelected.delete(chainageM.toFixed(2)); + revetSelection = key ? { at: chainageM.toFixed(2), key } : null; + revetOffsetControl.onSelectionChange?.(); }, syncApplied: (chainageM, role, applied) => { // 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. @@ -650,8 +670,36 @@ export function createStationControls(deps: StationControlDeps): StationControls }, }; + /** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */ + const followGradeOf = (section: CrossSection): boolean => { + const session = followGrades.get(chainageKey(section.chainage_m)); + if (session !== undefined) return session; + return section.design?.revet_follow_grade !== false; + }; + + /** + * 연동 해제로 **그 측점에만** 붙어 있던 4축 값을 지운다 — 경사 해제(수평)로 넘어갈 때 + * 쓴다(2026-08-30 사용자: 해제해 둔 값은 되살리지 않는다). 옛 D경로 독립 벽(`own`)은 + * 이 구조물 것이 아니라 남긴다. + */ + const dropLinkedAdjusts = (chainageM: number): void => { + const prefix = `${chainageM.toFixed(2)}:`; + for (const key of [...revetShifts.keys()]) { + if (!key.startsWith(prefix) || key.slice(prefix.length) === "own") continue; + revetShifts.delete(key); + } + persistRevetShifts(); + const design = sectionAt(chainageM)?.design; + if (!design?.revet_adjust) return; + const own = design.revet_adjust.own; + design.revet_adjust = own ? { own } : {}; + }; + const revetLinkControl: RevetLinkControl = { linkedFor: (section) => { + // 경사 해제(수평)면 연동은 잠긴다 — 측점마다 자리를 따로 잡으면 기슭막이 Z를 + // 하나로 묶을 수 없다(2026-08-30 사용자). + if (!followGradeOf(ownerOf(section) ?? section)) return true; const session = linkDetached.get(chainageKey(section.chainage_m)); if (session !== undefined) return !session; return section.design?.revet_link_detached !== true; @@ -662,34 +710,26 @@ export function createStationControls(deps: StationControlDeps): StationControls patchCachedDesign(chainageM, { revet_link_detached: !linked }); deps.refreshCard(chainageM); }, - followGradeFor: (section) => { - const session = followGrades.get(chainageKey(section.chainage_m)); - if (session !== undefined) return session; - return section.design?.revet_follow_grade !== false; - }, + followGradeFor: followGradeOf, setFollowGrade: (section, follow) => { followGrades.set(chainageKey(section.chainage_m), follow); - persistLinkFlags(); patchCachedDesign(section.chainage_m, { revet_follow_grade: follow }); // 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그 // 연장 안의 측점만 다시 그린다. - const spec = section.culvert; - const reach = spec - ? Math.max( - ...[spec.inlet, spec.outlet].flatMap((side) => { - const span = - side?.structure === "집수정" - ? CulvertConst.basinSpanOfSpec(side) - : CulvertConst.revetSpanOfSpec(side); - return [span.beforeM, span.afterM]; - }), - ) - : 0; + const span = section.culvert ? culvertReach(section) : null; + const reach = span ? Math.max(span.beforeM, span.afterM) : 0; for (const other of deps.detail()?.cross_sections ?? []) { - if (Math.abs(other.chainage_m - section.chainage_m) <= reach + 1e-9) { - deps.refreshCard(other.chainage_m); + if (Math.abs(other.chainage_m - section.chainage_m) > reach + 1e-9) continue; + // 수평으로 넘어가면 연동 해제를 되돌리고 그 측점만의 자리도 지운다 + // (2026-08-30 사용자: 경사를 다시 켜도 되살아나지 않는다). + if (!follow && Math.abs(other.chainage_m - section.chainage_m) > 0.01) { + linkDetached.set(chainageKey(other.chainage_m), false); + patchCachedDesign(other.chainage_m, { revet_link_detached: false }); + dropLinkedAdjusts(other.chainage_m); } + deps.refreshCard(other.chainage_m); } + persistLinkFlags(); }, }; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index e9ebc848..bbd85b5c 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -331,6 +331,22 @@ export function createSectionView( card?.applySelection?.(stationId === selectedStationId, activeAreaKey); }; + /** + * 연동으로 이어진 카드들의 **벽 강조**를 다시 칠한다(2026-08-30 사용자: 한 기슭막이는 + * 그 연장 안 모든 횡단도에서 같이 강조된다). 조정창은 고른 카드 하나에만 남는다. + * 카드는 다시 만들지 않고 클래스만 갈아 끼운다 — 줌·팬 보존. + */ + const syncRevetHighlights = (): void => { + if (!currentDetail || !revetOffset) return; + for (const section of currentDetail.cross_sections) { + const card = document.getElementById( + `cross-${section.station_id}`, + ) as CrossCardElement | null; + card?.applyRevetHighlight?.(revetOffset.highlightFor(section)); + } + }; + if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights; + /** * 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**. * 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다 From 2cad971f6ae9c9d723bd01723313f5ba5f478a12 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 13:07:04 +0900 Subject: [PATCH 02/42] =?UTF-8?q?fix(B06):=20=EC=97=B0=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=96=20=EB=8B=A4=EB=8B=A8=EC=9D=80=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B9=BC=20=EC=84=B1=ED=86=A0=EB=A9=B4?= =?UTF-8?q?=EC=9D=B4=20=EB=94=B0=EB=9D=BC=EC=98=A4=EA=B2=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상(사용자): 독립 기슭막이 우측 다단이 구간값에 따라 옆 측점에서 없어지는데, 그 위 기준벽 이후 성토면은 갱신되지 않는다. 원인: 링크 카드가 다단을 **다 세워 놓고 그림만 지웠다**(trimLinkedLayout이 extraWalls만 걸러낸다). 성토부선(outletFill·basinFill)은 원래 단 수로 계산된 값이라 마지막 남은 벽 뒤가 비어 있었다. 고침: 이 측점까지 오지 않는 단은 계산에 들어가기 전에 뺀다(tierPrefix — 단은 위에서부터 쌓이므로 덮이는 앞부분만 남긴다). buildOutletExtras가 마지막 단 뒤 성토부선을 지반까지 다시 긋는다. 화면 실측(234.1 소유 → 240.0 링크, bextra0 후 6.0m → 1.5m): 링크 카드 벽 4 → 3, 유입부 성토부선 0.91+3.00 → 5.27 한 줄로 지반까지. 소유 카드는 불변. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_Section_UI_Cross_Culvert_Wire.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 7c66da84..65969bc3 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -392,7 +392,23 @@ export function computeCardCulvert( )?.revetShift ?? ownerInput; // 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄 // 벽만 자기 값으로 선다(2026-08-30 사용자). - const ownerAdjusts = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied; + const merged = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied; + // 이 측점까지 오지 않는 단은 **계산에서부터** 뺀다. 다 세워 놓고 그림만 지우면 + // (`trimLinkedLayout`) 성토부선은 원래 단 수로 계산된 채 남아, 마지막 단 뒤 성토면이 + // 갱신되지 않는다(2026-08-30 사용자 지적: 우측 독립 기슭막이에서 단이 없어져도 + // 그 위 성토면이 그대로). 단은 위에서부터 쌓이므로 **덮이는 앞부분만** 남긴다. + const tierPrefix = (kind: "extra" | "bextra", total: number): number => { + let count = 0; + while (count < total && spanCovers(tierSpanOf(link.source, `${kind}${count}`), link.deltaM)) { + count += 1; + } + return count; + }; + const ownerAdjusts: WallAdjustSet = merged && { + ...merged, + extras: merged.extras.slice(0, tierPrefix("extra", merged.extras.length)), + basinExtras: merged.basinExtras.slice(0, tierPrefix("bextra", merged.basinExtras.length)), + }; // 경사 해제 = **수평(Z 일원화)**. 3D 링만 눕히는 게 아니라 횡단도에서도 기준벽 // 상단 표고를 소유 측점과 같게 맞춘다(2026-08-30 사용자 확정 — 종전 "3D 전용" // 규정을 갈아엎는다). 차이는 노견 확장 구간이 흡수하므로 성토선은 이 측점 From 11abd303e7d4f76409fe0bb91415427b374e4fa2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 13:22:50 +0900 Subject: [PATCH 03/42] =?UTF-8?q?fix(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90?= =?UTF-8?q?=20=EC=B8=A1=EB=B2=BD=EC=9D=84=20=EC=88=98=EC=A7=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=84=B8=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상(사용자): 세월교 횡단도 측벽이 기울어 있고 바닥판 모서리도 깎여 있다. 확인 결과 세로(수직)로 그려져야 한다. 원인: 세월교 측벽은 ㄴ형 집수정 부재를 재사용한다(2026-08-25). 그래서 집수정 규칙인 전면 1:0.3 기움과 거기서 파생된 바닥판 taper(0.3 × 두께)가 딸려 왔다. 세월교 근거가 아니라 부재를 빌려 오며 따라온 값이다. 근거: 지식DB·교본 원문에 세월교 단면 기울기 수치는 없다. 교본 그림 3-18 세월교 설치 종단도의 구체가 수직 벽이고, 사용자가 그렇게 확정했다. 고침: buildBasin에 leanRatio 인자를 두고(기본 0.3) 세월교만 0을 넘긴다. 집수정 호출부는 인자를 안 넘기므로 동작이 그대로다. 벽 상단 도로측 꼭짓점 (설계선 트림 경계)은 노견 끝 그대로라 트림·접근선 규칙도 불변이고, 바닥 길이는 날개벽 투영 연장 + 벽 두께로 유지된다. 3D는 같은 폴리곤을 스윕하므로 자동 반영된다. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_Section_UI_Cross_Culvert_Basin.ts | 20 +++++++++++++------ B06_Section/B06_Section_UI_Cross_Ford_Geom.ts | 10 ++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index 2316cc62..d789323a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -88,6 +88,12 @@ export interface BasinBuildInput { memberThicknessM?: number; /** 바닥판 두께(m). 기본 = 벽 두께. 세월교는 교본 물받이 최소 0.3m를 쓴다. */ floorThicknessM?: number; + /** + * 벽 기움(1:n의 n). 기본 = 집수정 규칙 0.3. **세월교는 0**(수직) — 기움은 집수정 + * 부재를 재사용하며 딸려 온 값이고, 교본 그림 3-18 세월교 단면은 수직 벽이다 + * (2026-08-30 사용자 확정: 집수정은 그대로 두고 세월교만 세운다). + */ + leanRatio?: number; } /** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */ @@ -127,6 +133,8 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { cutSlopeRatio, adjust, } = input; + /** 이 부재의 기움 — 집수정 0.3, 세월교 0(수직). */ + const lean = input.leanRatio ?? REVET_LEAN_RATIO; const movedAnchor = { offset: baseAnchor.offset + outward * (adjust.lateralM + adjust.slopeM), elevation: baseAnchor.elevation - adjust.slopeM / 1.2, @@ -147,7 +155,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { dir: number, bottomElevation = defaultBottom, ): OffsetPoint[] => { - const topShift = -dir * REVET_LEAN_RATIO * wallHeight; + const topShift = -dir * lean * wallHeight; const spanZ = wallHeight + floorThickness; const xAt = (elevation: number): number => baseOffset + topShift * ((elevation - defaultBottom) / spanZ); @@ -166,7 +174,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { }; // I형 벽 자리 = **상단 도로측 꼭짓점이 노견 끝 지점에 닿는다**(2026-08-22 사용자 // 확정). 벽 상단이 도로측으로 1:0.3 물러나므로 하단(base)은 그만큼 계류측 바깥. - const wallBase = anchor.offset + outward * (REVET_LEAN_RATIO * wallHeight); + const wallBase = anchor.offset + outward * (lean * wallHeight); // I형 벽 하단은 **원지반까지 묻는다**(2026-08-22 사용자 — 지반 아래 부재두께만큼 // 근입 후 배관을 둔다). ㄴ·ㄷ형은 바닥판이 원지반을 대체하므로 현행 유지. const iWallBottom = @@ -203,12 +211,12 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { const wallOuterAt = (elevation: number): number => { const bottom = anchor.elevation - floorThickness; const t = (elevation - bottom) / (wallHeight + floorThickness); - return wallBase + outward * memberT - outward * REVET_LEAN_RATIO * wallHeight * t; + return wallBase + outward * memberT - outward * lean * wallHeight * t; }; const floorTopInner = wallOuterAt(anchor.elevation); const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness); const floorOuter = floorTopInner + outward * (adjust.innerWidthM + memberT); - const floorTaper = REVET_LEAN_RATIO * floorThickness; + const floorTaper = lean * floorThickness; if (shape !== "I") { parts.push({ kind: "floor", @@ -240,7 +248,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { const refTop = shape === "U" ? { - offset: mirroredWallBase + outward * REVET_LEAN_RATIO * wallHeight, + offset: mirroredWallBase + outward * lean * wallHeight, elevation: anchor.elevation + wallHeight, } : { offset: floorOuter, elevation: anchor.elevation }; @@ -289,7 +297,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { : { offset: floorTopInner, elevation: anchor.elevation }, fillStart, fillBottomElevation: anchor.elevation - floorThickness, - trimOffset: wallBase - outward * REVET_LEAN_RATIO * wallHeight, + trimOffset: wallBase - outward * lean * wallHeight, trimElevation: anchor.elevation + wallHeight, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts index 6c480525..f862cce8 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -4,8 +4,10 @@ * 백엔드 `section.ford` 제원을 좌표로 옮긴다(치수 결정 금지). * * 구성(2026-08-25 사용자 확정): - * · 양측 **측벽** = ㄴ형 집수정을 그대로 재사용한다(`buildBasin`) — 전면 1:0.3 - * 기움·좌우·상하(대각) 이동·계류측 성토부선이 집수정과 같은 규칙으로 따라온다. + * · 양측 **측벽** = ㄴ형 집수정 부재를 재사용한다(`buildBasin`) — 좌우·상하(대각) + * 이동·계류측 성토부선이 집수정과 같은 규칙으로 따라온다. 단 **벽은 수직**이다 + * (`leanRatio: 0` — 2026-08-30 사용자 확정, 교본 그림 3-18). 집수정의 1:0.3 + * 기움은 세월교 근거가 아니라 부재를 빌려 오며 딸려 온 값이었다. * · **바닥판** = 두 측벽 사이를 잇는 판 + 각 측 바깥으로 **날개벽 투영**(길이×cos각) * 만큼 뻗은 판. 집수정 내공 폭 자리에 그 연장량을 넣어 같은 기하로 만든다. * · **관**은 바닥판 상면(측벽 바깥면)에 안히고, 관 위 ~ 노면 아래는 **성토(노체)** 다. @@ -187,6 +189,10 @@ export function computeFordLayout( }, memberThicknessM: ford.wall_thickness_m, floorThicknessM: ford.slab_thickness_m, + // 세월교 측벽은 **수직**이다(2026-08-30 사용자 확정 — 교본 그림 3-18 세월교 + // 단면). 1:0.3 기움은 ㄴ형 집수정 부재를 재사용하며 딸려 온 값이라 여기서 끈다. + // 바닥판 taper(0.3×두께)도 같은 값에서 나오므로 함께 사라져 직사각형이 된다. + leanRatio: 0, }); const wall = built.basin.parts.find((part) => part.kind === "wall"); const floor = built.basin.parts.find((part) => part.kind === "floor"); From 2e28af6e8697f5f8c4e401d30832f7bea0345147 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 14:20:49 +0900 Subject: [PATCH 04/42] =?UTF-8?q?feat(B05,B06):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=B0=B0=EC=B9=98=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=EB=A5=BC=20B06=EC=97=90=20=EC=9E=A5=EC=B0=A9=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20[=ED=9A=A1=EB=8B=A8=20=EC=A1=B0=EC=A0=95]=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=EC=9D=84=20=EC=84=B8=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05/B06 인터페이스 일원화 1차 (2026-08-29 사용자 확정 계획): - A00_Common/b_structures_section.ts: B05 구조물 배치 폼·목록 공용 진입점 (재수출 + 스타일 동봉) - B06 좌측 패널에 [구조물 배치] 섹션 + 하단 dock [목록][구분선][저장버튼] (B05 동일 템플릿). 구조물 조작은 세션 pending으로 쌓여 기존 flushPendingStructures([임시저장]·[확정])로 정본 반영 - 선택 연동: 목록·폼 → 최근접 카드 focusStation, 횡단도 벽·세월교·BOX → 좌측 폼에 소유 측점 시설 로드(wireStructureSelection) - [횡단 조정] 하위 컨테이너(Adjust_Dock): 오버레이 조정창을 같은 deps로 사본 인스턴스 생성해 세움 — 로직 이동 0, 카드 재렌더로 자동 동기. 9키·십자 행은 사본에서 숨김(사용자 지시 7), 방향키 이중 입력 방지 - A군 시설 추가·이동·삭제는 B06에서 안내 토스트(배수유역 체인은 B05 몫) 검증: 공용 브라우저 — 구간값 길이 사본 + 10.0→11.0m 양측 동기, 오버레이 - 원복, ✕ 닫기 시 사본 해제. B05 회귀 없음. typecheck 통과. --- A00_Common/b_structures_section.ts | 17 ++ B06_Section/B06_Section_UI_Adjust_Dock.ts | 60 +++++++ B06_Section/B06_Section_UI_Cross_Box_Panel.ts | 10 +- .../B06_Section_UI_Cross_Ford_Panel.ts | 10 +- .../B06_Section_UI_Cross_Structure_Panel.ts | 12 +- B06_Section/B06_Section_UI_Cross_View.ts | 70 ++++---- .../B06_Section_UI_Cross_View_Bodies.ts | 72 +++++--- B06_Section/B06_Section_UI_Page.ts | 37 ++++- .../B06_Section_UI_Page_Structures_Panel.ts | 156 ++++++++++++++++++ B06_Section/B06_Section_UI_Section_View.ts | 7 + .../B06_Section_UI_Style_Cross_Controls.css | 42 +++++ 11 files changed, 427 insertions(+), 66 deletions(-) create mode 100644 A00_Common/b_structures_section.ts create mode 100644 B06_Section/B06_Section_UI_Adjust_Dock.ts create mode 100644 B06_Section/B06_Section_UI_Page_Structures_Panel.ts diff --git a/A00_Common/b_structures_section.ts b/A00_Common/b_structures_section.ts new file mode 100644 index 00000000..051169fc --- /dev/null +++ b/A00_Common/b_structures_section.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * b_structures_section.ts + * 「구조물 배치」 컨테이너 공용 진입점 — B05·B06 두 페이지가 같은 주소로 쓴다 + * (2026-08-29 사용자: B05/B06 인터페이스 일원화 — 배치 폼·하단 목록 템플릿 동일). + * + * 구현은 B05 모듈(`B05_Profile_UI_Structures_Panel` 외)에 있다. 이 파일은 재수출과 + * 스타일 동봉만 한다 — B06이 이 파일 하나만 import해도 B05와 같은 모양이 실린다. + * ========================================================================== */ +import "../B05_Profile/B05_Profile_UI_Style.css"; +import "../B05_Profile/B05_Profile_UI_Style_Structures.css"; + +export { + createStructuresSection, + GROUP_LABELS, + type PipeFacilityItem, + type StructuresSection, +} from "../B05_Profile/B05_Profile_UI_Structures_Panel"; diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts new file mode 100644 index 00000000..f267c4be --- /dev/null +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * B06_Section_UI_Adjust_Dock.ts + * 좌측 [구조물 배치] 섹션 안 **[횡단 조정] 하위 컨테이너**(2026-08-29 사용자: + * B05/B06 일원화 — 오버레이 조정창 항목을 좌측에도 보이되, 기존 폼과 구분되게 + * 접이식 하위 컨테이너에 담는다. 9키·십자 위치제어는 오버레이 전용). + * + * 카드가 조정창을 만들 때 같은 deps로 **사본 창**을 하나 더 만들어 이 슬롯에 + * 세운다(adopt). 값·동작이 같은 함수라 오버레이·좌측 어느 쪽을 만져도 카드 + * 재렌더에서 두 창이 함께 새 값으로 그려진다 — 별도 동기화 코드가 없다. + * 카드가 여럿이어도 슬롯은 하나 — 마지막으로 고른 구조물의 사본만 선다. + * ========================================================================== */ + +let dockRoot: HTMLDetailsElement | null = null; +let slot: HTMLElement | null = null; +let placeholder: HTMLElement | null = null; + +function ensureDock(): void { + if (dockRoot) return; + dockRoot = document.createElement("details"); + dockRoot.className = "b06-adjust-dock"; + dockRoot.open = true; + const summary = document.createElement("summary"); + summary.textContent = "횡단 조정"; + placeholder = document.createElement("p"); + placeholder.className = "b06-adjust-dock__empty"; + placeholder.textContent = "횡단도에서 구조물(벽·구체)을 고르면 조정 항목이 여기에 뜹니다."; + slot = document.createElement("div"); + slot.className = "b06-adjust-dock__slot"; + dockRoot.append(summary, placeholder, slot); +} + +/** 하위 컨테이너 루트 — 페이지가 [구조물 배치] 섹션 본문에 붙인다. */ +export function adjustDockRoot(): HTMLElement { + ensureDock(); + return dockRoot!; +} + +/** 페이지 진입 시 초기 상태(빈 슬롯 + 안내문)로 되돌린다. */ +export function resetAdjustDock(): void { + ensureDock(); + slot!.replaceChildren(); + placeholder!.hidden = false; +} + +/** 조정창 사본을 슬롯에 세운다 — 이미 다른 사본이 있으면 갈아 끼운다. */ +export function adoptAdjustPanel(panelRoot: HTMLElement): void { + ensureDock(); + panelRoot.classList.add("b06-structure-panel--dock"); + if (slot!.firstChild !== panelRoot || slot!.childNodes.length !== 1) { + slot!.replaceChildren(panelRoot); + } + placeholder!.hidden = true; +} + +/** 이 사본이 슬롯에 서 있으면 내린다 — 다른 사본이 이미 대신 서 있으면 손대지 않는다. */ +export function releaseAdjustPanel(panelRoot: HTMLElement): void { + if (!slot || !slot.contains(panelRoot)) return; + slot.replaceChildren(); + placeholder!.hidden = false; +} diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index a4760a28..7897c67f 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -42,10 +42,12 @@ export interface BoxPanelHandle { /** 한 걸음(m) — 사용자 지정 0.1m. */ const STEP_M = 0.1; -/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ -export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle { +/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. + * `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */ +export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle { const shell = buildPanelShell(() => deps.close()); const { root, title } = shell; + if (opts?.dock) root.classList.add("b06-structure-panel--dock"); const value = document.createElement("div"); value.className = "b06-structure-panel__value"; @@ -93,7 +95,9 @@ export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle { root.append(value, moveRow.row); - const arrows = bindArrowKeys(root, () => [moveRow.controls]); + const arrows = opts?.dock + ? { attach: (): void => undefined, detach: (): void => undefined } + : bindArrowKeys(root, () => [moveRow.controls]); function render(): void { if (!current) return; diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index 1cf5e408..d2d967aa 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -65,10 +65,12 @@ const ANGLE_STEP_DEG = 5; /** 관경 선택지(mm) — B05 레지스트리 `ford_bridge.pipe_diameter_mm` choices와 같다. */ const DIAMETER_CHOICES_MM = [800, 1000, 1200, 1500]; -/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ -export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle { +/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. + * `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */ +export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle { const shell = buildPanelShell(() => deps.close()); const { root, title } = shell; + if (opts?.dock) root.classList.add("b06-structure-panel--dock"); const value = document.createElement("div"); value.className = "b06-structure-panel__value"; @@ -233,7 +235,9 @@ export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle { moveRow.row, ); - const arrows = bindArrowKeys(root, () => [moveRow.controls]); + const arrows = opts?.dock + ? { attach: (): void => undefined, detach: (): void => undefined } + : bindArrowKeys(root, () => [moveRow.controls]); function render(): void { if (!current) return; diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index dc00bb8b..a85cfd06 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -111,11 +111,17 @@ const LENGTH_STEP_M = SPAN_STEP_M * 2; */ const panelScrollTops = new Map(); -/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ -export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle { +/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. + * `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29 일원화): 흐름 배치 스타일을 입고, + * 방향키 문서 리스너를 달지 않는다(오버레이 창과 이중으로 눌리는 것 방지). */ +export function buildStructurePanel( + deps: StructurePanelDeps, + opts?: { dock?: boolean }, +): StructurePanelHandle { const root = document.createElement("div"); root.className = "b06-structure-panel"; root.classList.add("is-hidden"); + if (opts?.dock) root.classList.add("b06-structure-panel--dock"); root.addEventListener("scroll", () => { if (!root.classList.contains("is-hidden")) panelScrollTops.set(deps.scrollKey, root.scrollTop); }); @@ -482,7 +488,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan root.classList.toggle("is-hidden", key === null); document.removeEventListener("keydown", onArrowKey); if (!key) return; - document.addEventListener("keydown", onArrowKey); + if (!opts?.dock) document.addEventListener("keydown", onArrowKey); // 다단 벽 — 유출측(extra0…)과 유입·집수정 계류측(bextra0…)이 같은 이름을 쓴다. const isExtra = key.startsWith("extra") || key.startsWith("bextra"); const extraIndex = key.startsWith("bextra") ? Number(key.slice(6)) : Number(key.slice(5)); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index df4f6938..3e553132 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -42,6 +42,7 @@ import type { StructureSpanControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; +import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom"; @@ -685,36 +686,47 @@ export function createCrossSectionCard( const source = isLinkedCulvert && culvertLink ? culvertLink.source : section; return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null }; }; - const panel = buildStructurePanel( - structurePanelDeps({ - section, - scrollKey: section.station_id, - adjustChainage: () => adjustTarget().chainage_m, - isLinked: isLinkedCulvert, - revetOffset, - inletStructure, - extraWalls, - structureSpan, - revetLink, - adjustOf, - outwardOf, - heightOfWall, - formOfWall, - appliedD: () => culvertAppliedD, - pipeLengthM: () => culvertPipeLengthM, - inletIsBasin: () => culvertInletIsBasin, - hiddenPipe: () => culvertHiddenPipe, - inletOptions: () => culvertInletOptions, - extraState: () => culvertExtraState, - basinExtraState: () => culvertBasinExtraState, - activeRevet: () => activeRevet, - setActiveRevet: (key) => { - activeRevet = key; - }, - toggleRevet, - }), + const panelContext = { + section, + scrollKey: section.station_id, + adjustChainage: () => adjustTarget().chainage_m, + isLinked: isLinkedCulvert, + revetOffset, + inletStructure, + extraWalls, + structureSpan, + revetLink, + adjustOf, + outwardOf, + heightOfWall, + formOfWall, + appliedD: () => culvertAppliedD, + pipeLengthM: () => culvertPipeLengthM, + inletIsBasin: () => culvertInletIsBasin, + hiddenPipe: () => culvertHiddenPipe, + inletOptions: () => culvertInletOptions, + extraState: () => culvertExtraState, + basinExtraState: () => culvertBasinExtraState, + activeRevet: () => activeRevet, + setActiveRevet: (key: RevetKey | null) => { + activeRevet = key; + }, + toggleRevet, + }; + const panel = buildStructurePanel(structurePanelDeps(panelContext)); + // 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이 + // 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다. + const dockPanel = buildStructurePanel( + structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }), + { dock: true }, ); - showRevetControl = (visible) => panel.show(visible ? activeRevet : null); + showRevetControl = (visible) => { + const key = visible ? activeRevet : null; + panel.show(key); + dockPanel.show(key); + if (key) adoptAdjustPanel(dockPanel.root); + else releaseAdjustPanel(dockPanel.root); + }; showRevetControl(activeRevet !== null); // 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자). // 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23). diff --git a/B06_Section/B06_Section_UI_Cross_View_Bodies.ts b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts index b09845c5..d48b917a 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Bodies.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts @@ -24,6 +24,7 @@ import type { 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"; +import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; export interface BodyWiringDeps { section: CrossSection; @@ -60,39 +61,53 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { let fordHeights = new Map(); let boxLayout: BoxLayout | null = null; - const fordPanel = + const fordDeps = section.ford && ford - ? buildFordPanel( - fordPanelDeps({ - section, - ford, - heightFor: (role) => fordHeights.get(role) ?? 0, - slabLengthM: () => fordSlabLengthM, - close: () => { - if (fordRole) toggleFord(fordRole); - }, - }), - ) + ? fordPanelDeps({ + section, + ford, + heightFor: (role) => fordHeights.get(role) ?? 0, + slabLengthM: () => fordSlabLengthM, + close: () => { + if (fordRole) toggleFord(fordRole); + }, + }) : null; - const boxPanel = + const boxDeps = section.box && box - ? buildBoxPanel( - boxPanelDeps({ - chainageM: chainage, - box, - layout: () => boxLayout, - close: () => { - if (boxRole) toggleBox(boxRole); - }, - }), - ) + ? boxPanelDeps({ + chainageM: chainage, + box, + layout: () => boxLayout, + close: () => { + if (boxRole) toggleBox(boxRole); + }, + }) : null; + const fordPanel = fordDeps ? buildFordPanel(fordDeps) : null; + const boxPanel = boxDeps ? buildBoxPanel(boxDeps) : null; + // 좌측 [횡단 조정] 사본 — 배수관 조정창과 같은 규칙(2026-08-29 일원화): 같은 deps로 + // 하나 더 만들어 고른 동안만 좌측 슬롯에 세운다. + const fordDock = fordDeps ? buildFordPanel(fordDeps, { dock: true }) : null; + const boxDock = boxDeps ? buildBoxPanel(boxDeps, { dock: true }) : null; + const syncDock = ( + dock: FordPanelHandle | BoxPanelHandle | null, + role: FordWallRole | BoxSideRole | null, + ): void => { + if (!dock) return; + (dock as { show: (role: FordWallRole | BoxSideRole | null) => void }).show(role); + if (role) adoptAdjustPanel(dock.root); + else releaseAdjustPanel(dock.root); + }; const toggleFord = structureToggle({ current: () => fordRole, setCurrent: (value) => (fordRole = value), setActive: (value) => setFordActive(value), - showPanel: (visible) => fordPanel?.show(visible ? fordRole : null), + showPanel: (visible) => { + fordPanel?.show(visible ? fordRole : null); + syncDock(fordDock, visible ? fordRole : null); + }, persist: (value) => ford?.select(chainage, value), isCardSelected: deps.isCardSelected, clearArea: deps.clearArea, @@ -102,7 +117,10 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { current: () => boxRole, setCurrent: (value) => (boxRole = value), setActive: (value) => setBoxActive(value), - showPanel: (visible) => boxPanel?.show(visible ? boxRole : null), + showPanel: (visible) => { + boxPanel?.show(visible ? boxRole : null); + syncDock(boxDock, visible ? boxRole : null); + }, persist: (value) => box?.select(chainage, value), isCardSelected: deps.isCardSelected, clearArea: deps.clearArea, @@ -111,6 +129,8 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { fordPanel?.show(fordRole); boxPanel?.show(boxRole); + syncDock(fordDock, fordRole); + syncDock(boxDock, boxRole); return { fordAdjust: () => ford?.adjustFor(chainage), @@ -125,12 +145,14 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { if (fordRole) setter(fordRole); // 창은 그리기 전에 만들어져 값이 비어 있다 — 계산 결과가 들어온 뒤 다시 렌더한다. fordPanel?.show(fordRole); + syncDock(fordDock, fordRole); }, attachBox: (setter, layout) => { setBoxActive = setter; boxLayout = layout; if (boxRole) setter(boxRole); boxPanel?.show(boxRole); + syncDock(boxDock, boxRole); }, clearSelection: () => { if (fordRole !== null) toggleFord(fordRole); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index a1276877..d30d88a6 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -44,6 +44,10 @@ import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { staleDesignChainages } from "./B06_Section_UI_Section_Common"; import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel"; +import { + createB06StructuresPanel, + wireStructureSelection, +} from "./B06_Section_UI_Page_Structures_Panel"; import "./B06_Section_UI_Style.css"; import "./B06_Section_UI_Style_Cross.css"; import "./B06_Section_UI_Style_Cross_Controls.css"; @@ -115,13 +119,36 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }); confirmButton.disabled = true; const actionRow = document.createElement("div"); - // 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤에서 제외(2026-08-05 사용자 지시). - actionRow.className = "b06-profile__actions ui-sidebar-actions"; + actionRow.className = "b06-profile__actions"; actionRow.append(goProfileButton, saveButton, confirmButton); + /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */ + const focusStationAt = (chainageM: number): void => { + const sections = sectionDetail?.cross_sections; + if (!sections?.length) return; + const nearest = sections.reduce((best, entry) => + Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best, + ); + sectionView.focusStation(nearest.station_id); + }; + + // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). + // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. + const structuresPanel = createB06StructuresPanel({ + projectId, + stationInterval: () => stationInterval ?? 20, + focusChainage: focusStationAt, + }); + const dockDivider = document.createElement("hr"); + dockDivider.className = "b05-structure__divider"; + const actionDock = document.createElement("div"); + actionDock.className = "b05-route__dock ui-sidebar-actions"; + actionDock.append(structuresPanel.listRoot, dockDivider, actionRow); + const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; - leftForm.append(viewGroup, standardGroup, actionRow); + // 순서: 보기 반폭 → 구조물 배치(표준 횡단면 위 — 2026-08-29 사용자 확정) → 표준. + leftForm.append(viewGroup, structuresPanel.root, standardGroup, actionDock); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); @@ -437,6 +464,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.ford, stationControls.box, ); + // 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화). + wireStructureSelection(stationControls, () => sectionDetail, structuresPanel); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. const mainArea = document.createElement("div"); @@ -659,6 +688,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { currentRouteId = context.route_id; loadRockOffsets(); stationControls.load(); + // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. + void structuresPanel.load(); try { const existing = await getSections(projectId, context.route_id); if (!existing.longitudinal) { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts new file mode 100644 index 00000000..23f4c237 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * B06_Section_UI_Page_Structures_Panel.ts + * B06 좌측 「구조물 배치」 — B05와 **같은 컨테이너·목록 템플릿**을 세운다 + * (2026-08-29 사용자: B05/B06 인터페이스 일원화. 페이지 본체는 700줄 제한으로 + * 여기서 조립만 받아 간다). + * + * · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로. + * · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고 + * [임시저장]·[확정]의 `flushPendingStructures`가 정본에 쓴다(캐시→저장 원칙). + * · 계곡 통과 시설(A군, pipe_points 정본)은 **표시·선택만** — 추가·이동·삭제는 + * 배수유역 재분할 체인이 있는 B05 몫이라 안내만 한다(2026-08-29 1차 범위). + * · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설 + * 로드(소유 측점 기준). 하위 [횡단 조정] 컨테이너는 Adjust_Dock이 맡는다. + * ========================================================================== */ + +import { createStructuresSection, type PipeFacilityItem } from "../A00_Common/b_structures_section"; +import { + fetchStructures, + fetchStructureTypes, + readPendingStructures, + structureAnchorM, + writePendingStructures, + type StructureInstance, +} from "../B05_Profile/B05_Profile_Api_Structures"; +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { showToast } from "@ui/ui_template_elements"; +import { adjustDockRoot, resetAdjustDock } from "./B06_Section_UI_Adjust_Dock"; +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; + +const PIPE_GUIDE = "계곡 통과 시설의 추가·이동·삭제는 B05(종단) 화면에서 합니다."; + +export interface B06StructuresPanelDeps { + projectId: string | null; + /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ + stationInterval: () => number; + /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ + focusChainage: (chainageM: number) => void; +} + +export interface B06StructuresPanel { + /** 「구조물 배치」 섹션(하위 [횡단 조정] 포함) — 좌측 패널에 붙인다. */ + root: HTMLElement; + /** 배치된 구조물 목록 — 하단 고정 dock에 붙인다(B05와 같은 자리). */ + listRoot: HTMLElement; + /** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */ + load: () => Promise; + /** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */ + showPipeAt: (chainageM: number | null) => void; +} + +export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06StructuresPanel { + let structures: StructureInstance[] = []; + + /** 새 항목에 식별자를 미리 붙인다 — 저장 전에도 목록에서 고르고 지울 수 있어야 + * 하고, 서버는 빈 값일 때만 새로 발급한다(B05 다리와 같은 규칙). */ + const withLocalIds = (next: StructureInstance[]): StructureInstance[] => + next.map((item) => + item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") }, + ); + + const section = createStructuresSection({ + onChange: (next) => { + structures = withLocalIds(next); + section.setStructures(structures); + if (deps.projectId) writePendingStructures(deps.projectId, structures); + }, + onSelect: (structure) => { + if (structure) deps.focusChainage(structureAnchorM(structure)); + }, + getInterval: deps.stationInterval, + onPipeAdd: () => showToast(PIPE_GUIDE, "error"), + onPipeUpdate: () => showToast(PIPE_GUIDE, "error"), + onPipeRemove: () => showToast(PIPE_GUIDE, "error"), + onPipeSelect: (chainageM) => { + if (chainageM !== null) deps.focusChainage(chainageM); + }, + }); + + // 하위 [횡단 조정] 컨테이너 — 폼 본문 맨 아래, 기존 항목과 경계 구분(2026-08-29 + // 사용자: 병합 항목은 하위 컨테이너로 담아 혼동 방지). + resetAdjustDock(); + section.root.querySelector(".b05-route__panel-body")?.append(adjustDockRoot()); + + async function load(): Promise { + if (!deps.projectId) return; + const projectId = deps.projectId; + try { + const [types, stored, pipeResponse] = await Promise.all([ + fetchStructureTypes(), + fetchStructures(projectId), + fetchDetailPipePoints(projectId), + ]); + section.setTypes(types); + // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). + structures = readPendingStructures(projectId) ?? stored.structures; + section.setStructures(structures); + const pipes: PipeFacilityItem[] = pipeResponse.pipe_points.map((pipe) => ({ + chainage_m: pipe.chainage_m, + facility: pipe.facility ?? "pipe", + start_m: pipe.start_m, + end_m: pipe.end_m, + source: pipe.source, + options: pipe.options, + design_flow_m3s: null, + })); + section.setPipeFacilities(pipes); + } catch (error) { + showToast( + error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.", + "error", + ); + } + } + + return { + root: section.root, + listRoot: section.listRoot, + load, + showPipeAt: (chainageM) => section.selectPipeByChainage(chainageM), + }; +} + +/** + * 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그 + * 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계). + * 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다. + */ +export function wireStructureSelection( + stationControls: StationControls, + detail: () => SectionDetailResponse | null, + panel: B06StructuresPanel, +): void { + const ownerChainageOf = (chainageM: number): number => { + const sectionAt = detail()?.cross_sections.find( + (entry) => Math.abs(entry.chainage_m - chainageM) < 0.01, + ); + return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM; + }; + + const origRevet = stationControls.revetOffset.select; + stationControls.revetOffset.select = (chainageM, key) => { + origRevet(chainageM, key); + panel.showPipeAt(key ? ownerChainageOf(chainageM) : null); + }; + const origFord = stationControls.ford.select; + stationControls.ford.select = (chainageM, role) => { + origFord(chainageM, role); + panel.showPipeAt(role ? chainageM : null); + }; + const origBox = stationControls.box.select; + stationControls.box.select = (chainageM, role) => { + origBox(chainageM, role); + panel.showPipeAt(role ? chainageM : null); + }; +} diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index bbd85b5c..fd0cbe01 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -112,6 +112,9 @@ export interface SectionViewController { ) => void; /** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */ refreshCard: (chainageM: number) => void; + /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 + * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */ + focusStation: (stationId: string) => void; clear: () => void; dispose: () => void; } @@ -701,6 +704,10 @@ export function createSectionView( if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root)); }, refreshCard, + focusStation(stationId) { + if (selectedStationId !== stationId) selectStation(stationId, true); + else revealCard(stationId, "smooth"); + }, clear() { currentDetail = null; selectedStationId = null; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index 9a66455a..6294c3b8 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -227,3 +227,45 @@ color: var(--color-text); background: var(--color-surface); } + +/* ── 좌측 [횡단 조정] 사본(2026-08-29 B05/B06 일원화) ───────────────────── + * 오버레이 창과 같은 DOM을 좌측 사이드바에 흐름 배치로 세운다. */ +.b06-structure-panel--dock { + position: static; + z-index: auto; + max-height: none; + margin-top: var(--spacing-8); + border-color: var(--color-border); + overflow: visible; +} + +/* 9키·십자 위치제어(이동 D-pad·집수정 9키)는 횡단도 오버레이 전용 + * (2026-08-29 사용자 지시 7) — 사본에서는 그 행을 통째로 숨긴다. */ +.b06-structure-panel--dock + .b06-structure-panel__struct:has(> .b06-structure-panel__controls.b06-structure-panel__buttons), +.b06-structure-panel--dock + .b06-structure-panel__struct:has( + > .b06-structure-panel__controls.b06-structure-panel__basin-buttons + ) { + display: none; +} + +/* [횡단 조정] 하위 컨테이너 — 구조물 배치 폼과 경계 구분(접이식). */ +.b06-adjust-dock { + margin-top: var(--spacing-8); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.b06-adjust-dock > summary { + color: var(--color-text-body); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + cursor: pointer; +} + +.b06-adjust-dock__empty { + margin: var(--spacing-8) 0 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} From 2d6d66a389d59dbc7584d0bceca864e31746054a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 14:27:34 +0900 Subject: [PATCH 05/42] =?UTF-8?q?fix(B06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=EB=A5=BC=20=EC=A2=8C=EC=B8=A1=20=EC=B5=9C?= =?UTF-8?q?=EC=83=81=EB=8B=A8=EC=9C=BC=EB=A1=9C=20=EC=98=AC=EB=A6=AC?= =?UTF-8?q?=EA=B3=A0=20=EC=83=81=EB=8B=A8=20=EC=97=AC=EB=B0=B1=EC=9D=84=20?= =?UTF-8?q?B05=EC=99=80=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 패널 순서: 구조물 배치 → 횡단 보기 설정 → 표준 횡단면 설정 (2026-08-29 사용자 지시 — B05와 같은 첫 컨테이너) - .b06-profile__form .ui-sidebar-scroll 상단 패딩 8px — B05 .b05-route__panel .ui-sidebar-scroll과 같은 값으로 구분선과의 갭 일치 검증: 공용 브라우저 실측 — B06/B05 모두 첫 컨테이너 '구조물 배치', scroll padding-top 8px, 상단 갭 8.0px 동일. typecheck 통과. --- B06_Section/B06_Section_UI_Page.ts | 4 ++-- B06_Section/B06_Section_UI_Style.css | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index d30d88a6..976340fb 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -147,8 +147,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; - // 순서: 보기 반폭 → 구조물 배치(표준 횡단면 위 — 2026-08-29 사용자 확정) → 표준. - leftForm.append(viewGroup, structuresPanel.root, standardGroup, actionDock); + // 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. + leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index 506236ed..bc473aed 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -31,6 +31,12 @@ gap: var(--spacing-16); } +/* 스크롤 영역 상단 여유 — 첫 컨테이너(구조물 배치)가 패널 상단 경계에 붙지 않게 + B05(.b05-route__panel .ui-sidebar-scroll)와 같은 값을 쓴다(2026-08-29 일원화). */ +.b06-profile__form .ui-sidebar-scroll { + padding-top: var(--spacing-8); +} + /* 사이드 컨테이너 양식은 B05 경로 패널(.b05-route__panel-section)과 같게 맞춘다 — 두 화면의 좌측 패널이 같은 폼으로 보여야 한다(2026-08-23 사용자 지시). 여백·글자 크기·색을 B05 값으로 옮겼고, 표준단면 「변수 위치 안내」 모식도만 From 56ecc088919f64ba4fb383ec9d0dbb912577afa6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 14:47:44 +0900 Subject: [PATCH 06/42] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=EB=8F=84?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=A0=EB=A5=B4=EB=A9=B4=20=EA=B7=B8=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=9D=84=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8F=BC=EC=97=90=20=EC=98=AC=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SectionView.setStationSelectListener 신설 — 카드(측점) 선택 변경 알림 - 좌측 패널 showAtChainage: 계곡 통과 시설(pipe_points) 우선, 없으면 그 측점을 덮는 구조물 정본 항목(점형 근사·구간형 범위), 둘 다 없으면 선택 해제 - 연동 링크 카드는 structureSpan.ownerOf로 소유 측점 시설을 보여 준다 검증(공용 브라우저): 4+4.3 배수관·7+9.7 세월교·10+0.9 BOX암거 카드 클릭 시 종류·기준 측점·목록 강조 일치. 링크 카드 4+0.0→4+4.3, 14+0.0→13+15.7 소유 측점 표시. 구조물 없는 0+0.0 카드는 폼 해제. typecheck 통과. --- B06_Section/B06_Section_UI_Page.ts | 13 +++++++ .../B06_Section_UI_Page_Structures_Panel.ts | 36 +++++++++++++++++-- B06_Section/B06_Section_UI_Section_View.ts | 10 ++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 976340fb..c0261fb4 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -466,6 +466,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ); // 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화). wireStructureSelection(stationControls, () => sectionDetail, structuresPanel); + // 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자). + // 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다. + sectionView.setStationSelectListener((stationId) => { + const target = stationId + ? sectionDetail?.cross_sections.find((section) => section.station_id === stationId) + : null; + if (!target) { + structuresPanel.showAtChainage(null); + return; + } + const owner = stationControls.structureSpan.ownerOf(target); + structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); + }); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. const mainArea = document.createElement("div"); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 23f4c237..88e7aae5 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -47,10 +47,25 @@ export interface B06StructuresPanel { load: () => Promise; /** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */ showPipeAt: (chainageM: number | null) => void; + /** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고, + * 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). */ + showAtChainage: (chainageM: number | null) => void; +} + +/** 관 매칭 허용 오차(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다(B05와 같은 기준). */ +const PIPE_MATCH_M = 0.51; + +/** 이 구조물이 그 측점을 덮는가 — 점형은 근사 일치, 구간형은 시작~종료 안. */ +function coversChainage(structure: StructureInstance, chainageM: number): boolean { + if (structure.placement === "interval" && structure.start_m != null && structure.end_m != null) { + return chainageM >= structure.start_m - 0.01 && chainageM <= structure.end_m + 0.01; + } + return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M; } export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06StructuresPanel { let structures: StructureInstance[] = []; + let pipeFacilities: PipeFacilityItem[] = []; /** 새 항목에 식별자를 미리 붙인다 — 저장 전에도 목록에서 고르고 지울 수 있어야 * 하고, 서버는 빈 값일 때만 새로 발급한다(B05 다리와 같은 규칙). */ @@ -95,7 +110,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). structures = readPendingStructures(projectId) ?? stored.structures; section.setStructures(structures); - const pipes: PipeFacilityItem[] = pipeResponse.pipe_points.map((pipe) => ({ + pipeFacilities = pipeResponse.pipe_points.map((pipe) => ({ chainage_m: pipe.chainage_m, facility: pipe.facility ?? "pipe", start_m: pipe.start_m, @@ -104,7 +119,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc options: pipe.options, design_flow_m3s: null, })); - section.setPipeFacilities(pipes); + section.setPipeFacilities(pipeFacilities); } catch (error) { showToast( error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.", @@ -118,6 +133,23 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc listRoot: section.listRoot, load, showPipeAt: (chainageM) => section.selectPipeByChainage(chainageM), + showAtChainage: (chainageM) => { + if (chainageM === null) { + section.selectById(null); + return; + } + // 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에 + // 그려지는 것은 이쪽이다. + const pipe = pipeFacilities.find( + (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, + ); + if (pipe) { + section.selectPipeByChainage(pipe.chainage_m); + return; + } + const hit = structures.find((entry) => coversChainage(entry, chainageM)); + section.selectById(hit?.structure_id ?? null); + }, }; } diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index fd0cbe01..27064096 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -115,6 +115,9 @@ export interface SectionViewController { /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */ focusStation: (stationId: string) => void; + /** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을 + * 올린다(2026-08-29 일원화). null = 선택 해제. */ + setStationSelectListener: (listener: (stationId: string | null) => void) => void; clear: () => void; dispose: () => void; } @@ -356,10 +359,14 @@ export function createSectionView( * (2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이고, 그 둘도 클래스와 * 강조 상태만 갈아 끼운다. */ + /** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */ + let stationSelectListener: ((stationId: string | null) => void) | null = null; + const applySelection = (previousStationId: string | null): void => { markCard(previousStationId); if (selectedStationId !== previousStationId) markCard(selectedStationId); drawPanel(); + if (selectedStationId !== previousStationId) stationSelectListener?.(selectedStationId); }; /** 같은 측점을 다시 고르면 선택을 푼다(2026-08-02 사용자 지시) — 카드·측점선 어느 쪽이든. */ @@ -708,6 +715,9 @@ export function createSectionView( if (selectedStationId !== stationId) selectStation(stationId, true); else revealCard(stationId, "smooth"); }, + setStationSelectListener(listener) { + stationSelectListener = listener; + }, clear() { currentDetail = null; selectedStationId = null; From 59f5ee88c5f53c0a7e42c04c6c6beb0cc7f82628 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 14:54:41 +0900 Subject: [PATCH 07/42] =?UTF-8?q?fix(B06):=20=ED=9A=A1=EB=8B=A8=EB=8F=84?= =?UTF-8?q?=20=EC=A1=B0=EC=A0=95=EC=B0=BD=EC=9D=84=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=A0=84=EC=9A=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A4=84=EC=9D=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 좌측 [횡단 조정]과 오버레이 조정창의 항목이 같음을 실측 대조로 확인한 뒤 (기슭막이·세월교·BOX 모두 onlyDock=[], 차이는 이동 세트뿐), 오버레이에서 중복 항목 행을 숨긴다 — 십자(이동)·9키(집수정) 버튼 세트만 남는다. 집수정 기본값(↺)도 9키와 한 세트라 오버레이 전용으로 둔다. DOM·배선은 그대로라 두 창 값은 계속 함께 움직인다. 검증: 오버레이 rows = [이동]/[이동]/[길이◀▶·표고▲▼], 사본 rows = 형태·높이· 단 수·구간값 3행·옵션 / 측벽 높이·관경·날개벽 3행. 사본 길이 + 10.0→11.0m, - 원복 확인. --- .../B06_Section_UI_Style_Cross_Controls.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index 6294c3b8..5d9de262 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -269,3 +269,19 @@ color: var(--color-text-muted); font-size: var(--text-caption); } + +/* 횡단도 오버레이 창은 **위치제어 전용**으로 줄인다(2026-08-29 사용자 지시: 좌측 + * [횡단 조정]과 내용이 같음을 확인한 뒤 중복 삭제). 십자(이동)·9키(집수정) 버튼 + * 세트만 남기고, 값·형식·구간값 등 항목 행은 좌측 하나로 모은다. DOM·배선은 그대로라 + * 두 창의 값은 계속 같이 움직인다. */ +.b06-structure-panel:not(.b06-structure-panel--dock) + .b06-structure-panel__struct:not( + :has(> .b06-structure-panel__controls.b06-structure-panel__buttons) + ):not(:has(> .b06-structure-panel__controls.b06-structure-panel__basin-buttons)) { + display: none; +} + +/* 집수정 기본값(↺)은 9키와 한 세트라 오버레이에만 둔다. */ +.b06-structure-panel--dock .b06-structure-panel__basin-reset { + display: none; +} From 608bdc9f90aaa046d03c47ab761e60e804fdd6a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 15:03:35 +0900 Subject: [PATCH 08/42] =?UTF-8?q?fix(B06):=20=EC=A1=B0=EC=A0=95=EC=B0=BD?= =?UTF-8?q?=20=EA=B5=B0=EB=8D=94=EB=8D=94=EA=B8=B0=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=EB=A5=BC=20=EB=BA=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BOX암거: 구체 길이 뒤 '(이 측 자동)' 삭제(조정값이 있을 때의 '(이 측 +Xm)'은 유지) - 세월교: 바닥판 뒤 '(날개벽 각도 종속)' 삭제 검증: BOX '구체 길이 5.34m', 세월교 '바닥판 7.63m'로 표시(오버레이·좌측 동일). --- B06_Section/B06_Section_UI_Cross_Box_Panel.ts | 2 +- B06_Section/B06_Section_UI_Cross_Ford_Panel.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index 7897c67f..cbc5410c 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -106,7 +106,7 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo const adjust = deps.adjustFor(current); lengthLine.textContent = `구체 길이 ${deps.bodyLengthM().toFixed(2)}m` + - (adjust.lengthM > 0 ? ` (이 측 +${adjust.lengthM.toFixed(1)}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)}` : "구체 수평"; diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index d2d967aa..96bd8a1a 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -247,7 +247,7 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): heightLine.textContent = `높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : ""); moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`; - slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m (날개벽 각도 종속)`; + slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`; diameter.value = String(deps.pipeDiameterMm()); countValue.textContent = `${deps.pipeCount()}련`; const wing = deps.wingFor(current); From 8e8849e4ac06456d0bb4ef471955adf1337fb5de Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 15:30:55 +0900 Subject: [PATCH 09/42] =?UTF-8?q?feat(B05,B06):=20=EB=B0=B0=EA=B4=80=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=20=ED=95=AD=EB=AA=A9=EC=9D=84=20=EC=9C=A0?= =?UTF-8?q?=EC=9E=85=EA=B5=AC=C2=B7=EC=9C=A0=EC=B6=9C=EA=B5=AC=20=ED=8F=BC?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=AA=A8=EC=9D=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-29 사용자 지시(좌측 패널 항목 정리 — 배관 자리): - 길이·높이·기준측점 전/후 입력칸에 [-][+] 묶음 추가(stepper). 눈금은 조정창과 같다(길이 1m·전후 0.5m·높이 0.1m). 래퍼 여백 0이라 2열 격자는 그대로다. - 형태·구조물 형식 행은 [횡단 조정] 사본에서 숨김 — 폼의 형태·구조와 중복. - 단 수·옵션 행은 고른 벽 쪽(유입구/유출구) 그룹으로 DOM 이식. 배선을 옮기지 않아 값·동기는 그대로고, 사본이 갈릴 때마다 다시 이식한다. - 유입구 '구조' 드롭다운에 조정창 형식(자동·집수정 I/ㄴ/ㄷ)을 병합. 고른 값이 정본 둘(inlet_type·inlet_structure)을 함께 정하고, B06에서는 유입측 형식 제어와 양방향으로 맞춘다. 형태·형식 연결은 미연결 상태 그대로 둔다. 검증(공용 브라우저): 유입구 스텝 5·유출구 4개, 구조 목록 6종 병합, 벽 선택 시 단 수·옵션이 유출구 그룹으로 이동(dock에는 높이·구간값만 남음). B05도 동일 폼이 정상 표시되고 [횡단 조정]은 뜨지 않음. typecheck 통과. --- .../B05_Profile_UI_Drainage_Facility.ts | 94 +++++++++++++++++-- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 42 ++++++++- .../B05_Profile_UI_Structures_Panel.ts | 5 + .../B05_Profile_UI_Style_Structures.css | 33 +++++++ B06_Section/B06_Section_UI_Adjust_Dock.ts | 42 +++++++++ .../B06_Section_UI_Cross_Structure_Panel.ts | 16 ++++ B06_Section/B06_Section_UI_Page.ts | 7 +- .../B06_Section_UI_Page_Structures_Panel.ts | 45 ++++++++- .../B06_Section_UI_Style_Cross_Controls.css | 18 ++++ 9 files changed, 285 insertions(+), 17 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 1c25fc4b..0936561c 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -51,10 +51,35 @@ import { OUTLET_REVET_KEYS, putNumber, REVET_COMMON_DEFAULTS, + stepper, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; +/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합). + * 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */ +export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U"; + +/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한 + * 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다). + * 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06 + * 유입측 형식(`inlet_structure`). */ +const INLET_KINDS: ReadonlyArray<{ + label: string; + type: "기슭막이" | "집수정"; + structure: InletStructureKind; +}> = [ + { label: "기슭막이", type: "기슭막이", structure: "revet" }, + { label: "집수정", type: "집수정", structure: "auto" }, + { label: "자동(규칙)", type: "기슭막이", structure: "auto" }, + { label: "집수정 I형", type: "집수정", structure: "I" }, + { label: "집수정 ㄴ형", type: "집수정", structure: "L" }, + { label: "집수정 ㄷ형", type: "집수정", structure: "U" }, +]; + +const inletKindOf = (label: string): (typeof INLET_KINDS)[number] => + INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0]; + /** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을 * 가지면 저장한다. */ export interface FacilityAttributes { @@ -153,6 +178,14 @@ export interface FacilityOptionsForm { /** 설계유량만 갱신한다 — 필드 값은 건드리지 않는다. 임시 배치(2026-08-18)의 * 유역 재계산 결과를 입력 중인 폼에 살짝 반영하는 용도. */ setDesignFlow: (designFlow: number | null) => void; + /** 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — B06만 채운다(2026-08-29 지시 4). */ + inletSlot: HTMLElement; + outletSlot: HTMLElement; + /** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */ + inletStructure: () => InletStructureKind; + setInletStructure: (value: InletStructureKind) => void; + /** 구조가 바뀔 때 B06 유입측 형식 제어에 알린다(B05는 등록하지 않는다). */ + onInletStructureChange: (handler: (value: InletStructureKind) => void) => void; } /** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */ @@ -182,8 +215,8 @@ export function createFacilityOptionsForm( // 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로 // 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시). const inletType = document.createElement("select"); - inletType.replaceChildren(...["기슭막이", "집수정"].map((value) => new Option(value, value))); - inletType.value = "기슭막이"; + inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label))); + inletType.value = INLET_KINDS[0].label; const basinForm = optionalSelect("선택", [ "동물이동형", "□형(기본형)", @@ -201,17 +234,25 @@ export function createFacilityOptionsForm( // 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자). const basinLength = numberInput("0.1"); basinLength.value = "2"; - const basinRows = [grid(labeled("형식", basinForm), labeled("길이 (m)", basinLength))]; + const basinRows = [ + grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))), + ]; const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(찰)", }); // 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다 // (2026-08-17 사용자 지시 3). + // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4). + // 비어 있으면 아무 자리도 차지하지 않는다. + const inletSlot = document.createElement("div"); + inletSlot.className = "b05-structure__adjust-slot"; + const outletSlot = inletSlot.cloneNode() as HTMLDivElement; inletGroup.body.append( grid(labeled("구조", inletType), basinMaterialField, inletRevet.formField), ...basinRows, ...inletRevet.rows, + inletSlot, ); // ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ── @@ -225,6 +266,7 @@ export function createFacilityOptionsForm( outletGroup.body.append( grid(labeled("구조", outletType), outletRevet.formField), ...outletRevet.rows, + outletSlot, ); // ── 독립 기슭막이(2026-08-28 이관: 배관처럼 pipe_points 관리) ───────────── @@ -405,8 +447,10 @@ export function createFacilityOptionsForm( } if (isPipe) { // 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8). - const basin = inletType.value === "집수정"; - const revet = inletType.value === "기슭막이"; + // 병합 목록이라 판정은 라벨이 아니라 매핑된 정본 종류로 한다(2026-08-29). + const kind = inletKindOf(inletType.value); + const basin = kind.type === "집수정"; + const revet = kind.type === "기슭막이"; basinRows.forEach((row) => (row.hidden = !basin)); basinMaterialField.hidden = !basin; inletRevet.formField.hidden = !revet; @@ -444,8 +488,29 @@ export function createFacilityOptionsForm( fordWidth, ].forEach((input) => input.addEventListener("change", emit)); + /** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */ + let inletStructureHandler: ((value: InletStructureKind) => void) | null = null; + inletType.addEventListener("change", () => + inletStructureHandler?.(inletKindOf(inletType.value).structure), + ); + return { root, + inletSlot, + outletSlot, + inletStructure: () => inletKindOf(inletType.value).structure, + setInletStructure(value) { + // 지금 고른 정본 종류(기슭막이/집수정)는 지키고 형식만 맞춘다. + const type = inletKindOf(inletType.value).type; + const hit = + INLET_KINDS.find((kind) => kind.type === type && kind.structure === value) ?? + INLET_KINDS.find((kind) => kind.structure === value); + if (hit) inletType.value = hit.label; + syncVisibility(); + }, + onInletStructureChange(handler) { + inletStructureHandler = handler; + }, setFacility(facility, options = {}, designFlow = null) { current = facility; designFlowM3s = designFlow ?? null; @@ -458,7 +523,15 @@ export function createFacilityOptionsForm( const isFord = facility === "ford_bridge"; pipeMaterial.value = text("pipe_kind") || "파형강관"; pipeDiameter.value = text("pipe_diameter_mm") || "1000"; - inletType.value = text("inlet_type") || "기슭막이"; + // 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다. + const savedType = text("inlet_type") || "기슭막이"; + const savedStructure = text("inlet_structure"); + inletType.value = ( + INLET_KINDS.find( + (kind) => + kind.type === savedType && (!savedStructure || kind.structure === savedStructure), + ) ?? inletKindOf(savedType) + ).label; outletType.value = text("outlet_type") || "기슭막이"; basinForm.value = text("inlet_basin_form"); basinMaterial.value = text("inlet_basin_material"); @@ -510,12 +583,15 @@ export function createFacilityOptionsForm( if (current === "pipe") { options.pipe_diameter_mm = Number(pipeDiameter.value); options.pipe_kind = pipeMaterial.value; - options.inlet_type = inletType.value; - if (inletType.value === "집수정") { + const inletKind = inletKindOf(inletType.value); + options.inlet_type = inletKind.type; + // B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29). + options.inlet_structure = inletKind.structure; + if (inletKind.type === "집수정") { if (basinForm.value) options.inlet_basin_form = basinForm.value; if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value; putNumber(options, "inlet_basin_length_m", basinLength.value); - } else if (inletType.value === "기슭막이") { + } else { inletRevet.read(options); } options.outlet_type = outletType.value; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 3ab454dc..c43d7700 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -72,6 +72,39 @@ export function numberInput(step: string, min = "0", placeholder = ""): HTMLInpu return input; } +/** + * 숫자 칸 + [-][+] 한 묶음(2026-08-29 사용자 지시 2) — 조정창처럼 눈금으로도 + * 고칠 수 있게 한다. 래퍼 여백은 0이라 기존 격자 레이아웃을 흔들지 않는다. + * 버튼은 값을 고친 뒤 input·change를 모두 울려 기존 리스너(연동·저장)가 그대로 탄다. + */ +export function stepper(input: HTMLInputElement, stepM: number): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b05-structure__stepper"; + const nudge = (delta: number) => (): void => { + const current = Number.parseFloat(input.value); + const min = Number.parseFloat(input.min); + const floor = Number.isFinite(min) ? min : 0; + const next = Math.max( + floor, + Math.round(((Number.isFinite(current) ? current : 0) + delta) * 10) / 10, + ); + input.value = next.toFixed(1); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }; + const button = (label: string, delta: number): HTMLButtonElement => { + const element = document.createElement("button"); + element.type = "button"; + element.className = "b05-structure__step"; + element.textContent = label; + element.title = `${stepM}m ${delta > 0 ? "늘리기" : "줄이기"}`; + element.addEventListener("click", nudge(delta)); + return element; + }; + wrap.append(input, button("-", -stepM), button("+", stepM)); + return wrap; +} + /** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */ export function putNumber(target: Record, key: string, raw: string): void { const value = Number.parseFloat(raw); @@ -207,11 +240,12 @@ export function createRevetmentFields( (keep === before ? after : before).value = Math.max(total - kept, 0).toFixed(1); }; + // 눈금은 조정창과 같은 값이다 — 길이 1m, 전/후 0.5m, 높이 0.1m(2026-08-29 일원화). const formField = labeled("형태", form); - const lengthField = length ? labeled("길이 (m)", length) : null; - const heightField = labeled("높이 (m)", height); - const beforeField = before ? labeled("기준측점 전 (m)", before) : null; - const afterField = after ? labeled("기준측점 후 (m)", after) : null; + const lengthField = length ? labeled("길이 (m)", stepper(length, 1)) : null; + const heightField = labeled("높이 (m)", stepper(height, 0.1)); + const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5)) : null; + const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5)) : null; // 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다. const rows = [ diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 2b05d9bc..09e31e10 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -25,6 +25,7 @@ import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_ import { createFacilityOptionsForm, type FacilityAttributes, + type FacilityOptionsForm, } from "./B05_Profile_UI_Drainage_Facility"; import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; @@ -63,6 +64,9 @@ export interface PipeFacilityItem { export interface StructuresSection { root: HTMLElement; + /** 계곡 통과 시설 부속 옵션 서브폼 — B06이 조정창 행을 이 폼의 유입구·유출구 + * 그룹으로 옮겨 붙이고 유입측 형식을 맞춘다(2026-08-29 일원화). */ + facility: FacilityOptionsForm; /** 배치된 구조물 목록(