/* ============================================================================= * B05_Profile_UI_RouteEdit_Recommend_Map.ts * [노선 추천] 모달의 미리 보기 지도 — 캔버스 하나에 선 세 겹(예상노선 · 창을 연 노선 · 미리 보기). * * 화면 셈 · 조작은 편집 창 것을 그대로 가져다 쓴다(베끼지 않음) — 좌표 옮기기 `metricToScreen` · * 처음 맞춤 `computeRouteView` · 캔버스 크기 `fitCanvas`(`_View`) · 휠 확대 · 끌기 * `bindRouteEditNavigation`(`_Input`) · 회전 `createMapRotation`(`_Rotate`). 배경 지도(VWorld)는 * 없어서, 선들의 범위를 감싼 가짜 도엽(`VWorldMeta`)을 만들어 같은 셈에 넣는다. * ========================================================================== */ import { themeColor } from "@ui/ui_template_palette"; import { computeMapRect, computeRouteView, metricToScreen, ROUTE_VIEW_MARGIN_M, routeLineColor, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { HALF_TURN_ICON } from "./B05_Profile_UI_RouteEdit_Chrome"; import { bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input"; import type { MapLayer } from "./B05_Profile_UI_RouteEdit_Initial_Model"; import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { fitCanvas } from "./B05_Profile_UI_RouteEdit_View"; /** 처음 맞출 때 선 범위 둘레로 두는 여유(m) — 편집 창(200 m)보다 좁게, 선이 지도를 채우게. */ const FIT_MARGIN_M = 30; /** 지도 칸 마크업 — 캔버스 + 회전 단추 둘(편집 창과 같은 모양 · 같은 `data-act`). */ export const RECOMMEND_MAP_HTML = `
`; export interface RecommendMap { /** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */ setLayers: (layers: MapLayer[]) => void; dispose: () => void; } /** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */ export function createRecommendMap(box: HTMLElement): RecommendMap { const canvas = box.querySelector("canvas")!; const context = canvas.getContext("2d")!; let layers: MapLayer[] = []; 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: box, size: () => view, onChange: () => draw() }); function bounds(): { x_min: number; x_max: number; y_min: number; y_max: number } | null { const all = layers.flatMap((layer) => layer.points); if (!all.length) return null; const xs = all.map(([x]) => x); const ys = all.map(([, y]) => y); return { x_min: Math.min(...xs), x_max: Math.max(...xs), y_min: Math.min(...ys), y_max: Math.max(...ys), }; } /** 선 범위 + 여유를 도엽으로 본다 — 이후 확대 상한 · 맞춤이 편집 창과 같은 공식을 탄다. */ function makeMeta(range: NonNullable>): VWorldMeta { const x_min = range.x_min - ROUTE_VIEW_MARGIN_M; const y_min = range.y_min - ROUTE_VIEW_MARGIN_M; const x_max = range.x_max + ROUTE_VIEW_MARGIN_M; const y_max = range.y_max + ROUTE_VIEW_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, }; } function refit(): void { const b = bounds(); if (!b || !view.width) return; meta = makeMeta(b); view = { ...view, mapRect: computeMapRect(meta, view.width, view.height), ...computeRouteView(meta, b, view.width, view.height, FIT_MARGIN_M), }; fitted = true; } function draw(): void { context.clearRect(0, 0, view.width, view.height); if (!meta) return; context.save(); context.translate(view.width / 2, view.height / 2); context.rotate(rotation.radians()); context.translate(-view.width / 2, -view.height / 2); context.lineCap = "round"; context.lineJoin = "round"; const styles: Record void> = { expected: () => { context.strokeStyle = themeColor("--color-text-secondary", "#9ca3af"); context.lineWidth = 1.5; context.setLineDash([5, 4]); }, current: () => { context.strokeStyle = routeLineColor(); context.globalAlpha = 0.55; context.lineWidth = 2; context.setLineDash([]); }, preview: () => { context.strokeStyle = themeColor("--color-primary", "#6d28d9"); context.lineWidth = 3; context.setLineDash([]); }, }; for (const layer of layers) { if (layer.points.length < 2) continue; context.save(); styles[layer.kind](); context.beginPath(); layer.points.forEach(([x, y], index) => { const [sx, sy] = metricToScreen(meta!, view, x, y); if (index) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); context.stroke(); context.restore(); } context.restore(); } 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(); } bindRouteEditNavigation({ canvas, getView: () => view, setView: (next) => (view = next), getMeta: () => meta, unrotateDelta: rotation.unrotateDelta, leftPan: true, draw, }); // 모달이 붙기 전에는 칸 크기가 0 이다 — 붙은 뒤 크기가 잡히면 맞추고 그린다. const observer = new ResizeObserver(() => resize()); observer.observe(canvas.parentElement!); return { setLayers(next) { layers = next; if (!fitted) refit(); draw(); }, dispose: () => observer.disconnect(), }; }