- 새 logic/D03_PlanAnalysis_Select.ts — 토막 판정 · 끌어 고르기 토막 · 토막 지움(가운데 = 구역 둘 · 뒤쪽 새 구역) · 전체 지움 · 지점 ↔ 노드 같은 자리 - 새 UI_Select.ts — 고름 상태(다시 누름 = 한 단 넓힘 · 같은 해 = 왼쪽 해 줄로만) - UI_Edit.ts — 우클릭 즉시 지움 걷음 · 노드 끌면 지점 같이 · 지점 12 px 안 붙음 · 지움 창구(dropNode · dropRegion · dropAll · splitAt) - UI_Props.ts — 판 = 노드 · 선 · 구역 · 전체 · 구간 · 마커 몸 · [방향 바꾸기] · [갈래 시점 지정] 걷음 - 새 UI_Menu.ts — 우클릭 메뉴(Points 에서 옮김 · 노드 지움 · 지점으로 · 연도 구역 나눔) - UI_Points.ts — 핀 놓을 때 노드 12 px 안 붙음(nodeNear) - key_registry plan 줄 · 시험 585_2 갈래 시점 지정 줄 지움(요구 463-15 걷음) · 553_4 메뉴 자리 · 새 시험 598_1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
7.2 KiB
TypeScript
191 lines
7.2 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Points.ts
|
|
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
|
|
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
|
|
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) ·
|
|
* 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
|
|
* 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림).
|
|
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위).
|
|
* ========================================================================== */
|
|
|
|
import { createIconButton } from "@ui/ui_template_button";
|
|
import { createListRow } from "@ui/ui_template_list_row";
|
|
import { pointColor } from "@ui/ui_template_palette";
|
|
import { createText } from "@ui/ui_template_text";
|
|
import {
|
|
moveNodesAt,
|
|
samePoint,
|
|
type PlanNetwork,
|
|
type Vertex,
|
|
} from "./logic/D03_PlanAnalysis_Plan";
|
|
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
|
|
import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
|
|
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
|
|
|
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
|
|
const MAX_POINTS = 40;
|
|
/** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */
|
|
const PIN_HEAD_PX = 18;
|
|
|
|
export interface PlanPointsParams {
|
|
points: () => Vertex[];
|
|
/** 지점 이름(차례 = `points` · 빈 값 = 「지점 N」). */
|
|
names: () => string[];
|
|
network: () => PlanNetwork;
|
|
epsg: () => number;
|
|
/** 한 걸음 — 지점 · 이름 · 노선 함께. */
|
|
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;
|
|
/** 우클릭 메뉴가 빌림 — 핀 집기 · 지점 놓기(노드 둘레면 그 노드) · 지우기 · 이름 · 더 놓을 수 있나. */
|
|
pinAt: (px: number, py: number, map: ScreenMap) => number | null;
|
|
place: (px: number, py: number, map: ScreenMap) => void;
|
|
remove: (i: number) => void;
|
|
name: (i: number) => string;
|
|
full: () => boolean;
|
|
}
|
|
|
|
export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
|
const list = document.createElement("div");
|
|
list.className = "d03-plan__points";
|
|
const hint = createText("", "muted");
|
|
hint.classList.add("d03-plan__hint");
|
|
const panel = document.createElement("div");
|
|
panel.append(list, hint);
|
|
let drag: {
|
|
index: number;
|
|
points: Vertex[];
|
|
network: PlanNetwork;
|
|
last: [Vertex[], PlanNetwork] | null;
|
|
} | null = null;
|
|
|
|
const name = (i: number): string => `지점 ${i + 1}`;
|
|
|
|
/** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */
|
|
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 {
|
|
params.commit(
|
|
params.points().filter((_, k) => k !== i),
|
|
params.names().filter((_, k) => k !== i),
|
|
params.network(),
|
|
);
|
|
}
|
|
|
|
/** 붙음(598-3) — 화면 px 둘레(SNAP_PX) 가장 가까운 노드 자리 · `skip` 자리 노드(같이 가는 것) 빼고. */
|
|
function nodeNear(px: number, py: number, map: ScreenMap, skip?: Vertex): Vertex | null {
|
|
const node = params
|
|
.network()
|
|
.lines.flatMap((line) => line.vertices)
|
|
.filter((point) => !skip || !samePoint(point, skip))
|
|
.map((point) => ({ point, gap: screenGap(map, point, px, py) }))
|
|
.filter((item) => item.gap <= SNAP_PX)
|
|
.sort((a, b) => a.gap - b.gap)[0];
|
|
return node ? node.point : null;
|
|
}
|
|
|
|
/** 지점 놓기 — 노드 둘레면 그 노드 자리(노드에 붙음). */
|
|
function place(px: number, py: number, map: ScreenMap): void {
|
|
if (params.points().length >= MAX_POINTS) return;
|
|
const at = nodeNear(px, py, map) ?? map.toMetric(px, py);
|
|
params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network());
|
|
}
|
|
|
|
const pins = pointLayer(params.points);
|
|
|
|
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 from = drag.points[drag.index];
|
|
const to = nodeNear(px, py, map, from) ?? map.toMetric(px, py);
|
|
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 render(): void {
|
|
const tm = tmParams(params.epsg());
|
|
const editable = params.editable();
|
|
list.replaceChildren(
|
|
...params.points().map((point, i) => {
|
|
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
|
|
const drop = createIconButton({
|
|
icon: "trash",
|
|
title: `${name(i)} 지우기`,
|
|
size: 24,
|
|
onClick: () => remove(i),
|
|
});
|
|
drop.hidden = !editable;
|
|
return createListRow({
|
|
label: name(i),
|
|
sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined,
|
|
stripe: pointColor(i),
|
|
size: 28,
|
|
trailing: drop,
|
|
});
|
|
}),
|
|
);
|
|
hint.textContent = !editable
|
|
? params.points().length
|
|
? "보기만 — 지점은 산림청이 놓습니다."
|
|
: "지점 없음"
|
|
: params.points().length
|
|
? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함."
|
|
: "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작).";
|
|
}
|
|
|
|
return {
|
|
panel,
|
|
input,
|
|
pins,
|
|
render,
|
|
pinAt,
|
|
place,
|
|
remove,
|
|
name,
|
|
full: () => params.points().length >= MAX_POINTS,
|
|
};
|
|
}
|