/* ============================================================================= * B06_Section_UI_Cross_Ford_Pavement.ts * 물넘이포장 — 계획고를 파낸 노면을 횡단도에 그린다(2026-08-28 사용자 확정). * * 다른 시설은 구조물을 얹지만 물넘이는 **노면 자체가 내려앉는다**. 그래서 그림도 다르다. * · 파임 범위 = 노견까지 **전폭**. * · 깊이 = **노선 중심**에서 잰 월류 높이(`depth_m`). * · 바닥은 **유입(상류)이 높고 유출이 낮게** 기운다. 경사를 비우면 그 측점의 노면 * 횡단경사(`cross_slope_pct`)를 쓴다 — 사용자가 폼에서 바꿀 수 있다. * · 기존 계획고는 **점선**, 물넘이 바닥은 **실선**. * · 포장 필수 — 일반 포장층과 구분되게 진한 회색 + 빗금으로 채운다. * * 유입측 판정은 `section_mode`(상단측 절토 = 등고가 높은 쪽 = 상류)를 따른다. * ========================================================================== */ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; const SVG_NS = "http://www.w3.org/2000/svg"; /** 빗금 패턴 id는 문서 안에서 유일해야 한다 — 카드마다 하나씩 번호를 준다. */ let hatchSeq = 0; /** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */ export interface FordPavementSpec { span_m: number; /** 노선 중심에서 잰 파임 깊이(m). 없으면 그리지 않는다 — 수치를 지어내지 않는다. */ depth_m: number | null; /** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */ slope_pct: number | null; } interface Edge { offset_m: number; elevation_m: number; } /** 유입측(상류) 부호 — 그 방향으로 갈수록 바닥이 높아진다. */ function inflowSign(design: CrossDesign, offsetM: number): number { const inflowLeft = design.section_mode !== "right_cut"; const towardLeft = offsetM > 0; return towardLeft === inflowLeft ? 1 : -1; } /** * 파인 노면의 표고(m). 횡단도와 3D 코리도가 **같은 식**을 써야 두 그림이 어긋나지 않는다. * 중심에서 깊이만큼 내리고, 유입측으로 갈수록 경사만큼 올린다. */ export function fordDeckElevationAt( design: CrossDesign, spec: FordPavementSpec, offsetM: number, fallbackElevationM = 0, ): number { const depth = spec.depth_m ?? 0; const slope = (spec.slope_pct ?? design.cross_slope_pct) / 100; const center = design.design_elevation_m ?? fallbackElevationM; return center - depth + slope * Math.abs(offsetM) * inflowSign(design, offsetM); } function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number { return fordDeckElevationAt(design, spec, edge.offset_m, edge.elevation_m); } function line(points: string[], className: string): SVGPolylineElement { const polyline = document.createElementNS(SVG_NS, "polyline"); polyline.setAttribute("points", points.join(" ")); polyline.setAttribute("class", className); return polyline; } /** * 세월교로 **내려 앉힌 노면** 위에 "월류가 없었다면" 노면을 점선으로 되그린다 * (2026-08-30 사용자 확정). 계획고는 이미 백엔드가 월류 높이만큼 내려 잡았으므로 * (`design.surface_drop_m`) 여기서는 그 양만큼 위로 올린 선을 얹는다. * 물넘이포장의 「기존 계획고 점선」과 같은 표기라 사용자가 한 규칙으로 읽는다. * * 양 끝은 **측벽 상단의 계류측 꼭짓점까지 대각으로 내려 잇는다**(2026-08-30 사용자: * 좌측 벽이면 좌상단, 우측 벽이면 우상단, 각도는 무시). 월류 전 노면이 구체 밖에서 * 어디로 떨어지는지가 그 선으로 읽힌다. */ export function appendFordSurfaceDropPlan( svg: SVGElement, design: CrossDesign, x: (offset: number) => number, y: (elevation: number) => number, ford?: { sides: ReadonlyArray<{ outward: number; wallTopOuter: OffsetPoint }> } | null, ): void { const drop = design.surface_drop_m ?? 0; if (!(drop > 0) || !design.road_edges) return; const { left, right } = design.road_edges; // +offset이 좌측이다 — 좌측 벽 상단 → 좌 노견 → 우 노견 → 우측 벽 상단 순. const cornerAt = (side: 1 | -1): OffsetPoint | undefined => ford?.sides.find((entry) => Math.sign(entry.outward) === side)?.wallTopOuter; const points: OffsetPoint[] = [ { offset: left.offset_m, elevation: left.elevation_m + drop }, { offset: right.offset_m, elevation: right.elevation_m + drop }, ]; const leftCorner = cornerAt(1); if (leftCorner) points.unshift(leftCorner); const rightCorner = cornerAt(-1); if (rightCorner) points.push(rightCorner); svg.append( line( points.map((point) => `${x(point.offset)},${y(point.elevation)}`), "b06-chart__ford-deck-plan", ), ); } /** * 물넘이 파임을 그린다. 그렸으면 true — 호출부는 일반 포장층 박스를 건너뛴다 * (같은 자리에 두 겹으로 깔리면 색 구분이 사라진다). */ export function appendFordPavementOverlay( svg: SVGElement, spec: FordPavementSpec | undefined, design: CrossDesign, x: (offset: number) => number, y: (elevation: number) => number, ): boolean { if (!spec || !spec.depth_m || !design.road_edges) return false; const { left, right } = design.road_edges; const bottomLeft = bottomAt(design, spec, left); const bottomRight = bottomAt(design, spec, right); // ① 기존 계획고(점선) — 파기 전 노면이 어디였는지 남긴다. svg.append( line( [ `${x(left.offset_m)},${y(left.elevation_m)}`, `${x(right.offset_m)},${y(right.elevation_m)}`, ], "b06-chart__ford-deck-plan", ), ); // ② 포장층 — 바닥선에서 두께만큼 아래로. 일반 포장과 색·빗금으로 구분한다. const thickness = design.pavement_thickness_m ?? 0.2; const hatchId = `b06-ford-hatch-${(hatchSeq += 1)}`; const defs = document.createElementNS(SVG_NS, "defs"); const pattern = document.createElementNS(SVG_NS, "pattern"); pattern.setAttribute("id", hatchId); pattern.setAttribute("patternUnits", "userSpaceOnUse"); pattern.setAttribute("width", "6"); pattern.setAttribute("height", "6"); const stroke = document.createElementNS(SVG_NS, "path"); stroke.setAttribute("d", "M0,6 L6,0"); stroke.setAttribute("class", "b06-chart__ford-pavement-hatch"); pattern.append(stroke); defs.append(pattern); svg.append(defs); const polygon = document.createElementNS(SVG_NS, "polygon"); polygon.setAttribute( "points", [ `${x(left.offset_m)},${y(bottomLeft)}`, `${x(right.offset_m)},${y(bottomRight)}`, `${x(right.offset_m)},${y(bottomRight - thickness)}`, `${x(left.offset_m)},${y(bottomLeft - thickness)}`, ].join(" "), ); polygon.setAttribute("class", "b06-chart__ford-pavement"); polygon.setAttribute("fill", `url(#${hatchId})`); svg.append(polygon); // ③ 물넘이 바닥(실선) — 횡단 기준선이라 가장 위에 올린다. svg.append( line( [`${x(left.offset_m)},${y(bottomLeft)}`, `${x(right.offset_m)},${y(bottomRight)}`], "b06-chart__ford-deck", ), ); return true; }