/* ============================================================================= * B05_Profile_UI_RouteEdit_Recommend_Profile.ts * [노선 추천] 모달의 **균형 종단 미리 보기**(계획서 63-4) — 서버가 후보마다 세운 균형 종단 * (`metrics.profile`)을 편다 · 한 줄 · 종단 그림 · 후보별 표. * * 이 값은 **편집 창 안에서만** 산다 — 캐시(`routeedit-initial`)에 적지 않고(`writeInitialChoice` * 가 뺀다) 저장 · initial_snapshot · 확정 종단과 무관하다. 확정 종단은 [확인] 체인이 지금 규칙 * (계획고 = 지반고)으로 다시 세운다. 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다. * 적용될 초기 종단(476 · 요구 276) — 서버가 [적용]과 같은 코드로 후보마다 세운 종단 * (`initial_profile` · 438 직선화 · 종단곡선 포함)을 펴서 같은 그림에 선 하나 · 한 줄로 더한다. * ========================================================================== */ import { renderDataTableHtml, type DataColumn } from "@ui/ui_template_data_table"; /** 후보 하나의 균형 종단 — 서버 `metrics.profile`. */ export interface BalanceProfile { stationsM: number[]; groundM: number[]; planM: number[]; /** 절토(자연) · 성토 ㎥. */ cutM3: number; fillM3: number; /** 누가토량 끝값(절토 다짐 − 성토) · 허용 ±㎥. */ massEndM3: number; massToleranceM3: number; maxGradePct: number; gradeLimitPct: number; /** 종단곡선 넣을 자리가 모자란 변화점 수(대수차가 생략 한도를 넘음). */ curveOver: number; /** 이 노선 위 관 수 · 그중 덮개가 모자란 수 — 서버가 관을 몰랐으면 null. */ pipesOnRoute: number | null; coverShort: number | null; } const num = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; const numbers = (value: unknown): number[] => Array.isArray(value) ? value.filter((item): item is number => num(item) !== null) : []; /** 서버 `metrics.profile` 을 편다. 없거나(옛 답) 셈 실패면 null. */ export function parseBalance(raw: unknown): BalanceProfile | null { if (!raw || typeof raw !== "object") return null; const p = raw as Record; const stationsM = numbers(p.stations_m); const groundM = numbers(p.ground_m); const planM = numbers(p.plan_m); const need = [p.cut_m3, p.fill_m3, p.max_grade_pct].map(num); if (stationsM.length < 2 || groundM.length !== stationsM.length) return null; if (planM.length !== stationsM.length || need.some((value) => value === null)) return null; return { stationsM, groundM, planM, cutM3: num(p.cut_m3)!, fillM3: num(p.fill_m3)!, massEndM3: num(p.mass_end_m3) ?? 0, massToleranceM3: num(p.mass_tolerance_m3) ?? 500, maxGradePct: num(p.max_grade_pct)!, gradeLimitPct: num(p.grade_limit_pct) ?? 0, curveOver: num(p.curve_over) ?? 0, pipesOnRoute: num(p.pipes_on_route), coverShort: num(p.cover_short), }; } /** 후보 하나의 적용될 초기 종단 — 서버 `initial_profile`(476-1). */ export interface InitialProfile { withProfile: boolean; stationsM: number[]; groundM: number[]; /** 측점 계획고. */ planM: number[]; /** 종단 표본 간격으로 편 선(종단곡선 포함) — [누가거리, 계획고]. */ line: Array<[number, number]>; pviCount: number; curveCount: number; /** 438 로 직선화된 꼭짓점 수. */ straightened: number; maxGradePct: number; curveLengthM: number | null; warnings: string[]; } /** 서버 `initial_profile` 을 편다 — 셈 실패면 `{ error }` · 없거나(옛 답) 꼴이 틀리면 null. */ export function parseInitialProfile(raw: unknown): InitialProfile | { error: string } | null { if (!raw || typeof raw !== "object") return null; const p = raw as Record; if (typeof p.error === "string") return { error: p.error }; const stationsM = numbers(p.stations_m); const groundM = numbers(p.ground_m); const planM = numbers(p.plan_m); const line = Array.isArray(p.line) ? p.line .filter((xy): xy is unknown[] => Array.isArray(xy)) .map((xy) => [num(xy[0]), num(xy[1])]) .filter((xy): xy is [number, number] => xy[0] !== null && xy[1] !== null) : []; if (stationsM.length < 2 || groundM.length !== stationsM.length) return null; if (planM.length !== stationsM.length || line.length < 2) return null; return { withProfile: p.with_profile === true, stationsM, groundM, planM, line, pviCount: num(p.pvi_count) ?? 0, curveCount: num(p.curve_count) ?? 0, straightened: num(p.straightened) ?? 0, maxGradePct: num(p.max_grade_pct) ?? 0, curveLengthM: num(p.curve_length_m), warnings: Array.isArray(p.warnings) ? p.warnings.map(String) : [], }; } /** 한 줄 — 「초기 종단: 꼭짓점 12 · 종단곡선 10 · 직선화 3 · 최대 종단경사 11.2%(L 20) · 알림 2건」 · * 실패면 이유. 알림 글은 줄 title(`initialProfile.warnings`). */ export function initialLine(initial: InitialProfile | { error: string } | null): string { if (!initial) return ""; if ("error" in initial) return `초기 종단을 셈하지 못했습니다 — ${initial.error}`; const parts = [ `초기 종단: 꼭짓점 ${initial.pviCount} · 종단곡선 ${initial.curveCount}`, `직선화 ${initial.straightened}`, `최대 종단경사 ${pct(initial.maxGradePct)}${ initial.curveLengthM !== null ? `(L ${initial.curveLengthM})` : "" }`, ]; if (initial.warnings.length) parts.push(`알림 ${initial.warnings.length}건`); return parts.join(" · "); } const m3 = (value: number): string => `${Math.round(value).toLocaleString("ko-KR")}㎥`; const pct = (value: number): string => `${value.toFixed(1)}%`; const signedM3 = (value: number): string => `${value > 0 ? "+" : ""}${m3(value)}`; /** 한 줄 — 「균형 종단: 절 7,646 · 성 7,944㎥ · 누가토량 +212㎥(허용 ±500㎥) · …」. */ export function balanceLine(balance: BalanceProfile | null): string { if (!balance) return ""; const parts = [ `균형 종단: 절 ${m3(balance.cutM3)} · 성 ${m3(balance.fillM3)}`, `누가토량 ${signedM3(balance.massEndM3)}(허용 ±${m3(balance.massToleranceM3)})`, `최대 종단경사 ${pct(balance.maxGradePct)}(상한 ${pct(balance.gradeLimitPct)})`, ]; if (balance.curveOver > 0) parts.push(`종단곡선 자리 모자람 ${balance.curveOver}곳`); if (balance.pipesOnRoute !== null && balance.pipesOnRoute > 0) parts.push(`덮개 모자란 관 ${balance.coverShort ?? 0}/${balance.pipesOnRoute}`); return parts.join(" · "); } const PROFILE_W = 600; const PROFILE_H = 96; const PAD = 6; /** 종단 그림(SVG) — 지반선 · 균형 계획선 · 적용될 초기 종단. 가로 = 측점 · 세로 = 표고(창에 맞춰 * 늘림). 계획선이 비면(종단 반영 끔) 지반선 · 초기 종단만 · 균형 종단이 없으면 초기 종단의 지반선. */ export function profileSvg( balance: Pick | null, initial: Pick | null = null, ): string { const base = balance ?? (initial && { ...initial, planM: [] }); if (!base) return ""; const { stationsM, groundM, planM } = base; const lineS = initial?.line.map(([s]) => s) ?? []; const lineZ = initial?.line.map(([, z]) => z) ?? []; const total = Math.max(stationsM[stationsM.length - 1], ...lineS) || 1; const low = Math.min(...groundM, ...planM, ...lineZ); const span = Math.max(Math.max(...groundM, ...planM, ...lineZ) - low, 1); const path = (xs: number[], zs: number[]): string => zs .map((z, i) => { const x = PAD + ((PROFILE_W - 2 * PAD) * xs[i]) / total; const y = PROFILE_H - PAD - ((PROFILE_H - 2 * PAD) * (z - low)) / span; return `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`; }) .join(""); const initialPath = initial ? ` ` : ""; return ` ${initialPath}`; } /** 그림 아래 범례 — 그린 선만. */ export function profileLegend(hasPlan: boolean, hasInitial: boolean): string { const item = (cls: string, name: string): string => `${name}`; return `
${[ item("is-ground", "지반선"), hasPlan ? item("is-plan", "균형 계획선") : "", hasInitial ? item("is-initial", "적용될 초기 종단") : "", ].join("")}
`; } const CANDIDATE_COLUMNS: DataColumn[] = [ { key: "name", label: "후보" }, { key: "cut", label: "절토" }, { key: "fill", label: "성토" }, { key: "mass", label: "누가토량" }, { key: "grade", label: "최대 종단경사" }, ]; /** 이 모달에서 셈한 후보들 — 갈래 이름 · 절 · 성 · 누가토량(±) · 최대 종단경사 표. 비면 빈 글. */ export function candidatesTable(rows: Array<[string, BalanceProfile]>): string { if (!rows.length) return ""; return renderDataTableHtml({ columns: CANDIDATE_COLUMNS, rows: rows.map(([name, b]) => ({ name, cut: m3(b.cutM3), fill: m3(b.fillM3), mass: signedM3(b.massEndM3), grade: pct(b.maxGradePct), })), }); }