/* ============================================================================= * B06_Section_UI_Cross_Design_Line.ts * 횡단 설계선 오버레이 — `_Cross_Design.ts` 에서 분리(700줄 제한 · 91-3). * 사면 끝 = 설계선이 원지반과 처음 만나는 자리 · 수치(0.01 m)로 판정 — 화면 축척과 무관. * ========================================================================== */ import type { CrossDesign, SectionSample } from "./B06_Section_Api_Fetch"; const SVG_NS = "http://www.w3.org/2000/svg"; /** * 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는 * 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다. * * `trim`이 오면 그 offset 범위 밖 구간은 그리지 않는다 — 배수관 측점에서 기슭막이가 * 성토를 받치므로 벽과 교차된 이후의 성토 경사는 끊는다(2026-08-20 사용자 확정). * 경계에 걸린 구간은 경계 지점까지 잘라 그린다. */ export function appendCrossDesignOverlay( svg: SVGElement, design: CrossDesign, x: (offset: number) => number, toDisplayY: (elevation: number) => number, groundSamples: SectionSample[], trim?: { minOffset: number; maxOffset: number; minElevation?: number; maxElevation?: number; minSlope?: { points: Array<{ offset: number; elevation: number }> }; maxSlope?: { points: Array<{ offset: number; elevation: number }> }; }, // 노폭 라벨 고정 자리(B06 카드 — X 축 라벨 바로 위 가운데). 없으면 노면 위. labelAt?: { layer: SVGElement; x: number; y: number }, ): void { const line = design.design_line; if (!line || line.length < 2) return; // 지면선 표고 보간기(정렬된 유효 샘플, 범위 밖 끝값 클램프). const ground = groundSamples .filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m)) .map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number })) .sort((a, b) => a.offset - b.offset); const groundAt = (offset: number): number | null => { if (!ground.length) return null; if (offset <= ground[0].offset) return ground[0].elevation; const last = ground[ground.length - 1]; if (offset >= last.offset) return last.elevation; for (let i = 1; i < ground.length; i += 1) { if (offset > ground[i].offset) continue; const a = ground[i - 1]; const b = ground[i]; const span = b.offset - a.offset; if (span <= 0) return b.elevation; return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span); } return last.elevation; }; // 각 설계선 점이 지면선과 겹치는지 — 수치(0.01 m)로 판정 · 화면 축척(줌)과 무관(91-3). const OVERLAP_M = 0.01; const overlap = line.map((point) => { const g = groundAt(point.offset_m); if (g === null) return false; return Math.abs(point.elevation_m - g) <= OVERLAP_M; }); // 사면 끝(계획선이 원지반과 처음 만나는 지점) **바깥**은 계획선을 그리지 않는다 — // 그 너머는 손대지 않는 원지반이라 지반선이 이미 표현한다(2026-08-20 사용자 지적: // 12+0.0 좌측 성토부에서 접점 이후 계획선이 계속 그려짐). // 탐색은 **노견 바깥**에서 시작한다 — 노면·측구·노견을 지나는 계획선은 지반과 // 우연히 겹쳐도 지워선 안 되고, 노견 이후 사면이 지반과 만나는 2차 접점만 종료점이다 // (2026-08-20 사용자 ①). const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m); const edgesForTrim = design.road_edges; const leftEdge = edgesForTrim ? Math.max(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m) : 0; const rightEdge = edgesForTrim ? Math.min(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m) : 0; // 측구는 노견 **바깥**에 붙는다(road_edges 밖). 측구가 있는 쪽은 측구 바깥 끝까지 // 보호하고 그 다음부터 접점을 찾는다 — 노견에서 바로 찾으면 측구가 통째로 잘린다 // (2026-08-20 사용자: "측구 이후 성토부가 존재하는 경사로의 1차 교차점까지 보호"). const ditchSpec = design.ditch; const ditchWidth = design.ditch_enabled === false || !ditchSpec || ditchSpec.type === "none" ? 0 : ditchSpec.type === "standard" ? ditchSpec.top_width_m : ditchSpec.width_m; // 좌표 규약: +offset = 좌측. ditch_side "left" = 큰 offset 쪽. const protectMax = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0); const protectMin = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0); const meetOffset = (startOffset: number, step: number): number | null => { const indices = step > 0 ? sorted.map((_, i) => i) : sorted.map((_, i) => sorted.length - 1 - i); for (const i of indices) { const point = sorted[i]; // 노견 안쪽(도로 구간)은 건너뛴다. if (step > 0 ? point.offset_m <= startOffset : point.offset_m >= startOffset) continue; const g = groundAt(point.offset_m); if (g === null) continue; if (Math.abs(point.elevation_m - g) <= OVERLAP_M) return point.offset_m; } return null; }; const meetLeft = meetOffset(protectMin, -1); // 작은 offset 쪽(측구까지 지난 뒤) const meetRight = meetOffset(protectMax, 1); // 큰 offset 쪽(측구까지 지난 뒤) // 트림 범위로 구간 하나를 자른다. 완전히 밖이면 null, 걸치면 경계에서 보간해 끊는다. const clipSegment = ( a: { offset_m: number; elevation_m: number }, b: { offset_m: number; elevation_m: number }, ): | [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }] | null => { // 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위. // 배수관 레이아웃이 사면 폴리라인(min/maxSlope)을 준 쪽은 **그 시작점(노견)에서** // 설계선을 끊는다 — 그 바깥은 폴리라인이 그리므로 이중선이 되고, 트림 경계에서 // 표고를 벽 상단으로 스냅하면서 수직으로 튀는 선이 남는다(2026-08-22 사용자 지적: // 집수정을 좌우로 옮기면 벽 상단이 노견 표고 그대로라 스냅 폭이 그만큼 커진다). const minSlopeStart = trim?.minSlope?.points[0]?.offset; const maxSlopeStart = trim?.maxSlope?.points[0]?.offset; const minOffset = Math.max( trim?.minOffset ?? -Infinity, meetLeft ?? -Infinity, minSlopeStart ?? -Infinity, ); const maxOffset = Math.min( trim?.maxOffset ?? Infinity, meetRight ?? Infinity, maxSlopeStart ?? Infinity, ); if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b]; const effective = { minOffset, maxOffset }; const lo = Math.min(a.offset_m, b.offset_m); const hi = Math.max(a.offset_m, b.offset_m); if (hi <= effective.minOffset || lo >= effective.maxOffset) return null; const at = (offset: number) => { const span = b.offset_m - a.offset_m; const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0; return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t }; }; // 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가 // 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다 // (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다. const snap = ( p: { offset_m: number; elevation_m: number }, elevation: number | undefined, wins: boolean, ) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p); const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9; const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9; const clamp = (p: { offset_m: number; elevation_m: number }) => p.offset_m < effective.minOffset ? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert) : p.offset_m > effective.maxOffset ? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert) : p; return [clamp(a), clamp(b)]; }; // 세그먼트별로 그린다. 지면선과 겹치는 구간(= 계획선이 원지반과 차이가 없는 구간)은 // **그리지 않는다** — 지반선이 이미 같은 자리를 표현하므로 정보가 없다 // (2026-08-20 사용자 지시: 보라색 점선 삭제). // 단 **노면·노견·측구부터 1차 접점까지는 언제나 그린다** — 계획고가 지반과 우연히 // 몇 ㎝ 차이면 노면 계획선이 통째로 끊겨 보이고(8·10·10+0.9), 노견 끝 성토고가 // 4㎝ 정도면 접점까지의 짧은 사면 조각도 사라진다(9+0.0 — 2026-08-20 사용자 지적). // 접점을 못 찾은 쪽은 보호 구간을 노견·측구까지로 둔다. const keepMin = meetLeft ?? protectMin; const keepMax = meetRight ?? protectMax; const isProtected = (a: { offset_m: number }, b: { offset_m: number }): boolean => Math.max(a.offset_m, b.offset_m) > keepMin && Math.min(a.offset_m, b.offset_m) < keepMax; for (let i = 1; i < line.length; i += 1) { if (overlap[i - 1] && overlap[i] && !isProtected(line[i - 1], line[i])) continue; const clipped = clipSegment(line[i - 1], line[i]); if (!clipped) continue; const seg = document.createElementNS(SVG_NS, "line"); seg.setAttribute("x1", String(x(clipped[0].offset_m))); seg.setAttribute("y1", String(toDisplayY(clipped[0].elevation_m))); seg.setAttribute("x2", String(x(clipped[1].offset_m))); seg.setAttribute("y2", String(toDisplayY(clipped[1].elevation_m))); seg.setAttribute("class", "b06-chart__design-cross"); svg.append(seg); } // 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선** // 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일 // 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도). for (const segment of [trim?.minSlope, trim?.maxSlope]) { if (!segment || segment.points.length < 2) continue; // 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. 물매가 1:1.2 고정이라 // 벽이 밖으로 나간 만큼 노면이 연장된다(2026-08-21 사용자 확정). for (let i = 1; i < segment.points.length; i += 1) { const a = segment.points[i - 1]; const b = segment.points[i]; const seg = document.createElementNS(SVG_NS, "line"); seg.setAttribute("x1", String(x(a.offset))); seg.setAttribute("y1", String(toDisplayY(a.elevation))); seg.setAttribute("x2", String(x(b.offset))); seg.setAttribute("y2", String(toDisplayY(b.elevation))); seg.setAttribute("class", "b06-chart__design-cross"); svg.append(seg); } } // 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안 // 구분이 안 되는 경계를 표시한다. const edges = design.carriageway_edges; if (edges) { const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => { const cx = x(offsetM); const cy = toDisplayY(elevationM); const tick = document.createElementNS(SVG_NS, "line"); tick.setAttribute("x1", String(cx)); tick.setAttribute("y1", String(cy - half)); tick.setAttribute("x2", String(cx)); tick.setAttribute("y2", String(cy + half)); tick.setAttribute("class", className); svg.append(tick); }; for (const edge of [edges.left, edges.right]) { tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6); } // 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어 // **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로 // 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다. const roadEdges = design.road_edges; if (roadEdges) { for (const edge of [roadEdges.left, roadEdges.right]) { tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4); } } // 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다. // 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다. const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0); const standardWidth = design.carriageway_standard_width_m; const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null; const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null; const label = document.createElementNS(SVG_NS, "text"); label.setAttribute( "x", String(labelAt?.x ?? (x(edges.left.offset_m) + x(edges.right.offset_m)) / 2), ); label.setAttribute("y", String(labelAt?.y ?? toDisplayY(edges.left.elevation_m) - 6)); label.setAttribute("class", "b06-chart__carriageway-label"); const widthText = widened > 0.001 && typeof standardWidth === "number" ? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})` : `노폭 ${design.carriageway_width_m.toFixed(2)}m`; // 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다. label.textContent = shoulderLeft != null && shoulderRight != null ? `${widthText} · 노견 ${ Math.abs(shoulderLeft - shoulderRight) < 0.005 ? `${shoulderLeft.toFixed(2)}m` : `좌 ${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m` }` : widthText; (labelAt?.layer ?? svg).append(label); } }