Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts
T
eomsangdonandClaude Opus 5.5 cdcdd5f597 feat(562-3 · 563-1): D03 손 긋기 화면 — 도구 여섯 · 그리기 · 거리재기 · 구역 연도 · 횡단 판 · 예상 코리도 걷음
- UI_Map: 바탕(위성 · 등고선) + 겹(그리기 · 거리재기) · 도구줄 부품(되돌리기 · 다시하기 · 3D · 좌 · 우 회전 · 거리재기) · B05 createMapRotation · 왼쪽 끌기 이동 끔(가운데 단추 · 휠 방향 = 설정)
- 새 UI_Draw: 시점에서 점 찍어 직선 · 미리보기 길이 · 기울기 · 제약 칸(자유 · 길이 고정 · 기울기 고정 · 둘 다 · 20 m · 9 %) · 종점 붙기 · 갈래 · 구역 마크 · Enter · Esc · Backspace
- 새 UI_Measure: 두 점 길이 · 기울기 · 높이 차(지형 heightAt)
- logic/Plan: 선 · 갈래 · 조각 꼴 다루기로 다시 씀(옛 갈래 넷 · 이은 누가 걷음)
- UI_Page · Api: 지형 범위 카카오 지도 미리보기 → [이 범위 받기] · [범위 밖 더 받기] · GET · PUT plan/network · [초기화] · 조각 연도 고름 · 캐시 판 2
- UI_3D: 지형 + 그은 선(선 고름) · 예상 코리도 · 체크 칸 걷음
- 지움: UI_Cross · UI_Edit · logic/Expect · logic/Edit · test_544_1(예상 횡단 · 예상 코리도 셈이 화면에서 걷혀 지킬 대상 없음)
- 시험: 530_3 = 손 긋기 꼴 다루기 + 서버 derive 대조로 다시 씀 · 553_4 D03 부품 단언 새 화면으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:16:08 +09:00

