- 새 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>
242 lines
9.6 KiB
TypeScript
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));
|
|
}
|