403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* B05_wf2_Route_UI_Profile_Panel.ts
|
|
* 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집.
|
|
*
|
|
* 화면 높이의 60%를 쓰며, 그래프와 12행 도면 테이블이 **하나의 가로 스크롤러** 안에
|
|
* 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요).
|
|
* 본문 세로는 그래프 30% : 테이블 70%로 나눈다.
|
|
*
|
|
* 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에
|
|
* `saveProfileAlignment()`로 편집 델타만 보낸다.
|
|
* ========================================================================== */
|
|
|
|
import type {
|
|
DesignProfile,
|
|
LongitudinalSection,
|
|
SectionDetailResponse,
|
|
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
|
|
import {
|
|
createLongitudinalProfile,
|
|
longitudinalMinimumWidth,
|
|
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal";
|
|
import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
|
|
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { saveProfileAlignment } from "./B05_wf2_Route_Api_Fetch";
|
|
import type {
|
|
AlignmentBase,
|
|
AlignmentEdits,
|
|
ProfileAlignment,
|
|
} from "./B05_wf2_Route_UI_Profile_Alignment";
|
|
import {
|
|
adjustStation,
|
|
buildAlignment,
|
|
emptyEdits,
|
|
setCurveRadius,
|
|
shiftSegment,
|
|
toAlignmentBase,
|
|
} from "./B05_wf2_Route_UI_Profile_Alignment";
|
|
import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit";
|
|
import { createProfileTable } from "./B05_wf2_Route_UI_Profile_Table";
|
|
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
|
|
|
|
const COLLAPSED_KEY = "b05-route-profile-collapsed";
|
|
/** 정보 라인을 뺀 본문 세로를 그래프 30% : 테이블 70%로 나눈다. */
|
|
const CHART_HEIGHT_RATIO = 0.3;
|
|
const MIN_CHART_HEIGHT = 100;
|
|
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
|
|
const TABLE_ROW_COUNT = 12;
|
|
|
|
function readAlignment(data: LongitudinalSection): ProfileAlignment | null {
|
|
const candidate = data.profile_alignment as ProfileAlignment | undefined;
|
|
if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null;
|
|
return candidate;
|
|
}
|
|
|
|
/** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */
|
|
function toDesignProfile(
|
|
alignment: ProfileAlignment,
|
|
original: DesignProfile | undefined,
|
|
): DesignProfile {
|
|
const balance = alignment.balance;
|
|
return {
|
|
id: original?.id ?? "design_grade_line",
|
|
name: original?.name ?? "계획선",
|
|
basis: original?.basis ?? "station_alignment",
|
|
samples: alignment.samples,
|
|
balance_segments: [
|
|
{
|
|
index: 0,
|
|
start_chainage_m: alignment.samples[0]?.chainage_m ?? 0,
|
|
end_chainage_m: alignment.samples[alignment.samples.length - 1]?.chainage_m ?? 0,
|
|
cut_area_m2: balance.cut_area_m2,
|
|
fill_area_m2: balance.fill_area_m2,
|
|
balance_error_m2: balance.net_area_m2,
|
|
},
|
|
],
|
|
summary: {
|
|
...(original?.summary ?? {
|
|
max_grade_pct: 0,
|
|
vertical_curve_count: 0,
|
|
pvi_count: 0,
|
|
balance_segment_count: 1,
|
|
main_direction: "none",
|
|
suggested_elevation_offset_m: null,
|
|
warnings: [],
|
|
}),
|
|
cut_area_m2: balance.cut_area_m2,
|
|
fill_area_m2: balance.fill_area_m2,
|
|
balance_error_m2: balance.net_area_m2,
|
|
balanced: balance.within_tolerance,
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection {
|
|
return {
|
|
...data,
|
|
samples: data.samples.map((sample) => ({
|
|
...sample,
|
|
elevation_m: sample.elevation_m ?? sample.z ?? null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
|
|
function chainageMapper(data: LongitudinalSection, width: number): (chainage: number) => number {
|
|
const samples = normalizedLongitudinal(data).samples.filter(
|
|
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
|
|
);
|
|
const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
|
const plotWidth = width - LONG_PAD.left - LONG_PAD.right;
|
|
return (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
|
|
}
|
|
|
|
/** 이웃 측점과 겹치지 않는 테이블 셀 폭 (글자 크기를 정하는 기준이기도 하다). */
|
|
function stationCellWidth(data: LongitudinalSection, x: (chainage: number) => number): number {
|
|
const stations = data.stations;
|
|
if (stations.length < 2) return 72;
|
|
const span = x(stations[stations.length - 1].chainage_m) - x(stations[0].chainage_m);
|
|
return Math.max(30, Math.min(span / (stations.length - 1) - 2, 96));
|
|
}
|
|
|
|
export function createRouteProfilePanel(
|
|
projectId: string,
|
|
onSelectStation: (stationId: string) => void,
|
|
) {
|
|
const root = document.createElement("section");
|
|
root.className = "b05-route-profile";
|
|
const panelHandle = createWorkflowPanelHandle("bottom");
|
|
const balanceBar = document.createElement("div");
|
|
balanceBar.className = "b05-route-profile__balance";
|
|
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(panelHandle.root, balanceBar, body);
|
|
|
|
let detail: SectionDetailResponse | null = null;
|
|
let selectedStationId: string | null = null;
|
|
let stationInterval: number | undefined;
|
|
let routeId: number | null = null;
|
|
let base: AlignmentBase | null = null;
|
|
let alignment: ProfileAlignment | null = null;
|
|
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
|
let resizeTimer = 0;
|
|
let redrawPending = false;
|
|
let lastWidth = 0;
|
|
let lastHeight = 0;
|
|
|
|
function renderBalance(): void {
|
|
balanceBar.replaceChildren();
|
|
if (!alignment) return;
|
|
const { balance, policy, violations } = alignment;
|
|
const entries: Array<[string, string, string?]> = [
|
|
["절토", `${balance.cut_area_m2.toFixed(1)} m²`, "cut"],
|
|
["성토", `${balance.fill_area_m2.toFixed(1)} m²`, "fill"],
|
|
[
|
|
"불균형",
|
|
`${balance.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`,
|
|
balance.within_tolerance ? undefined : "over",
|
|
],
|
|
["변화점", `${alignment.pvi.length} 개`],
|
|
["종단곡선", `${alignment.curves.filter((curve) => !curve.omitted).length} 개`],
|
|
["기본 R", `${policy.default_curve_radius_m.toFixed(1)} m`],
|
|
];
|
|
const editedCount = Object.keys(alignment.edits.station_offsets).length;
|
|
if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]);
|
|
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));
|
|
balanceBar.append(item);
|
|
});
|
|
if (violations.length) {
|
|
const warning = document.createElement("span");
|
|
warning.className = "b05-route-profile__balance-warning";
|
|
warning.textContent = `⚠ 종단기울기 초과 ${violations.length}개 구간`;
|
|
warning.title = violations
|
|
.map((item) => `구간 ${item.segment_index + 1}: ${item.value.toFixed(2)}% > ${item.limit}%`)
|
|
.join("\n");
|
|
balanceBar.append(warning);
|
|
}
|
|
if (store.edited()) {
|
|
const reset = document.createElement("button");
|
|
reset.type = "button";
|
|
reset.className = "b05-route-profile__balance-reset";
|
|
reset.textContent = "초기선 복원";
|
|
reset.title = "모든 편집을 지우고 자동 산출된 계획선으로 되돌립니다.";
|
|
reset.addEventListener("click", () => store.resetAll());
|
|
balanceBar.append(reset);
|
|
}
|
|
if (store.dirty()) {
|
|
const badge = document.createElement("span");
|
|
badge.className = "b05-route-profile__balance-item is-unsaved";
|
|
badge.textContent = "미저장 (확정 시 반영)";
|
|
balanceBar.append(badge);
|
|
}
|
|
}
|
|
|
|
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
|
|
function applyEdits(next: AlignmentEdits): void {
|
|
if (!base || !alignment) return;
|
|
const candidate = buildAlignment(base, next);
|
|
if (
|
|
base.policy.grade_violation_policy === "block" &&
|
|
candidate.violations.length > alignment.violations.length
|
|
) {
|
|
showToast(
|
|
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
store.replace(next);
|
|
}
|
|
|
|
/**
|
|
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
|
* 실제 렌더링하도록 모은다.
|
|
*/
|
|
function rebuild(): void {
|
|
if (base) alignment = buildAlignment(base, store.edits());
|
|
if (redrawPending) return;
|
|
redrawPending = true;
|
|
requestAnimationFrame(() => {
|
|
redrawPending = false;
|
|
draw();
|
|
});
|
|
}
|
|
|
|
function draw(): void {
|
|
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
|
|
lastWidth = body.clientWidth;
|
|
lastHeight = body.clientHeight;
|
|
// 편집할 때마다 본문을 갈아끼우므로 보고 있던 가로 위치를 잃지 않게 되돌린다.
|
|
const scrollLeft = body.scrollLeft;
|
|
renderBalance();
|
|
|
|
const longitudinal = detail.longitudinal;
|
|
const minimumWidth = longitudinalMinimumWidth(longitudinal, stationInterval);
|
|
const width = Math.max(Math.max(1, body.clientWidth - 30), minimumWidth);
|
|
const canvas = document.createElement("div");
|
|
canvas.className = "b05-profile__canvas";
|
|
canvas.style.width = `${width}px`;
|
|
|
|
const x = chainageMapper(longitudinal, width);
|
|
// 그래프 30% : 테이블 70% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
|
|
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
|
|
const available = Math.max(120, body.clientHeight);
|
|
const chartHeight = alignment
|
|
? Math.max(MIN_CHART_HEIGHT, Math.round(available * CHART_HEIGHT_RATIO))
|
|
: available;
|
|
const tableHeight = available - chartHeight;
|
|
|
|
const table = alignment
|
|
? createProfileTable({
|
|
alignment,
|
|
stationInterval: stationInterval ?? alignment.policy.station_interval_m,
|
|
width,
|
|
height: tableHeight,
|
|
cellWidth: stationCellWidth(longitudinal, x),
|
|
rowCount: TABLE_ROW_COUNT,
|
|
x,
|
|
onCurveRadiusChange: (curve, radius) =>
|
|
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
|
|
})
|
|
: null;
|
|
|
|
const chartWrap = document.createElement("div");
|
|
chartWrap.className = "b05-profile__chart";
|
|
chartWrap.style.height = `${chartHeight}px`;
|
|
const designProfiles = alignment
|
|
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
|
|
: (longitudinal.design_profiles ?? []);
|
|
chartWrap.append(
|
|
createLongitudinalProfile(
|
|
normalizedLongitudinal(longitudinal),
|
|
selectedStationId,
|
|
1,
|
|
undefined,
|
|
onSelectStation,
|
|
stationInterval,
|
|
width,
|
|
chartHeight,
|
|
width,
|
|
designProfiles,
|
|
),
|
|
);
|
|
if (alignment) {
|
|
chartWrap.append(
|
|
createEditOverlay({
|
|
alignment,
|
|
width,
|
|
x,
|
|
step: alignment.policy.edit_step_m,
|
|
onStation: (chainage, delta) =>
|
|
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
|
|
onSegment: (segment, delta) =>
|
|
base && applyEdits(shiftSegment(base, store.edits(), segment, delta)),
|
|
onResetStation: (chainage) => store.resetStation(chainage),
|
|
}),
|
|
);
|
|
}
|
|
canvas.style.height = `${available}px`;
|
|
canvas.append(chartWrap);
|
|
if (table) canvas.append(table);
|
|
body.replaceChildren(canvas);
|
|
body.scrollLeft = scrollLeft;
|
|
}
|
|
|
|
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
|
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
|
body.addEventListener(
|
|
"wheel",
|
|
(event) => {
|
|
if (event.shiftKey || event.deltaY === 0) return;
|
|
const delta = event.deltaY;
|
|
const limit = body.scrollWidth - body.clientWidth;
|
|
if (limit <= 0) return;
|
|
if ((delta < 0 && body.scrollLeft <= 0) || (delta > 0 && body.scrollLeft >= limit)) return;
|
|
body.scrollLeft += delta;
|
|
event.preventDefault();
|
|
},
|
|
{ passive: false },
|
|
);
|
|
|
|
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);
|
|
}
|
|
|
|
panelHandle.root.addEventListener("click", () =>
|
|
setCollapsed(!root.classList.contains("is-collapsed")),
|
|
);
|
|
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true");
|
|
|
|
return {
|
|
root,
|
|
render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) {
|
|
detail = nextDetail;
|
|
stationInterval = nextStationInterval;
|
|
const stored = readAlignment(nextDetail.longitudinal);
|
|
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
|
|
if (nextRouteId !== routeId || !store.dirty()) {
|
|
routeId = nextRouteId ?? routeId;
|
|
store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild());
|
|
}
|
|
base = stored ? toAlignmentBase(stored) : null;
|
|
alignment = base ? buildAlignment(base, store.edits()) : null;
|
|
draw();
|
|
requestAnimationFrame(draw);
|
|
},
|
|
setSelectedStation(stationId: string | null) {
|
|
selectedStationId = stationId;
|
|
draw();
|
|
},
|
|
isDirty: () => store.dirty(),
|
|
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
|
async save(): Promise<void> {
|
|
if (!routeId || !store.dirty()) return;
|
|
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
|
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
|
if (next?.base_pvi?.length) {
|
|
base = toAlignmentBase(next);
|
|
alignment = next;
|
|
if (detail) detail.longitudinal.profile_alignment = next;
|
|
}
|
|
store.markSaved();
|
|
draw();
|
|
},
|
|
clear() {
|
|
detail = null;
|
|
base = null;
|
|
alignment = null;
|
|
selectedStationId = null;
|
|
balanceBar.replaceChildren();
|
|
body.replaceChildren(empty);
|
|
},
|
|
dispose() {
|
|
window.clearTimeout(resizeTimer);
|
|
resizeObserver.disconnect();
|
|
},
|
|
};
|
|
}
|