Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts
T

184 lines
6.6 KiB
TypeScript

/* =============================================================================
* 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 = `
<canvas class="b05-routerec__map"></canvas>
<div class="b05-routeedit__spin">
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
</div>`;
export interface RecommendMap {
/** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */
setLayers: (layers: MapLayer[]) => void;
dispose: () => void;
}
/** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */
export function createRecommendMap(box: HTMLElement): RecommendMap {
const canvas = box.querySelector<HTMLCanvasElement>("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<ReturnType<typeof bounds>>): 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<MapLayer["kind"], () => 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(),
};
}