Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts
T
eomsangdonandClaude Opus 5.5 ff49f2c8a2 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>
2026-10-30 14:47:39 +09:00

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