/* ============================================================================= * B05_Profile_UI_RouteEdit_Cross_Draw.ts * 횡단 한 장을 **SVG 선**으로 짓는다 — 원지반선·기본 계획 횡단선과 아래 한 줄 요약. * * `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정). * 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다. * * **왜 캔버스가 아니라 SVG 인가**(2026-09-12 사용자 지시 ③) — 캔버스는 한 번 찍으면 그림이라 * 확대하면 뭉개진다. SVG 는 선이 그대로 남아 휠로 키워도 또렷하다. 확대·이동은 **공용 * 조각**(`A00_Common/b_svg_zoom_pan.ts`)이 맡는다 — B06 횡단 카드와 한 코드다. * * **계획 횡단선은 절·성토 교차점까지만 그린다**(2026-09-12 사용자 지시 ②). 서버가 주는 * `design_line` 은 계산 반폭 끝까지 이어지는데 **사면 끝 바깥은 손대지 않는 원지반**이라, * 그대로 그으면 지반선과 겹쳐 같은 지형이 두 번 그려진다. B06 횡단 카드는 2026-08-20 에 * 같은 지적을 받아 이미 이렇게 끊고 있다(`appendCrossDesignOverlay`) — 교차점 찾기도 * 그쪽이 쓰는 `toeOffsets` 를 그대로 부른다. * ========================================================================== */ import { svgElement, svgText } from "@util/common_util_svg"; import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan"; import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch"; import { fillSlopeLengths, toeOffsets } from "./../B06_Section/B06_Section_UI_Cross_Fit"; import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; /** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */ export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 }; /** 지은 한 판 — 확대·이동은 `layer` 에만 걸린다(범례·안내 글자는 제자리). */ export interface CrossPlot { svg: SVGSVGElement; layer: SVGGElement; width: number; height: number; /** 실제로 선이 놓인 범위 — 확대했을 때 팬이 갈 수 있는 데까지. */ content: ContentBounds; } /** 성토사면 길이·절성토 면적 한 줄. */ export function summarizeCross(preview: CrossPreviewResponse): string { const design = preview.design; if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다."; // 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를 // 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다. const lengths = fillSlopeLengths(asSection(preview)); const sides = (["left", "right"] as const) .filter((side) => lengths[side] !== null) .map((side) => { const value = lengths[side]!; // 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다. return `${side === "left" ? "좌" : "우"} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`; }); const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음"; return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`; } /** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */ function asSection(preview: CrossPreviewResponse): CrossSection { return { samples: preview.samples, design: preview.design } as unknown as CrossSection; } type Point = [number, number]; /** * 선을 **절·성토 교차점 사이만** 남긴다(2026-09-12 사용자 지시 ②). * * 교차점이 꼭짓점 사이에 떨어지면 그 자리에 점을 하나 만들어 **딱 거기서 끊는다** — 가까운 * 꼭짓점에서 끊으면 사면 끝이 지반에 닿기 전에 멈춘 것처럼 보인다. * 그 측에서 교차점을 못 찾으면(사면이 지반을 못 만난 측점) 그쪽은 안 자른다. */ function clipToToes(line: Point[], low: number, high: number): Point[] { if (line.length < 2) return line; const inside = (point: Point): boolean => point[0] >= low - 1e-9 && point[0] <= high + 1e-9; const cutAt = (from: Point, to: Point, edge: number): Point => { const span = to[0] - from[0]; const ratio = Math.abs(span) <= 1e-9 ? 0 : (edge - from[0]) / span; return [edge, from[1] + (to[1] - from[1]) * ratio]; }; const out: Point[] = []; line.forEach((point, index) => { if (inside(point)) out.push(point); const next = line[index + 1]; if (!next) return; // 경계를 넘어가는 구간은 경계 자리에 점을 하나 세운다. 가까운 경계부터 넣어야 순서가 산다. [low, high] .filter((edge) => (point[0] - edge) * (next[0] - edge) < 0) .sort((a, b) => Math.abs(a - point[0]) - Math.abs(b - point[0])) .forEach((edge) => out.push(cutAt(point, next, edge))); }); return out; } /** * 원지반선과 기본 계획 횡단선을 한 판에 짓는다. 좌(+offset)가 화면 왼쪽이다 * (`generate_sections` cad_exchange 규약과 같은 방향). * * **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는 * 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다). */ export function buildCrossSvg( preview: CrossPreviewResponse, width: number, height: number, ): CrossPlot { const svg = svgElement("svg", { viewBox: `0 0 ${width} ${height}`, width: "100%", height: "100%", preserveAspectRatio: "none", class: "b05-routeedit__cross-svg", }); const layer = svgElement("g", { class: "b05-routeedit__cross-plot" }); const ground = preview.samples .filter((sample) => sample.valid && sample.elevation_m !== null) .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point); const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null }; // 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다. const design = clipToToes( (preview.design?.design_line ?? []).map( (point) => [point.offset_m, point.elevation_m] as Point, ), toes.right ?? -Infinity, // −offset = 우 toes.left ?? Infinity, // +offset = 좌 ); const all = [...ground, ...design]; if (all.length < 2) { svg.append(layer); return { svg, layer, width, height, content: { x: 0, y: 0, width, height } }; } const offsets = all.map((point) => point[0]); const heights = all.map((point) => point[1]); const minOffset = Math.min(...offsets); const maxOffset = Math.max(...offsets); const minZ = Math.min(...heights); const maxZ = Math.max(...heights); const spanX = maxOffset - minOffset || 1; const spanZ = maxZ - minZ || 1; const plotWidth = Math.max(width - CROSS_PLOT_PAD.left - CROSS_PLOT_PAD.right, 1); const plotHeight = Math.max(height - CROSS_PLOT_PAD.top - CROSS_PLOT_PAD.bottom, 1); const scale = Math.min(plotWidth / spanX, plotHeight / spanZ); const centerOffset = (minOffset + maxOffset) / 2; const centerZ = (minZ + maxZ) / 2; const toScreen = (point: Point): Point => [ width / 2 + (centerOffset - point[0]) * scale, height / 2 + (centerZ - point[1]) * scale, ]; const path = (points: Point[], className: string): void => { if (points.length < 2) return; layer.append( svgElement("polyline", { class: className, points: points.map((point) => toScreen(point).join(",")).join(" "), }), ); }; // 중심선 — 어디가 노선 가운데인지 먼저 보이게. const [centerX] = toScreen([0, centerZ]); layer.append( svgElement("line", { class: "b05-routeedit__cross-axis", x1: centerX, y1: CROSS_PLOT_PAD.top / 2, x2: centerX, y2: height - CROSS_PLOT_PAD.bottom / 2, }), ); path(ground, "b05-routeedit__cross-ground"); path(design, "b05-routeedit__cross-design"); // 범례·눈금 안내는 **확대해도 제자리**다 — 확대 묶음 밖에 둔다. svg.append(layer); svg.append( svgText("원지반", { class: "b05-routeedit__cross-key is-ground", x: CROSS_PLOT_PAD.left, y: 13, "text-anchor": "start", }), svgText("기본 계획 횡단", { class: "b05-routeedit__cross-key is-design", x: width - CROSS_PLOT_PAD.right, y: 13, "text-anchor": "end", }), svgText( `좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` + ` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`, { class: "b05-routeedit__cross-key", x: width / 2, y: height - 6, "text-anchor": "middle", }, ), ); const screens = all.map(toScreen); const xs = screens.map((point) => point[0]); const ys = screens.map((point) => point[1]); return { svg, layer, width, height, content: { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys), }, }; }