/* ============================================================================= * D03_PlanAnalysis_UI_Map.ts * D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 아홉 * (공용 `createMapTools` 568-3 — [?] · 되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 측점 · 기울기 · 거리재기). * * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤 * `computeRouteView` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` · * 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠). * 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향). * 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김. * 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음. * 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF). * TM 겹(그은 선 · 재기 · 그림 겹)은 경위도로 바꿔 바탕과 같은 자리(586-0 · `localTmFrame`) — 좌표계를 모르면 옛 꼴. * ========================================================================== */ import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { computeMapRect, computeRouteView, createMetricProjector, createNormalizer, lonLatToScreen, normalizedToScreen, routeLineColor, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; import { createMapRotation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Rotate"; import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm"; /** 측점 간격(m) — B 규칙 측점과 같음. */ export const PLAN_STATION_INTERVAL_M = 20; /** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */ export function planColors(): { line: string; active: string; warn: string; halo: string } { return { line: routeLineColor(), active: themeColor("--draw-primary", "#6d28d9"), warn: themeColor("--draw-danger", "#dc2626"), halo: "rgba(0, 0, 0, 0.65)", }; } /** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(m). */ const FAKE_MARGIN_M = 600; export interface PlanMapScene { meta: VWorldMeta | null; image: HTMLImageElement | null; /** 처음 맞출 점(시점 · 종점 · 그은 선). */ fit: Vertex[]; /** 작업 좌표계 — TM 겹을 경위도 바탕에 맞춤(586-0). */ epsg?: number | null; } /** 겹이 쓰는 좌표 — 그림 px(돌리기 전) ↔ 작업 좌표계 m · 글자 세우기. */ export interface ScreenMap { toScreen: (point: Vertex) => [number, number]; toMetric: (px: number, py: number) => Vertex; pxPerMeter: number; /** B04 그리기 틀(`drawPreparedLayer` 등이 받음). */ view: ViewState; /** 측점 눈금 · 구간 길이 · 기울기 글 켬(도구줄 측점 · 기울기). */ stations: boolean; slopes: boolean; /** 글자를 세울 각(라디안 · 안 돌렸으면 0). */ uprightRad: number; /** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */ upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void; } /** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁(왼쪽 끌기 중도) · 놓음 · 나감. */ export interface PlanMapLayer { /** 바탕 겹(아래 캔버스 · 마우스 움직임엔 안 다시 그림). */ base?: boolean; draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void; press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean; hover?: (px: number, py: number, map: ScreenMap) => void; release?: () => void; leave?: () => void; } export interface PlanMapEvents { onUndo: () => void; onRedo: () => void; on3d: () => void; onMeasure: () => void; } export interface PlanTools { undo: boolean; redo: boolean; view3d: boolean; /** 거리재기 켜짐(눌림). */ measure: boolean; } export interface PlanMap { root: HTMLElement; update: (patch: Partial) => void; setLayers: (layers: PlanMapLayer[]) => void; setTools: (tools: Partial) => void; /** 위 겹 다시 그림(그은 선 · 재기 값이 바뀜). */ redraw: () => void; /** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */ redrawBase: () => void; /** 맞출 점이 들어오게 다시 맞춤. */ fit: () => void; /** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */ screenOf: (point: Vertex) => [number, number] | null; /** 검증 창구 — 경위도 → 화면 px(바탕 겹 꼴 · 586-0 맞춤 대조). */ screenOfLonLat: (lon: number, lat: number) => [number, number] | null; /** 검증 창구 — 지금 돌린 각(도). */ rotationDeg: () => number; 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 baseCanvas = document.createElement("canvas"); baseCanvas.className = "d03-plan__base"; const canvas = document.createElement("canvas"); let marks = { stations: true, slopes: true }; const markButton = (act: "stations" | "slopes", title: string): HTMLButtonElement => { const button = mapToolButton(act, { title, pressed: true, onClick: () => { marks = { ...marks, [act]: !marks[act] }; button.setAttribute("aria-pressed", String(marks[act])); drawTop(); }, }); return button; }; const undoButton = mapToolButton("undo", { onClick: events.onUndo }); const redoButton = mapToolButton("redo", { onClick: events.onRedo }); const viewButton = mapToolButton("view3d", { onClick: events.on3d }); const measureButton = mapToolButton("measure", { onClick: events.onMeasure }); const tools = createMapTools({ label: "평면 도구", hint: { lines: [ "[긋기] 뒤 지점 · 선 끝 · 선 위 누름 = 시작", "누름 = 점 찍기", "Enter = 긋기 끝", "Esc = 긋기 취소", "Backspace = 마지막 점 빼기", "선 누름 = 고름 · 고른 선 위 누름 = 꺾임점 더함", "꺾임점 · 마크 끌기 = 옮김", "오른쪽 누름 = 꺾임점 · 마크 지움", "Delete = 고른 선 지움", "가운데 끌기 = 옮기기", "휠 = 확대 · 축소", "Ctrl+휠 = 돌리기", ], }, groups: [ [undoButton, redoButton], [viewButton, "rotate-ccw", "rotate-cw"], [markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton], ], }); const toolbar = tools.root; toolbar.classList.add("d03-plan__tools"); root.append(baseCanvas, canvas, toolbar); const baseContext = baseCanvas.getContext("2d")!; const context = canvas.getContext("2d")!; let scene: PlanMapScene = { meta: null, image: null, fit: [] }; let layers: PlanMapLayer[] = []; let meta: VWorldMeta | null = null; let fitted = false; let view: ViewState = { width: 0, height: 0, scale: 1, offsetX: 0, offsetY: 0, mapRect: computeMapRect(null, 0, 0), }; const rotation = createMapRotation({ overlay: toolbar, size: () => view, onChange: () => draw(), }); /** TM ↔ 그림 px — 경위도 바탕에 맞춤(586-0) · 가짜 도엽 · 모르는 좌표계 = 옛 「같은 직사각형」 꼴. */ const metricFrame = (): Pick => { const params = tmParams(scene.epsg); if (!params || !scene.meta || meta !== scene.meta) { const projector = createMetricProjector(meta!, view); return { toScreen: (point) => projector.toScreen(point[0], point[1]), toMetric: (px, py) => screenToMetric(meta!, view, px, py), pxPerMeter: projector.pxPerMeter, }; } const normalizer = createNormalizer(scene.meta); const [x0, y0] = normalizedToScreen(view, 0, 0); const [x1, y1] = normalizedToScreen(view, 1, 1); const frame = localTmFrame( params, (lon, lat) => lonLatToScreen(normalizer, view, lon, lat), (px, py) => [ normalizer.lonMin + ((px - x0) / (x1 - x0)) * normalizer.lonRange, normalizer.latMin + (1 - (py - y0) / (y1 - y0)) * normalizer.latRange, ], view.width / 2, view.height / 2, ); return { toScreen: (point) => frame.toScreen(point[0], point[1]), toMetric: (px, py) => frame.toMetric(px, py), pxPerMeter: frame.pxPerMeter, }; }; const screenMap = (): ScreenMap => { return { ...metricFrame(), view, ...marks, uprightRad: rotation.uprightRad(), upright: (ctx, x, y, paint) => { const angle = rotation.uprightRad(); if (!angle) return paint(); ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.translate(-x, -y); paint(); ctx.restore(); }, }; }; function refit(): void { meta = scene.meta ?? fakeMeta(scene.fit); if (!meta || !view.width || !scene.fit.length) return; const xs = scene.fit.map(([x]) => x); const ys = scene.fit.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 paint(ctx: CanvasRenderingContext2D, base: boolean): void { ctx.clearRect(0, 0, view.width, view.height); if (!meta) return; ctx.save(); ctx.translate(view.width / 2, view.height / 2); ctx.rotate(rotation.radians()); ctx.translate(-view.width / 2, -view.height / 2); if (base && scene.image?.complete && scene.image.naturalWidth && scene.meta) { const [x0, y0] = normalizedToScreen(view, 0, 0); const [x1, y1] = normalizedToScreen(view, 1, 1); ctx.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0); } const map = screenMap(); for (const layer of layers) { if (!layer.base !== !base) continue; ctx.save(); layer.draw(ctx, map); ctx.restore(); } ctx.restore(); } let frame = 0; let baseDirty = false; let topDirty = false; /** 모인 다시 그림을 지금 그림. */ function flush(): void { cancelAnimationFrame(frame); frame = 0; if (baseDirty) paint(baseContext, true); if (topDirty) paint(context, false); baseDirty = topDirty = false; } /** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */ function schedule(base: boolean): void { baseDirty ||= base; topDirty = true; if (!frame) frame = requestAnimationFrame(flush); } const draw = (): void => schedule(true); const drawTop = (): void => schedule(false); function resize(): void { fitCanvas(baseCanvas, baseContext); const { width, height } = fitCanvas(canvas, context); if (!width || !height) return; view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; if (!fitted) refit(); // 크기를 바꾸면 캔버스가 비므로 같은 화면에 바로 그림(빈 화면 한 번 없음) baseDirty = topDirty = true; flush(); } /** 누른 화면 자리 → 그림 px(돌리기 전). */ const at = (event: PointerEvent | MouseEvent): [number, number] => { const rect = canvas.getBoundingClientRect(); return rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); }; canvas.addEventListener("pointerdown", (event) => { if (!meta || event.button !== 0) return; const [px, py] = at(event); const map = screenMap(); if (layers.some((layer) => layer.press?.(px, py, map, 0))) { canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음 drawTop(); } }); canvas.addEventListener("pointerup", (event) => { if (event.button !== 0) return; for (const layer of layers) layer.release?.(); drawTop(); }); canvas.addEventListener("pointermove", (event) => { if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중 const [px, py] = at(event); const map = screenMap(); for (const layer of layers) layer.hover?.(px, py, map); drawTop(); }); canvas.addEventListener("pointerleave", () => { for (const layer of layers) layer.leave?.(); drawTop(); }); canvas.addEventListener("contextmenu", (event) => { event.preventDefault(); if (!meta) return; const [px, py] = at(event); const map = screenMap(); if (layers.some((layer) => layer.press?.(px, py, map, 2))) drawTop(); }); bindRouteEditNavigation({ canvas, getView: () => view, setView: (next) => (view = next), getMeta: () => meta, unrotateDelta: rotation.unrotateDelta, rotateWheel: rotation.wheel, draw, }); 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(); }, setLayers(next) { layers = next; draw(); }, setTools(tools) { if (tools.undo !== undefined) undoButton.disabled = !tools.undo; if (tools.redo !== undefined) redoButton.disabled = !tools.redo; if (tools.view3d !== undefined) viewButton.disabled = !tools.view3d; if (tools.measure !== undefined) measureButton.setAttribute("aria-pressed", String(tools.measure)); }, redraw: drawTop, redrawBase: draw, fit() { refit(); draw(); }, screenOf: (point) => { if (!meta) return null; const [x, y] = screenMap().toScreen(point); return rotation.rerotate(x, y); }, screenOfLonLat: (lon, lat) => { if (!scene.meta) return null; return rotation.rerotate(...lonLatToScreen(createNormalizer(scene.meta), view, lon, lat)); }, rotationDeg: () => (rotation.radians() * 180) / Math.PI, dispose: () => { observer.disconnect(); cancelAnimationFrame(frame); }, }; }