Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts
T
eomsangdonandClaude Opus 5 bf7d780f9a fix(B06): 구간 물량 말풍선화, 패널 제목·캐럿 정리, 곡선 2종 축소
- 횡단 카드 측점 종류 표기에서 "일반 측점" 제거(BP/EP만 남김).
- 구간 물량을 패널 아래 칩 줄에서 유토곡선 위 둥근 사각 말풍선으로 옮긴다. 말풍선이 놓일
  가로 구간의 곡선 세로 범위를 실측해 위/아래 빈 곳 중 들어가는 쪽에 놓고, 좌우 후보를
  모두 재서 여유가 큰 조합을 고른다. HTML 오버레이 대신 SVG 안에 그려 X축 정렬을 지킨다.
- 패널 제목 문구 삭제. 헤더는 캐럿만 남긴 손잡이 행이며, 캐럿은 좌측 사이드 패널과 같은
  공용 규칙을 쓰되 방향만 상하(펼침 ▴ / 접힘 ▾)로 바꿨다.
- 측점 라벨을 종단 그래프 바닥에 붙이고 유토곡선 상하 여백을 좁혀 두 그래프가 측점 표기를
  공유하는 것처럼 보이게 한다. 좌우 여백(LONG_PAD)은 X축 정렬 때문에 그대로 둔다.
- 패널을 줄이면 그래프 폭까지 줄던 문제: SVG가 flex 기본값으로 세로로 눌리면서
  preserveAspectRatio(meet)가 가로도 같은 비율로 축소한 것. flex: none으로 막고 패널에
  최소 높이를 건다.
- 토사 단일 환산·환산 없음 곡선 삭제. 절토가 암반 경계선으로 갈려 나온 뒤로는 부피비를
  단일값이나 1로 넣은 곡선에 정보가 없다. 곡선 키 형식이 바뀌어 세션 저장 키를 v2로 올렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:50:38 +09:00

478 lines
21 KiB
TypeScript

