/* ============================================================================= * D03_PlanAnalysis_Group.ts * D 예정노선 노선 세트(PLAN 638-2 · 요구 547 · 548) — 세트 = 「연도-번호」(2027-1) · 같은 번호 = 갈라져 있어도 같은 노선. * 세트 지정(고른 구간 끝에서 조각 나눔 → 세트 바꿈 → 같은 선 이웃 같은 세트 하나로) · 고를 세트 목록 · * [잇기](두 선 끝 · 닿으면 바로 · 떨어지면 곧은 토막 덧 · 이은 토막 = 앞 선 세트). * 새 선 처음 세트 · 갈래 · 이어 긋기 = `D03_PlanAnalysis_Plan.normalizePieces`. * ⚠ 짝 = `D03_PlanAnalysis_Network.derive` 세트 합침(서버 [저장] 정본) — 거울 시험 638(`fixtures/d03_638_set_cases.json`). * 끊김 · 갈래 판정은 서버 한 곳(`derived.sets` parts · branch) — 여기는 그 경고 글 · 빈틈 숫자만(`setWarnings`). * DOM · fetch · sessionStorage 없음. * ========================================================================== */ import { segmentLength } from "./D03_PlanAnalysis_Draw"; import { appendVertex, attachHead, nextSet, normalizePieces, reverseLine, samePoint, splitPiece, type PlanNetwork, type PlanPiece, type Vertex, } from "./D03_PlanAnalysis_Plan"; /** 세트 — 연도 · 그 해 번호(1 부터). */ export interface PieceSet { year: number; set: number; } /** 이웃 조각이 맞닿았다고 볼 틈(m) — 서버 합침과 같음(1 cm). */ const TOUCH_M = 0.01; /** 세트 열쇠 「연도-번호」(업체 지정 · B 받기 단위). */ export const setKey = (s: PieceSet): string => `${s.year}-${s.set}`; export const sameSet = (a: PieceSet, b: PieceSet): boolean => a.year === b.year && a.set === b.set; /** 같은 선 이웃(from_m 차례 · 맞닿음) 같은 세트 = 하나(앞 조각 id · 사이 마크 걷음) · 다른 선끼리 안 합침 · * `lines` = 이 선만(없으면 모두) · 결과 = 선 차례 · from_m 차례. */ export function mergeSets(network: PlanNetwork, lines?: ReadonlySet): PlanNetwork { const pieces: PlanPiece[] = []; for (const line of network.lines) { const own = network.pieces .filter((piece) => piece.line === line.id) .sort((a, b) => a.from_m - b.from_m); for (const piece of own) { const last = pieces[pieces.length - 1]; const join = (!lines || lines.has(line.id)) && last?.line === line.id && sameSet(last, piece) && Math.abs(last.to_m - piece.from_m) <= TOUCH_M; if (join) pieces[pieces.length - 1] = { ...last, to_m: piece.to_m }; else pieces.push({ ...piece }); } } return { ...network, pieces }; } /** 선 위 구간(m). */ export interface LineRange { line: string; from_m: number; to_m: number; } /** 세트 지정 — 고른 구간 끝에서 조각을 나눔(끝 · 경계와 1 m 안이면 그 경계) → 가운데가 구간 안인 조각 = `target` → * 고친 선 이웃 같은 세트 하나로. 연도를 바꾸면 `target` = 그 해 새 세트(`setChoices`). */ export function assignSet( network: PlanNetwork, ranges: ReadonlyArray, target: PieceSet, ): PlanNetwork { let next = network; for (const range of ranges) for (const at of [range.from_m, range.to_m]) next = splitPiece(next, range.line, at); const inside = (piece: PlanPiece): boolean => ranges.some((range) => { const mid = (piece.from_m + piece.to_m) / 2; const [a, b] = [Math.min(range.from_m, range.to_m), Math.max(range.from_m, range.to_m)]; return range.line === piece.line && mid > a && mid < b; }); const pieces = next.pieces.map((piece) => inside(piece) ? { ...piece, year: target.year, set: target.set } : piece, ); return mergeSets({ ...next, pieces }, new Set(ranges.map((range) => range.line))); } /** 고를 세트 — 있는 세트 + 해마다 「새 세트」(`fresh` · 번호 = 그 해 최댓값 + 1) · 연도 · 번호 차례. */ export interface SetChoice extends PieceSet { key: string; fresh: boolean; } export function setChoices(network: PlanNetwork, years: ReadonlyArray): SetChoice[] { const have = new Map(); for (const piece of network.pieces) if (piece.set > 0) have.set(setKey(piece), { year: piece.year, set: piece.set }); const allYears = new Set([...years, ...[...have.values()].map((s) => s.year)]); const out: SetChoice[] = []; for (const year of [...allYears].sort((a, b) => a - b)) { const own = [...have.values()].filter((s) => s.year === year).sort((a, b) => a.set - b.set); for (const s of own) out.push({ ...s, key: setKey(s), fresh: false }); const fresh = { year, set: nextSet(network.pieces, year) }; out.push({ ...fresh, key: setKey(fresh), fresh: true }); } return out; } /** 선 끝 — 선 id · 머리(첫 점) 쪽인가. */ export interface LineEnd { line: string; head: boolean; } const indexOf = (network: PlanNetwork, id: string): number => network.lines.findIndex((line) => line.id === id); const endPoint = (network: PlanNetwork, end: LineEnd): [number, number] => { const v = network.lines[indexOf(network, end.line)].vertices; return end.head ? v[0] : v[v.length - 1]; }; /** `id` 선 방향 바꿈(갈래 = null). */ function flip(network: PlanNetwork, id: string): PlanNetwork | null { const out = reverseLine(network, indexOf(network, id)); return typeof out === "string" ? null : out; } /** `x` 끝이 꼬리 · `y` 끝이 머리가 되게 돌려 `y` 를 `x` 꼬리에 이음 — 못 하면 null. */ function joinAt(network: PlanNetwork, x: LineEnd, y: LineEnd): PlanNetwork | null { let next: PlanNetwork | null = network; if (x.head) next = flip(next, x.line); if (next && !y.head) next = flip(next, y.line); if (!next) return null; const xn = next.lines[indexOf(next, x.line)].vertices.length; const yn = next.lines[indexOf(next, y.line)].vertices.length; const joined = attachHead(next, indexOf(next, y.line)); const after = joined.lines[indexOf(joined, x.line)]; // 다른 선 꼬리에 붙거나 갈래가 됨 = 이 두 선 이음이 아님 return indexOf(joined, y.line) < 0 && after?.vertices.length === xn + yn - 1 ? joined : null; } /** [잇기] — 두 선 끝을 한 선으로 · 떨어져 있으면 `a` 끝에서 `b` 끝까지 곧은 토막을 덧댐(그 토막 = `a` 끝 조각 세트) · * 이음 자리 두 조각이 같은 세트면 하나 · 못 이음 = 까닭 글. `year` = 새 선 처음 해(`normalizePieces`). */ export function joinEnds( network: PlanNetwork, a: LineEnd, b: LineEnd, year: number, ): PlanNetwork | string { const [ia, ib] = [indexOf(network, a.line), indexOf(network, b.line)]; if (ia < 0 || ib < 0) return "이을 선이 없습니다."; if (ia === ib) return "같은 선의 두 끝은 이을 수 없습니다."; const isBranch = (end: LineEnd): boolean => end.head && !!network.lines[indexOf(network, end.line)].branch; if (isBranch(a) || isBranch(b)) return "갈래 첫 점은 붙은 선 위라 이을 수 없습니다."; let next = network; const to = endPoint(network, b); if (!samePoint(endPoint(network, a), to)) { // `a` 끝에 곧은 토막 — 머리 쪽이면 뒤집어 꼬리에 덧대고 다시 뒤집음(조각이 따라감) if (a.head) next = flip(next, a.line) ?? next; next = normalizePieces(appendVertex(next, indexOf(next, a.line), [to[0], to[1]]), year); if (a.head) next = flip(next, a.line) ?? next; } // 덜 뒤집는 쪽 먼저(머리 · 꼬리면 뒤 선 꼬리에 앞 선) const [x, y] = a.head && !b.head ? [b, a] : [a, b]; const joined = joinAt(next, x, y) ?? joinAt(next, y, x); return joined ? normalizePieces(joined, year) : "두 선을 이을 수 없습니다 — 선 끝을 고르세요."; } /** 세트 조각 꺾은선 사이 가장 짧은 빈틈(m) — 끝이 다른 조각 꼭짓점에 닿으면 한 덩이(서버 `_set_shape` 닿음) · * 다른 덩이 끝끼리 가장 가까운 거리 · 덩이 하나 = 0. */ export function setGapM(lines: ReadonlyArray>): number { const root = lines.map((_, i) => i); const find = (i: number): number => (root[i] === i ? i : (root[i] = find(root[i]))); const ends = (line: ReadonlyArray): Vertex[] => [line[0], line[line.length - 1]]; lines.forEach((a, i) => lines.forEach((b, j) => { const touch = ends(a).some((end) => b.some((p) => segmentLength(end, p) <= TOUCH_M)); if (i !== j && touch) root[find(i)] = find(j); }), ); let gap = Infinity; lines.forEach((a, i) => lines.forEach((b, j) => { if (find(i) === find(j)) return; for (const p of ends(a)) for (const q of ends(b)) gap = Math.min(gap, segmentLength(p, q)); }), ); return Number.isFinite(gap) ? gap : 0; } /** [저장] 응답 세트 → 경고 글 — 덩이 2 이상 「2027-1 이 2 덩이 · 빈틈 35.0 m」 · 갈래 「2027-1 안에 갈래」. */ export function setWarnings( sets: ReadonlyArray<{ key: string; parts: number; branch: boolean; pieces: ReadonlyArray; }>, pieces: ReadonlyArray<{ id: string; planned: ReadonlyArray }>, ): string[] { const out: string[] = []; for (const s of sets) { if (s.parts > 1) { const lines = pieces.filter((p) => s.pieces.includes(p.id) && p.planned.length > 1); const gap = setGapM(lines.map((p) => p.planned)); out.push(`${s.key} 이 ${s.parts} 덩이 · 빈틈 ${gap.toFixed(1)} m`); } if (s.branch) out.push(`${s.key} 안에 갈래`); } return out; }