/* ============================================================================= * B05_wf2_Route_UI_Profile_Panel.ts * 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집. * * 화면 높이의 60%를 쓰며, 그래프와 12행 도면 테이블이 **하나의 가로 스크롤러** 안에 * 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요). * 본문 세로는 그래프 40% : 테이블 60%로 나눈다. * * 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에 * `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, chainageKey, emptyEdits, setCurveRadius, shiftSegment, toAlignmentBase, } from "./B05_wf2_Route_UI_Profile_Alignment"; import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit"; import { createProfileTable, tableCellWidthFor, TABLE_TARGET_FONT_PX, } from "./B05_wf2_Route_UI_Profile_Table"; import { irregularLabel, irregularStationId, type IrregularStation, } from "./B05_wf2_Route_UI_IrregularStations"; import type { SectionStation } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; /** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */ const CHART_HEIGHT_RATIO = 0.4; const MIN_CHART_HEIGHT = 100; /** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */ const TABLE_ROW_COUNT = 12; /** * 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**. * * 곡선 기준이 길이(L)에서 반경(R)으로 바뀌기 전에 만들어진 데이터는 `policy`에 * `default_curve_radius_m`가 없어 그대로 쓰면 계산 도중 터진다. 그런 데이터는 * 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다. */ function readAlignment(data: LongitudinalSection): ProfileAlignment | null { const candidate = data.profile_alignment as ProfileAlignment | undefined; if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null; if (!Number.isFinite(candidate.policy?.default_curve_radius_m)) return null; if (!candidate.stations || !candidate.segments || !candidate.curves) return null; candidate.edits = { station_offsets: candidate.edits?.station_offsets ?? {}, curve_radii: candidate.edits?.curve_radii ?? {}, }; return candidate; } /** 저장분이 구버전이라 편집을 붙일 수 없는 상태인가 (재계산 안내용). */ function hasLegacyAlignment(data: LongitudinalSection): boolean { return Boolean(data.profile_alignment) && readAlignment(data) === null; } /** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */ 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, })), }; } /** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */ const CELL_GAP_PX = 2; /** * 측점 한 칸의 **기준 폭(px)** — 목표 글자 크기(12px)로 7자리 값(`3000.00`)이 잘리지 않는 크기. * 실제 기본 간격은 여기에 배수(`PROFILE_SPACING_MULTIPLIER`)를 곱한 값을 쓴다. */ const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX; /** * **측점 간격 기본 배수**. 기준 폭의 1.5배를 한 측점 칸의 기본 간격으로 삼는다. * * 이 배수로 펼친 폭이 **최소 폭**이다 — 브라우저가 이보다 넓으면 폭맞춤으로 늘리고, 좁으면 * 이 간격을 유지한 채 스크롤로 훑는다. 넉넉한 기본값은 다음 세션의 **비정규 측점**(`+18` 등)이 * 규칙 칸 안에서 chainage 비례로 자리 잡을 여유도 함께 확보한다. */ const PROFILE_SPACING_MULTIPLIER = 1.5; /** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */ function maxChainageOf(data: LongitudinalSection): number { const samples = normalizedLongitudinal(data).samples.filter( (sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN), ); return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); } /** * 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만 * 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외. */ function irregularGraphStations(list: IrregularStation[], maxChainage: number): SectionStation[] { return list .filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6) .map((entry) => ({ station_id: irregularStationId(entry.id), chainage_m: entry.chainage_m, label: irregularLabel(entry), kind: "irregular" as const, center_z: null, azimuth_deg: null, center_x: 0, center_y: 0, frame: { left_xy: [0, 0] as [number, number] }, })); } /** * 가로 스크롤에도 좌측에 고정되는 Y축(표고 눈금) 오버레이. * * SVG와 **같은 눈금**(렌더러가 콜백으로 넘김)을 쓰고, 불투명 배경으로 스크롤되는 그래프가 * 새어 보이지 않게 가린다. 레이아웃에 영향을 주지 않도록 0크기 sticky 앵커 위에 축을 절대배치한다. */ function buildStickyYAxis( axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }, chartHeight: number, ): HTMLElement { const anchor = document.createElement("div"); anchor.className = "b05-profile__yaxis"; const inner = document.createElement("div"); inner.className = "b05-profile__yaxis-inner"; inner.style.width = `${axis.padLeft}px`; inner.style.height = `${chartHeight}px`; axis.ticks.forEach(({ y, label }) => { const tick = document.createElement("span"); tick.className = "b05-profile__yaxis-tick"; tick.style.top = `${y}px`; tick.textContent = label; inner.append(tick); }); anchor.append(inner); return anchor; } /** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */ function chainageMapper( data: LongitudinalSection, width: number, originOffset: number, ): (chainage: number) => number { const maxChainage = maxChainageOf(data); const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset; return (chainage: number) => LONG_PAD.left + originOffset + (chainage / maxChainage) * plotWidth; } interface ProfileLayout { /** 캔버스 폭(px). 화면이 넓으면 폭맞춤으로, 좁으면 최소 폭으로. */ width: number; /** 0측점·종점을 축 프레임 안으로 반 칸씩 들여쓰는 여백(px) — 그래프·테이블 공통. */ originOffset: number; /** 이웃 측점과 겹치지 않는 테이블 셀 폭(px). 실제 측점 간격에 맞춰 함께 늘어난다. */ cellWidth: number; } /** * 측점 간격 기본값(기준 폭 × 1.5)으로 노선을 펼치되, 화면이 더 넓으면 폭맞춤으로 늘린다. * * 매핑은 `x(c) = LONG_PAD.left + halfCell + c·pxPerMeter`이고, 좌우로 반 칸(halfCell)씩 띄워 * 0측점 셀이 이름표 열 밖으로, 종점 셀이 오른쪽 끝 밖으로 나오게 한다. 좌우 여백을 합치면 * 한 칸(측점간격)이므로 `width = pads + (maxChainage + interval)·pxPerMeter`가 되고, 이를 뒤집어 * pxPerMeter를 구하면 halfCell·셀 폭이 실제 간격과 항상 맞물린다. */ function computeProfileLayout( data: LongitudinalSection, stationIntervalM: number, availableWidth: number, ): ProfileLayout { const maxChainage = maxChainageOf(data); const interval = Math.max(stationIntervalM, 1e-6); const framePad = LONG_PAD.left + LONG_PAD.right; const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER; // 기본 배수로 펼친 최소 폭 (좌우 반 칸 = 한 칸 여백 포함). const minWidth = framePad + ((maxChainage + interval) / interval) * minSpacing; const width = Math.max(minWidth, availableWidth); const pxPerMeter = (width - framePad) / (maxChainage + interval); const spacing = interval * pxPerMeter; return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) }; } export function createRouteProfilePanel( projectId: string, onSelectStation: (stationId: string) => void, /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 Page가 넘긴다). */ onResetAll?: () => 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 irregularStations: IrregularStation[] = []; // 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지). let stationDisplay = { station: 0, cumulative: 0 }; 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) { if (detail && hasLegacyAlignment(detail.longitudinal)) { const note = document.createElement("span"); note.className = "b05-route-profile__balance-warning"; note.textContent = "⚠ 계획선 데이터가 구버전 형식입니다 — [최적 경로 계산]을 다시 실행하세요."; balanceBar.append(note); } 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() || irregularStations.length) { const reset = document.createElement("button"); reset.type = "button"; reset.className = "b05-route-profile__balance-reset"; reset.textContent = "초기선 복원"; reset.title = "모든 편집과 추가한 비정규 측점을 지우고 자동 산출된 계획선으로 되돌립니다."; reset.addEventListener("click", () => { store.resetAll(); onResetAll?.(); }); 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 availableWidth = Math.max(1, body.clientWidth - 15); // 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면 // 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다. const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m; const layout = alignment && stationIntervalM ? computeProfileLayout(longitudinal, stationIntervalM, availableWidth) : { width: Math.max( availableWidth, longitudinalMinimumWidth(longitudinal, stationInterval), ), originOffset: 0, cellWidth: STATION_SPACING_PX - CELL_GAP_PX, }; const { width, originOffset } = layout; const canvas = document.createElement("div"); canvas.className = "b05-profile__canvas"; canvas.style.width = `${width}px`; const x = chainageMapper(longitudinal, width, originOffset); // 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다). // 가로 스크롤바를 `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: layout.cellWidth, // 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다. labelWidth: LONG_PAD.left, rowCount: TABLE_ROW_COUNT, x, // 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다. irregularStations: irregularStations.filter( (entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, ), selectedStationId, stationDisplay, onCurveRadiusChange: (curve, radius) => applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. onAdjustStation: (chainage, delta) => base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), }) : 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 ?? []); // 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다. const graphData = normalizedLongitudinal(longitudinal); const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal)); const graphLongitudinal = injected.length ? { ...graphData, stations: [...graphData.stations, ...injected].sort( (a, b) => a.chainage_m - b.chainage_m, ), } : graphData; let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; chartWrap.append( createLongitudinalProfile( graphLongitudinal, selectedStationId, 1, undefined, onSelectStation, stationInterval, width, chartHeight, width, designProfiles, originOffset, (axis) => { yAxis = axis; }, stationDisplay.station, ), ); // 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단). if (yAxis) chartWrap.append(buildStickyYAxis(yAxis, chartHeight)); if (alignment) { chartWrap.append( createEditOverlay({ alignment, width, x, step: alignment.policy.edit_step_m, // 비정규 측점도 규칙 측점처럼 ▲/▼ 버튼으로 계획고 조정(임의 chainage 변화점 승격). irregularStations: irregularStations.filter( (entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, ), 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); // 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다. // 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭). const routeChanged = nextRouteId !== routeId; const carried = routeChanged && store.edited() ? store.edits() : null; // 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다. if (routeChanged || !store.dirty()) { routeId = nextRouteId ?? routeId; store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild()); } base = stored ? toAlignmentBase(stored) : null; // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). if (carried) store.replace(carried); alignment = base ? buildAlignment(base, store.edits()) : null; draw(); requestAnimationFrame(draw); }, setSelectedStation(stationId: string | null) { selectedStationId = stationId; draw(); }, /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ setIrregularStations(stations: IrregularStation[]) { irregularStations = stations; draw(); }, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ setStationDisplay(next: { station: number; cumulative: number }) { stationDisplay = next; draw(); }, /** * 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다. * 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다. */ resetStationEdit(chainageM: number) { const key = chainageKey(chainageM); const edits = store.edits(); if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return; store.resetStation(chainageM); }, isDirty: () => store.dirty(), /** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */ async save(): Promise { 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(); }, }; }