233 lines
8.6 KiB
TypeScript
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]));
|
|
}
|