/* ============================================================================= * D03_PlanAnalysis_Select.ts * D 예정노선 고름 층 셈(PLAN 598 · 요구 463 9 · 12 · 13·16 · 15) — 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체. * 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음. * 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감). * 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기. * 노드 판(598-4) 방식 · 값 바꿈 = 앞 노드 기준 같은 방향으로 다시 놓음 · 뒤 노드 그대로(어긋남 ⚠ = 599-2 `nodeOff`). * DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`). * ========================================================================== */ import { compassPoint, fixedLengthPoint, fixedSlopePoint, lineLength, segmentLength, type HeightAt, } from "./D03_PlanAnalysis_Draw"; import { moveNodesAt, moveVertex, piecesInBox, removeLine, samePoint, orderLines, type NodeRecord, 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): 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, 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): string[] { const out = new Set(); 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, set: 0, }; }), }; return piecesInBox(probe, toView, box, crossing).map((i) => segs[Number(i)]); } /** 새 구역 id — `line-`(있는 것과 안 겹침). */ function nextLineId(lines: ReadonlyArray): 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`(있는 것 중 가장 큰 수 + 1). */ function nextPieceId(pieces: ReadonlyArray): 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 vertices = entry.vertices.slice(a, b); if (!entry.nodes) return { ...entry, vertices }; const nodes = entry.nodes.slice(a, b); if (a > 0 && nodes.length) nodes[0] = null; return { ...entry, vertices, nodes }; } /** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) · * 사라지는 노드에 갈래가 붙었으면 못 지움(까닭 글) · 조각 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, ): 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, from: Vertex, to: Vertex): Vertex[] { return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p)); } /** 노드 판(598-4) — `vertex` 노드 기록을 `record` 로 · 앞 노드 기준 지금 방향으로 다시 놓음(자유 = 자리 그대로) · * 뒤 노드 그대로 · 그 자리 지점 · 같은 자리 노드도 같이 · 첫 노드 · 자리 못 찾음 = 까닭 글. */ export function placeNode( network: PlanNetwork, points: ReadonlyArray, line: number, vertex: number, record: NodeRecord, heightAt: HeightAt, ): { network: PlanNetwork; points: Vertex[] } | string { const entry = network.lines[line]; if (!entry || vertex < 1 || vertex >= entry.vertices.length) return "첫 노드는 방식이 없습니다."; const [prev, here] = [entry.vertices[vertex - 1], entry.vertices[vertex]]; const { kind, length_m: lengthM, slope_pct: slopePct } = record; const to = kind === "free" ? here : kind === "length" ? fixedLengthPoint(prev, here, lengthM ?? 0) : kind === "slope" ? fixedSlopePoint(prev, here, slopePct ?? 0, heightAt) : compassPoint(prev, here, lengthM ?? 0, slopePct ?? 0, heightAt); if (!to) return "그 값으로 놓을 자리가 없습니다(지형 · 길이를 보세요)."; const bound = points.some((p) => samePoint(p, here)); let next = bound ? moveNodesAt(network, here, to) : moveVertex(network, line, vertex, to); next = { ...next, lines: next.lines.map((l) => { if (l.id !== entry.id) return l; const nodes = l.nodes ? [...l.nodes] : l.vertices.map(() => null); nodes[vertex] = record; return { ...l, nodes }; }), }; return { network: next, points: bound ? movePointsAt(points, here, to) : [...points] }; }