Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Profile.ts
T
eomsangdonandClaude Opus 5.5 b916397141 fix(476-2 추천 초기 종단): 초기 종단 한 줄 = 알림 N건 · 알림 글은 마우스 올림
화면 확인(검증_sub1) 알림 37~94건이 한 줄에 다 붙어 줄이 넘침.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:02:56 +09:00

218 lines
9.6 KiB
TypeScript

/* =============================================================================
* 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<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),
};
}
/** 후보 하나의 적용될 초기 종단 — 서버 `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<string, unknown>;
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<BalanceProfile, "stationsM" | "groundM" | "planM"> | null,
initial: Pick<InitialProfile, "stationsM" | "groundM" | "line"> | 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
? `
<path class="is-initial" d="${path(lineS, lineZ)}" vector-effect="non-scaling-stroke" />`
: "";
return `<svg viewBox="0 0 ${PROFILE_W} ${PROFILE_H}" preserveAspectRatio="none"
role="img" aria-label="종단 미리 보기 — 지반선 · 계획선 · 적용될 초기 종단">
<path class="is-ground" d="${path(stationsM, groundM)}" vector-effect="non-scaling-stroke" />
<path class="is-plan" d="${planM.length ? path(stationsM, planM) : ""}" vector-effect="non-scaling-stroke" />${initialPath}</svg>`;
}
/** 그림 아래 범례 — 그린 선만. */
export function profileLegend(hasPlan: boolean, hasInitial: boolean): string {
const item = (cls: string, name: string): string => `<span><i class="${cls}"></i>${name}</span>`;
return `<div class="b05-routerec__plegend">${[
item("is-ground", "지반선"),
hasPlan ? item("is-plan", "균형 계획선") : "",
hasInitial ? item("is-initial", "적용될 초기 종단") : "",
].join("")}</div>`;
}
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),
})),
});
}