Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
184 lines
6.6 KiB
TypeScript
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(),
|
|
};
|
|
}
|