- 카드 고름 = 서버 이은 누가(저장 갈래) 위에 캐시 고름 - 지점 누름(손 떨림 안)은 끌기 아님 · 집은 자리 차 지킴 - 횡단 판 B05 RouteEdit 꾸밈 css · 끌기 끝나면 옛 횡단 비움 - 진행 요청 실패 = 알림 뒤 화면 되돌림(서버 일은 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Map.ts
|
|
* D03 메인 평면(PLAN 530-3) — 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점 눈금 · 이름 +
|
|
* 지점(연도 경계) + 지점 끌기 겹.
|
|
*
|
|
* 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `metricToScreen` · `screenToMetric` · 맞춤
|
|
* `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` ·
|
|
* 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선
|
|
* `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시).
|
|
* ========================================================================== */
|
|
|
|
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;
|
|
}
|
|
|
|
export interface PlanMapEvents {
|
|
onDrag: (index: number, point: Vertex) => void;
|
|
onDragEnd: (index: number) => void;
|
|
onStation: (chainage: number) => void;
|
|
}
|
|
|
|
export interface PlanMap {
|
|
root: HTMLElement;
|
|
update: (patch: Partial<PlanMapScene>) => void;
|
|
/** 지점 · 노선이 들어오게 다시 맞춤. */
|
|
fit: () => void;
|
|
/** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */
|
|
stationScreen: (chainage: number) => [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]);
|
|
|
|
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();
|
|
}
|
|
}
|
|
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 index = nodeAt(...at(event));
|
|
if (index < 0) return;
|
|
event.stopImmediatePropagation();
|
|
const [px, py] = at(event);
|
|
const [sx, sy] = 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);
|
|
events.onDrag(dragging.index, screenToMetric(meta, view, px + dragging.dx, py + dragging.dy));
|
|
});
|
|
const endDrag = (event: PointerEvent): void => {
|
|
if (!dragging || event.pointerId !== dragging.id) return;
|
|
const { index, moved } = dragging;
|
|
dragging = null;
|
|
canvas.style.cursor = "";
|
|
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);
|
|
});
|
|
|
|
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,
|
|
dispose: () => observer.disconnect(),
|
|
};
|
|
}
|