- 새 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>
237 lines
8.7 KiB
TypeScript
237 lines
8.7 KiB
TypeScript
/* =============================================================================
|
|
* 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);
|
|
}
|