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),
};
}