348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Points.ts
|
|
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
|
|
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
|
|
* [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음).
|
|
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
|
|
* 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 ·
|
|
* 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝.
|
|
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 ·
|
|
* 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움).
|
|
* ========================================================================== */
|
|
|
|
import { createIconButton } from "@ui/ui_template_button";
|
|
import { createButton } from "@ui/ui_template_elements";
|
|
import { createInputField } from "@ui/ui_template_input";
|
|
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
|
import { createListRow } from "@ui/ui_template_list_row";
|
|
import { pointColor } from "@ui/ui_template_palette";
|
|
import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
|
import {
|
|
addPoint,
|
|
MAX_POINTS,
|
|
movePointTo,
|
|
parseLonLat,
|
|
pointLabel,
|
|
pointNameError,
|
|
renamePoint,
|
|
type PointsState,
|
|
} from "./logic/D03_PlanAnalysis_PointEdit";
|
|
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
|
|
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
|
import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
|
|
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
|
|
|
/** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */
|
|
const PIN_HEAD_PX = 18;
|
|
|
|
export interface PlanPointsParams {
|
|
points: () => Vertex[];
|
|
/** 지점 이름(차례 = `points` · 빈 값 = 「지점 N」). */
|
|
names: () => string[];
|
|
network: () => PlanNetwork;
|
|
epsg: () => number;
|
|
draw: PlanDraw;
|
|
/** 한 걸음 — 지점 · 이름 · 노선 함께. */
|
|
commit: (points: Vertex[], names: string[], network: PlanNetwork) => void;
|
|
/** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */
|
|
center: () => Vertex | null;
|
|
/** 끌기 중 — 화면만. */
|
|
show: (points: Vertex[], network: PlanNetwork) => void;
|
|
/** 쉴 때(긋기 · 재기 아님)만 핀을 끎. */
|
|
idle: () => boolean;
|
|
editable: () => boolean;
|
|
}
|
|
|
|
export interface PlanPoints {
|
|
/** 좌측 「지점」 칸 몸. */
|
|
panel: HTMLElement;
|
|
input: PlanMapLayer;
|
|
pins: PlanMapLayer;
|
|
render: () => void;
|
|
/** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */
|
|
mount: (viewport: HTMLElement) => void;
|
|
}
|
|
|
|
export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
|
const list = document.createElement("div");
|
|
list.className = "d03-plan__points";
|
|
const addButton = createButton({
|
|
label: "추가",
|
|
iconStart: "plus",
|
|
variant: "ghost",
|
|
size: 24,
|
|
onClick: addAtCenter,
|
|
});
|
|
addButton.dataset.role = "points-add";
|
|
const head = document.createElement("div");
|
|
head.className = "d03-plan__points-head";
|
|
head.append(addButton);
|
|
const panel = document.createElement("div");
|
|
panel.append(head, list);
|
|
let openIndex: number | null = null; // 펼친 줄(하나만)
|
|
let syncEditor: (() => void) | null = null;
|
|
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
|
|
const menu = createMapContextMenu("d03-plan");
|
|
let menuAt: [number, number] = [0, 0];
|
|
let drag: {
|
|
index: number;
|
|
points: Vertex[];
|
|
network: PlanNetwork;
|
|
last: [Vertex[], PlanNetwork] | null;
|
|
} | null = null;
|
|
|
|
const name = (i: number): string => pointLabel(params.names(), i);
|
|
const state = (): PointsState => ({
|
|
points: params.points(),
|
|
names: params.names(),
|
|
network: params.network(),
|
|
});
|
|
const commitState = (next: PointsState): void =>
|
|
params.commit(next.points, next.names, next.network);
|
|
|
|
/** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */
|
|
function pinAt(px: number, py: number, map: ScreenMap): number | null {
|
|
const [cx, cy] = map.rerotate(px, py);
|
|
let best: number | null = null;
|
|
let bestGap = SNAP_PX;
|
|
params.points().forEach((point, i) => {
|
|
const [sx, sy] = map.rerotate(...map.toScreen(point));
|
|
const gap = Math.min(
|
|
Math.hypot(cx - sx, cy - sy),
|
|
Math.hypot(cx - sx, cy - sy + PIN_HEAD_PX),
|
|
);
|
|
if (gap <= bestGap) [best, bestGap] = [i, gap];
|
|
});
|
|
return best;
|
|
}
|
|
|
|
function remove(i: number): void {
|
|
openIndex = null;
|
|
params.commit(
|
|
params.points().filter((_, k) => k !== i),
|
|
params.names().filter((_, k) => k !== i),
|
|
params.network(),
|
|
);
|
|
}
|
|
|
|
/** 지점 놓기 — 꺾임점 둘레(SNAP_PX)면 그 점 자리(노드에 붙음). */
|
|
function place(px: number, py: number, map: ScreenMap): void {
|
|
if (params.points().length >= MAX_POINTS) return;
|
|
const node = params
|
|
.network()
|
|
.lines.flatMap((line) => line.vertices)
|
|
.map((point) => ({ point, gap: screenGap(map, point, px, py) }))
|
|
.filter((item) => item.gap <= SNAP_PX)
|
|
.sort((a, b) => a.gap - b.gap)[0];
|
|
const at = node?.point ?? map.toMetric(px, py);
|
|
params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network());
|
|
}
|
|
|
|
function openMenu(px: number, py: number, map: ScreenMap): boolean {
|
|
if (!params.editable()) return false;
|
|
const draw = params.draw;
|
|
const pin = pinAt(px, py, map);
|
|
const items: MapContextMenuItem[] = [];
|
|
if (draw.mode() === "draw" && draw.stroke())
|
|
items.push(["긋기 끝", () => draw.setMode("idle")]);
|
|
else if (pin !== null) {
|
|
const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서
|
|
items.push([`${name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]);
|
|
items.push([`${name(pin)} 지우기`, () => remove(pin)]);
|
|
} else {
|
|
const label = {
|
|
tip: "이어 긋기",
|
|
point: "지점에서 긋기 시작",
|
|
line: "갈래 시작",
|
|
empty: "여기서 긋기 시작",
|
|
}[draw.aim(px, py, map)];
|
|
items.push([label, () => draw.startAt(px, py, map)]);
|
|
}
|
|
if (pin === null && params.points().length < MAX_POINTS)
|
|
items.push(["지점 놓기", () => place(px, py, map)]);
|
|
menu.open(menuAt[0], menuAt[1], items);
|
|
return true;
|
|
}
|
|
|
|
const pins = pointLayer(params.points, params.names);
|
|
pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false);
|
|
|
|
const input: PlanMapLayer = {
|
|
draw: () => undefined,
|
|
press(px, py, map, button) {
|
|
if (button !== 0 || !params.editable() || !params.idle()) return false;
|
|
const index = pinAt(px, py, map);
|
|
if (index === null) return false;
|
|
drag = { index, points: params.points(), network: params.network(), last: null };
|
|
return true;
|
|
},
|
|
hover(px, py, map) {
|
|
if (!drag) return;
|
|
const to = map.toMetric(px, py);
|
|
const from = drag.points[drag.index];
|
|
const points = drag.points.map((p, k): Vertex => (k === drag!.index ? to : p));
|
|
drag.last = [points, moveNodesAt(drag.network, from, to)];
|
|
params.show(...drag.last);
|
|
},
|
|
release() {
|
|
const done = drag;
|
|
drag = null;
|
|
if (!done?.last) return;
|
|
params.show(done.points, done.network);
|
|
params.commit(done.last[0], params.names(), done.last[1]);
|
|
},
|
|
};
|
|
|
|
/** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */
|
|
function addAtCenter(): void {
|
|
const at = params.center();
|
|
if (!at || !params.editable() || params.points().length >= MAX_POINTS) return;
|
|
openIndex = params.points().length;
|
|
commitState(addPoint(state(), at));
|
|
}
|
|
|
|
/** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */
|
|
function editorOf(i: number): { root: HTMLElement; sync: () => void } {
|
|
const tm = tmParams(params.epsg());
|
|
const editable = params.editable();
|
|
const field = (label: string, role: string, placeholder?: string) => {
|
|
const f = createInputField({ label, size: 24, placeholder });
|
|
f.input.dataset.role = role;
|
|
f.input.disabled = !editable || (role !== "point-name" && !tm);
|
|
return f;
|
|
};
|
|
const nameField = field("이름", "point-name", `지점 ${i + 1}`);
|
|
const lonField = field("경도", "point-lon");
|
|
const latField = field("위도", "point-lat");
|
|
const lonLatNow = (): [number, number] | null => {
|
|
const p = params.points()[i];
|
|
return tm && p ? tmToLonLat(tm, p[0], p[1]) : null;
|
|
};
|
|
const sync = (): void => {
|
|
const set = (f: ReturnType<typeof field>, text: string): void => {
|
|
if (document.activeElement !== f.input) f.input.value = text;
|
|
};
|
|
set(nameField, params.names()[i] ?? "");
|
|
const ll = lonLatNow();
|
|
set(lonField, ll ? ll[0].toFixed(6) : "");
|
|
set(latField, ll ? ll[1].toFixed(6) : "");
|
|
};
|
|
nameField.input.addEventListener("input", () =>
|
|
nameField.setError(pointNameError(nameField.input.value.trim()) ?? undefined),
|
|
);
|
|
nameField.input.addEventListener("change", () => {
|
|
const text = nameField.input.value.trim();
|
|
const error = pointNameError(text);
|
|
nameField.setError(error ?? undefined);
|
|
if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text));
|
|
});
|
|
const moved = (): void => {
|
|
const parsed = parseLonLat(lonField.input.value, latField.input.value);
|
|
const bad = typeof parsed === "string";
|
|
lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined);
|
|
latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined);
|
|
if (bad || !tm) return;
|
|
const now = lonLatNow();
|
|
if (
|
|
now &&
|
|
now[0].toFixed(6) === parsed[0].toFixed(6) &&
|
|
now[1].toFixed(6) === parsed[1].toFixed(6)
|
|
)
|
|
return;
|
|
commitState(movePointTo(state(), i, tm, parsed[0], parsed[1]));
|
|
};
|
|
lonField.input.addEventListener("change", moved);
|
|
latField.input.addEventListener("change", moved);
|
|
const root = document.createElement("div");
|
|
root.className = "d03-plan__point-edit";
|
|
root.append(nameField.root, lonField.root, latField.root);
|
|
sync();
|
|
return { root, sync };
|
|
}
|
|
|
|
function render(): void {
|
|
const tm = tmParams(params.epsg());
|
|
const editable = params.editable();
|
|
const points = params.points();
|
|
if (openIndex !== null && openIndex >= points.length) openIndex = null;
|
|
addButton.hidden = !editable;
|
|
addButton.disabled = points.length >= MAX_POINTS || !params.center();
|
|
const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`;
|
|
const lonLatOf = (i: number): string | undefined => {
|
|
const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null;
|
|
return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined;
|
|
};
|
|
if (key === shown) {
|
|
// 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움
|
|
points.forEach((_, i) => {
|
|
const row = list.children[i]?.querySelector(".ui-list-row");
|
|
const label = row?.querySelector(".ui-list-row__label");
|
|
const sub = row?.querySelector(".ui-list-row__sub");
|
|
if (label) label.textContent = name(i);
|
|
if (sub) sub.textContent = lonLatOf(i) ?? "";
|
|
});
|
|
syncEditor?.();
|
|
return;
|
|
}
|
|
shown = key;
|
|
syncEditor = null;
|
|
list.replaceChildren(
|
|
...points.map((_, i) => {
|
|
const drop = createIconButton({
|
|
icon: "trash",
|
|
title: `${name(i)} 지우기`,
|
|
size: 24,
|
|
onClick: () => remove(i),
|
|
});
|
|
drop.hidden = !editable;
|
|
const row = createListRow({
|
|
label: name(i),
|
|
sub: lonLatOf(i),
|
|
stripe: pointColor(i),
|
|
size: 28,
|
|
trailing: drop,
|
|
active: i === openIndex,
|
|
});
|
|
row.dataset.point = String(i);
|
|
row.tabIndex = 0;
|
|
const toggle = (): void => {
|
|
openIndex = openIndex === i ? null : i;
|
|
render();
|
|
};
|
|
row.addEventListener("click", (event) => {
|
|
if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle();
|
|
});
|
|
row.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" && event.target === row) toggle();
|
|
});
|
|
const item = document.createElement("div");
|
|
item.append(row);
|
|
if (i === openIndex) {
|
|
const editor = editorOf(i);
|
|
syncEditor = editor.sync;
|
|
item.append(editor.root);
|
|
}
|
|
return item;
|
|
}),
|
|
);
|
|
}
|
|
|
|
return {
|
|
panel,
|
|
input,
|
|
pins,
|
|
render,
|
|
mount(viewport) {
|
|
viewport.append(menu.element);
|
|
viewport.addEventListener(
|
|
"contextmenu",
|
|
(event) => {
|
|
const rect = viewport.getBoundingClientRect();
|
|
menuAt = [event.clientX - rect.left, event.clientY - rect.top];
|
|
},
|
|
true,
|
|
);
|
|
},
|
|
};
|
|
}
|