Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts
T
2026-07-25 13:56:46 +09:00

237 lines
9.1 KiB
TypeScript

/* =============================================================================
* B06_wf3_ProfileCross_UI_Section_View.ts
* 종·횡단 도면 뷰 컨트롤러: 종단 렌더러·횡단 카드 렌더러를 조립하고, 측점 선택/스크롤,
* 반응형 폭 측정(ResizeObserver), 같은 행 높이 통일, 단건 카드 갱신을 관장한다.
*
* 700줄 제한 대응으로 렌더러 본체는 분리했다:
* - 공통 상수·유틸: `_UI_Section_Common`
* - 종단 렌더러: `_UI_Longitudinal`
* - 횡단 카드 렌더러: `_UI_Cross_View`
* 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다.
* ========================================================================== */
import type { CrossSection, 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 {
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 };
export interface SectionViewController {
root: HTMLElement;
render: (
detail: SectionDetailResponse,
verticalExaggeration: number,
crossHalfWidth?: number,
stationInterval?: number,
) => 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 renderWidth = 0;
let resizeTimer = 0;
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
let cachedYScale: YScaleOptions | undefined;
let cachedStationInterval = 1;
let cachedCardWidth = CROSS_WIDTH;
// 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용).
const cachedRowHeight = new Map<string, number>();
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;
};
const selectStation = (stationId: string, scroll: boolean): void => {
selectedStationId = stationId;
draw();
if (scroll) {
document
.getElementById(`cross-${stationId}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
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 draw = (): void => {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
root.replaceChildren();
const detail = currentDetail;
cachedYScale = calculateYScale(detail);
cachedStationInterval =
currentStationInterval ?? inferStationInterval(detail.longitudinal.stations);
const longitudinalPanel = document.createElement("section");
longitudinalPanel.className = "b06-section__panel";
const longitudinalMinWidth = longitudinalMinimumWidth(
detail.longitudinal,
cachedStationInterval,
);
longitudinalPanel.append(
createLongitudinalProfile(
detail.longitudinal,
selectedStationId,
currentExaggeration,
cachedYScale,
(stationId) => selectStation(stationId, true),
cachedStationInterval,
Math.max(renderWidth, longitudinalMinWidth),
LONG_HEIGHT,
longitudinalMinWidth,
detail.longitudinal.design_profiles ?? [],
),
);
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")));
}
root.append(longitudinalPanel, crossHeading, grid);
};
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)));
};
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) {
currentDetail = detail;
currentExaggeration = Math.max(verticalExaggeration, 0.1);
currentCrossHalfWidth =
crossHalfWidth !== undefined && crossHalfWidth > 0 ? crossHalfWidth : undefined;
currentStationInterval =
stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined;
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);
resizeObserver.disconnect();
},
};
}