/* ============================================================================= * 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(); 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(); }, }; }