feat(D 예정노선): 고름 층 노드 → 선 → 구역 → 전체 · 판 하나 재활용 · 우클릭 메뉴 · 지점 ↔ 노드 붙음 (PLAN 598-1~3 · 요구 463)

- 새 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>
This commit is contained in:
2026-10-30 14:47:39 +09:00
co-authored by Claude Opus 5.5
parent ad8506b186
commit ff49f2c8a2
11 changed files with 1252 additions and 427 deletions
+268 -258
View File
@@ -1,57 +1,60 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Edit.ts
* D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 요구서 440 3 · 6 · 7 · 445 A4 · A5) — 쉴 때(긋기 · 재기 아님)만.
* D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 598 · 요구 463 9 · 12 · 13·16 · 15) — 쉴 때(긋기 · 재기 아님)만.
*
* 고름 = 구간(조각) 묶음 + 넓이(구간 · 선 · 같은 해) — 선 누름 = 그 구간 · 이미 고른 곳 다시 누름 = 넓이 한 단 위
* (구간 → 선 → 같은 해 → 구간) · Shift+누름 = 구간 더함 · 뺌 · 빈 곳 누름 · Esc = 풂.
* 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 보이는 화면 가로 방향) — 오른쪽으로 = 창(실선 · 다 든 구간만) ·
* 왼쪽으로 = 걸침(점선 · 걸친 구간까지) · Shift = 지금 고름에 더함.
* 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
* 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 ·
* Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움.
* 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 ·
* 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침).
* 잠금 = 지점에 놓인 점(지점 핀을 끌면 같이 감 · 오른쪽 누름 = 지도 메뉴 591-2) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함).
* 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름).
* 고름 층 = 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체(`UI_Select`) — 노드 누름 = 노드 · 선 위 누름 = 토막 ·
* 고른 것 다시 누름 = 한 단 넓힘(전체 다음 = 처음) · Shift+누름 = 토막 더함 · 뺌 · 빈 곳 누름 · Esc = 풂.
* 「같은 해」 = 왼쪽 해 줄로만(`selectYear`).
* 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 토막) — 오른쪽으로 = 창(실선 · 다 든 토막만) ·
* 왼쪽으로 = 걸침(점선 · 걸친 토막까지) · Shift = 지금 고름에 더함.
* 노드 끌기 = 옮김(붙은 갈래 첫 점 같이) · 지점 자리 노드 = 지점도 같이 · 지점 12 px 안에 놓으면 그 자리로 붙음 ·
* 고른 토막 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 옮김 ·
* 선 밖으로 끌어냄 = 지움(합침).
* 지움 = Delete · 속성 판 · 왼쪽 목록 · 우클릭 메뉴가 같은 함수 — 노드 · 토막(가운데 = 구역 둘) · 구역 · 전체 · 마크.
* 우클릭 = 메뉴(`UI_Menu` — 이 겹은 오른쪽 누름을 안 받음).
* 연도 구역 마커(585-3) = 「연도 마커」를 선 위로 끌어 놓음 · 메뉴 「여기서 연도 구역 나눔」(`splitAt`) = 그 자리 구간을 둘로.
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 = 화면 기억(저장 안 함).
* 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 동작을 부름).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { themeColor } from "@ui/ui_template_palette";
import { keyIs } from "../A00_Common/key_registry";
import {
distanceAlong,
lineLength,
pointAtDistance,
sliceLine,
} from "./logic/D03_PlanAnalysis_Draw";
import { distanceAlong, pointAtDistance } from "./logic/D03_PlanAnalysis_Draw";
import {
insertVertex,
moveMark,
moveNodesAt,
moveVertex,
piecesInBox,
removeLine,
removeMark,
removeVertex,
reverseLine,
samePoint,
splitPiece,
branchFromEnd,
type PlanNetwork,
type Vertex,
type ViewBox,
} from "./logic/D03_PlanAnalysis_Plan";
import {
movePointsAt,
nodeSegment,
removeAll,
removeSegments,
segmentsInBox,
type PlanNode,
type PlanSegment,
} from "./logic/D03_PlanAnalysis_Select";
import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select";
export type { PlanScope, PlanSelection } from "./D03_PlanAnalysis_UI_Select";
/** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */
const DRAG_PX = 4;
/** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */
const MARK_OFF_PX = 3 * SNAP_PX;
export type PlanScope = "piece" | "line" | "year";
const NEXT_SCOPE: Record<PlanScope, PlanScope> = { piece: "line", line: "year", year: "piece" };
export interface PlanEditParams {
network: () => PlanNetwork;
/** 지점(긋기에 붙는 점 · 591-2). */
@@ -59,6 +62,9 @@ export interface PlanEditParams {
commit: (next: PlanNetwork) => void;
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
show: (next: PlanNetwork) => void;
/** 지점 자리 노드를 끎 — 지점 · 노선 한 걸음 · 화면만. */
commitPoints: (points: Vertex[], next: PlanNetwork) => void;
showPoints: (points: Vertex[], next: PlanNetwork) => void;
/** 고르기 받는 때(쉴 때 · 재기 아님). */
enabled: () => boolean;
/** 고치기 됨(산림청). */
@@ -67,151 +73,123 @@ export interface PlanEditParams {
onSelect: () => void;
}
/** 고름 — 구간 id 묶음 · 넓이 · 넓힘 씨(넓이를 바꿀 때 기준 구간) · 고른 마크(그 마크가 여는 구간 id). */
export interface PlanSelection {
pieces: string[];
scope: PlanScope;
seed: string | null;
/** 누른 자리 — 마크(구간 id) · 노드 · 토막(선 위 · 노드면 넓힐 토막). */
export interface PlanHit {
mark: string | null;
node: PlanNode | null;
segment: PlanSegment | null;
}
export interface PlanEdit {
layer: PlanMapLayer;
selected: () => PlanSelection | null;
/** 구간 id 묶음 고름(빈 묶음 = 풂) · 넓이 = 구간. */
/** 밝힐 꺾은선(고른 토막 · 구역 · 전체 · 구간). */
paths: () => Vertex[][];
/** 구간 id 묶음 고름(빈 묶음 = 풂) · 마크 고름. */
select: (pieces: string[], mark?: string | null) => void;
/** 씨 구간 기준 넓이 바꿈(구간 · 선 · 같은 해). */
/** 구간 고름 넓이 바꿈(구간 · 선 · 같은 해 — 왼쪽 목록). */
setScope: (scope: PlanScope) => void;
/** 고른 구간이 든 선 하나 — 방향 바꾸기 · 지움(선 둘 이상 · 없음 = 까닭 글). */
flipLine: () => void;
dropLine: () => void;
selectNode: (node: PlanNode) => void;
selectRegion: (line: string) => void;
selectYear: (year: number) => void;
selectAll: () => void;
/** 고른 것 지움(Delete · 판 [지움]). */
dropSelected: () => void;
dropNode: (node: PlanNode) => void;
dropMark: () => void;
/** 갈래 시점 지정 — 다른 선에 닿은 끝을 시점으로 그 선의 갈래(585-3). */
branchLine: () => void;
/** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */
lineIndex: () => number;
dropRegion: (line: string) => void;
/** 전체 지움 — 선 · 구역 모두(지점 남김) · 되돌리기 한 걸음. */
dropAll: () => void;
/** 이 자리 선 위에 연도 구역 마커 — 놓음 = 참. */
splitAt: (px: number, py: number, map: ScreenMap) => boolean;
/** 이 자리에 무엇이 있나(우클릭 메뉴). */
hit: (px: number, py: number, map: ScreenMap) => PlanHit;
/** 키 — 받았으면 참(Delete · Esc). */
key: (event: KeyboardEvent) => boolean;
/** 되돌리기 · 저장 뒤 — 사라진 구간은 뺌 · 다 사라지면 풂. */
/** 되돌리기 · 저장 뒤 — 사라진 것은 뺌 · 다 사라지면 풂. */
sync: () => void;
/** 검증 창구 — 지금 끄는 상자(보이는 px · 방향). */
box: () => (ViewBox & { crossing: boolean }) | null;
}
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 }
/** 고른 구간 다시 누름 — 놓으면 넓힘 · 끌면(고칠 수 있을 때) 꺾임점 더해 옮김. */
| {
kind: "grow";
piece: string;
kind: "vertex";
line: number;
vertex: number;
/** 끌기 앞 자리 · 같이 가는 지점(같은 자리)이 있나. */
from: Vertex;
follow: boolean;
before: PlanNetwork;
points: Vertex[];
last: [Vertex[], PlanNetwork] | null;
}
| { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null }
/** 노드 · 선 누름 — 놓으면(고른 것이었으면) 넓힘 · 끌면 노드 옮김 · 고른 토막이면 꺾임점 더해 옮김. */
| {
kind: "press";
x: number;
y: number;
hit: { line: number; segment: number; point: Vertex } | null;
node: PlanNode | null;
on: { line: number; segment: number; point: Vertex } | null;
grow: boolean;
}
/** 빈 곳 끌기 — 보이는 px 시작 · 지금. */
| { kind: "box"; box: ViewBox; shift: boolean };
export function createPlanEdit(params: PlanEditParams): PlanEdit {
let selected: PlanSelection | null = null;
const sel = createPlanSelect(params.network, params.onSelect);
let drag: Drag | null = null;
/** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */
let markDrop: { point: Vertex; ok: boolean; text: string } | null = null;
const pieceIds = (network: PlanNetwork): Set<string> =>
new Set(network.pieces.map((piece) => piece.id));
const lineIndexOf = (network: PlanNetwork, id: string): number =>
network.lines.findIndex((line) => line.id === id);
/** 씨 구간 → 넓이만큼 구간 묶음. */
function expand(seed: string, scope: PlanScope): string[] {
const network = params.network();
const piece = network.pieces.find((p) => p.id === seed);
if (!piece) return [];
if (scope === "piece") return [seed];
return network.pieces
.filter((p) => (scope === "line" ? p.line === piece.line : p.year === piece.year))
.map((p) => p.id);
}
function choose(next: PlanSelection | null): void {
selected = next && next.pieces.length ? next : null;
params.onSelect();
}
function select(pieces: string[], mark: string | null = null): void {
choose({ pieces: [...new Set(pieces)], scope: "piece", seed: pieces[0] ?? null, mark });
}
function setScope(scope: PlanScope): void {
const seed = selected?.seed ?? selected?.pieces[0];
if (!seed) return;
choose({ pieces: expand(seed, scope), scope, seed, mark: null });
}
function lineIndex(): number {
const network = params.network();
if (!selected) return -1;
const lines = new Set(
selected.pieces.map((id) => network.pieces.find((p) => p.id === id)?.line),
);
if (lines.size !== 1) return -1;
const [id] = lines;
return network.lines.findIndex((line) => line.id === id);
}
/** 고른 구간의 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */
function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void {
const network = params.network();
const index = lineIndex();
/** 꼴 바꿈 하나 — 까닭 글이면 알림. */
function apply(next: PlanNetwork | string): void {
if (!params.editable()) return;
if (index < 0) return void showToast("선 하나의 구간만 골랐을 때 됩니다.", "info");
const next = step(network, index);
if (typeof next === "string") return void showToast(next, "info");
params.commit(next);
sync();
sel.sync();
}
function dropMark(): void {
const network = params.network();
const piece = network.pieces.find((p) => p.id === selected?.mark);
if (!piece || !params.editable()) return;
params.commit(removeMark(network, piece.line, piece.from_m, 1e-3));
sync();
const piece = network.pieces.find((p) => p.id === sel.get()?.mark);
if (piece) apply(removeMark(network, piece.line, piece.from_m, 1e-3));
}
/** Delete — 마크 · 선 통째 고름 = 그 선. */
function dropSelected(): void {
if (selected?.mark) return dropMark();
function dropNode(node: PlanNode): void {
const network = params.network();
const index = lineIndex();
const whole =
index >= 0 &&
network.pieces
.filter((p) => p.line === network.lines[index].id)
.every((p) => selected!.pieces.includes(p.id));
if (!whole)
return void showToast("선 넓이로 고르면 지웁니다(다시 누름 · 속성 판 [선]).", "info");
apply((net, i) => removeLine(net, i));
const index = lineIndexOf(network, node.line);
if (index >= 0) apply(removeVertex(network, index, node.vertex));
}
/** 사라진 구간 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onSelect 를 부르면 돌고 돎). */
function sync(): void {
if (!selected) return;
const ids = pieceIds(params.network());
const pieces = selected.pieces.filter((id) => ids.has(id));
selected = pieces.length
? {
...selected,
pieces,
seed: selected.seed && ids.has(selected.seed) ? selected.seed : pieces[0],
mark: selected.mark && ids.has(selected.mark) ? selected.mark : null,
}
: null;
function dropRegion(line: string): void {
const network = params.network();
const index = lineIndexOf(network, line);
if (index >= 0) apply(removeLine(network, index));
}
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;
function dropAll(): void {
const network = params.network();
if (!network.lines.length) return;
apply(removeAll(network));
showToast("노선을 모두 지웠습니다 — 되돌리기(Ctrl+Z) · [저장] 때 정본.", "info");
}
/** Delete · 판 [지움] — 고름 꼴마다. */
function dropSelected(): void {
const picked = sel.get();
if (!picked) return;
if (picked.kind === "mark") return dropMark();
if (picked.kind === "node") return dropNode(picked.node!);
if (picked.kind === "segment") return apply(removeSegments(params.network(), picked.segments));
if (picked.kind === "line") return dropRegion(picked.line!);
if (picked.kind === "all") return dropAll();
showToast("연도 구간은 지우지 않습니다 — 노드 · 선 · 구역으로 고르세요.", "info");
}
/** 화면 px 둘레 마크(조각 시작) — 조각 차례. */
function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null {
@@ -245,12 +223,33 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
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))
? "지점 자리 점은 지점 핀을 끌어 옮깁니다."
: null;
/** 지점 12 px 안이면 그 자리(노드가 붙음) — `skip` 자리 지점(같이 가는 것) 빼고. */
function pointNear(px: number, py: number, map: ScreenMap, skip: Vertex): Vertex | null {
const near = params
.base()
.filter((p) => !samePoint(p, skip))
.map((p) => ({ p, gap: screenGap(map, p, px, py) }))
.filter((item) => item.gap <= SNAP_PX)
.sort((a, b) => a.gap - b.gap)[0];
return near ? near.p : null;
}
function hit(px: number, py: number, map: ScreenMap): PlanHit {
const network = params.network();
const mark = markAt(network, px, py, map);
if (mark !== null) return { mark: network.pieces[mark].id, node: null, segment: null };
const vertex = vertexAt(network, px, py, map);
if (vertex) {
const node = { line: network.lines[vertex.line].id, vertex: vertex.vertex };
return { mark: null, node, segment: nodeSegment(network, node) };
}
const on = lineAt(network, px, py, map);
if (!on) return { mark: null, node: null, segment: null };
return {
mark: null,
node: null,
segment: { line: network.lines[on.line].id, index: on.segment },
};
}
/** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */
@@ -260,46 +259,56 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
map: ScreenMap,
): { point: Vertex; next: PlanNetwork | null; piece: string | null } {
const network = params.network();
const hit = lineAt(network, px, py, map);
if (!hit) return { point: map.toMetric(px, py), next: null, piece: null };
const next = splitPiece(network, network.lines[hit.line].id, hit.atM);
const on = lineAt(network, px, py, map);
if (!on) return { point: map.toMetric(px, py), next: null, piece: null };
const next = splitPiece(network, network.lines[on.line].id, on.atM);
const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id));
return { point: hit.point, next: added ? next : null, piece: added?.id ?? null };
return { point: on.point, next: added ? next : null, piece: added?.id ?? null };
}
/** 구간 누름 — Shift = 더함 · 뺌 · 이미 고른 곳 = 넓힘(놓을 때) · 아니면 그 구간. */
function pressPiece(
piece: string,
shift: boolean,
x: number,
y: number,
hit: { line: number; segment: number; point: Vertex } | null,
): void {
if (shift && selected) {
const has = selected.pieces.includes(piece);
const pieces = has
? selected.pieces.filter((id) => id !== piece)
: [...selected.pieces, piece];
return choose({ pieces, scope: "piece", seed: has ? pieces[0] : piece, mark: null });
function splitAt(px: number, py: number, map: ScreenMap): boolean {
if (!params.enabled() || !params.editable()) return false;
const place = markPlace(px, py, map);
if (!place.next || !place.piece) {
showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info");
return false;
}
if (selected?.pieces.includes(piece) && !selected.mark) {
drag = { kind: "grow", piece, x, y, hit };
return;
}
select([piece]);
params.commit(place.next);
sel.pieces([place.piece], place.piece);
return true;
}
/** 고른 선 꼭짓점 손잡이 그릴 선(고른 구간이 든 선). */
const pickedLines = (network: PlanNetwork): Set<string> =>
new Set(
(selected?.pieces ?? []).map((id) => network.pieces.find((p) => p.id === id)?.line ?? ""),
);
/** 노드 끌기 시작 — 지점 자리면 지점도 같이. */
function startVertex(line: number, vertex: number): Drag {
const before = params.network();
const from = before.lines[line].vertices[vertex];
const points = params.base();
const follow = points.some((p) => samePoint(p, from));
return { kind: "vertex", line, vertex, from, follow, before, points, last: null };
}
/** 고른 노드 · 토막 · 구역이 든 선(꺾임점 손잡이). */
function pickedLines(network: PlanNetwork): Set<string> {
const picked = sel.get();
if (!picked) return new Set();
if (picked.kind === "all") return new Set(network.lines.map((line) => line.id));
const ids = picked.segments.map((seg) => seg.line);
for (const id of picked.pieces) ids.push(network.pieces.find((p) => p.id === id)?.line ?? "");
if (picked.line) ids.push(picked.line);
return new Set(ids);
}
const layer: PlanMapLayer = {
draw(context, map) {
const network = params.network();
const colors = planColors();
const path = (points: Vertex[]): void => {
const picked = sel.get();
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = colors.active;
context.globalAlpha = 0.8;
context.lineWidth = 8;
for (const points of sel.paths()) {
context.beginPath();
points.forEach((point, i) => {
const [sx, sy] = map.toScreen(point);
@@ -307,19 +316,9 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
else context.moveTo(sx, sy);
});
context.stroke();
};
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = colors.active;
context.globalAlpha = 0.8;
context.lineWidth = 8;
for (const id of selected?.pieces ?? []) {
const piece = network.pieces.find((p) => p.id === id);
const vertices = network.lines.find((line) => line.id === piece?.line)?.vertices;
if (piece && vertices) path(sliceLine(vertices, piece.from_m, piece.to_m));
}
context.globalAlpha = 1;
// 꺾임점 손잡이(고른 구간이 든 선)
// 꺾임점 손잡이(고른 것이 든 선)
const lines = pickedLines(network);
context.lineWidth = 2;
context.fillStyle = "#ffffff";
@@ -331,11 +330,16 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
context.strokeRect(sx - 4, sy - 4, 8, 8);
}
}
// 고른 마크 테
const mark = network.pieces.find((p) => p.id === selected?.mark);
// 고른 노드 · 마크 테
const node = picked?.kind === "node" ? picked.node : null;
const nodePoint = node
? network.lines.find((line) => line.id === node.line)?.vertices[node.vertex]
: null;
const mark = network.pieces.find((p) => p.id === picked?.mark);
const markLine = network.lines.find((line) => line.id === mark?.line)?.vertices;
if (mark && markLine) {
const [sx, sy] = map.toScreen(pointAtDistance(markLine, mark.from_m));
const ring = nodePoint ?? (mark && markLine ? pointAtDistance(markLine, mark.from_m) : null);
if (ring) {
const [sx, sy] = map.toScreen(ring);
context.beginPath();
context.arc(sx, sy, 12, 0, Math.PI * 2);
context.lineWidth = 3;
@@ -378,48 +382,28 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
}
},
press(px, py, map, button, shift) {
if (!params.enabled()) return false;
if (!params.enabled() || button !== 0) return false; // 우클릭 = 메뉴(UI_Menu)
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 || lockOf(network, vertex.line, vertex.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.id], piece.id);
if (editable) drag = { kind: "mark", index: mark, before: network, last: null };
sel.pieces([piece.id], piece.id);
if (params.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));
const piece = pieceAt(network, id, atM);
if (piece && !selected?.pieces.includes(piece)) pressPiece(piece, shift, px, py, null);
const lock = lockOf(network, vertex.line, vertex.vertex);
if (editable && !shift)
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 piece = pieceAt(network, network.lines[hit.line].id, hit.atM);
if (piece) pressPiece(piece, shift, px, py, hit);
const at = hit(px, py, map);
if (at.segment) {
if (shift) return (sel.segments([at.segment], true), true);
const grow = sel.holds({
node: at.node ?? undefined,
segment: at.node ? undefined : at.segment,
});
if (!grow) {
if (at.node) sel.node(at.node);
else sel.segments([at.segment]);
}
const on = at.node ? null : lineAt(network, px, py, map);
drag = { kind: "press", x: px, y: py, node: at.node, on, grow };
return true;
}
const [vx, vy] = map.rerotate(px, py);
@@ -432,31 +416,34 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
[drag.box.x1, drag.box.y1] = map.rerotate(px, py);
return;
}
if (drag.kind === "grow") {
if (drag.kind === "press") {
if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) return;
const hit = drag.hit;
if (!params.editable() || !hit) return void (drag = null);
params.commit(insertVertex(params.network(), hit.line, hit.segment, hit.point));
const before = params.network();
drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null };
}
if (drag.kind === "locked") {
if (!drag.told) showToast(drag.note, "info");
drag.told = true;
return;
const { node, on, grow } = drag;
const network = params.network();
if (!params.editable()) return void (drag = null);
if (node) drag = startVertex(lineIndexOf(network, node.line), node.vertex);
else if (grow && on) {
params.commit(insertVertex(network, on.line, on.segment, on.point));
drag = startVertex(on.line, on.segment + 1);
} else return void (drag = null);
}
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;
const atM = distanceAlong(vertices, cursor);
const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX;
drag.last = off
? removeMark(drag.before, piece.line, piece.from_m, 1e-3)
: moveMark(drag.before, drag.index, atM);
const to = pointNear(px, py, map, drag.from) ?? cursor;
drag.last = drag.follow
? [movePointsAt(drag.points, drag.from, to), moveNodesAt(drag.before, drag.from, to)]
: [drag.points, moveVertex(drag.before, drag.line, drag.vertex, to)];
params.showPoints(...drag.last);
return;
}
if (drag.kind !== "mark") return;
const piece = drag.before.pieces[drag.index];
const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices;
const atM = distanceAlong(vertices, cursor);
const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX;
drag.last = off
? removeMark(drag.before, piece.line, piece.from_m, 1e-3)
: moveMark(drag.before, drag.index, atM);
params.show(drag.last);
},
dragOver(px, py, map) {
@@ -476,11 +463,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
drop(px, py, map) {
markDrop = null;
if (!params.enabled() || !params.editable()) return false;
const place = markPlace(px, py, map);
if (!place.next || !place.piece)
return (void showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"), true);
params.commit(place.next);
select([place.piece], place.piece);
splitAt(px, py, map);
return true;
},
leave() {
@@ -489,56 +472,83 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
release(map) {
const done = drag;
drag = null;
if (!done || done.kind === "locked") return;
if (done.kind === "grow") {
if (selected) {
const scope = NEXT_SCOPE[selected.scope];
choose({ pieces: expand(done.piece, scope), scope, seed: done.piece, mark: null });
}
if (!done) return;
if (done.kind === "press") {
if (done.grow) sel.grow();
return;
}
if (done.kind === "box") {
const { box, shift } = done;
if (Math.hypot(box.x1 - box.x0, box.y1 - box.y0) <= DRAG_PX) {
if (!shift) choose(null);
if (!shift) sel.clear();
return;
}
const network = params.network();
const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point));
const ids = piecesInBox(network, toView, box, box.x1 < box.x0);
select(shift && selected ? [...selected.pieces, ...ids] : ids);
const found = segmentsInBox(network, toView, box, box.x1 < box.x0);
const now = sel.get();
sel.segments(shift && now?.kind === "segment" ? [...now.segments, ...found] : found);
return;
}
if (!done.last) return;
params.show(done.before);
params.commit(done.last);
sync();
if (done.kind === "vertex") {
params.showPoints(done.points, done.before);
params.commitPoints(...done.last);
} else {
params.show(done.before);
params.commit(done.last);
}
sel.sync();
},
};
/** 왼쪽 목록 넓이 — 씨 구간(처음 고른 구간) 기준. */
function setScope(scope: PlanScope): void {
const network = params.network();
const seed = network.pieces.find((p) => p.id === sel.get()?.pieces[0]);
if (!seed) return;
if (scope === "line") return sel.region(seed.line);
if (scope === "piece") return sel.pieces([seed.id]);
const same = network.pieces.filter((p) => p.year === seed.year).map((p) => p.id);
sel.pieces([seed.id, ...same], null, "year");
}
return {
layer,
selected: () => selected,
select,
selected: sel.get,
paths: sel.paths,
select: (pieces, mark = null) => sel.pieces(pieces, mark),
setScope,
flipLine: () => apply((network, index) => reverseLine(network, index)),
dropLine: () => apply((network, index) => removeLine(network, index)),
selectNode: sel.node,
selectRegion: sel.region,
selectYear(year) {
const ids = params
.network()
.pieces.filter((p) => p.year === year)
.map((p) => p.id);
sel.pieces(ids, null, "year");
},
selectAll: sel.all,
dropSelected,
dropNode,
dropMark,
branchLine: () => apply((network, index) => branchFromEnd(network, index)),
lineIndex,
dropRegion,
dropAll,
splitAt,
hit,
key(event) {
if (!selected || !params.enabled()) return false;
if (!sel.get() || !params.enabled()) return false;
if (keyIs(event, "plan.delete") && params.editable()) {
dropSelected();
return true;
}
if (event.key === "Escape") {
choose(null);
sel.clear();
return true;
}
return false;
},
sync,
sync: sel.sync,
box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null),
};
}
@@ -0,0 +1,107 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Menu.ts
* D03 지도 우클릭 메뉴(PLAN 591-2 · 598-2 · 요구 463 16 · 22) — 부품 상자 `createMapContextMenu` 하나 · 누른 자리마다 항목.
* 긋는 중 = 긋기 끝 · 핀 = 그 지점에서 긋기 · 지우기 · 마커 = 마커 지움 ·
* 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 위 = 갈래 시작 · 여기서 연도 구역 나눔 · 다시 긋기(599) ·
* 빈 땅 = 여기서 긋기 시작 · 핀 · 노드 밖 = 지점 놓기(노드 둘레면 그 노드에 붙음).
* 우클릭 즉시 지움 없음 — 지움은 메뉴 · 고르고 Delete. B05 우클릭(`bindNodeSplice`)과 코드 안 나눔.
* 겹 = 맨 끝(맨 위) · 오른쪽 누름만 받음.
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points";
export interface PlanMenuParams {
network: () => PlanNetwork;
points: () => Vertex[];
marks: PlanPoints;
edit: PlanEdit;
draw: PlanDraw;
editable: () => boolean;
/** [다시 긋기](599 · 없으면 항목 없음) — 구역 id · 앞 노드 · 뒤 노드. */
respan?: (line: string, from: number, to: number) => boolean;
}
export interface PlanMenu {
layer: PlanMapLayer;
/** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */
mount: (viewport: HTMLElement) => void;
}
export function createPlanMenu(params: PlanMenuParams): PlanMenu {
const { marks, edit, draw } = params;
const menu = createMapContextMenu("d03-plan");
let menuAt: [number, number] = [0, 0];
const layer: PlanMapLayer = {
draw: () => undefined,
press(px, py, map, button) {
if (button !== 2 || !params.editable()) return false;
const items: MapContextMenuItem[] = [];
const pin = marks.pinAt(px, py, map);
const at = pin === null ? edit.hit(px, py, map) : null;
const start = (x: number, y: number): void => {
const label = {
tip: "이어 긋기",
point: "지점에서 긋기 시작",
line: "갈래 시작",
empty: "여기서 긋기 시작",
}[draw.aim(x, y, map)];
items.push([label, () => draw.startAt(x, y, map)]);
};
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([`${marks.name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]);
items.push([`${marks.name(pin)} 지우기`, () => marks.remove(pin)]);
} else if (at?.mark) {
const mark = at.mark;
items.push([
"마커 지움",
() => {
edit.select([mark], mark);
edit.dropMark();
},
]);
} else if (at?.node) {
const node = at.node;
const point = params.network().lines.find((l) => l.id === node.line)!.vertices[node.vertex];
const [sx, sy] = map.toScreen(point);
start(sx, sy);
items.push("-", ["노드 지움", () => edit.dropNode(node)]);
if (!marks.full() && !params.points().some((p) => samePoint(p, point)))
items.push(["지점으로", () => marks.place(sx, sy, map)]);
} else if (at?.segment) {
const seg = at.segment;
start(px, py);
items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]);
if (params.respan)
items.push(["다시 긋기", () => params.respan!(seg.line, seg.index, seg.index + 1)]);
} else start(px, py);
if (pin === null && !at?.node && !at?.mark && !marks.full())
items.push("-", ["지점 놓기", () => marks.place(px, py, map)]);
menu.open(menuAt[0], menuAt[1], items);
return true;
},
};
return {
layer,
mount(viewport) {
viewport.append(menu.element);
viewport.addEventListener(
"contextmenu",
(event) => {
const rect = viewport.getBoundingClientRect();
menuAt = [event.clientX - rect.left, event.clientY - rect.top];
},
true,
);
},
};
}
+21 -3
View File
@@ -55,6 +55,7 @@ import {
type PlanMapLayer,
} from "./D03_PlanAnalysis_UI_Map";
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu";
import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
@@ -139,6 +140,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network = normalizePieces(next, yearStart);
map.redraw();
},
commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }),
showPoints: (nextPoints, next) => {
[points, network] = [nextPoints, next];
map.redraw();
},
enabled: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
onSelect: () => refresh(),
@@ -149,6 +155,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
editable: () => editable,
yearStart: () => yearStart,
commit,
heightAt: () => z,
epsg: () => epsg,
points: () => points,
addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }),
});
const pieces = createPlanPieces({ network: () => network, edit });
const measure = createPlanMeasure(() => z);
@@ -157,7 +167,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network: () => network,
names: () => pointNames,
epsg: () => epsg,
draw,
commit: (nextPoints, nextNames, next) =>
step({ network: next, points: nextPoints, pointNames: nextNames }),
center: () => viewMid,
@@ -168,6 +177,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
idle: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
});
const menu = createPlanMenu({
network: () => network,
points: () => points,
marks,
edit,
draw,
editable: () => editable,
});
// 지도 보기 가운데 상자 — 그릴 때마다 보이는 화면 가운데 반을 작업 좌표로(바뀌면 잠시 뒤 범위 봄)
const viewWatch: PlanMapLayer = {
draw(context, screen) {
@@ -307,7 +324,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
refresh();
}
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림(`marks.pins`) · 우클릭 메뉴(`menu.layer`)는 맨 끝(맨 위). */
const layerList = (): PlanMapLayer[] => [
...backdrop.layers,
draw.layer,
@@ -316,6 +333,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
...(measuring ? [measure.layer] : []),
viewWatch,
marks.pins,
menu.layer,
];
/** 점들이 받은 범위 안인가. */
@@ -553,7 +571,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
};
window.addEventListener("keydown", onKey);
map.setLayers(layerList());
marks.mount(map.root);
menu.mount(map.root);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
+32 -56
View File
@@ -2,21 +2,23 @@
* D03_PlanAnalysis_UI_Points.ts
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 ·
* 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝.
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 ·
* 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) ·
* 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림).
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위).
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
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 { createText } from "@ui/ui_template_text";
import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
moveNodesAt,
samePoint,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
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";
@@ -31,7 +33,6 @@ export interface PlanPointsParams {
names: () => string[];
network: () => PlanNetwork;
epsg: () => number;
draw: PlanDraw;
/** 한 걸음 — 지점 · 이름 · 노선 함께. */
commit: (points: Vertex[], names: string[], network: PlanNetwork) => void;
/** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */
@@ -49,8 +50,12 @@ export interface PlanPoints {
input: PlanMapLayer;
pins: PlanMapLayer;
render: () => void;
/** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */
mount: (viewport: HTMLElement) => 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 {
@@ -60,8 +65,6 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
hint.classList.add("d03-plan__hint");
const panel = document.createElement("div");
panel.append(list, hint);
const menu = createMapContextMenu("d03-plan");
let menuAt: [number, number] = [0, 0];
let drag: {
index: number;
points: Vertex[];
@@ -95,47 +98,26 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
);
}
/** 지점 놓기 — 꺾임점 둘레(SNAP_PX)면 그 점 자리(노드에 붙음). */
function place(px: number, py: number, map: ScreenMap): void {
if (params.points().length >= MAX_POINTS) return;
/** 붙음(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];
const at = node?.point ?? map.toMetric(px, py);
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());
}
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);
pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false);
const input: PlanMapLayer = {
draw: () => undefined,
@@ -148,8 +130,8 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
},
hover(px, py, map) {
if (!drag) return;
const to = map.toMetric(px, py);
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);
@@ -199,16 +181,10 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
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,
);
},
pinAt,
place,
remove,
name,
full: () => params.points().length >= MAX_POINTS,
};
}
+204 -85
View File
@@ -1,30 +1,43 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Props.ts
* D03 고른 구간 속성 판 · 좌측 「구역 · 연도」 목록(PLAN 585-2 · 요구서 445 A4) — 오토캐드 특성 창 꼴.
* D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈.
*
* 속성 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 —
* 머리 「구간 N · 선 M · L m」 · ✕ = 풂 · 넓이 [구간][선][같은 해] · 연도(모두 같으면 그 해 · 다르면 「여러 해」 →
* 고르면 고른 구간 모두 그 해 = 되돌리기 한 걸음) · 고른 구간 줄(누름 = 그 구간만) ·
* [방향 바꾸기] [갈래 시점 지정] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만.
* 측점 방향(585-3 · 요구 446 3) = 사용자가 정함 — 본선 = [방향 바꾸기] · 다른 선에 닿은 끝 = [갈래 시점 지정]
* (그 점이 시점 · 꼬리에 닿은 머리 = 한 선으로 이음) · 자동 판정 없음.
* 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂.
* 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김).
* 선(토막) — 측점 · 길이 · 지반 기울기 · 든 연도 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘).
* 구역 — 길이 · 노드 수 · 연도(구역 모든 구간) · 구간 줄 · [구역 지움].
* 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨).
* 연도 구간(왼쪽 해 · 구간 줄) — 연도(모두 같으면 그 해 · 다르면 「여러 해」) · 구간 줄 · 마커 — 앞뒤 해 · [마커 지움].
* 연도 고름 = 고른 구간 모두 그 해 = 되돌리기 한 걸음 · 보기만 계정 = 값만.
* 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임).
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { createSelectField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createKv, type KvRow } from "@ui/ui_template_kv";
import { stationLabel } from "../common_util/common_util_station";
import { lineNames, setPiecesYear, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
import type { PlanEdit, PlanScope } from "./D03_PlanAnalysis_UI_Edit";
import { groundSlopePct, lineLength, type HeightAt } from "./logic/D03_PlanAnalysis_Draw";
import {
lineNames,
samePoint,
setPiecesYear,
type PlanNetwork,
type PlanPiece,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { segmentPath, segmentPieces, segmentRange } from "./logic/D03_PlanAnalysis_Select";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import { selectionLength } from "./D03_PlanAnalysis_UI_Select";
/** 조각 연도 고를 수 — 사업연도 시작부터. */
const YEAR_SPAN = 10;
const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1);
const meters = (m: number): string => `${m.toFixed(1)} m`;
const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`);
export interface PlanPropsParams {
network: () => PlanNetwork;
@@ -32,6 +45,13 @@ export interface PlanPropsParams {
editable: () => boolean;
yearStart: () => number;
commit: (next: PlanNetwork) => void;
heightAt: () => HeightAt;
epsg: () => number;
/** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */
points: () => Vertex[];
addPoint: (point: Vertex) => void;
/** [다시 긋기](599 · 없으면 단추 없음) — 구역 id · 앞 노드 · 뒤 노드. */
respan?: (line: string, from: number, to: number) => boolean;
}
export interface PlanProps {
@@ -46,7 +66,7 @@ export interface PlanProps {
export function createPlanProps(params: PlanPropsParams): PlanProps {
const { edit } = params;
const float = createFloatPanel({
title: "고른 구간",
title: "고름",
draggable: true,
onClose: () => edit.select([]),
});
@@ -61,104 +81,203 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
}
function renderPanel(): void {
const picked = edit.selected();
float.root.hidden = !picked;
if (!picked) return float.body.replaceChildren();
const network = params.network();
const names = lineNames(network.lines);
const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id));
const lineIds = new Set(pieces.map((piece) => piece.line));
const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0);
const title = float.root.querySelector(".ui-float-panel__title");
if (title) title.textContent = `구간 ${pieces.length} · 선 ${lineIds.size} · ${meters(total)}`;
const editable = params.editable();
const scope = createSegment({
options: [
{ value: "piece", label: "구간" },
{ value: "line", label: "선" },
{ value: "year", label: "같은 해" },
],
value: picked.scope,
size: 24,
ariaLabel: "고름 넓이",
onChange: (value) => edit.setScope(value as PlanScope),
});
scope.root.dataset.role = "props-scope";
const button = (label: string, role: string, onClick: () => void): HTMLButtonElement => {
const b = createButton({ label, variant: "ghost", size: 24, onClick });
b.dataset.role = role;
return b;
};
/** 연도 고름 — 구간 모두 그 해(되돌리기 한 걸음). */
function yearField(pieces: PlanPiece[]): HTMLElement {
const years = new Set(pieces.map((piece) => piece.year));
const options = yearOptions();
const ids = pieces.map((piece) => piece.id);
const year = createSelectField({
label: "연도",
options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""),
size: 24,
ariaLabel: "고른 구간 연도",
disabled: !editable,
disabled: !params.editable(),
onChange: (value) =>
value && params.commit(setPiecesYear(params.network(), picked.pieces, Number(value))),
value && params.commit(setPiecesYear(params.network(), ids, Number(value))),
});
year.select.dataset.role = "props-year";
return year.root;
}
/** 구간 줄 — 누름 = 그 구간만. */
function pieceRows(network: PlanNetwork, pieces: PlanPiece[]): HTMLElement {
const names = lineNames(network.lines);
const rows = document.createElement("div");
rows.className = "d03-plan__props-rows";
for (const piece of pieces) {
const index = network.lines.findIndex((line) => line.id === piece.line);
const row = createButton({
label:
`${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` +
const row = button(
`${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` +
`${meters(piece.to_m - piece.from_m)} · ${piece.year}년`,
variant: "ghost",
size: 24,
onClick: () => edit.select([piece.id]),
});
"props-piece",
() => edit.select([piece.id]),
);
row.dataset.piece = piece.id;
rows.append(row);
}
return rows;
}
const actions = document.createElement("div");
actions.className = "d03-plan__row";
const index = edit.lineIndex();
if (editable && index >= 0) {
const flip = createButton({
label: "방향 바꾸기",
variant: "ghost",
size: 24,
onClick: edit.flipLine,
});
flip.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다.";
flip.disabled = !!network.lines[index].branch;
flip.dataset.role = "props-flip";
const drop = createButton({
label: "선 지움",
variant: "ghost",
size: 24,
onClick: edit.dropLine,
});
drop.dataset.role = "props-drop";
const branch = createButton({
label: "갈래 시점 지정",
variant: "ghost",
size: 24,
onClick: edit.branchLine,
});
branch.title = "다른 선에 닿은 끝을 시점(No.0)으로 · 그 선의 갈래로 붙입니다.";
branch.hidden = !!network.lines[index].branch;
branch.dataset.role = "props-branch";
actions.append(flip, branch, drop);
/** 고름 꼴마다 — 머리 글 · 몸 · 단추. */
function body(
picked: PlanSelection,
network: PlanNetwork,
): { title: string; parts: HTMLElement[]; actions: HTMLButtonElement[] } {
const names = lineNames(network.lines);
const nameOf = (id: string | null): string =>
names[network.lines.findIndex((line) => line.id === id)] ?? "";
const z = params.heightAt();
const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id));
const length = selectionLength(edit.paths());
if (picked.kind === "node") {
const { line, vertex } = picked.node!;
const vertices = network.lines.find((l) => l.id === line)!.vertices;
const point = vertices[vertex];
const tm = tmParams(params.epsg());
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
const height = z(point[0], point[1]);
const rows: KvRow[] = [
{
label: "경위도",
value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null,
},
{ label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null },
{ label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) },
];
if (vertex > 0) {
const [a, b] = [vertices[vertex - 1], vertices[vertex]];
rows.push(
{ label: "앞 선 길이", value: meters(lineLength([a, b])) },
{ label: "앞 선 기울기", value: percent(groundSlopePct(a, b, z)) },
);
}
const toPoint = button("지점으로", "props-to-point", () =>
params.addPoint([point[0], point[1]]),
);
toPoint.hidden = params.points().some((p) => samePoint(p, point));
return {
title: `${nameOf(line)} · 노드 ${vertex + 1}`,
parts: [createKv(rows)],
actions: [
button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)),
toPoint,
],
};
}
if (editable && picked.mark) {
const dropMark = createButton({
label: "마크 지움",
variant: "ghost",
size: 24,
onClick: edit.dropMark,
});
dropMark.dataset.role = "props-drop-mark";
actions.append(dropMark);
if (picked.kind === "segment") {
const single = picked.segments.length === 1 ? picked.segments[0] : null;
const years = [
...new Set(
segmentPieces(network, picked.segments).map(
(id) => network.pieces.find((p) => p.id === id)?.year,
),
),
].sort();
const rows: KvRow[] = [{ label: "길이", value: meters(length) }];
if (single) {
const [from, to] = segmentRange(network, single)!;
const [a, b] = segmentPath(network, single)!;
rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` });
rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) });
}
rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") });
const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)];
if (single && params.respan)
actions.unshift(
button("다시 긋기", "props-respan", () =>
params.respan!(single.line, single.index, single.index + 1),
),
);
return {
title: single
? `${nameOf(single.line)} · 선 ${single.index + 1}`
: `선 ${picked.segments.length}개`,
parts: [createKv(rows)],
actions,
};
}
float.body.replaceChildren(scope.root, year.root, rows, actions);
if (picked.kind === "line") {
const vertices = network.lines.find((l) => l.id === picked.line)?.vertices ?? [];
return {
title: nameOf(picked.line),
parts: [
createKv([
{ label: "길이", value: meters(length) },
{ label: "노드", value: `${vertices.length}개` },
]),
yearField(pieces),
pieceRows(network, pieces),
],
actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))],
};
}
if (picked.kind === "all") {
const byYear = new Map<number, number>();
for (const p of network.pieces)
byYear.set(p.year, (byYear.get(p.year) ?? 0) + p.to_m - p.from_m);
return {
title: "전체",
parts: [
createKv([
{ label: "길이", value: meters(length) },
{ label: "구역", value: `${network.lines.length}개` },
...[...byYear]
.sort(([a], [b]) => a - b)
.map(([year, m]) => ({ label: `${year}년`, value: meters(m) })),
]),
],
actions: [button("전체 지움", "props-drop-all", edit.dropAll)],
};
}
// 연도 구간 · 마커
const lineIds = new Set(pieces.map((piece) => piece.line));
const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0);
const parts: HTMLElement[] = [yearField(pieces), pieceRows(network, pieces)];
const actions: HTMLButtonElement[] = [];
const mark = network.pieces.find((p) => p.id === picked.mark);
if (mark) {
const before = network.pieces.find(
(p) => p.line === mark.line && Math.abs(p.to_m - mark.from_m) < 1e-3,
);
parts.unshift(
createKv([
{ label: "측점", value: station(mark.from_m) },
{ label: "앞 · 뒤 해", value: `${before?.year ?? "-"}년 · ${mark.year}년` },
]),
);
actions.push(button("마커 지움", "props-drop-mark", edit.dropMark));
}
return {
title: `구간 ${pieces.length} · 구역 ${lineIds.size} · ${meters(total)}`,
parts,
actions,
};
}
function renderPanel(): void {
const picked = edit.selected();
float.root.hidden = !picked;
if (!picked) return float.body.replaceChildren();
const { title, parts, actions } = body(picked, params.network());
const head = float.root.querySelector(".ui-float-panel__title");
if (head) head.textContent = title;
float.root.dataset.kind = picked.kind;
const row = document.createElement("div");
row.className = "d03-plan__row";
if (params.editable()) row.append(...actions);
float.body.replaceChildren(...parts, row);
}
return {
@@ -0,0 +1,236 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Select.ts
* D03 고름 상태(PLAN 598-1 · 요구 463 9 · 13) — 노드 → 선(토막) → 구역 → 전체 · 연도 구간(왼쪽 해 줄) · 마커.
* 넓힘 = 같은 것을 다시 누름 — 씨(처음 누른 노드 · 토막)에서 한 단 위 · 전체 다음 = 씨로 돌아옴.
* 「같은 해」 = 왼쪽 목록 해 줄로만(`year`).
* 상태 = 화면 기억(저장 안 함) · 꼴이 바뀌면 `sync` 가 사라진 것을 뺌.
* ========================================================================== */
import { lineLength, sliceLine } from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
lineSegments,
nodeSegment,
sameSegment,
segmentPath,
segmentPieces,
type PlanNode,
type PlanSegment,
} from "./logic/D03_PlanAnalysis_Select";
export type PlanSelectKind = "node" | "segment" | "line" | "all" | "pieces" | "mark";
/** 왼쪽 목록 밝힘 꼴(구간 · 선 · 같은 해). */
export type PlanScope = "piece" | "line" | "year";
export interface PlanSelection {
kind: PlanSelectKind;
/** 노드 고름 · 노드 씨. */
node: PlanNode | null;
/** 토막 고름(Shift · 끌어 고르기 = 여럿). */
segments: PlanSegment[];
/** 구역 고름 · 씨가 든 구역. */
line: string | null;
/** 고름에 든 연도 구간 id(구간 · 마커 고름 = 그대로 · 그 밖 = 걸친 구간). */
pieces: string[];
/** 고른 마커(그 마커가 여는 구간 id). */
mark: string | null;
scope: PlanScope;
/** 넓힘 씨 — 처음 누른 노드 · 토막. */
seed: { node: PlanNode } | { segment: PlanSegment } | null;
}
const NEXT: Partial<Record<PlanSelectKind, PlanSelectKind>> = {
node: "segment",
segment: "line",
line: "all",
};
export interface PlanSelect {
get: () => PlanSelection | null;
clear: () => void;
node: (node: PlanNode) => void;
/** 토막 — `toggle` = 지금 토막 고름에 더함 · 뺌. */
segments: (segments: PlanSegment[], toggle?: boolean) => void;
region: (line: string) => void;
all: () => void;
/** 연도 구간 — 마커 고름 = 그 마커가 여는 구간. */
pieces: (ids: string[], mark?: string | null, scope?: PlanScope) => void;
/** 넓힘 한 단(씨 기준). */
grow: () => void;
/** 이 노드 · 토막이 지금 고름 안인가(다시 누름 = 넓힘). */
holds: (hit: { node?: PlanNode; segment?: PlanSegment }) => boolean;
/** 밝힐 꺾은선. */
paths: () => Vertex[][];
sync: () => void;
}
export function createPlanSelect(network: () => PlanNetwork, onChange: () => void): PlanSelect {
let selected: PlanSelection | null = null;
function piecesOf(next: Omit<PlanSelection, "pieces" | "scope">): string[] {
const net = network();
if (next.kind === "segment") return segmentPieces(net, next.segments);
if (next.kind === "line")
return net.pieces.filter((p) => p.line === next.line).map((p) => p.id);
if (next.kind === "all") return net.pieces.map((p) => p.id);
return [];
}
function choose(
next: Omit<PlanSelection, "pieces" | "scope"> & Partial<PlanSelection>,
quiet = false,
): void {
selected = {
...next,
pieces: next.pieces ?? piecesOf(next),
scope: next.scope ?? (next.kind === "line" ? "line" : "piece"),
};
if (!quiet) onChange();
}
const empty = { node: null, segments: [], line: null, mark: null, seed: null };
function node(at: PlanNode): void {
choose({ ...empty, kind: "node", node: at, line: at.line, seed: { node: at } });
}
function segments(list: PlanSegment[], toggle = false): void {
let next = list;
if (toggle && selected?.kind === "segment") {
const has = (s: PlanSegment): boolean => selected!.segments.some((t) => sameSegment(s, t));
next = [
...selected.segments.filter((s) => !list.some((t) => sameSegment(s, t))),
...list.filter((s) => !has(s)),
];
}
if (!next.length) return clear();
choose({
...empty,
kind: "segment",
segments: next,
line: next[0].line,
seed: { segment: next[0] },
});
}
function clear(): void {
selected = null;
onChange();
}
/** 씨 기준 한 단 — 노드 → 토막 → 구역 → 전체 → 씨. */
function grow(): void {
if (!selected?.seed) return;
const seed = selected.seed;
const next = NEXT[selected.kind];
const line = "node" in seed ? seed.node.line : seed.segment.line;
if (next === "segment") {
const seg = "node" in seed ? nodeSegment(network(), seed.node) : seed.segment;
if (seg) choose({ ...empty, kind: "segment", segments: [seg], line, seed });
} else if (next === "line") choose({ ...empty, kind: "line", line, seed });
else if (next === "all") choose({ ...empty, kind: "all", line, seed });
else if ("node" in seed) node(seed.node);
else choose({ ...empty, kind: "segment", segments: [seed.segment], line, seed });
}
function holds(hit: { node?: PlanNode; segment?: PlanSegment }): boolean {
const s = selected;
if (!s || s.kind === "pieces" || s.kind === "mark") return false;
if (s.kind === "all") return true;
const line = hit.node?.line ?? hit.segment?.line;
if (s.kind === "line") return line === s.line;
if (s.kind === "node")
return !!hit.node && hit.node.line === s.node!.line && hit.node.vertex === s.node!.vertex;
if (hit.segment) return s.segments.some((t) => sameSegment(t, hit.segment!));
return s.segments.some(
(t) =>
t.line === hit.node!.line &&
(t.index === hit.node!.vertex || t.index + 1 === hit.node!.vertex),
);
}
function paths(): Vertex[][] {
const s = selected;
const net = network();
if (!s || s.kind === "node") return [];
if (s.kind === "segment")
return s.segments
.map((seg) => segmentPath(net, seg))
.filter((p): p is [Vertex, Vertex] => !!p);
if (s.kind === "line") return net.lines.filter((l) => l.id === s.line).map((l) => l.vertices);
if (s.kind === "all") return net.lines.map((l) => l.vertices);
return s.pieces.flatMap((id) => {
const piece = net.pieces.find((p) => p.id === id);
const v = net.lines.find((l) => l.id === piece?.line)?.vertices;
return piece && v ? [sliceLine(v, piece.from_m, piece.to_m)] : [];
});
}
/** 사라진 것 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onChange 를 부르면 돌고 돎). */
function sync(): void {
const s = selected;
if (!s) return;
const net = network();
const vertexCount = (line: string | null): number =>
net.lines.find((l) => l.id === line)?.vertices.length ?? 0;
const okNode = (n: PlanNode): boolean => n.vertex < vertexCount(n.line);
const okSeg = (g: PlanSegment): boolean => g.index + 1 < vertexCount(g.line);
if (s.kind === "pieces" || s.kind === "mark") {
const ids = new Set(net.pieces.map((p) => p.id));
const pieces = s.pieces.filter((id) => ids.has(id));
selected = pieces.length
? { ...s, pieces, mark: s.mark && ids.has(s.mark) ? s.mark : null }
: null;
if (selected?.kind === "mark" && !selected.mark) selected = { ...selected, kind: "pieces" };
return;
}
const seed =
s.seed && ("node" in s.seed ? okNode(s.seed.node) : okSeg(s.seed.segment)) ? s.seed : null;
const segments = s.segments.filter(okSeg);
const alive = {
all: net.lines.length > 0,
line: vertexCount(s.line) > 0,
node: !!s.node && okNode(s.node),
segment: segments.length > 0,
}[s.kind];
if (alive) choose({ ...s, segments, seed, pieces: undefined }, true);
else selected = null;
}
return {
get: () => selected,
clear,
node,
segments,
region: (line) => choose({ ...empty, kind: "line", line, seed: lineSeed(line) }),
all: () => choose({ ...empty, kind: "all", seed: null }),
pieces(ids, mark = null, scope = "piece") {
const list = [...new Set(ids)];
if (!list.length) return clear();
const net = network();
choose({
...empty,
kind: mark ? "mark" : "pieces",
line: net.pieces.find((p) => p.id === list[0])?.line ?? null,
pieces: list,
mark,
scope,
});
},
grow,
holds,
paths,
sync,
};
/** 구역 씨 = 첫 토막(구역에서 넓힘 = 전체 → 첫 토막). */
function lineSeed(line: string): { segment: PlanSegment } | null {
const first = lineSegments(network(), line)[0];
return first ? { segment: first } : null;
}
}
/** 고름 길이(m) — 토막 · 구역 · 전체 · 구간. */
export function selectionLength(paths: Vertex[][]): number {
return paths.reduce((sum, path) => sum + lineLength(path), 0);
}
@@ -0,0 +1,241 @@
/* =============================================================================
* D03_PlanAnalysis_Select.ts
* D 예정노선 고름 층 셈(PLAN 598 · 요구 463 9 · 12 · 13·16 · 15) — 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체.
* 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음.
* 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감).
* 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기.
* DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`).
* ========================================================================== */
import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw";
import {
piecesInBox,
removeLine,
samePoint,
orderLines,
type PlanLine,
type PlanNetwork,
type PlanPiece,
type Vertex,
type ViewBox,
} from "./D03_PlanAnalysis_Plan";
/** 선(토막) — 구역 id · 토막 차례(꼭짓점 `index` → `index + 1`). */
export interface PlanSegment {
line: string;
index: number;
}
/** 노드 — 구역 id · 꼭짓점 차례. */
export interface PlanNode {
line: string;
vertex: number;
}
const EPS = 1e-6;
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
/** 꺾임점마다 선 위 거리. */
export function chainages(vertices: ReadonlyArray<Vertex>): number[] {
const out = [0];
for (let i = 1; i < vertices.length; i += 1)
out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i]));
return out;
}
export const sameSegment = (a: PlanSegment, b: PlanSegment): boolean =>
a.line === b.line && a.index === b.index;
/** 구역 토막 모두. */
export function lineSegments(network: PlanNetwork, line: string): PlanSegment[] {
const entry = network.lines.find((l) => l.id === line);
const count = Math.max(0, (entry?.vertices.length ?? 0) - 1);
return Array.from({ length: count }, (_, index) => ({ line, index }));
}
/** 노선망 토막 모두. */
export const allSegments = (network: PlanNetwork): PlanSegment[] =>
network.lines.flatMap((line) => lineSegments(network, line.id));
/** 토막 꼭짓점 둘(없으면 null). */
export function segmentPath(network: PlanNetwork, seg: PlanSegment): [Vertex, Vertex] | null {
const v = network.lines.find((l) => l.id === seg.line)?.vertices;
return v && seg.index >= 0 && seg.index + 1 < v.length ? [v[seg.index], v[seg.index + 1]] : null;
}
/** 토막 선 위 거리 [시작, 끝](m). */
export function segmentRange(network: PlanNetwork, seg: PlanSegment): [number, number] | null {
const v = network.lines.find((l) => l.id === seg.line)?.vertices;
if (!v || seg.index < 0 || seg.index + 1 >= v.length) return null;
const from = lineLength(v.slice(0, seg.index + 1));
return [from, from + segmentLength(v[seg.index], v[seg.index + 1])];
}
/** 선 위 거리 `atM` 이 든 토막 차례(꺾임점 위 = 뒤 토막 · 끝 = 마지막). */
export function segmentAt(vertices: ReadonlyArray<Vertex>, atM: number): number {
const c = chainages(vertices);
for (let i = 0; i + 1 < c.length; i += 1) if (atM < c[i + 1] - EPS) return i;
return Math.max(0, c.length - 2);
}
/** 노드에서 넓힐 토막 — 뒤 토막(끝 노드 = 앞 토막). */
export function nodeSegment(network: PlanNetwork, node: PlanNode): PlanSegment | null {
const n = network.lines.find((l) => l.id === node.line)?.vertices.length ?? 0;
if (n < 2) return null;
return { line: node.line, index: Math.min(node.vertex, n - 2) };
}
/** 토막에 걸친 조각 id(연도 구간). */
export function segmentPieces(network: PlanNetwork, segs: ReadonlyArray<PlanSegment>): string[] {
const out = new Set<string>();
for (const seg of segs) {
const range = segmentRange(network, seg);
if (!range) continue;
for (const piece of network.pieces)
if (piece.line === seg.line && piece.from_m < range[1] - EPS && piece.to_m > range[0] + EPS)
out.add(piece.id);
}
return [...out];
}
/** 끌어 고르기 — 토막마다 판정(창 = 두 끝 모두 안 · 걸침 = 상자와 만남 · `piecesInBox` 와 같은 셈). */
export function segmentsInBox(
network: PlanNetwork,
toView: (point: Vertex) => Vertex,
box: ViewBox,
crossing: boolean,
): PlanSegment[] {
const segs = allSegments(network);
const probe: PlanNetwork = {
lines: segs.map((seg, i) => ({
id: `s${i}`,
vertices: segmentPath(network, seg)!,
branch: null,
})),
pieces: segs.map((seg, i) => {
const [a, b] = segmentPath(network, seg)!;
return { id: String(i), line: `s${i}`, from_m: 0, to_m: segmentLength(a, b), year: 0 };
}),
};
return piecesInBox(probe, toView, box, crossing).map((i) => segs[Number(i)]);
}
/** 새 구역 id — `line-<n>`(있는 것과 안 겹침). */
function nextLineId(lines: ReadonlyArray<PlanLine>): string {
const used = new Set(lines.map((line) => line.id));
let n = lines.length;
while (used.has(`line-${n}`)) n += 1;
return `line-${n}`;
}
/** 새 조각 id — `p<n>`(있는 것 중 가장 큰 수 + 1). */
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0));
return `p${Math.max(0, ...used) + 1}`;
}
/** 꼭짓점 [a, b) 만 남긴 선 — 꼭짓점과 같은 길이 덧 칸(노드 기록 594-1)도 같이 자름 · 첫 기록 = 없음. */
function cutVertices(entry: PlanLine, a: number, b: number): PlanLine {
const out: PlanLine & { nodes?: unknown[] } = { ...entry, vertices: entry.vertices.slice(a, b) };
const nodes = (entry as { nodes?: unknown }).nodes;
if (Array.isArray(nodes) && nodes.length === entry.vertices.length) {
out.nodes = nodes.slice(a, b);
if (a > 0 && out.nodes.length) out.nodes[0] = null;
}
return out;
}
/** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) ·
* 사라지는 노드에 갈래가 붙었으면 못 지움(까닭 글) · 조각 id 는 앞쪽이 지님(걸친 조각은 뒤쪽 새 id). */
export function removeSegment(network: PlanNetwork, seg: PlanSegment): PlanNetwork | string {
const li = network.lines.findIndex((l) => l.id === seg.line);
const entry = network.lines[li];
const n = (entry?.vertices.length ?? 0) - 1;
if (!entry || seg.index < 0 || seg.index >= n) return "고른 선이 없습니다.";
if (n === 1) return removeLine(network, li);
const i = seg.index;
const keepFront = i > 0;
const keepBack = i < n - 1;
const children = network.lines.filter((l) => l.branch?.line === entry.id);
if (
children.some(
(l) => (!keepFront && l.branch!.vertex === 0) || (!keepBack && l.branch!.vertex === n),
)
)
return "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요.";
const backId = keepFront ? nextLineId(network.lines) : entry.id;
const c = chainages(entry.vertices);
const [cut0, cut1] = [c[i], c[i + 1]];
const front: PlanPiece[] = [];
const back: PlanPiece[] = [];
for (const piece of network.pieces.filter((p) => p.line === entry.id)) {
if (keepFront && piece.from_m < cut0 - EPS)
front.push({ ...piece, to_m: round3(Math.min(piece.to_m, cut0)) });
if (keepBack && piece.to_m > cut1 + EPS)
back.push({
...piece,
line: backId,
from_m: round3(Math.max(0, piece.from_m - cut1)),
to_m: round3(piece.to_m - cut1),
});
}
const others = network.pieces.filter((p) => p.line !== entry.id);
for (const piece of back)
if (front.some((p) => p.id === piece.id))
piece.id = nextPieceId([...others, ...front, ...back]);
const lines: PlanLine[] = [];
for (const line of network.lines) {
if (line === entry) {
if (keepFront) lines.push(cutVertices(entry, 0, i + 1));
if (keepBack) lines.push({ ...cutVertices(entry, i + 1, n + 1), id: backId, branch: null });
continue;
}
const b = line.branch;
if (b?.line === entry.id && b.vertex > i)
lines.push({ ...line, branch: { line: backId, vertex: b.vertex - (i + 1) } });
else lines.push(line);
}
return { ...network, lines: orderLines(lines), pieces: [...others, ...front, ...back] };
}
/** 토막 여럿 지움 — 구역마다 뒤 토막부터(앞 토막 차례가 안 바뀜) · 하나라도 못 지우면 까닭 글. */
export function removeSegments(
network: PlanNetwork,
segs: ReadonlyArray<PlanSegment>,
): PlanNetwork | string {
let next = network;
const order = [...segs].sort((a, b) =>
a.line === b.line ? b.index - a.index : a.line < b.line ? -1 : 1,
);
for (const seg of order) {
const out = removeSegment(next, seg);
if (typeof out === "string") return out;
next = out;
}
return next;
}
/** 전체 지움 — 선 · 조각 모두(지점은 부르는 쪽이 남김). */
export const removeAll = (network: PlanNetwork): PlanNetwork => ({
...network,
lines: [],
pieces: [],
});
/** `point` 자리 노드 모두(갈래 첫 점 = 붙은 선 점이 대신 · 빼고). */
export function nodesAt(network: PlanNetwork, point: Vertex): PlanNode[] {
const out: PlanNode[] = [];
for (const line of network.lines)
line.vertices.forEach((v, vertex) => {
if (!(vertex === 0 && line.branch) && samePoint(v, point))
out.push({ line: line.id, vertex });
});
return out;
}
/** `from` 자리 지점을 모두 `to` 로(지점 ↔ 노드 따라감). */
export function movePointsAt(points: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p));
}