/* ============================================================================= * B05_Profile_UI_RouteEdit_Render.ts * 계획노선 편집 모달의 **그리기** — 등고선·예상노선·계획노선·노드·곡선 손잡이, * 그 위에 시점·종점·규칙측점 눈금. * * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과 * 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `scene` 으로 받는다. * * 측점 눈금은 **B04 지도·배수유역도와 같은 한 곳**(`drawStationTicks`)을 부른다 — 표기가 * 화면마다 갈리면 같은 자리를 두 이름으로 부르게 된다(계획서 0-9 ②). * ========================================================================== */ import { drawPreparedFeature, drawPreparedLabels, drawPreparedLayer, layerScreenBounds, normalizedToScreen, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input"; import { formatStation } from "./B05_Profile_Util_Station"; /** 노드 반지름(px). */ const NODE_R = 4; /** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). * * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ const CONTOUR_BAND_M = 300; /** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ const CURVE_HANDLE_PX = 5; /** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */ const OUTWARD_PX = 26; /** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */ const MEASURE_COLOR = "#22c55e"; /** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */ export function polylineLengthM(points: ReadonlyArray): number { let total = 0; for (let index = 1; index < points.length; index += 1) { total += Math.hypot( points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1], ); } return total; } export interface RouteEditScene { view: ViewState; /** 사업지 좌표(m) → 캔버스 px. */ toScreen: (vertex: Vertex) => [number, number]; /** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 이 값으로 정한다. */ pxPerMeter: number; /** 도엽 메타를 읽었나 — 못 읽었으면 등고선 띠를 씌우지 않는다. */ hasMeta: boolean; /** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */ contours: RouteEditContours | null; /** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */ otherSheets: ReadonlyArray; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ pickedContour: number; /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */ contourStepM: number; expected: ReadonlyArray; /** 그려 보이는 계획노선(원호 포함). */ plannedLine: ReadonlyArray; /** 잡아 옮기는 노드(꺾임점). */ planned: ReadonlyArray; nodeInfo: ReadonlyArray; curveInfo: ReadonlyArray; curveOn: ReadonlyArray; /** 지금 고른 꺾임점. 없으면 -1. */ picked: number; /** 규칙 측점 간격(m). */ stationIntervalM: number; /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */ measure: ReadonlyArray<{ point: Vertex; chainageM: number }>; /** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */ rotationRad: number; /** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */ uprightRad: number; } /** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */ function upright( context: CanvasRenderingContext2D, radians: number, x: number, y: number, paint: () => void, ): void { if (!radians) { paint(); return; } context.save(); context.translate(x, y); context.rotate(radians); context.translate(-x, -y); paint(); context.restore(); } export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void { const { view, toScreen } = scene; const style = getComputedStyle(document.documentElement); const line = scene.plannedLine.length ? scene.plannedLine : scene.planned; context.clearRect(0, 0, view.width, view.height); context.fillStyle = style.getPropertyValue("--color-surface") || "#111"; context.fillRect(0, 0, view.width, view.height); // 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯). // 바탕칠은 돌리기 **전에** 해 두었다 — 돌린 뒤에 칠하면 모서리에 빈 곳이 생긴다. context.save(); if (scene.rotationRad) { context.translate(view.width / 2, view.height / 2); context.rotate(scene.rotationRad); context.translate(-view.width / 2, -view.height / 2); } context.save(); // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). const band = scene.hasMeta ? contourBandRect(line as Vertex[], toScreen, CONTOUR_BAND_M) : null; if (band) { context.beginPath(); context.rect(band.x, band.y, band.width, band.height); context.clip(); } context.strokeStyle = style.getPropertyValue("--map-sheet-stream") || "#2563eb"; context.lineWidth = 1.2; // 바탕이 LAS 면 세류선도 **등고선이 있는 데까지만** 그린다(2026-09-12 사용자 지시 ⑮) — // 도엽 하천중심선은 도엽 전체를 덮어 LAS 자료 밖까지 길게 뻗는다. const lasBox = scene.contours?.source === "las" ? layerScreenBounds(scene.contours.layer, view) : null; context.save(); if (lasBox) { context.beginPath(); context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height); context.clip(); } for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot"); context.restore(); if (scene.contours) { // 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다. const everyM = scene.contourStepM; context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc"; context.lineWidth = 0.8; drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM); // 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다. if (scene.pickedContour >= 0) { context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed"; context.lineWidth = 2.6; drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view); } // 높이값 라벨은 여기서 안 낸다 — 노선·눈금 **뒤에** 그려야 안 묻힌다(맨 아래 참고). } context.restore(); strokePolyline( context, toScreen, scene.expected, [6, 5], style.getPropertyValue("--color-text-secondary") || "#9ca3af", 1.6, ); // 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다. // 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다. strokePolyline( context, toScreen, line, [], style.getPropertyValue("--map-route") || "#f97316", 2.4, ); context.save(); context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; context.lineWidth = 1; scene.planned.forEach((vertex, index) => { const [x, y] = toScreen(vertex); // 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정). const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0; context.fillStyle = bad ? style.getPropertyValue("--color-danger") || "#dc2626" : style.getPropertyValue("--map-route") || "#f97316"; context.beginPath(); context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); context.fill(); context.stroke(); // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. if ( scene.curveOn.length && !scene.curveOn[index] && index > 0 && index < scene.planned.length - 1 ) { context.save(); context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; context.beginPath(); context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); context.fill(); context.restore(); } }); // 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시). // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. context.lineWidth = 2; scene.curveInfo.forEach((curve) => { // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다. // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. const isPicked = curve.node_first === scene.picked; [curve.start, curve.end].forEach((point) => { const [x, y] = toScreen([point[0], point[1]]); context.beginPath(); const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; context.rect(x - size, y - size, size * 2, size * 2); context.fillStyle = isPicked ? style.getPropertyValue("--map-route") || "#f97316" : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; context.fill(); context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; context.stroke(); }); }); context.restore(); drawStationMarks(context, scene, line); drawMeasureMarks(context, scene, line); // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저 // 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다. drawContourLabels(context, scene, band, style); context.restore(); // 회전 끝 } /** 등고 높이값 라벨 — **그림 맨 위**에 얹는다(2026-09-12 사용자 지시 ⑦). * * 고른 가닥의 큰 이름표도 여기서 낸다. 줄과 라벨은 **같은 눈금**(`contourStepM`)으로 솎아 * 그린 줄에만 숫자가 붙는다 — 촘촘함은 부르는 쪽이 그 눈금으로 정한다. */ function drawContourLabels( context: CanvasRenderingContext2D, scene: RouteEditScene, band: { x: number; y: number; width: number; height: number } | null, style: CSSStyleDeclaration, ): void { if (!scene.contours) return; context.save(); if (band) { context.beginPath(); context.rect(band.x, band.y, band.width, band.height); context.clip(); } context.font = "10px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; drawPreparedLabels( context, scene.contours.layer, scene.view, style.getPropertyValue("--map-sheet-contour") || "#a5b4fc", scene.contourStepM, scene.uprightRad, ); if (scene.pickedContour >= 0) drawPickedContourLabel(context, scene, scene.view); context.restore(); } /** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */ function drawMeasureMarks( context: CanvasRenderingContext2D, scene: RouteEditScene, line: ReadonlyArray, ): void { if (scene.measure.length === 0) return; context.save(); if (scene.measure.length >= 2) { const span = spanBetween(line, scene.measure[0], scene.measure[1]); if (span.length >= 2) { context.strokeStyle = MEASURE_COLOR; context.lineWidth = 4; context.beginPath(); span.forEach((vertex, index) => { const [x, y] = scene.toScreen(vertex); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); } } context.lineWidth = 2.4; context.strokeStyle = MEASURE_COLOR; context.font = "bold 11px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; scene.measure.forEach((mark, index) => { const [x, y] = scene.toScreen(mark.point); context.fillStyle = "rgba(255,255,255,0.95)"; context.beginPath(); context.arc(x, y, 7, 0, Math.PI * 2); context.fill(); context.stroke(); context.fillStyle = "#14532d"; upright(context, scene.uprightRad, x, y, () => context.fillText(index === 0 ? "a" : "b", x, y)); }); context.restore(); } /** * 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 0-9 ㉕). * * ⚠ 예전에는 **가장 가까운 정점**으로 잘랐다. 노선이 되꺾이는 자리에서는 a 옆에 b 쪽 정점이 * 더 가까이 붙어 있어 엉뚱한 자리를 골랐고, 그 결과 초록 띠가 노선을 벗어나 **삼각형으로 * 얽혔다**(2026-09-12 사용자 화면). 찍을 때 이미 누가거리를 알고 있으므로 그것으로 자른다. */ function spanBetween( line: ReadonlyArray, from: { point: Vertex; chainageM: number }, to: { point: Vertex; chainageM: number }, ): Vertex[] { const low = Math.min(from.chainageM, to.chainageM); const high = Math.max(from.chainageM, to.chainageM); const head = from.chainageM <= to.chainageM ? from.point : to.point; const tail = from.chainageM <= to.chainageM ? to.point : from.point; const inside: Vertex[] = []; let travelled = 0; for (let index = 1; index < line.length; index += 1) { const step = Math.hypot( line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1], ); // 정점의 누가거리가 두 점 사이면 그대로 잇는다 — 사이에 없는 정점은 건너뛴다. if (travelled > low && travelled < high) inside.push(line[index - 1]); travelled += step; } return [head, ...inside, tail]; } /** 고른 등고선의 **높이값을 크게** 붙인다(계획서 0-9 ㉘) — 색만 바뀌면 몇 m 인지 안 보인다. */ function drawPickedContourLabel( context: CanvasRenderingContext2D, scene: RouteEditScene, view: ViewState, ): void { const feature = scene.contours?.layer.features[scene.pickedContour]; if (!feature || feature.labelValue === null) return; const [x, y] = normalizedToScreen(view, feature.labelAnchorX, feature.labelAnchorY); const text = `${feature.labelValue}m`; upright(context, scene.uprightRad, x, y, () => { context.save(); context.font = "bold 13px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; const width = context.measureText(text).width + 10; context.fillStyle = "#7c3aed"; context.fillRect(x - width / 2, y - 9, width, 18); context.fillStyle = "#ffffff"; context.fillText(text, x, y); context.restore(); }); } /** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */ function drawStationMarks( context: CanvasRenderingContext2D, scene: RouteEditScene, line: ReadonlyArray, ): void { if (line.length < 2) return; // 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게. drawStationTicks( context, line.map(([x, y]) => ({ x, y })), { intervalM: scene.stationIntervalM, pxPerMeter: scene.pxPerMeter, toScreen: (x, y) => scene.toScreen([x, y]), uprightRad: scene.uprightRad, }, ); const total = polylineLengthM(line); const last = line.length - 1; endLabel(context, scene, line[0], line[1], `시점 ${formatStation(0, scene.stationIntervalM)}`); endLabel( context, scene, line[last], line[last - 1], `종점 ${formatStation(total, scene.stationIntervalM)}`, ); } /** 시점·종점 이름표 — 측점 라벨보다 크고 짙게 찍어 양 끝을 한눈에 알게 한다. * * 자리는 **노선 바깥쪽**(끝점에서 노선을 등진 방향)이다. 위로만 띄웠더니 같은 자리의 측점 * 라벨(0+0.0 · 50+0.0)과 겹쳐 두 글자가 포개졌다 — 측점 라벨은 노선에 **직각**으로 나가므로 * 노선을 따라 밀면 서로 안 물린다(2026-09-12 실화면). */ function endLabel( context: CanvasRenderingContext2D, scene: RouteEditScene, at: Vertex, inward: Vertex, text: string, ): void { const [x0, y0] = scene.toScreen(at); const [x1, y1] = scene.toScreen(inward); const length = Math.hypot(x0 - x1, y0 - y1) || 1; const x = x0 + ((x0 - x1) / length) * OUTWARD_PX; const y = y0 + ((y0 - y1) / length) * OUTWARD_PX; context.save(); if (scene.uprightRad) { context.translate(x, y); context.rotate(scene.uprightRad); context.translate(-x, -y); } context.font = "bold 12px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; const width = context.measureText(text).width + 10; context.fillStyle = "rgba(255, 255, 255, 0.9)"; context.fillRect(x - width / 2, y - 26, width, 17); context.strokeStyle = "#f97316"; context.lineWidth = 1; context.strokeRect(x - width / 2, y - 26, width, 17); context.fillStyle = "#111111"; context.fillText(text, x, y - 17.5); context.restore(); } function strokePolyline( context: CanvasRenderingContext2D, toScreen: (vertex: Vertex) => [number, number], points: ReadonlyArray, dash: number[], color: string, width: number, ): void { if (points.length < 2) return; context.save(); context.setLineDash(dash); context.strokeStyle = color; context.lineWidth = width; context.beginPath(); points.forEach((vertex, index) => { const [x, y] = toScreen(vertex); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); context.restore(); }