/* ============================================================================= * 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> = { 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): 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 & Partial, 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); }