Files
Aislo/D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts
T

217 lines
9.4 KiB
TypeScript

/* =============================================================================
* 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<string>): 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<LineRange>,
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<number>): SetChoice[] {
const have = new Map<string, PieceSet>();
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<ReadonlyArray<Vertex>>): 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>): 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<string>;
}>,
pieces: ReadonlyArray<{ id: string; planned: ReadonlyArray<Vertex> }>,
): 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;
}