Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts
T

145 lines
6.3 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend_Profile.ts
* [노선 추천] 모달의 **균형 종단 미리 보기**(계획서 63-4) — 서버가 후보마다 세운 균형 종단
* (`metrics.profile`)을 편다 · 한 줄 · 종단 그림 · 후보별 표.
*
* 이 값은 **편집 창 안에서만** 산다 — 캐시(`routeedit-initial`)에 적지 않고(`writeInitialChoice`
* 가 뺀다) 저장 · initial_snapshot · 확정 종단과 무관하다. 확정 종단은 [확인] 체인이 지금 규칙
* (계획고 = 지반고)으로 다시 세운다. 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다.
* ========================================================================== */
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<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.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),
};
}
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) — 지반선 · 균형 계획선. 가로 = 측점 · 세로 = 표고(창에 맞춰 늘림).
* `ticks` 면 아래 가장자리에 측점 눈금(100 m 마다 길게) — 편집 창 종단 띠(PLAN 75-1).
* 계획선이 비면(종단 반영 끔) 지반선만. */
export function profileSvg(
balance: Pick<BalanceProfile, "stationsM" | "groundM" | "planM"> | null,
ticks = false,
): 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("");
const tickPath = ticks
? stationsM
.map((s) => {
const x = (PAD + ((PROFILE_W - 2 * PAD) * s) / total).toFixed(1);
const long = Math.abs(s / 100 - Math.round(s / 100)) < 1e-6;
return `M${x} ${PROFILE_H}V${PROFILE_H - (long ? 7 : 3)}`;
})
.join("")
: "";
return `<svg viewBox="0 0 ${PROFILE_W} ${PROFILE_H}" preserveAspectRatio="none"
role="img" aria-label="균형 종단 미리 보기 — 지반선과 계획선">
${ticks ? `<path class="is-tick" d="${tickPath}" vector-effect="non-scaling-stroke" />` : ""}
<path class="is-ground" d="${path(groundM)}" vector-effect="non-scaling-stroke" />
<path class="is-plan" d="${planM.length ? path(planM) : ""}" vector-effect="non-scaling-stroke" /></svg>`;
}
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),
})),
});
}