Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts
T
eomsangdonandClaude Opus 5.5 50e9c1b015 feat(530-3): D03 예정노선 분석 화면 — 평면 · 지점 끌기 · 더 받기 · 갈래 카드 · 측점 · 지반 횡단 · 3D
- 평면 = 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점(누가 = 등록 시작부터) + 지점 끌기 겹
- 끈 지점 = 캐시 · 받기 · 분석 · 보기는 points 로 실어 보냄 · [저장] 때만 D02 규칙으로 정본
- 연도별 갈래 넷 카드(연장 · 최대 기울기 · 위반 수) · 고름 = 캐시 · [저장] = 서버 저장본으로 다시 셈
- 측점 누름 = 하 패널 지반선 횡단(sectionsGround · buildCrossSvg 지반만 인자 · 기본 그대로)
- 3D 모달 = 연도 DEM + 노선 + 측점 막대(코리도 · 구조물 안 부름)
- 화면 계산 logic 짝(pickMode · chainRows · joinedLine ↔ 파이썬) 거울 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:11:23 +09:00

367 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;
};
// 지점 끌기 — 지도 옮기기보다 먼저 듣고 지점을 집었으면 옮기기에 안 넘김
let dragging: { index: number; id: number } | 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();
dragging = { index, id: event.pointerId };
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;
}
events.onDrag(dragging.index, screenToMetric(meta, view, ...at(event)));
});
const endDrag = (event: PointerEvent): void => {
if (!dragging || event.pointerId !== dragging.id) return;
const index = dragging.index;
dragging = null;
canvas.style.cursor = "";
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(),
};
}