/* ============================================================================= * B05_Profile_UI_RouteEdit_Recommend_Profile.ts * [노선 추천] 모달의 **균형 종단 미리 보기**(계획서 63-4) — 서버가 후보마다 세운 균형 종단 * (`metrics.profile`)을 편다 · 한 줄 · 종단 그림 · 후보별 표. * * 이 값은 **편집 창 안에서만** 산다 — 캐시(`routeedit-initial`)에 적지 않고(`writeInitialChoice` * 가 뺀다) 저장 · initial_snapshot · 확정 종단과 무관하다. 확정 종단은 [확인] 체인이 지금 규칙 * (계획고 = 지반고)으로 다시 세운다. 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다. * ========================================================================== */ /** 후보 하나의 균형 종단 — 서버 `metrics.profile`. */ export interface BalanceProfile { stationsM: number[]; groundM: number[]; planM: number[]; /** 절토(자연) · 성토 · 반입 · 사토 ㎥. */ cutM3: number; fillM3: number; borrowM3: number; spoilM3: number; /** (성 − 절 다짐 환산) / 큰 쪽 % · 허용 %. */ imbalancePct: number; tolerancePct: 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.borrow_m3, p.spoil_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)!, borrowM3: num(p.borrow_m3)!, spoilM3: num(p.spoil_m3)!, imbalancePct: num(p.imbalance_pct) ?? 0, tolerancePct: num(p.tolerance_pct) ?? 10, 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), }; } const m3 = (value: number): string => `${Math.round(value).toLocaleString("ko-KR")}㎥`; const pct = (value: number): string => `${value.toFixed(1)}%`; /** 한 줄 — 「균형 종단: 절 7,646 · 성 7,944 · 반입 0 · 사토 848㎥ · 불균형 −9.6%(허용 10%) · …」. */ export function balanceLine(balance: BalanceProfile | null): string { if (!balance) return ""; const parts = [ `균형 종단: 절 ${m3(balance.cutM3)} · 성 ${m3(balance.fillM3)}`, `반입 ${m3(balance.borrowM3)} · 사토 ${m3(balance.spoilM3)}`, `불균형 ${pct(balance.imbalancePct)}(허용 ${pct(balance.tolerancePct)})`, `최대 종단경사 ${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: BalanceProfile | null): string { if (!balance) return ""; const { stationsM, groundM, planM } = balance; const total = stationsM[stationsM.length - 1] || 1; const low = Math.min(...groundM, ...planM); const span = Math.max(Math.max(...groundM, ...planM) - low, 1); const path = (values: number[]): string => values .map((z, i) => { const x = PAD + ((PROFILE_W - 2 * PAD) * stationsM[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(""); return ` `; } /** 이 모달에서 셈한 후보들 — 갈래 이름 · 절 · 성 · 반입 · 최대 종단경사 표. 비면 빈 글. */ export function candidatesTable(rows: Array<[string, BalanceProfile]>): string { if (!rows.length) return ""; const body = rows .map( ([name, b]) => `${name}${m3(b.cutM3)}${m3(b.fillM3)}` + `${m3(b.borrowM3)}${pct(b.maxGradePct)}`, ) .join(""); return `${body}
후보절토 성토반입최대 종단경사
`; }