355 lines
12 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Map.ts
* D03 메인 평면(PLAN 530-3 · 562-3) — 위성 + 도엽 등고선 바탕 · 위에 겹(그리기 · 거리재기) · 도구줄 여섯
* (되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 거리재기).
*
* 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤
* `computeRouteView` · 등고선 `drawPreparedLayer` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` ·
* 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠).
* 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향).
* 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김.
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { themeColor } from "@ui/ui_template_palette";
import { createToolbar } from "@ui/ui_template_toolbar";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
computeMapRect,
computeRouteView,
createMetricProjector,
drawPreparedLabels,
drawPreparedLayer,
normalizedToScreen,
pickLevelStep,
routeLineColor,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
import { createMapRotation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Rotate";
import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
/** 측점 간격(m) — B 규칙 측점과 같음. */
export const PLAN_STATION_INTERVAL_M = 20;
/** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */
export function planColors(): { line: string; active: string; warn: string; halo: string } {
return {
line: routeLineColor(),
active: themeColor("--draw-primary", "#6d28d9"),
warn: themeColor("--draw-danger", "#dc2626"),
halo: "rgba(0, 0, 0, 0.65)",
};
}
/** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(m). */
const FAKE_MARGIN_M = 600;
export interface PlanMapScene {
meta: VWorldMeta | null;
image: HTMLImageElement | null;
contours: PreparedLayer | null;
/** 처음 맞출 점(시점 · 종점 · 그은 선). */
fit: Vertex[];
}
/** 겹이 쓰는 좌표 — 그림 px(돌리기 전) ↔ 작업 좌표계 m · 글자 세우기. */
export interface ScreenMap {
toScreen: (point: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
pxPerMeter: number;
/** 글자를 세울 각(라디안 · 안 돌렸으면 0). */
uprightRad: number;
/** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */
upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void;
}
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁 · 나감. */
export interface PlanMapLayer {
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean;
hover?: (px: number, py: number, map: ScreenMap) => void;
leave?: () => void;
}
export interface PlanMapEvents {
onUndo: () => void;
onRedo: () => void;
on3d: () => void;
onMeasure: () => void;
}
export interface PlanTools {
undo: boolean;
redo: boolean;
view3d: boolean;
/** 거리재기 켜짐(눌림). */
measure: boolean;
}
export interface PlanMap {
root: HTMLElement;
update: (patch: Partial<PlanMapScene>) => void;
setLayers: (layers: PlanMapLayer[]) => void;
setTools: (tools: Partial<PlanTools>) => void;
/** 다시 그림(겹 값이 바뀜). */
redraw: () => void;
/** 맞출 점이 들어오게 다시 맞춤. */
fit: () => void;
/** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */
screenOf: (point: Vertex) => [number, number] | null;
/** 검증 창구 — 지금 돌린 각(도). */
rotationDeg: () => number;
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");
const tool = (
act: string,
icon: Parameters<typeof createIconButton>[0]["icon"],
title: string,
onClick?: () => void,
): HTMLButtonElement => {
const button = createIconButton({ icon, title, onClick });
button.dataset.act = act;
return button;
};
const undoButton = tool("undo", "undo", "되돌리기(Ctrl+Z)", events.onUndo);
const redoButton = tool("redo", "redo", "다시하기(Ctrl+Y)", events.onRedo);
const viewButton = tool("view3d", "cube", "3D 보기", events.on3d);
const measureButton = tool("measure", "ruler", "거리재기", events.onMeasure);
measureButton.setAttribute("aria-pressed", "false");
const toolbar = createToolbar({
glass: true,
size: 32,
label: "평면 도구",
items: [
undoButton,
redoButton,
"divider",
viewButton,
tool("rotate-ccw", "rotateCcw", "왼쪽으로 돌림(15°)"),
tool("rotate-cw", "rotateCw", "오른쪽으로 돌림(15°)"),
"divider",
measureButton,
],
});
toolbar.classList.add("d03-plan__tools");
root.append(canvas, toolbar);
const context = canvas.getContext("2d")!;
let scene: PlanMapScene = { meta: null, image: null, contours: null, fit: [] };
let layers: PlanMapLayer[] = [];
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: toolbar,
size: () => view,
onChange: () => draw(),
});
const screenMap = (): ScreenMap => {
const projector = createMetricProjector(meta!, view);
return {
toScreen: (point) => projector.toScreen(point[0], point[1]),
toMetric: (px, py) => screenToMetric(meta!, view, px, py),
pxPerMeter: projector.pxPerMeter,
uprightRad: rotation.uprightRad(),
upright: (ctx, x, y, paint) => {
const angle = rotation.uprightRad();
if (!angle) return paint();
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.translate(-x, -y);
paint();
ctx.restore();
},
};
};
function refit(): void {
meta = scene.meta ?? fakeMeta(scene.fit);
if (!meta || !view.width || !scene.fit.length) return;
const xs = scene.fit.map(([x]) => x);
const ys = scene.fit.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 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);
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,
rotation.uprightRad(),
"rgba(0, 0, 0, 0.6)",
);
context.restore();
}
const map = screenMap();
for (const layer of layers) {
context.save();
layer.draw(context, map);
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();
}
/** 누른 화면 자리 → 그림 px(돌리기 전). */
const at = (event: PointerEvent | MouseEvent): [number, number] => {
const rect = canvas.getBoundingClientRect();
return rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
};
canvas.addEventListener("pointerdown", (event) => {
if (!meta || event.button !== 0) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.press?.(px, py, map, 0))) draw();
});
canvas.addEventListener("pointermove", (event) => {
if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중
const [px, py] = at(event);
const map = screenMap();
for (const layer of layers) layer.hover?.(px, py, map);
draw();
});
canvas.addEventListener("pointerleave", () => {
for (const layer of layers) layer.leave?.();
draw();
});
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
if (!meta) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.press?.(px, py, map, 2))) draw();
});
bindRouteEditNavigation({
canvas,
getView: () => view,
setView: (next) => (view = next),
getMeta: () => meta,
unrotateDelta: rotation.unrotateDelta,
rotateWheel: rotation.wheel,
draw,
});
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();
},
setLayers(next) {
layers = next;
draw();
},
setTools(tools) {
if (tools.undo !== undefined) undoButton.disabled = !tools.undo;
if (tools.redo !== undefined) redoButton.disabled = !tools.redo;
if (tools.view3d !== undefined) viewButton.disabled = !tools.view3d;
if (tools.measure !== undefined)
measureButton.setAttribute("aria-pressed", String(tools.measure));
},
redraw: draw,
fit() {
refit();
draw();
},
screenOf: (point) => {
if (!meta) return null;
const [x, y] = screenMap().toScreen(point);
return rotation.rerotate(x, y);
},
rotationDeg: () => (rotation.radians() * 180) / Math.PI,
dispose: () => observer.disconnect(),
};
}