/* ============================================================================= * D03_PlanAnalysis_Plan.ts * D 예정노선 2단계 손 긋기 꼴(PLAN 562-3) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기. * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). * [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음. * ========================================================================== */ import { lineLength } from "./D03_PlanAnalysis_Draw"; export type Vertex = [number, number]; /** 그은 선 하나 — 첫 선 = 본선(D02 시점에서) · 둘째부터 = 갈래(`branch` = 붙은 선 · 꺾임점 차례). */ export interface PlanLine { id: string; vertices: Vertex[]; branch: { line: string; vertex: number } | null; } /** 조각 = 선(id) 위 [from_m, to_m] 구역 · 연도(구역 마크 = 조각 경계 · id = 업체 지정이 붙는 열쇠라 나눠도 앞 조각이 지님). */ export interface PlanPiece { id: string; line: string; from_m: number; to_m: number; year: number; } export interface PlanNetwork { lines: PlanLine[]; pieces: PlanPiece[]; } /** 마크가 끝 · 다른 마크와 이보다 가까우면 안 놓음(m). */ export const MARK_GAP_M = 1; const round3 = (value: number): number => Math.round(value * 1000) / 1000; /** 새 조각 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}`; } /** 빈 선 — 본선 시점만. */ export function emptyNetwork(start: Vertex): PlanNetwork { return { lines: [{ id: "main", vertices: [start], branch: null }], pieces: [] }; } /** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 · * 조각 없는 선 = 한 조각(`year`). 길이 0 선은 조각 없음. */ export function normalizePieces(network: PlanNetwork, year: number): PlanNetwork { const pieces: PlanPiece[] = []; for (const line of network.lines) { const length = round3(lineLength(line.vertices)); if (!(length > 0)) continue; const own = network.pieces .filter((piece) => piece.line === line.id) .sort((a, b) => a.from_m - b.from_m); const starts = own .filter((piece) => piece.from_m > MARK_GAP_M / 2 && piece.from_m < length - MARK_GAP_M / 2) .map((piece) => ({ id: piece.id, from: round3(piece.from_m), year: piece.year })); const head = own.find((piece) => piece.from_m <= MARK_GAP_M / 2) ?? { id: "", year }; const cuts = [{ id: head.id, from: 0, year: head.year }, ...starts]; cuts.forEach((cut, i) => pieces.push({ // id 빈 칸 · 연도 없음(서버 마크만 있던 구역) = 새 id · 처음 연도 id: cut.id || nextPieceId([...network.pieces, ...pieces]), line: line.id, from_m: cut.from, to_m: i + 1 < cuts.length ? cuts[i + 1].from : length, year: Number.isFinite(cut.year) ? cut.year : year, }), ); } return { ...network, pieces }; } /** 선 위 `atM` 에 마크 — 그 자리 조각을 둘로(같은 연도) · 끝 · 경계와 가까우면 그대로. */ export function splitPiece(network: PlanNetwork, line: string, atM: number): PlanNetwork { const index = network.pieces.findIndex( (piece) => piece.line === line && atM > piece.from_m && atM < piece.to_m, ); const piece = network.pieces[index]; if (!piece || atM - piece.from_m < MARK_GAP_M || piece.to_m - atM < MARK_GAP_M) return network; const at = round3(atM); const pieces = [...network.pieces]; pieces.splice( index, 1, { ...piece, to_m: at }, { ...piece, id: nextPieceId(pieces), from_m: at }, ); return { ...network, pieces }; } /** `atM` 둘레 `toleranceM` 안 마크를 지움 — 두 조각을 합침(앞 조각 연도). 없으면 그대로. */ export function removeMark( network: PlanNetwork, line: string, atM: number, toleranceM: number, ): PlanNetwork { const index = network.pieces.findIndex( (piece) => piece.line === line && piece.from_m > 0 && Math.abs(piece.from_m - atM) <= toleranceM, ); if (index < 1 || network.pieces[index - 1].line !== line) return network; const pieces = [...network.pieces]; const before = pieces[index - 1]; pieces.splice(index - 1, 2, { ...before, to_m: pieces[index].to_m }); return { ...network, pieces }; } /** 조각 연도 바꿈. */ export function setPieceYear(network: PlanNetwork, index: number, year: number): PlanNetwork { return { ...network, pieces: network.pieces.map((piece, i) => (i === index ? { ...piece, year } : piece)), }; } /** `line` 째 선의 `segment` 번째 구간 안에 꺾임점을 끼움 — 그 선에 붙은 갈래 차례를 한 칸 밈. * 선 위 점이라 누가 · 조각은 그대로. */ export function insertVertex( network: PlanNetwork, line: number, segment: number, point: Vertex, ): PlanNetwork { const id = network.lines[line].id; return { ...network, lines: network.lines.map((entry, i) => { if (i === line) { const vertices = [...entry.vertices]; vertices.splice(segment + 1, 0, point); return { ...entry, vertices }; } const branch = entry.branch; return branch && branch.line === id && branch.vertex > segment ? { ...entry, branch: { ...branch, vertex: branch.vertex + 1 } } : entry; }), }; } /** 선 끝에 점을 더함. */ export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork { return { ...network, lines: network.lines.map((entry, i) => i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry, ), }; } /** 선 끝 점을 뺌 — 첫 점(본선 시점 · 갈래 붙은 점)은 못 뺌 · 갈래가 붙은 점도 못 뺌(까닭 글). */ export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork | string { const entry = network.lines[line]; if (!entry || entry.vertices.length < 2) return "더 뺄 점이 없습니다."; const last = entry.vertices.length - 1; if ( network.lines.some((other) => other.branch?.line === entry.id && other.branch.vertex === last) ) return "갈래가 붙은 점은 뺄 수 없습니다 — 갈래를 먼저 지우세요."; return { ...network, lines: network.lines.map((other, i) => i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other, ), }; } /** 마지막 선(그리던 갈래)을 지움 — 그 선 조각도. 갈래가 붙어 있으면 그대로. */ export function dropLastLine(network: PlanNetwork): PlanNetwork { const index = network.lines.length - 1; const id = network.lines[index]?.id; if (index < 1 || network.lines.some((line) => line.branch?.line === id)) return network; return { lines: network.lines.slice(0, index), pieces: network.pieces.filter((piece) => piece.line !== id), }; } /** 새 갈래 — `line` 째 선의 `vertex` 꺾임점에서 시작. */ export function addBranch(network: PlanNetwork, line: number, vertex: number): PlanNetwork { const parent = network.lines[line]; const from = parent.vertices[vertex]; const used = new Set(network.lines.map((entry) => entry.id)); let n = network.lines.length; while (used.has(`branch-${n}`)) n += 1; return { ...network, lines: [ ...network.lines, { id: `branch-${n}`, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } }, ], }; } /** 서버 꼴(선 · 마크 · 조각 · 조각 없는 구역 있음) → 화면 꼴(조각이 선을 빈틈없이 덮음). */ export function fromServer( lines: PlanLine[], marks: ReadonlyArray<{ line: string; at_m: number }>, pieces: ReadonlyArray, year: number, ): PlanNetwork { const cuts: PlanPiece[] = marks .filter( (mark) => !pieces.some( (piece) => piece.line === mark.line && Math.abs(piece.from_m - mark.at_m) < 0.01, ), ) .map((mark) => ({ id: "", line: mark.line, from_m: mark.at_m, to_m: mark.at_m, year: NaN })); return normalizePieces({ lines, pieces: [...pieces, ...cuts] }, year); } /** 서버 꼴 마크 — 조각 경계(선마다 0 빼고 시작 자리). */ export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: number }> { return network.pieces .filter((piece) => piece.from_m > 0) .map((piece) => ({ line: piece.line, at_m: piece.from_m })); } /** 본선이 종점에 닿았나(1 mm). */ export function reachedEnd(network: PlanNetwork, end: Vertex): boolean { const main = network.lines[0]?.vertices ?? []; const last = main[main.length - 1]; return !!last && Math.hypot(last[0] - end[0], last[1] - end[1]) < 1e-3; } /** 선 이름 — 본선 · 갈래 1 · 갈래 2 …(선 차례). */ export function lineName(index: number): string { return index === 0 ? "본선" : `갈래 ${index}`; }