- 속성 판 · 왼쪽 칸 세트 고름 = setChoices(있는 세트 + 해마다 새 세트) → assignSet(토막 · 구역 · 구간 고름 모두) · 옛 setPiecesYear · setLineYear 자리 갈음 - 왼쪽 해 줄 밑 세트 줄(길이 · 구간 수 · 덩이 수 = 마지막 서버 셈) · 같은 세트 고름 · 되돌리기 뒤 길이 글 옛 값 흠 고침(열쇠에 구간 꼴) - 지도 이름표 「2027-1」 · 속 빈 마름모 = 같은 세트 · 마커 이름 「구간 나눔」 - [잇기] = 선 끝 노드 속성 판 · 우클릭 → 다른 선 끝 누름 = joinEnds 한 걸음 · Esc 그만 - [저장] 뒤 derived.sets parts > 1 · branch = 알림 「2027-1 이 2 덩이 · 빈틈 35.0 m」(Group.setWarnings · setGapM) - D04 표 첫 칸 = 세트 · 선 · 덩이 · 갈래 + 확정 막힘 알림 띠 - 기능 표 set(B05 보기만) · join(B05 숨김) 줄 · 시험 638_group 빈틈 · 626_3 표 줄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.8 KiB
TypeScript
237 lines
8.8 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Select.ts
|
|
* D03 고름 상태(PLAN 598-1 · 요구 463 9 · 13) — 노드 → 선(토막) → 구역 → 전체 · 연도 구간(왼쪽 해 줄) · 마커.
|
|
* 넓힘 = 같은 것을 다시 누름 — 씨(처음 누른 노드 · 토막)에서 한 단 위 · 전체 다음 = 씨로 돌아옴.
|
|
* 「같은 세트」 = 왼쪽 목록 세트 줄로만(`set`) · 해 줄 = 그 해 모두(`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" | "set";
|
|
|
|
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);
|
|
}
|