Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts
T
2026-07-22 20:28:33 +09:00

155 lines
5.5 KiB
TypeScript

import type {
LongitudinalSection,
SectionDetailResponse,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
const COLLAPSED_KEY = "b05-route-profile-collapsed";
const HORIZONTAL_SCROLLBAR_HEIGHT = 16;
const BALANCE_BAR_HEIGHT = 24;
/** 계획선 절·성토 균형 결과를 종단도 위에 한 줄 요약으로 보여준다. */
function renderBalanceSummary(data: LongitudinalSection): HTMLElement | null {
const profile = data.design_profiles?.[0];
if (!profile) return null;
const bar = document.createElement("div");
bar.className = "b05-route-profile__balance";
const summary = profile.summary;
const entries: Array<[string, string, string?]> = [
["절토", `${summary.cut_area_m2.toFixed(1)} m²`, "cut"],
["성토", `${summary.fill_area_m2.toFixed(1)} m²`, "fill"],
["균형오차", `${summary.balance_error_m2.toFixed(2)} m²${summary.balanced ? "" : " (근사)"}`],
["최대 종단기울기", `${summary.max_grade_pct.toFixed(2)} %`],
["종단곡선", `${summary.vertical_curve_count} 개`],
];
if (summary.balance_segment_count > 1) {
entries.push(["균형구역", `${summary.balance_segment_count} 개`]);
}
if (summary.suggested_elevation_offset_m !== null) {
entries.push(["시·종점 조정 제안", `${summary.suggested_elevation_offset_m.toFixed(1)} m`]);
}
entries.forEach(([label, value, tone]) => {
const item = document.createElement("span");
item.className = `b05-route-profile__balance-item${tone ? ` is-${tone}` : ""}`;
const caption = document.createElement("em");
caption.textContent = label;
item.append(caption, document.createTextNode(value));
bar.append(item);
});
if (summary.warnings.length) {
const warning = document.createElement("span");
warning.className = "b05-route-profile__balance-warning";
warning.textContent = `⚠ ${summary.warnings[0]}`;
warning.title = summary.warnings.join("\n");
bar.append(warning);
}
return bar;
}
function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection {
return {
...data,
samples: data.samples.map((sample) => ({
...sample,
elevation_m: sample.elevation_m ?? sample.z ?? null,
})),
};
}
export function createRouteProfilePanel(onSelectStation: (stationId: string) => void) {
const root = document.createElement("section");
root.className = "b05-route-profile";
const panelHandle = createWorkflowPanelHandle("bottom");
const toggle = panelHandle.root;
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
root.append(toggle, body);
let detail: SectionDetailResponse | null = null;
let selectedStationId: string | null = null;
let stationInterval: number | undefined;
let resizeTimer = 0;
let lastWidth = 0;
let lastHeight = 0;
function draw(): void {
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
const availableWidth = Math.max(1, body.clientWidth - 30);
const balance = renderBalanceSummary(detail.longitudinal);
const height = Math.max(
1,
body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT - (balance ? BALANCE_BAR_HEIGHT : 0),
);
const minimumWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval);
const width = Math.max(availableWidth, minimumWidth);
lastWidth = body.clientWidth;
lastHeight = height;
const chart = createLongitudinalProfile(
normalizedLongitudinal(detail.longitudinal),
selectedStationId,
1,
undefined,
onSelectStation,
stationInterval,
width,
height,
minimumWidth,
detail.longitudinal.design_profiles ?? [],
);
body.replaceChildren(...(balance ? [balance, chart] : [chart]));
}
const resizeObserver = new ResizeObserver(() => {
if (
body.clientWidth <= 0 ||
body.clientHeight <= 0 ||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
)
return;
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(draw, 150);
});
resizeObserver.observe(body);
function setCollapsed(collapsed: boolean): void {
root.classList.toggle("is-collapsed", collapsed);
panelHandle.setOpen(!collapsed);
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
if (!collapsed) requestAnimationFrame(draw);
}
toggle.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed")));
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true");
return {
root,
render(nextDetail: SectionDetailResponse, nextStationInterval?: number) {
detail = nextDetail;
stationInterval = nextStationInterval;
draw();
requestAnimationFrame(draw);
},
setSelectedStation(stationId: string | null) {
selectedStationId = stationId;
draw();
},
clear() {
detail = null;
selectedStationId = null;
body.replaceChildren(empty);
},
dispose() {
window.clearTimeout(resizeTimer);
resizeObserver.disconnect();
},
};
}