Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts
T

414 lines
14 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Map.ts
* D03 메인 평면(PLAN 530-3) — 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점 눈금 · 이름 +
* 지점(연도 경계) + 지점 끌기 겹.
*
* 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `metricToScreen` · `screenToMetric` · 맞춤
* `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` ·
* 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선
* `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시).
* 꺾임점 고침 겹(543 · `overlay`) = 지점 다음 차례로 집음 · 두 번 누름 = 넣기 · 오른쪽 누름 = 지우기.
* ========================================================================== */
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;
}
/** 화면 px ↔ 작업 좌표계 m. */
export interface ScreenMap {
toScreen: (point: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
}
/** 평면 위 고침 겹 — 그리기 · 집기(참 = 잡음) · 끌기 · 놓기 · 넣기 · 지우기. */
export interface PlanMapOverlay {
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
grab: (px: number, py: number, map: ScreenMap) => boolean;
move: (point: Vertex) => void;
release: () => void;
insert: (px: number, py: number, map: ScreenMap) => void;
remove: (px: number, py: number, map: ScreenMap) => void;
}
export interface PlanMapEvents {
onDrag: (index: number, point: Vertex) => void;
onDragEnd: (index: number) => void;
onStation: (chainage: number) => void;
overlay?: PlanMapOverlay;
}
export interface PlanMap {
root: HTMLElement;
update: (patch: Partial<PlanMapScene>) => void;
/** 지점 · 노선이 들어오게 다시 맞춤. */
fit: () => void;
/** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */
stationScreen: (chainage: number) => [number, number] | null;
/** 검증 창구 — 작업 좌표계 점 → 화면 px(그림 좌표 · 그리기 전이면 null). */
screenOf: (point: Vertex) => [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]);
const screenMap: ScreenMap = {
toScreen,
toMetric: (px, py) => screenToMetric(meta!, view, px, py),
};
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();
}
}
if (scene.editable) events.overlay?.draw(context, screenMap);
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;
};
// 지점 끌기 — 지도 옮기기보다 먼저 듣고 지점을 집었으면 옮기기에 안 넘김
// 집은 자리와 지점 가운데 차(px)를 지킴 · 손 떨림(CLICK_SLOP_PX) 안이면 끌기 아님(누름)
let dragging: { index: number; id: number; dx: number; dy: number; moved: boolean } | 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 [px, py] = at(event);
// 지점 먼저 · 다음 고침 겹(index -1)
const index = nodeAt(px, py);
if (index < 0 && !events.overlay?.grab(px, py, screenMap)) return;
event.stopImmediatePropagation();
const [sx, sy] = index < 0 ? [px, py] : toScreen(scene.nodes[index]);
dragging = { index, id: event.pointerId, dx: sx - px, dy: sy - py, moved: false };
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;
}
if (!press) return;
const far = Math.hypot(event.clientX - press[0], event.clientY - press[1]) > CLICK_SLOP_PX;
if (!dragging.moved && !far) return;
dragging.moved = true;
const [px, py] = at(event);
const point = screenToMetric(meta, view, px + dragging.dx, py + dragging.dy);
if (dragging.index < 0) events.overlay?.move(point);
else events.onDrag(dragging.index, point);
});
const endDrag = (event: PointerEvent): void => {
if (!dragging || event.pointerId !== dragging.id) return;
const { index, moved } = dragging;
dragging = null;
canvas.style.cursor = "";
if (index < 0) events.overlay?.release();
else if (moved) 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);
});
canvas.addEventListener("dblclick", (event) => {
if (meta && scene.editable) events.overlay?.insert(...at(event), screenMap);
});
canvas.addEventListener("contextmenu", (event) => {
if (!meta || !scene.editable) return;
event.preventDefault();
events.overlay?.remove(...at(event), screenMap);
});
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,
screenOf: (point) => (meta ? toScreen(point) : null),
dispose: () => observer.disconnect(),
};
}