/* ============================================================================= * D03_PlanAnalysis_UI_DrawPaint.ts * D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) · * `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · * 시점 · 종점 · 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리. * ========================================================================== */ import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { distanceAlong, groundSlopePct, lineLength, pointAtDistance, segmentLength, stationChainages, type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { planColors, PLAN_STATION_INTERVAL_M, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; import type { DrawPreview } from "./D03_PlanAnalysis_UI_Draw"; /** 시점 · 종점 · 꺾임점에 붙는 반경(px). */ export const SNAP_PX = 12; /** 선 위 누름으로 볼 반경(px). */ export const LINE_HIT_PX = 8; /** 구간 길이 라벨을 보일 화면 길이(px). */ const LABEL_MIN_PX = 70; export const fmt = (value: number): string => value.toFixed(1); const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`; /** 기울기 % → 각(°) — 가상 종단 기울기를 두 단위로. */ export const slopeDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI; const slopeText = (pct: number): string => `${signed(pct)} % (${signed(slopeDeg(pct))}°)`; /** 화면 px 거리(작업 좌표계 점 ↔ 마우스). */ export const screenGap = (map: ScreenMap, point: Vertex, px: number, py: number): number => { const [sx, sy] = map.toScreen(point); return Math.hypot(sx - px, sy - py); }; /** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간 · `skip` 선 빼고. */ export function lineAt( network: PlanNetwork, px: number, py: number, map: ScreenMap, skip: number | null = null, ): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null { const cursor = map.toMetric(px, py); let best: ReturnType = null; let bestPx = LINE_HIT_PX; network.lines.forEach((line, index) => { if (line.vertices.length < 2 || index === skip) return; // 붙는 반경 안 꺾임점 중 가장 가까운 것 let vertex = -1; let vertexPx = SNAP_PX; line.vertices.forEach((v, i) => { const [vx, vy] = map.toScreen(v); const d = Math.hypot(vx - px, vy - py); if (d <= vertexPx) [vertex, vertexPx] = [i, d]; }); const atM = vertex >= 0 ? lineLength(line.vertices.slice(0, vertex + 1)) : distanceAlong(line.vertices, cursor); const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM); const [sx, sy] = map.toScreen(point); const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py); if (gap > bestPx || (best && gap === bestPx)) return; // 누가가 든 구간(꺾임점 끼울 자리) let segment = 0; for (let walked = 0; segment < line.vertices.length - 2; segment += 1) { walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]); if (walked >= atM) break; } best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment }; bestPx = gap; }); return best; } /** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기) · 시점(초록) · 종점(빨강). * `active` = 그리는 선(굵은 보라). */ export function paintNetwork( context: CanvasRenderingContext2D, map: ScreenMap, network: PlanNetwork, base: { starts: Vertex[]; ends: Vertex[] }, active: number | null, z: HeightAt, extra: () => void, ): void { const { starts, ends } = base; const colors = planColors(); context.lineCap = "round"; context.lineJoin = "round"; network.lines.forEach((line, index) => { if (line.vertices.length < 2) return; context.strokeStyle = index === active ? colors.active : colors.line; context.lineWidth = line.branch ? 2.5 : 3; context.beginPath(); line.vertices.forEach((v, i) => { const [sx, sy] = map.toScreen(v); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); context.stroke(); if (map.stations) drawStationTicks( context, line.vertices.map(([x, y]) => ({ x, y })), { intervalM: PLAN_STATION_INTERVAL_M, pxPerMeter: map.pxPerMeter, toScreen: (x, y) => map.toScreen([x, y]), uprightRad: map.uprightRad, chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), }, ); if (map.slopes) segmentLabels(context, map, line.vertices, z); }); // 꺾임점 context.fillStyle = "#ffffff"; for (const line of network.lines) for (const v of line.vertices.slice(1)) { const [sx, sy] = map.toScreen(v); context.beginPath(); context.arc(sx, sy, 3, 0, Math.PI * 2); context.fill(); } drawPieces(context, map, network); extra(); // 시점(초록) · 종점(빨강) for (const [points, fill] of [ [starts, "#16a34a"], [ends, "#dc2626"], ] as const) for (const point of points) { const [sx, sy] = map.toScreen(point); context.beginPath(); context.arc(sx, sy, 7, 0, Math.PI * 2); context.fillStyle = fill; context.fill(); context.lineWidth = 2; context.strokeStyle = "#ffffff"; context.stroke(); } } function segmentLabels( context: CanvasRenderingContext2D, map: ScreenMap, vertices: Vertex[], z: HeightAt, ): void { for (let i = 1; i < vertices.length; i += 1) { const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])]; if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue; const slope = groundSlopePct(vertices[i - 1], vertices[i], z); const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`; planLabel(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px"); } } function drawPieces(context: CanvasRenderingContext2D, map: ScreenMap, network: PlanNetwork): void { const colors = planColors(); network.pieces.forEach((piece) => { const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; if (!vertices || vertices.length < 2) return; if (piece.from_m > 0) { const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m)); context.beginPath(); context.moveTo(sx, sy - 8); context.lineTo(sx + 6, sy); context.lineTo(sx, sy + 8); context.lineTo(sx - 6, sy); context.closePath(); context.fillStyle = colors.active; context.fill(); context.lineWidth = 1.5; context.strokeStyle = "#ffffff"; context.stroke(); } const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2)); planLabel(context, map, mx, my + 16, `${piece.year}년`, "bold 12px"); }); } /** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */ export function paintPreview( context: CanvasRenderingContext2D, map: ScreenMap, from: Vertex, preview: DrawPreview, compass: { lengthM: number; candidates: Vertex[] } | null, ): void { const colors = planColors(); const [ax, ay] = map.toScreen(from); const [bx, by] = map.toScreen(preview.point); if (compass) { context.strokeStyle = colors.active; context.lineWidth = 1; context.setLineDash([3, 4]); context.beginPath(); context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2); context.stroke(); context.fillStyle = colors.active; for (const c of compass.candidates) { const [cx, cy] = map.toScreen(c); context.beginPath(); context.arc(cx, cy, 3, 0, Math.PI * 2); context.fill(); } } context.strokeStyle = preview.blocked ? colors.warn : colors.active; context.lineWidth = 2; context.setLineDash([6, 4]); context.beginPath(); context.moveTo(ax, ay); context.lineTo(bx, by); context.stroke(); context.setLineDash([]); context.beginPath(); context.arc(bx, by, 5, 0, Math.PI * 2); context.stroke(); const value = `${fmt(preview.lengthM)} m · ` + (preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct)); planLabel( context, map, bx + 14, by - 14, preview.note ? `${value} · ${preview.note}` : value, "12px", "left", ); } export function planLabel( context: CanvasRenderingContext2D, map: ScreenMap, x: number, y: number, text: string, font: string, align: CanvasTextAlign = "center", ): void { map.upright(context, x, y, () => { context.font = `${font} system-ui, sans-serif`; context.textAlign = align; context.textBaseline = "middle"; context.lineWidth = 3; context.strokeStyle = planColors().halo; context.strokeText(text, x, y); context.fillStyle = "#ffffff"; context.fillText(text, x, y); }); }