/* =============================================================================
* B06_wf3_ProfileCross_UI_Section_View.ts
* 종·횡단 도면 뷰 컨트롤러: 상단 패널(종단면도 + 유토곡선)과 횡단 카드 그리드를 조립하고,
* 측점 선택/스크롤, 반응형 폭 측정(ResizeObserver), 패널 높이 조절, 같은 행 높이 통일,
* 단건 카드 갱신을 관장한다.
*
* 700줄 제한 대응으로 렌더러 본체는 분리했다:
* - 공통 상수·유틸: `_UI_Section_Common`
* - 종단 렌더러: `_UI_Longitudinal`
* - 유토곡선 계산: `_UI_MassHaul`
* - 유토곡선 렌더러: `_UI_MassHaul_View`
* - 횡단 카드 렌더러: `_UI_Cross_View`
* 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다.
* ========================================================================== */
import { createPanelResizer } from "@ui/ui_template_resizer";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import type {
CrossSection,
EarthworkConversion,
SectionDetailResponse,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import type { RockBoundaryControl } from "./B06_wf3_ProfileCross_UI_Cross_Design";
import {
createCrossSectionCard,
crossCardNaturalHeight,
} from "./B06_wf3_ProfileCross_UI_Cross_View";
import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "./B06_wf3_ProfileCross_UI_Longitudinal";
import {
computeMassHaulSeries,
MASS_HAUL_DEFAULT_VISIBLE,
type MassHaulSeries,
} from "./B06_wf3_ProfileCross_UI_MassHaul";
import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulSummary,
MASS_HAUL_HEIGHT,
MASS_HAUL_MIN_HEIGHT,
} from "./B06_wf3_ProfileCross_UI_MassHaul_View";
import {
calculateYScale,
CROSS_GRID_GAP,
CROSS_GRID_MIN_WIDTH,
CROSS_WIDTH,
type CrossDesignChange,
type DesignChangeHandler,
designElevationAt,
emptyView,
inferStationInterval,
L,
LONG_HEIGHT,
type YScaleOptions,
} from "./B06_wf3_ProfileCross_UI_Section_Common";
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
/**
* 헤더(캐럿 행)·범례줄·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프
* 몫이 된다. (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래가 잘린다.
* 범례는 CSS에서 한 줄 고정 가로 스크롤이라 개수가 늘어도 이 값이 흔들리지 않는다.)
*/
const PANEL_CHROME_PX = 130;
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
const MIN_LONG_HEIGHT = 110;
const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_PX;
const MAX_PANEL_HEIGHT_RATIO = 0.8;
const PANEL_HEIGHT_KEY = "b06:profile-panel-height";
const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed";
// 곡선 키가 `${기준}_${환산}`에서 기준 하나로 바뀌었다 — 옛 세션값을 읽으면 전부 꺼진 것처럼
// 보이므로 저장 키의 판을 올려 갈아탄다.
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v2";
/** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */
function readVisibleSeries(): Set<string> {
try {
const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY);
if (!raw) return new Set(MASS_HAUL_DEFAULT_VISIBLE);
const parsed: unknown = JSON.parse(raw);
// 전부 끈 상태(빈 배열)도 사용자의 선택이므로 기본값으로 되돌리지 않는다.
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(MASS_HAUL_DEFAULT_VISIBLE);
} catch {
return new Set(MASS_HAUL_DEFAULT_VISIBLE);
}
}
/**
* 패널 높이를 두 그래프에 나눈다 (2026-08-02 사용자 지시).
* 기본값 이상으로 키우면 → 종단도는 현재 높이 그대로 두고 늘어난 몫은 유토곡선이 먹는다.
* 기본값보다 줄이면 → 두 그래프가 같은 비율로 함께 작아진다.
*/
function chartHeights(panelHeightPx: number): { long: number; mass: number } {
if (!Number.isFinite(panelHeightPx) || panelHeightPx <= 0) {
return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT };
}
if (panelHeightPx >= BASE_PANEL_HEIGHT) {
return {
long: LONG_HEIGHT,
mass: Math.max(MASS_HAUL_MIN_HEIGHT, panelHeightPx - PANEL_CHROME_PX - LONG_HEIGHT),
};
}
const ratio = panelHeightPx / BASE_PANEL_HEIGHT;
return {
long: Math.max(MIN_LONG_HEIGHT, Math.round(LONG_HEIGHT * ratio)),
mass: Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)),
};
}
/**
* 종단 렌더러가 돌려준 `chart-wrap`에서 SVG만 꺼낸다.
* 종단도와 유토곡선이 **하나의 가로 스크롤 컨테이너**를 공유해야 X축 정렬이 유지된다
* (각자 wrap을 가지면 스크롤이 따로 놀아 같은 측점이 어긋나 보인다).
*/
function unwrapChart(node: HTMLElement): Element {
return node.classList.contains("b06-section__chart-wrap") && node.firstElementChild
? node.firstElementChild
: node;
}
export interface SectionViewController {
root: HTMLElement;
render: (
detail: SectionDetailResponse,
verticalExaggeration: number,
crossHalfWidth?: number,
stationInterval?: number,
earthworkConversion?: EarthworkConversion,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
clear: () => void;
dispose: () => void;
}
export function createSectionView(
onDesignChange?: DesignChangeHandler,
rockBoundary?: RockBoundaryControl,
): SectionViewController {
const root = document.createElement("div");
root.className = "b06-section";
let currentDetail: SectionDetailResponse | null = null;
let selectedStationId: string | null = null;
let currentExaggeration = 1;
let currentCrossHalfWidth: number | undefined;
let currentStationInterval: number | undefined;
let currentConversion: EarthworkConversion | undefined;
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
// 유토곡선 표시 곡선 집합. 토글해도 Y 스케일은 전체 곡선 기준이라 축이 움직이지 않는다.
const visibleSeries = readVisibleSeries();
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
let cachedYScale: YScaleOptions | undefined;
let cachedStationInterval = 1;
let cachedCardWidth = CROSS_WIDTH;
// 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용).
const cachedRowHeight = new Map<string, number>();
/* ── 상단 슬라이드 패널(종단면도 + 유토곡선) ─────────────────────────
* 패널·리사이저는 한 번만 만들고 draw()에서는 차트 내용만 갈아 끼운다.
* 매 렌더마다 새로 만들면 리사이저 핸들과 접힘 상태가 초기화된다. */
const panel = document.createElement("section");
panel.className = "b06-section__panel ui-collapsible";
// 최소 높이는 TS 상수 하나로만 정의한다(세션에 저장된 옛 높이가 더 작아도 여기서 걸린다).
panel.style.minHeight = `${MIN_PANEL_HEIGHT}px`;
// 제목 문구는 없앴다(2026-08-02 사용자 지시). 헤더 행은 접기/펼치기 손잡이 겸 상태 표시줄이며,
// 캐럿은 공용 `ui-collapsible` 규칙이 그린다(방향만 B06 CSS에서 상하로 바꾼다).
const panelHeader = document.createElement("header");
panelHeader.className = "ui-collapsible__title";
panelHeader.setAttribute("aria-label", L("B06_Profile_View_Longitudinal"));
const panelCount = document.createElement("span");
panelHeader.append(panelCount);
const chartWrap = document.createElement("div");
chartWrap.className = "b06-section__chart-wrap";
const panelBody = document.createElement("div");
panelBody.className = "b06-section__panel-body";
panelBody.append(chartWrap);
panel.append(panelHeader, panelBody);
attachCollapsible(panel);
// 접힘 상태는 세션에만 남긴다(리사이저와 같은 규칙).
if (sessionStorage.getItem(PANEL_COLLAPSED_KEY) === "true") panel.classList.add("is-collapsed");
// attachCollapsible보다 **뒤에** 붙여야 토글이 끝난 상태를 읽는다(헤더에 붙이면 토글 전 값이 저장된다).
panel.addEventListener("click", (event) => {
if (!(event.target as HTMLElement).closest(".ui-collapsible__title")) return;
sessionStorage.setItem(PANEL_COLLAPSED_KEY, String(panel.classList.contains("is-collapsed")));
});
const panelResizer = createPanelResizer({
axis: "vertical",
target: panel,
cssVar: "--b06-profile-height",
// 손잡이가 패널 아래쪽에 있으므로 아래로 끌면 커진다.
direction: 1,
min: MIN_PANEL_HEIGHT,
// 부모(`.b06-profile__main`)는 횡단 카드까지 담은 스크롤 콘텐츠라 높이가 수천 px다.
// 이 패널은 화면 위에 sticky로 붙어 있으니 상한은 화면 높이 기준이어야 한다.
max: () => window.innerHeight * MAX_PANEL_HEIGHT_RATIO,
storageKey: PANEL_HEIGHT_KEY,
onResize: () => {
// 끄는 동안 매 프레임 SVG를 다시 그리면 손이 늦게 따라온다 — 멈춘 뒤 한 번만 그린다.
window.clearTimeout(panelResizeTimer);
panelResizeTimer = window.setTimeout(() => {
drawPanel();
// 패널이 커지면 선택 카드가 그 아래로 들어간다 — 새 패널 높이에 맞춰 다시 끌어올린다.
revealCard(selectedStationId, "auto");
}, 120);
},
});
panel.append(panelResizer.root);
/**
* 패널 위에서 굴린 휠은 페이지가 아니라 **그래프를 좌우로** 민다(2026-08-02 사용자 지시).
* 가로로 더 밀 곳이 없으면 기본 동작(세로 스크롤)을 그대로 넘긴다.
*/
panel.addEventListener(
"wheel",
(event) => {
if (event.ctrlKey) return; // 브라우저 확대 제스처는 건드리지 않는다
const room = chartWrap.scrollWidth - chartWrap.clientWidth;
if (room <= 1) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (!delta) return;
const before = chartWrap.scrollLeft;
chartWrap.scrollLeft = Math.min(Math.max(before + delta, 0), room);
if (chartWrap.scrollLeft !== before) event.preventDefault();
},
{ passive: false },
);
const panelHeight = (): number => {
// 첫 렌더 때 panel은 아직 root에 붙기 전(detached)이라 getComputedStyle이 빈 값을 준다.
// 리사이저가 복원해 둔 인라인 변수부터 읽어야 저장된 높이가 첫 화면부터 반영된다.
// (computed만 읽으면 기본값으로 폴백해 종단도가 잘린 채로 그려진다 — C05 검증 F1)
const inline = Number.parseFloat(panel.style.getPropertyValue("--b06-profile-height"));
if (Number.isFinite(inline) && inline > 0) return inline;
const raw = Number.parseFloat(getComputedStyle(panel).getPropertyValue("--b06-profile-height"));
return Number.isFinite(raw) && raw > 0 ? raw : BASE_PANEL_HEIGHT;
};
const contentWidth = (): number => {
// 요소가 DOM 밖(detached)이면 computed padding이 ""라 parseFloat이 NaN을 만든다.
// NaN이 renderWidth로 흘러가면 종단 SVG 전 좌표가 NaN이 되므로 0으로 방어한다.
const style = getComputedStyle(root);
const pad = (value: string): number => Number.parseFloat(value) || 0;
const width = root.clientWidth - pad(style.paddingLeft) - pad(style.paddingRight);
return Number.isFinite(width) ? Math.max(0, width) : 0;
};
/**
* 선택 카드를 화면에 남긴다. 상단 패널이 sticky라 `center`로 보내면 패널이 커졌을 때
* 그 아래로 숨는다 — `nearest` + `scroll-margin-top`(패널 높이)이면 가려질 때만,
* 가려지지 않을 만큼만 움직인다.
*/
const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => {
if (!stationId) return;
document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "nearest" });
};
const selectStation = (stationId: string, scroll: boolean): void => {
selectedStationId = stationId;
draw();
if (scroll) revealCard(stationId, "smooth");
};
const buildCrossCard = (section: CrossSection, forcedHeightPx?: number): HTMLElement =>
createCrossSectionCard(
section,
section.station_id === selectedStationId,
currentExaggeration,
(stationId) => selectStation(stationId, false),
cachedStationInterval,
currentCrossHalfWidth,
cachedCardWidth,
forcedHeightPx,
currentDetail
? designElevationAt(currentDetail.longitudinal.design_profiles, section.chainage_m)
: undefined,
onDesignChange,
rockBoundary,
);
/** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */
const toggleSeries = (key: string): void => {
if (visibleSeries.has(key)) visibleSeries.delete(key);
else visibleSeries.add(key);
sessionStorage.setItem(MASS_HAUL_VISIBLE_KEY, JSON.stringify([...visibleSeries]));
drawPanel();
};
/** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */
function drawPanel(): void {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
// 차트를 갈아 끼우면 가로 스크롤이 0으로 돌아간다 — 측점 버튼 하나 눌렀다고
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
const keepScrollLeft = chartWrap.scrollLeft;
const detail = currentDetail;
const heights = chartHeights(panelHeight());
const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval);
const chartWidth = Math.max(renderWidth, minWidth);
// 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다.
cachedYScale = calculateYScale(detail, heights.long);
const nodes: Element[] = [
unwrapChart(
createLongitudinalProfile(
detail.longitudinal,
selectedStationId,
currentExaggeration,
cachedYScale,
(stationId) => selectStation(stationId, true),
cachedStationInterval,
chartWidth,
heights.long,
minWidth,
detail.longitudinal.design_profiles ?? [],
),
),
];
const series: MassHaulSeries[] = currentConversion
? computeMassHaulSeries(detail.longitudinal, detail.cross_sections, currentConversion)
: [];
if (series.length) {
// 유토곡선 SVG는 종단면도와 **같은 부모의 형제**여야 한다. 감싸는 상자를 하나라도 끼우면
// 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점 세로선이 어긋난다.
nodes.push(
createMassHaulChart(
series,
visibleSeries,
detail.longitudinal,
selectedStationId,
cachedStationInterval,
chartWidth,
heights.mass,
minWidth,
(stationId) => selectStation(stationId, true),
),
);
}
chartWrap.replaceChildren(...nodes);
panel.querySelector(".b06-masshaul__legend")?.remove();
panel.querySelector(".b06-masshaul__summary")?.remove();
// 범례는 그래프 아래, 스크롤 컨테이너 밖에 둔다(곡선 폭에 관여하지 않게).
if (series.length) panelBody.append(createMassHaulLegend(series, visibleSeries, toggleSeries));
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
// (선택 측점의 구간 물량은 곡선 위 말풍선이 맡는다.)
const summarySeries = series.find((entry) => visibleSeries.has(entry.key));
if (summarySeries) {
panelCount.textContent = "";
panelBody.append(createMassHaulSummary(summarySeries));
} else {
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
}
chartWrap.scrollLeft = keepScrollLeft;
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
const panelPx = panel.getBoundingClientRect().height || panelHeight();
root.style.setProperty("--b06-cross-scroll-margin", `${Math.round(panelPx + 12)}px`);
}
const draw = (): void => {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
const detail = currentDetail;
cachedStationInterval =
currentStationInterval ?? inferStationInterval(detail.longitudinal.stations);
drawPanel();
const crossHeading = document.createElement("div");
crossHeading.className = "b06-section__heading";
const crossTitle = document.createElement("h3");
crossTitle.textContent = L("B06_Profile_View_Cross");
const crossCount = document.createElement("span");
crossCount.textContent = `${detail.cross_sections.length}${L("B06_Profile_View_CrossCountSuffix")}`;
crossHeading.append(crossTitle, crossCount);
const grid = document.createElement("div");
grid.className = "b06-section__grid";
const columnCount = Math.max(
1,
Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)),
);
// CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수를 JS가 명시해 일치시킨다.
grid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`;
cachedCardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount;
cachedRowHeight.clear();
if (detail.cross_sections.length) {
// 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로.
const sections = detail.cross_sections;
const naturalHeights = sections.map((section) =>
crossCardNaturalHeight(
section,
currentExaggeration,
cachedCardWidth,
currentCrossHalfWidth,
designElevationAt(detail.longitudinal.design_profiles, section.chainage_m),
),
);
// 2차: 행별 최댓값으로 강제 높이를 정해 같은 행 카드를 동일 높이로 렌더한다.
for (let start = 0; start < sections.length; start += columnCount) {
const rowHeight = Math.max(...naturalHeights.slice(start, start + columnCount));
for (
let index = start;
index < Math.min(start + columnCount, sections.length);
index += 1
) {
cachedRowHeight.set(sections[index].station_id, rowHeight);
grid.append(buildCrossCard(sections[index], rowHeight));
}
}
} else {
grid.append(emptyView(L("B06_Profile_View_NoCross")));
}
// panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다.
const keepScrollLeft = chartWrap.scrollLeft;
root.replaceChildren(panel, crossHeading, grid);
chartWrap.scrollLeft = keepScrollLeft;
};
const refreshCard = (chainageM: number): void => {
if (!currentDetail) return;
const section = currentDetail.cross_sections.find(
(candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01,
);
if (!section) return;
const existing = document.getElementById(`cross-${section.station_id}`);
// 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다.
if (existing)
existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id)));
// 단면적이 바뀌면 유토곡선도 함께 흔들리므로 상단 패널만 다시 그린다(카드 전체 재렌더 없음).
drawPanel();
};
const resizeObserver = new ResizeObserver(() => {
const nextWidth = contentWidth();
if (!Number.isFinite(nextWidth) || nextWidth <= 0 || Math.abs(nextWidth - renderWidth) < 1)
return;
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
renderWidth = nextWidth;
draw();
}, 150);
});
resizeObserver.observe(root);
return {
root,
render(detail, verticalExaggeration, crossHalfWidth, stationInterval, earthworkConversion) {
currentDetail = detail;
currentExaggeration = Math.max(verticalExaggeration, 0.1);
currentCrossHalfWidth =
crossHalfWidth !== undefined && crossHalfWidth > 0 ? crossHalfWidth : undefined;
currentStationInterval =
stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined;
if (earthworkConversion) currentConversion = earthworkConversion;
selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null;
renderWidth = contentWidth();
draw();
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root));
},
refreshCard,
clear() {
currentDetail = null;
selectedStationId = null;
root.replaceChildren();
},
dispose() {
window.clearTimeout(resizeTimer);
window.clearTimeout(panelResizeTimer);
panelResizer.dispose();
resizeObserver.disconnect();
},
};
}