Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
124 lines
5.6 KiB
TypeScript
124 lines
5.6 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, unknown>;
|
|
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 `<svg viewBox="0 0 ${PROFILE_W} ${PROFILE_H}" preserveAspectRatio="none"
|
|
role="img" aria-label="균형 종단 미리 보기 — 지반선과 계획선">
|
|
<path class="is-ground" d="${path(groundM)}" vector-effect="non-scaling-stroke" />
|
|
<path class="is-plan" d="${path(planM)}" vector-effect="non-scaling-stroke" /></svg>`;
|
|
}
|
|
|
|
/** 이 모달에서 셈한 후보들 — 갈래 이름 · 절 · 성 · 반입 · 최대 종단경사 표. 비면 빈 글. */
|
|
export function candidatesTable(rows: Array<[string, BalanceProfile]>): string {
|
|
if (!rows.length) return "";
|
|
const body = rows
|
|
.map(
|
|
([name, b]) =>
|
|
`<tr><th scope="row">${name}</th><td>${m3(b.cutM3)}</td><td>${m3(b.fillM3)}</td>` +
|
|
`<td>${m3(b.borrowM3)}</td><td>${pct(b.maxGradePct)}</td></tr>`,
|
|
)
|
|
.join("");
|
|
return `<table><thead><tr><th scope="col">후보</th><th scope="col">절토</th>
|
|
<th scope="col">성토</th><th scope="col">반입</th><th scope="col">최대 종단경사</th></tr></thead>
|
|
<tbody>${body}</tbody></table>`;
|
|
}
|