Files
Aislo/D03_PlanAnalysis/logic/D03_PlanAnalysis_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

242 lines
9.6 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_Select.ts
* D 예정노선 고름 층 셈(PLAN 598 · 요구 463 9 · 12 · 13·16 · 15) — 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체.
* 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음.
* 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감).
* 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기.
* DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`).
* ========================================================================== */
import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw";
import {
piecesInBox,
removeLine,
samePoint,
orderLines,
type PlanLine,
type PlanNetwork,
type PlanPiece,
type Vertex,
type ViewBox,
} from "./D03_PlanAnalysis_Plan";
/** 선(토막) — 구역 id · 토막 차례(꼭짓점 `index` → `index + 1`). */
export interface PlanSegment {
line: string;
index: number;
}
/** 노드 — 구역 id · 꼭짓점 차례. */
export interface PlanNode {
line: string;
vertex: number;
}
const EPS = 1e-6;
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
/** 꺾임점마다 선 위 거리. */
export function chainages(vertices: ReadonlyArray<Vertex>): number[] {
const out = [0];
for (let i = 1; i < vertices.length; i += 1)
out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i]));
return out;
}
export const sameSegment = (a: PlanSegment, b: PlanSegment): boolean =>
a.line === b.line && a.index === b.index;
/** 구역 토막 모두. */
export function lineSegments(network: PlanNetwork, line: string): PlanSegment[] {
const entry = network.lines.find((l) => l.id === line);
const count = Math.max(0, (entry?.vertices.length ?? 0) - 1);
return Array.from({ length: count }, (_, index) => ({ line, index }));
}
/** 노선망 토막 모두. */
export const allSegments = (network: PlanNetwork): PlanSegment[] =>
network.lines.flatMap((line) => lineSegments(network, line.id));
/** 토막 꼭짓점 둘(없으면 null). */
export function segmentPath(network: PlanNetwork, seg: PlanSegment): [Vertex, Vertex] | null {
const v = network.lines.find((l) => l.id === seg.line)?.vertices;
return v && seg.index >= 0 && seg.index + 1 < v.length ? [v[seg.index], v[seg.index + 1]] : null;
}
/** 토막 선 위 거리 [시작, 끝](m). */
export function segmentRange(network: PlanNetwork, seg: PlanSegment): [number, number] | null {
const v = network.lines.find((l) => l.id === seg.line)?.vertices;
if (!v || seg.index < 0 || seg.index + 1 >= v.length) return null;
const from = lineLength(v.slice(0, seg.index + 1));
return [from, from + segmentLength(v[seg.index], v[seg.index + 1])];
}
/** 선 위 거리 `atM` 이 든 토막 차례(꺾임점 위 = 뒤 토막 · 끝 = 마지막). */
export function segmentAt(vertices: ReadonlyArray<Vertex>, atM: number): number {
const c = chainages(vertices);
for (let i = 0; i + 1 < c.length; i += 1) if (atM < c[i + 1] - EPS) return i;
return Math.max(0, c.length - 2);
}
/** 노드에서 넓힐 토막 — 뒤 토막(끝 노드 = 앞 토막). */
export function nodeSegment(network: PlanNetwork, node: PlanNode): PlanSegment | null {
const n = network.lines.find((l) => l.id === node.line)?.vertices.length ?? 0;
if (n < 2) return null;
return { line: node.line, index: Math.min(node.vertex, n - 2) };
}
/** 토막에 걸친 조각 id(연도 구간). */
export function segmentPieces(network: PlanNetwork, segs: ReadonlyArray<PlanSegment>): string[] {
const out = new Set<string>();
for (const seg of segs) {
const range = segmentRange(network, seg);
if (!range) continue;
for (const piece of network.pieces)
if (piece.line === seg.line && piece.from_m < range[1] - EPS && piece.to_m > range[0] + EPS)
out.add(piece.id);
}
return [...out];
}
/** 끌어 고르기 — 토막마다 판정(창 = 두 끝 모두 안 · 걸침 = 상자와 만남 · `piecesInBox` 와 같은 셈). */
export function segmentsInBox(
network: PlanNetwork,
toView: (point: Vertex) => Vertex,
box: ViewBox,
crossing: boolean,
): PlanSegment[] {
const segs = allSegments(network);
const probe: PlanNetwork = {
lines: segs.map((seg, i) => ({
id: `s${i}`,
vertices: segmentPath(network, seg)!,
branch: null,
})),
pieces: segs.map((seg, i) => {
const [a, b] = segmentPath(network, seg)!;
return { id: String(i), line: `s${i}`, from_m: 0, to_m: segmentLength(a, b), year: 0 };
}),
};
return piecesInBox(probe, toView, box, crossing).map((i) => segs[Number(i)]);
}
/** 새 구역 id — `line-<n>`(있는 것과 안 겹침). */
function nextLineId(lines: ReadonlyArray<PlanLine>): string {
const used = new Set(lines.map((line) => line.id));
let n = lines.length;
while (used.has(`line-${n}`)) n += 1;
return `line-${n}`;
}
/** 새 조각 id — `p<n>`(있는 것 중 가장 큰 수 + 1). */
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0));
return `p${Math.max(0, ...used) + 1}`;
}
/** 꼭짓점 [a, b) 만 남긴 선 — 꼭짓점과 같은 길이 덧 칸(노드 기록 594-1)도 같이 자름 · 첫 기록 = 없음. */
function cutVertices(entry: PlanLine, a: number, b: number): PlanLine {
const out: PlanLine & { nodes?: unknown[] } = { ...entry, vertices: entry.vertices.slice(a, b) };
const nodes = (entry as { nodes?: unknown }).nodes;
if (Array.isArray(nodes) && nodes.length === entry.vertices.length) {
out.nodes = nodes.slice(a, b);
if (a > 0 && out.nodes.length) out.nodes[0] = null;
}
return out;
}
/** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) ·
* 사라지는 노드에 갈래가 붙었으면 못 지움(까닭 글) · 조각 id 는 앞쪽이 지님(걸친 조각은 뒤쪽 새 id). */
export function removeSegment(network: PlanNetwork, seg: PlanSegment): PlanNetwork | string {
const li = network.lines.findIndex((l) => l.id === seg.line);
const entry = network.lines[li];
const n = (entry?.vertices.length ?? 0) - 1;
if (!entry || seg.index < 0 || seg.index >= n) return "고른 선이 없습니다.";
if (n === 1) return removeLine(network, li);
const i = seg.index;
const keepFront = i > 0;
const keepBack = i < n - 1;
const children = network.lines.filter((l) => l.branch?.line === entry.id);
if (
children.some(
(l) => (!keepFront && l.branch!.vertex === 0) || (!keepBack && l.branch!.vertex === n),
)
)
return "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요.";
const backId = keepFront ? nextLineId(network.lines) : entry.id;
const c = chainages(entry.vertices);
const [cut0, cut1] = [c[i], c[i + 1]];
const front: PlanPiece[] = [];
const back: PlanPiece[] = [];
for (const piece of network.pieces.filter((p) => p.line === entry.id)) {
if (keepFront && piece.from_m < cut0 - EPS)
front.push({ ...piece, to_m: round3(Math.min(piece.to_m, cut0)) });
if (keepBack && piece.to_m > cut1 + EPS)
back.push({
...piece,
line: backId,
from_m: round3(Math.max(0, piece.from_m - cut1)),
to_m: round3(piece.to_m - cut1),
});
}
const others = network.pieces.filter((p) => p.line !== entry.id);
for (const piece of back)
if (front.some((p) => p.id === piece.id))
piece.id = nextPieceId([...others, ...front, ...back]);
const lines: PlanLine[] = [];
for (const line of network.lines) {
if (line === entry) {
if (keepFront) lines.push(cutVertices(entry, 0, i + 1));
if (keepBack) lines.push({ ...cutVertices(entry, i + 1, n + 1), id: backId, branch: null });
continue;
}
const b = line.branch;
if (b?.line === entry.id && b.vertex > i)
lines.push({ ...line, branch: { line: backId, vertex: b.vertex - (i + 1) } });
else lines.push(line);
}
return { ...network, lines: orderLines(lines), pieces: [...others, ...front, ...back] };
}
/** 토막 여럿 지움 — 구역마다 뒤 토막부터(앞 토막 차례가 안 바뀜) · 하나라도 못 지우면 까닭 글. */
export function removeSegments(
network: PlanNetwork,
segs: ReadonlyArray<PlanSegment>,
): PlanNetwork | string {
let next = network;
const order = [...segs].sort((a, b) =>
a.line === b.line ? b.index - a.index : a.line < b.line ? -1 : 1,
);
for (const seg of order) {
const out = removeSegment(next, seg);
if (typeof out === "string") return out;
next = out;
}
return next;
}
/** 전체 지움 — 선 · 조각 모두(지점은 부르는 쪽이 남김). */
export const removeAll = (network: PlanNetwork): PlanNetwork => ({
...network,
lines: [],
pieces: [],
});
/** `point` 자리 노드 모두(갈래 첫 점 = 붙은 선 점이 대신 · 빼고). */
export function nodesAt(network: PlanNetwork, point: Vertex): PlanNode[] {
const out: PlanNode[] = [];
for (const line of network.lines)
line.vertices.forEach((v, vertex) => {
if (!(vertex === 0 && line.branch) && samePoint(v, point))
out.push({ line: line.id, vertex });
});
return out;
}
/** `from` 자리 지점을 모두 `to` 로(지점 ↔ 노드 따라감). */
export function movePointsAt(points: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p));
}