- 579-3: D02 시점 · 종점 목록 받음 · 점 그림 · 맞춤 · 등고선 범위 · 「범위 밖」 판정에 시점 · 종점 · 들어올 때 받은 범위가 못 덮으면 바로 받기(진행 막대 · 직전 실패면 안 함) - 선 이름 = 갈래 없는 선 「본선」(둘 이상 「본선 N」) · 갈래 「갈래 N」 — TS lineNames ↔ D04 line_names 짝 · 거울 시험 - 580-1: [긋기] 하나 — 시점 · 종점(끝낼 때 뒤집어 No.0 = 반대쪽) · 선 꼬리 · 선 머리 · 선 위(갈래)에서 시작 · 머리가 다른 선 꼬리에 닿으면 한 선 · 꺾임점이면 갈래 · 꺾임점 끌기 · 더하기 · 지우기(마크는 구간 비율) - 580-2: 미리보기 글 = 거리 + 가상 종단 기울기 % · ° · 기울기 고정 칸 옆 ° 환산 · 고른 선 [방향 바꾸기] - 580-3: 지도에서 선 · 조각 고름(좌측 줄 밝힘) · Delete · [선 지움] · 선 통째 연도 · 긋는 해(긋는 중 바꾸면 다음 점부터) · [연도 구분] 뒤 연도 고름 바로 · 마크 끌기 - 새 파일: D03_PlanAnalysis_UI_Edit.ts(고르기 · 고치기) · D03_PlanAnalysis_UI_DrawPaint.ts(그림 · 집기 — UI_Draw 700 줄 넘어 나눔) - UI_Map = 겹 꼴에 release · pointerup · 끌기 중 포인터 잡기 · [?] 안내 줄(그리기 본체 draw() 는 sub2 몫이라 손 안 댐) - 시험: 530-3 새 꼴 함수 · 서버 derive 대조 · 선 이름 거울 / 553-4 그림 자리 옮김 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
11 KiB
TypeScript
322 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: () => { starts: Vertex[]; ends: 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];
|
|
const { starts, ends } = params.base();
|
|
return [...starts, ...ends].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,
|
|
};
|
|
}
|