Files
Aislo/D03_PlanAnalysis/logic/D03_PlanAnalysis_Group.ts
T
eomsangdonandClaude Opus 5.5 83f3bc0a5c feat(638-2): 예정노선 노선 세트 화면 셈 — 세트 지정 · 끝에서 나눔 · 이웃 합침 · 고를 세트 · [잇기](요구 547 · 548)
- 새 logic/D03_PlanAnalysis_Group.ts: assignSet(고른 구간 끝 나눔 → 세트 → 같은 선 이웃 같은 세트 하나 · 앞 id) · mergeSets(서버 Network.derive 짝) · setChoices(있는 세트 + 해마다 새 번호) · joinEnds(닿음 바로 · 떨어짐 곧은 토막 덧 = 앞 선 세트)
- Plan.ts 세 곳: PlanPiece.set · 새 선 처음 세트(사업연도 시작 해 새 번호 · 갈래 = 붙은 자리 세트 · 옛 꼴 = 그 해 최댓값 + 1) · 이음 합침 비교 = 세트 · 661 줄 그대로
- Select.ts 끌어 고르기 시험 조각에 set 칸(타입 맞춤)
- Api.ts 꼴: derived.sets(PlanSetView · 638-1 서버 셈)
- 시험 test_638_group.py · 거울 사례 fixtures/d03_638_set_cases.json(sub3 638-1 과 같은 파일)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:31:17 +09:00

170 lines
7.5 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`).
* 끊김 · 갈래 경고 셈은 서버 한 곳(여기 없음). DOM · fetch · sessionStorage 없음.
* ========================================================================== */
import {
appendVertex,
attachHead,
nextSet,
normalizePieces,
reverseLine,
samePoint,
splitPiece,
type PlanNetwork,
type PlanPiece,
} 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) : "두 선을 이을 수 없습니다 — 선 끝을 고르세요.";
}