/* ============================================================================= * D03_PlanAnalysis_Plan.ts * D 예정노선 2단계 분석 화면 계산(PLAN 530-3) — 갈래 고름 · 이은 누가 · 이은 중심선 · 측점 · * 지점(연도 경계) · 받은 지형 범위 판정. DOM · fetch · sessionStorage 없음. * * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` ↔ * `pickMode` · `chainRows` · `joinedLine`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험 * `resources/tester/test_530_3_plan_logic.py`. 화면은 고르는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. * ========================================================================== */ export type Vertex = [number, number]; /** 짝: `D03_PlanAnalysis_Analyze.PLAN_MODES`(follow 뺌). */ export const PLAN_MODES = ["gradeline", "earthwork", "gentle", "weighted"] as const; export type PlanMode = (typeof PLAN_MODES)[number]; /** 짝: `D03_PlanAnalysis_Terrain.PLAN_CORRIDOR_M` — 지점이 연도 DEM 가장자리에서 이만큼 안이어야 * 탐색 복도가 받은 지형 안에 듦(DEM = 구간 상자 + 복도 + 50 m). */ export const PLAN_REACH_M = 300; export interface PlanRoute { planned?: Vertex[]; metrics?: { length_m: number; max_grade_pct?: number; over_limit?: { total: number }; }; error?: string; } export interface YearAnalysis { year: number; start: Vertex; end: Vertex; routes: Record; } export interface ChainRow { year: number; mode: string | null; length_m: number; start_chainage_m: number; end_chainage_m: number; } const round3 = (value: number): number => Number(value.toFixed(3)); /** 고른 갈래 — 없거나 빈 갈래면 차례대로 첫 성공 갈래. */ export function pickMode(result: YearAnalysis, wanted: string | null | undefined): string | null { const routes = result.routes ?? {}; const ok = (mode: string): boolean => mode in routes && !("error" in routes[mode]); if (wanted && ok(wanted)) return wanted; return PLAN_MODES.find(ok) ?? null; } /** 연도별 고른 갈래 · 시작 · 끝 누가(이어 셈) — 앞 연도 끝 누가 = 다음 연도 시작 누가. */ export function chainRows( results: ReadonlyArray, choices: Readonly>, startChainageM: number, ): ChainRow[] { let at = startChainageM || 0; return results.map((result) => { const mode = pickMode(result, choices[result.year]); const length = mode ? Number(result.routes[mode].metrics?.length_m ?? 0) : 0; const row = { year: result.year, mode, length_m: round3(length), start_chainage_m: round3(at), end_chainage_m: round3(at + length), }; at += length; return row; }); } const samePoint = (a: Vertex, b: Vertex): boolean => Math.abs(a[0] - b[0]) < 1e-3 && Math.abs(a[1] - b[1]) < 1e-3; /** 고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채). */ export function joinedLine( results: ReadonlyArray, rows: ReadonlyArray, ): Vertex[] { const line: Vertex[] = []; results.forEach((result, i) => { const mode = rows[i]?.mode; if (!mode) return; const planned = result.routes[mode].planned ?? []; const head = line.length && planned.length && samePoint(line[line.length - 1], planned[0]); line.push(...(head ? planned.slice(1) : planned)); }); return line; } /** 측점 누가(오름차순) — 연도 경계마다(경계점 = 측점) + 간격 배수 + 끝. 노선 없는 연도는 건너뜀. */ export function stationChainages(rows: ReadonlyArray, intervalM: number): number[] { const out: number[] = []; const push = (value: number): void => { if (!out.length || value - out[out.length - 1] > 1e-6) out.push(round3(value)); }; for (const row of rows) { if (!row.mode) continue; push(row.start_chainage_m); let next = Math.floor(row.start_chainage_m / intervalM + 1e-9) * intervalM + intervalM; for (; next < row.end_chainage_m - 1e-6; next += intervalM) push(next); push(row.end_chainage_m); } return out; } /** 측점 그리기 · 집기용 선 — 첫 점 앞에 첫 구간 방향으로 `startM` 만큼 뒤로 뺀 점을 붙여 선 위 누가 = * 등록 시작 누가부터(`drawStationTicks` · `stationAtScreen` 은 선 첫 점을 0 으로 셈). */ export function leadInLine(line: ReadonlyArray, startM: number): Vertex[] { if (line.length < 2 || !(startM > 0)) return [...line]; const [a, b] = line; const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; const back: Vertex = [ a[0] - ((b[0] - a[0]) / length) * startM, a[1] - ((b[1] - a[1]) / length) * startM, ]; return [back, ...line]; } /** 누가가 든 연도 행(경계는 앞 연도) — 노선 있는 연도만 · 밖이면 null. */ export function rowAt(rows: ReadonlyArray, chainageM: number): ChainRow | null { return ( rows.find( (row) => row.mode && chainageM >= row.start_chainage_m - 1e-6 && chainageM <= row.end_chainage_m + 1e-6, ) ?? null ); } /** 연도별 지점 → 마디 P0…Pn(직전 종점 = 다음 시점). */ export function nodesOf(years: ReadonlyArray<{ start: Vertex; end: Vertex }>): Vertex[] { return years.length ? [years[0].start, ...years.map((year) => year.end)] : []; } /** 마디 → 연도별 지점(서버 `points` 꼴). */ export function pointsOf( years: ReadonlyArray, nodes: ReadonlyArray, ): Array<{ year: number; start: Vertex; end: Vertex }> { return years.map((year, i) => ({ year, start: nodes[i], end: nodes[i + 1] })); } export interface Box { x0: number; y0: number; x1: number; y1: number; } /** 마디마다 받은 지형 안인가 — 그 마디를 끝점으로 쓰는 연도(앞 · 뒤) DEM 상자를 `reachM` 만큼 줄인 안. * 연도 DEM 이 없으면 밖. */ export function nodesInReach( nodes: ReadonlyArray, boxes: ReadonlyArray, reachM: number = PLAN_REACH_M, ): boolean[] { const inside = (point: Vertex, box: Box | null): boolean => !!box && point[0] >= box.x0 + reachM && point[0] <= box.x1 - reachM && point[1] >= box.y0 + reachM && point[1] <= box.y1 - reachM; return nodes.map((point, i) => { const years = [i - 1, i].filter((year) => year >= 0 && year < boxes.length); return years.every((year) => inside(point, boxes[year])); }); } /** 마디가 정본과 같은가(1 mm — 짝: `D02_PlanBase_Router.SAME_M`). */ export function sameNodes(a: ReadonlyArray, b: ReadonlyArray): boolean { return a.length === b.length && a.every((point, i) => samePoint(point, b[i])); }