/* ============================================================================= * D03_PlanAnalysis_UI_Map.ts * D03 메인 평면(PLAN 530-3) — 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점 눈금 · 이름 + * 지점(연도 경계) + 지점 끌기 겹. * * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `metricToScreen` · `screenToMetric` · 맞춤 * `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` · * 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선 * `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시). * ========================================================================== */ import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { drawStationTicks, type StationLabelBox, } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { computeMapRect, computeRouteView, createMetricProjector, drawPreparedLabels, drawPreparedLayer, normalizedToScreen, pickLevelStep, routeLineColor, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { bindRouteEditNavigation, stationAtScreen, } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View"; import { leadInLine, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; /** 측점 간격(m) — B 규칙 측점과 같음. */ export const PLAN_STATION_INTERVAL_M = 20; /** 지점 집는 반경(px). */ const NODE_HIT_PX = 12; /** 누름으로 볼 손 떨림(px) — 넘으면 지도 옮기기. */ const CLICK_SLOP_PX = 4; /** 위성이 없을 때 지점 둘레로 둘 가짜 도엽 여유(m). */ const FAKE_MARGIN_M = 600; export interface PlanMapScene { meta: VWorldMeta | null; image: HTMLImageElement | null; contours: PreparedLayer | null; nodes: Vertex[]; /** 마디마다 받은 지형 안인가 — 밖이면 빨간 고리. */ reach: boolean[]; /** 고르지 않은 갈래(옅게). */ others: Vertex[][]; /** 고른 갈래를 이은 중심선. */ line: Vertex[]; startM: number; stations: number[]; selected: number | null; editable: boolean; } export interface PlanMapEvents { onDrag: (index: number, point: Vertex) => void; onDragEnd: (index: number) => void; onStation: (chainage: number) => void; } export interface PlanMap { root: HTMLElement; update: (patch: Partial) => void; /** 지점 · 노선이 들어오게 다시 맞춤. */ fit: () => void; /** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */ stationScreen: (chainage: number) => [number, number] | null; dispose: () => void; } function fakeMeta(points: Vertex[]): VWorldMeta | null { if (!points.length) return null; const xs = points.map(([x]) => x); const ys = points.map(([, y]) => y); const x_min = Math.min(...xs) - FAKE_MARGIN_M; const y_min = Math.min(...ys) - FAKE_MARGIN_M; const x_max = Math.max(...xs) + FAKE_MARGIN_M; const y_max = Math.max(...ys) + FAKE_MARGIN_M; return { x_min, x_max, y_min, y_max, width_meters: x_max - x_min, height_meters: y_max - y_min, center_x: (x_min + x_max) / 2, center_y: (y_min + y_max) / 2, lon_min: 0, lon_max: 1, lat_min: 0, lat_max: 1, }; } export function createPlanMap(events: PlanMapEvents): PlanMap { const root = document.createElement("div"); root.className = "d03-plan__map"; const canvas = document.createElement("canvas"); root.append(canvas); const context = canvas.getContext("2d")!; let scene: PlanMapScene = { meta: null, image: null, contours: null, nodes: [], reach: [], others: [], line: [], startM: 0, stations: [], selected: null, editable: false, }; let meta: VWorldMeta | null = null; let fitted = false; let labels: StationLabelBox[] = []; let view: ViewState = { width: 0, height: 0, scale: 1, offsetX: 0, offsetY: 0, mapRect: computeMapRect(null, 0, 0), }; const toScreen = (point: Vertex): [number, number] => createMetricProjector(meta!, view).toScreen(point[0], point[1]); function refit(): void { const all = [...scene.nodes, ...scene.line]; meta = scene.meta ?? fakeMeta(all); if (!meta || !view.width || !all.length) return; const xs = all.map(([x]) => x); const ys = all.map(([, y]) => y); const box = { x_min: Math.min(...xs), x_max: Math.max(...xs), y_min: Math.min(...ys), y_max: Math.max(...ys), }; view = { ...view, mapRect: computeMapRect(meta, view.width, view.height), ...computeRouteView(meta, box, view.width, view.height, 150), }; fitted = true; } function polyline(points: Vertex[]): void { context.beginPath(); points.forEach((point, i) => { const [sx, sy] = toScreen(point); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); context.stroke(); } function draw(): void { context.clearRect(0, 0, view.width, view.height); if (!meta) return; const projector = createMetricProjector(meta, view); if (scene.image?.complete && scene.image.naturalWidth && scene.meta) { const [x0, y0] = normalizedToScreen(view, 0, 0); const [x1, y1] = normalizedToScreen(view, 1, 1); context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0); } if (scene.contours) { context.save(); context.strokeStyle = "rgba(255, 214, 140, 0.7)"; context.lineWidth = 0.8; drawPreparedLayer( context, scene.contours, view, "dot", pickLevelStep(scene.contours, view, 5), ); context.font = "10px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; drawPreparedLabels(context, scene.contours, view, "#fde68a", 25, 0, "rgba(0, 0, 0, 0.6)"); context.restore(); } context.save(); context.lineCap = "round"; context.lineJoin = "round"; context.strokeStyle = "rgba(255, 255, 255, 0.55)"; context.lineWidth = 1.5; context.setLineDash([6, 4]); for (const other of scene.others) if (other.length > 1) polyline(other); context.setLineDash([]); if (scene.line.length > 1) { context.strokeStyle = routeLineColor(); context.lineWidth = 3; polyline(scene.line); } context.restore(); labels = []; if (scene.line.length > 1) { drawStationTicks( context, leadInLine(scene.line, scene.startM).map(([x, y]) => ({ x, y })), { intervalM: PLAN_STATION_INTERVAL_M, pxPerMeter: projector.pxPerMeter, toScreen: projector.toScreen, chainages: scene.stations, onLabel: (box) => labels.push(box), }, ); } if (scene.selected !== null) { const at = stationScreen(scene.selected); if (at) { context.beginPath(); context.arc(at[0], at[1], 7, 0, Math.PI * 2); context.lineWidth = 2.5; context.strokeStyle = themeColor("--color-primary", "#6d28d9"); context.stroke(); } } scene.nodes.forEach((node, i) => { const [sx, sy] = toScreen(node); const last = i === scene.nodes.length - 1; context.beginPath(); context.arc(sx, sy, 7, 0, Math.PI * 2); context.fillStyle = i === 0 ? "#16a34a" : last ? "#dc2626" : "#2563eb"; context.fill(); context.lineWidth = 2; context.strokeStyle = scene.reach[i] === false ? "#ef4444" : "#ffffff"; context.stroke(); if (scene.reach[i] === false) { context.beginPath(); context.arc(sx, sy, 12, 0, Math.PI * 2); context.setLineDash([3, 3]); context.stroke(); context.setLineDash([]); } }); } /** 측점 누가 → 화면 px — 앞붙임 선 위 보간(눈금과 같은 자리). */ function stationScreen(chainage: number): [number, number] | null { if (!meta || scene.line.length < 2) return null; const line = leadInLine(scene.line, scene.startM); let travelled = 0; for (let i = 1; i < line.length; i += 1) { const [a, b] = [line[i - 1], line[i]]; const span = Math.hypot(b[0] - a[0], b[1] - a[1]); if (travelled + span >= chainage - 1e-6) { const t = span ? (chainage - travelled) / span : 0; return toScreen([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); } travelled += span; } return toScreen(line[line.length - 1]); } function resize(): void { const { width, height } = fitCanvas(canvas, context); if (!width || !height) return; view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; if (!fitted) refit(); draw(); } const at = (event: PointerEvent | MouseEvent): [number, number] => { const rect = canvas.getBoundingClientRect(); return [event.clientX - rect.left, event.clientY - rect.top]; }; const nodeAt = (px: number, py: number): number => { let best = -1; let bestDistance = NODE_HIT_PX; scene.nodes.forEach((node, i) => { const [sx, sy] = toScreen(node); const distance = Math.hypot(sx - px, sy - py); if (distance < bestDistance) [best, bestDistance] = [i, distance]; }); return best; }; // 지점 끌기 — 지도 옮기기보다 먼저 듣고 지점을 집었으면 옮기기에 안 넘김 let dragging: { index: number; id: number } | null = null; let press: [number, number] | null = null; canvas.addEventListener("pointerdown", (event) => { press = [event.clientX, event.clientY]; if (!meta || !scene.editable || event.button !== 0) return; const index = nodeAt(...at(event)); if (index < 0) return; event.stopImmediatePropagation(); dragging = { index, id: event.pointerId }; canvas.setPointerCapture(event.pointerId); canvas.style.cursor = "grabbing"; }); canvas.addEventListener("pointermove", (event) => { if (!dragging || event.pointerId !== dragging.id || !meta) { if (meta && scene.editable && !dragging) canvas.style.cursor = nodeAt(...at(event)) >= 0 ? "grab" : ""; return; } events.onDrag(dragging.index, screenToMetric(meta, view, ...at(event))); }); const endDrag = (event: PointerEvent): void => { if (!dragging || event.pointerId !== dragging.id) return; const index = dragging.index; dragging = null; canvas.style.cursor = ""; events.onDragEnd(index); }; canvas.addEventListener("pointerup", endDrag); canvas.addEventListener("pointercancel", endDrag); bindRouteEditNavigation({ canvas, getView: () => view, setView: (next) => (view = next), getMeta: () => meta, leftPan: true, draw, }); // 측점 누름 — 손을 거의 안 움직인 누름만(옮기기 끝은 안 셈) canvas.addEventListener("click", (event) => { if (!press || Math.hypot(event.clientX - press[0], event.clientY - press[1]) > CLICK_SLOP_PX) return; if (!meta || scene.line.length < 2) return; const [px, py] = at(event); const hit = stationAtScreen( leadInLine(scene.line, scene.startM), toScreen, PLAN_STATION_INTERVAL_M, px, py, 14, labels, scene.stations, ); if (hit) events.onStation(hit.chainage); }); const observer = new ResizeObserver(() => resize()); observer.observe(root); return { root, update(patch) { const metaChanged = "meta" in patch && patch.meta !== scene.meta; scene = { ...scene, ...patch }; if (metaChanged || !fitted) refit(); draw(); }, fit() { refit(); draw(); }, stationScreen, dispose: () => observer.disconnect(), }; }