Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts
T
eomsangdonandClaude Opus 5.5 f5645c256d feat(D02·D03): 예정노선 지점 하나 · 차례 색 · 노선분석 핀 · 카카오 지도 마우스 설정 따름 (PLAN 585-1 · 요구 445 A1~A3)
- 기준지정 시점 · 종점 목록 → 「지점 목록」 하나 · [+ 지점] · 지점마다 차례 색 여섯 돌림(줄 왼 띠 · 지도 이름표 바탕)
- 저장 꼴 plan_base_json {epsg, points} · 옛 starts + ends · start · end 는 읽을 때 이어 붙임(옮김 없음) · 상한 40
- 노선분석 지점 = 핀 + 「지점 N」 · 기준지정과 같은 색 · 겹 목록 맨 끝(늘 맨 위) · 지점 누름 = 새 본선(No.0 = 그 지점) · 선 놓인 지점 = 갈래
- 공용 카카오 지도 = 휠 설정 방향 한 단(마우스 자리 고정) · 가운데 끌기 옮김 — 기준지정 · 지형 범위 창 · B08 이정표 같이
- 시험: 529-3 · 530-2 새 꼴 · 585-1 새 지킴 시험 · 낡은 B08 가짜 카카오(바탕 종류 · 사건 받기 없음) 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:40:49 +09:00

