Files
Aislo/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts
T

233 lines
8.6 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 분석 화면 계산(PLAN 530-3) — 갈래 고름 · 이은 누가 · 이은 중심선 · 측점 ·
* 지점(연도 경계) · 받은 지형 범위 판정. DOM · fetch · sessionStorage 없음.
*
* ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` · `with_edits` ↔
* `pickMode` · `chainRows` · `joinedLine` · `withEdits`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험
* `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 RouteMetrics {
length_m: number;
max_grade_pct?: number;
over_limit?: { total: number };
}
export interface PlanRoute {
planned?: Vertex[];
/** 엔진 노드 · 곡선(B05 `RouteNode` · `RouteCurve` 꼴) — 꺾임점 고침의 시작 마디. */
nodes?: Array<{
x: number;
y: number;
radius_m: number | null;
inner_angle_deg: number | null;
violations?: string[];
}>;
curves?: unknown[];
metrics?: RouteMetrics;
/** 꺾임점을 고친 노선(`withEdits`). */
edited?: boolean;
error?: string;
}
export interface YearAnalysis {
year: number;
start: Vertex;
end: Vertex;
/** 분석 입력 지문 — 꺾임점 고침이 이 분석 위에서 한 것인가. */
fingerprint?: string;
criteria?: { min_radius_m: number; max_grade_pct?: number };
routes: Record<string, PlanRoute>;
}
/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */
export interface RouteEdit {
mode: string;
planned: Vertex[];
metrics?: RouteMetrics | null;
}
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<YearAnalysis>,
choices: Readonly<Record<number, string | null | undefined>>,
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;
});
}
/** 선 길이(m) — 짝: `D03_PlanAnalysis_Analyze.line_length`(같은 차례로 더함). */
export function lineLength(line: ReadonlyArray<Vertex>): number {
let total = 0;
for (let i = 1; i < line.length; i += 1)
total += Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]);
return total;
}
/** 고친 노선을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 길이(이은 누가 · 측점이 따라감).
* 그 갈래가 없거나 빈 갈래면 그대로. */
export function withEdits(
results: ReadonlyArray<YearAnalysis>,
edits: Readonly<Record<number, RouteEdit | undefined>>,
): YearAnalysis[] {
return results.map((result) => {
const edit = edits[result.year];
const route = edit ? result.routes?.[edit.mode] : undefined;
if (!edit || !route || "error" in route) return result;
const metrics = { ...(edit.metrics ?? {}), length_m: lineLength(edit.planned) };
return {
...result,
routes: {
...result.routes,
[edit.mode]: { ...route, planned: edit.planned, metrics, edited: true },
},
};
});
}
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<YearAnalysis>,
rows: ReadonlyArray<ChainRow>,
): 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<ChainRow>, 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<Vertex>, 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<ChainRow>, 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<number>,
nodes: ReadonlyArray<Vertex>,
): 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<Vertex>,
boxes: ReadonlyArray<Box | null>,
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<Vertex>, b: ReadonlyArray<Vertex>): boolean {
return a.length === b.length && a.every((point, i) => samePoint(point, b[i]));
}