321 lines
11 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Edit.ts
* D03 고르기 · 고치기 겹(PLAN 580 · 요구서 440 3 · 6 · 7) — 쉴 때(긋기 · 연도 구분 · 재기 아님)만.
*
* 선 누름 = 그 선 · 조각 고름(굵게 · 좌측 「구역 · 연도」 그 줄 밝힘) · 빈 곳 = 고름 풂.
* 고른 선 위 누름 = 꺾임점 더함(그대로 끌면 옮김) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
* 마크 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · Delete = 고른 선 지움.
* 잠금 = 지점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음.
* 좌측 「고른 선」 칸 = 이름 · 길이 · [방향 바꾸기](측점 No.0 쪽 바꿈) · [선 지움].
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import {
distanceAlong,
lineLength,
pointAtDistance,
sliceLine,
} from "./logic/D03_PlanAnalysis_Draw";
import {
insertVertex,
lineNames,
moveMark,
moveVertex,
removeLine,
removeMark,
removeVertex,
reverseLine,
samePoint,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { lineAt, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
export interface PlanEditParams {
network: () => PlanNetwork;
base: () => Vertex[];
commit: (next: PlanNetwork) => void;
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
show: (next: PlanNetwork) => void;
/** 고르기 받는 때(쉴 때 · 재기 아님). */
enabled: () => boolean;
/** 고치기 됨(산림청). */
editable: () => boolean;
/** 고름이 바뀜 — 페이지가 좌측 줄 밝힘. */
onSelect: () => void;
}
export interface PlanSelection {
line: string;
piece: string | null;
}
export interface PlanEdit {
layer: PlanMapLayer;
/** 좌측 「고른 선」 칸. */
panel: HTMLElement;
selected: () => PlanSelection | null;
select: (line: string | null, piece?: string | null) => void;
/** 키 — 받았으면 참(Delete · Esc). */
key: (event: KeyboardEvent) => boolean;
/** 되돌리기 · 저장 뒤 — 고른 선이 사라졌으면 풂 · 칸 다시. */
sync: () => void;
}
type Drag =
| { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null }
| { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null }
| { kind: "locked"; note: string; told: boolean };
export function createPlanEdit(params: PlanEditParams): PlanEdit {
let selected: PlanSelection | null = null;
let drag: Drag | null = null;
const info = createText("", "muted");
const flipButton = createButton({
label: "방향 바꾸기",
variant: "ghost",
size: 24,
onClick: () => apply((network, index) => reverseLine(network, index)),
});
flipButton.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다.";
const dropButton = createButton({
label: "선 지움",
variant: "ghost",
size: 24,
onClick: () => apply((network, index) => removeLine(network, index)),
});
const row = document.createElement("div");
row.className = "d03-plan__row";
row.append(flipButton, dropButton);
const panel = document.createElement("div");
panel.className = "d03-plan__pick";
panel.dataset.role = "plan-pick";
panel.append(info, row);
const indexOf = (network: PlanNetwork): number =>
selected ? network.lines.findIndex((line) => line.id === selected!.line) : -1;
/** 고른 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */
function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void {
const network = params.network();
const index = indexOf(network);
if (index < 0 || !params.editable()) return;
const next = step(network, index);
if (typeof next === "string") return void showToast(next, "info");
params.commit(next);
sync();
}
function panelState(): void {
const network = params.network();
const index = indexOf(network);
panel.hidden = index < 0;
if (index < 0) return;
const line = network.lines[index];
info.textContent = `고른 선 — ${lineNames(network.lines)[index]} · ${lineLength(line.vertices).toFixed(1)} m`;
flipButton.hidden = dropButton.hidden = !params.editable();
flipButton.disabled = !!line.branch;
}
function select(line: string | null, piece: string | null = null): void {
selected = line ? { line, piece } : null;
panelState();
params.onSelect();
}
function sync(): void {
if (selected && indexOf(params.network()) < 0) selected = null;
if (selected?.piece && !params.network().pieces.some((p) => p.id === selected!.piece))
selected = { ...selected, piece: null };
panelState();
}
const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null =>
network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6)
?.id ?? null;
/** 화면 px 둘레 마크(조각 시작) — 조각 차례. */
function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null {
let best: number | null = null;
let bestPx = SNAP_PX;
network.pieces.forEach((piece, index) => {
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
if (!vertices || !(piece.from_m > 0)) return;
const gap = screenGap(map, pointAtDistance(vertices, piece.from_m), px, py);
if (gap <= bestPx) [best, bestPx] = [index, gap];
});
return best;
}
/** 화면 px 둘레 꺾임점 — 갈래 첫 점은 붙은 선 점이 대신 집힘. */
function vertexAt(
network: PlanNetwork,
px: number,
py: number,
map: ScreenMap,
): { line: number; vertex: number } | null {
let best: { line: number; vertex: number } | null = null;
let bestPx = SNAP_PX;
network.lines.forEach((line, i) =>
line.vertices.forEach((point, v) => {
if (v === 0 && line.branch) return;
const gap = screenGap(map, point, px, py);
if (gap <= bestPx) [best, bestPx] = [{ line: i, vertex: v }, gap];
}),
);
return best;
}
/** 잠금 까닭 — 지점에 놓인 점. */
function lockOf(network: PlanNetwork, line: number, vertex: number): string | null {
const point = network.lines[line].vertices[vertex];
return params.base().some((p) => samePoint(p, point))
? "지점 자리 점은 1단계(기준 지정)에서 옮깁니다."
: null;
}
const layer: PlanMapLayer = {
draw(context, map) {
const network = params.network();
const index = indexOf(network);
if (index < 0) return;
const colors = planColors();
const line = network.lines[index];
const path = (points: Vertex[]): void => {
context.beginPath();
points.forEach((point, i) => {
const [sx, sy] = map.toScreen(point);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
};
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = colors.active;
context.globalAlpha = 0.45;
context.lineWidth = 9;
path(line.vertices);
const piece = network.pieces.find((p) => p.id === selected?.piece);
if (piece) {
context.globalAlpha = 0.85;
context.lineWidth = 6;
path(sliceLine(line.vertices, piece.from_m, piece.to_m));
}
context.globalAlpha = 1;
// 꺾임점 손잡이
context.lineWidth = 2;
context.fillStyle = "#ffffff";
for (const point of line.vertices) {
const [sx, sy] = map.toScreen(point);
context.fillRect(sx - 4, sy - 4, 8, 8);
context.strokeRect(sx - 4, sy - 4, 8, 8);
}
},
press(px, py, map, button) {
if (!params.enabled()) return false;
const network = params.network();
const editable = params.editable();
const mark = markAt(network, px, py, map);
const vertex = vertexAt(network, px, py, map);
if (button === 2) {
if (!editable) return false;
if (mark !== null) {
const piece = network.pieces[mark];
params.commit(removeMark(network, piece.line, piece.from_m, 1e-3));
sync();
return true;
}
if (!vertex) return false;
const next = removeVertex(network, vertex.line, vertex.vertex);
if (typeof next === "string") showToast(next, "info");
else params.commit(next);
sync();
return true;
}
if (mark !== null) {
const piece = network.pieces[mark];
select(piece.line, piece.id);
if (editable) drag = { kind: "mark", index: mark, before: network, last: null };
return true;
}
if (vertex) {
const id = network.lines[vertex.line].id;
const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1));
select(id, pieceAt(network, id, atM));
const lock = lockOf(network, vertex.line, vertex.vertex);
if (editable)
drag = lock
? { kind: "locked", note: lock, told: false }
: { kind: "vertex", ...vertex, before: network, last: null };
return true;
}
const hit = lineAt(network, px, py, map);
if (hit) {
const id = network.lines[hit.line].id;
if (editable && selected?.line === id) {
params.commit(insertVertex(network, hit.line, hit.segment, hit.point));
const before = params.network();
drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null };
}
select(id, pieceAt(params.network(), id, hit.atM));
return true;
}
if (!selected) return false;
select(null);
return true;
},
hover(px, py, map) {
if (!drag) return;
if (drag.kind === "locked") {
if (!drag.told) showToast(drag.note, "info");
drag.told = true;
return;
}
const cursor = map.toMetric(px, py);
if (drag.kind === "vertex") {
drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor);
} else {
const piece = drag.before.pieces[drag.index];
const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices;
drag.last = moveMark(drag.before, drag.index, distanceAlong(vertices, cursor));
}
params.show(drag.last);
},
release() {
const done = drag;
drag = null;
if (!done || done.kind === "locked" || !done.last) return;
params.show(done.before);
params.commit(done.last);
sync();
},
};
panelState();
return {
layer,
panel,
selected: () => selected,
select,
key(event) {
if (!selected || !params.enabled()) return false;
if (event.key === "Delete" && params.editable()) {
apply((network, index) => removeLine(network, index));
return true;
}
if (event.key === "Escape") {
select(null);
return true;
}
return false;
},
sync,
};
}