/* ============================================================================= * B06_Section_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 { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; // 가로 스크롤 고정 Y축 — B05와 같은 오버레이를 쓴다(정의처: B05 MassHaul 모듈 + 그 CSS). import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul"; import type { CrossSection, EarthworkConversion, HaulEquipmentLimit, SectionDetailResponse, } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; import { createCrossSectionCard, type ExtraWallControl, type InletStructureControl, type RevetOffsetControl, type RevetLinkControl, type StructureSpanControl, crossCardNaturalHeight, type CrossCardElement, type StationWidthControl, } from "./B06_Section_UI_Cross_View"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import { createLongitudinalProfile, longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; import { applyLegendToggle, computeMassHaulSeries, MASS_HAUL_BALANCE_KEY, type MassHaulSeries, } from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { configureBalloonOffsets, resetBalloonOffsets, } from "@util/common_util_mass_haul_balance_view"; import { createMassHaulChart, createMassHaulLegend, createMassHaulSummary, MASS_HAUL_MIN_HEIGHT, } from "@util/common_util_mass_haul_view"; import { BASE_PANEL_HEIGHT, chartHeights, MASS_HAUL_VISIBLE_KEY, MAX_PANEL_HEIGHT_RATIO, MIN_LONG_HEIGHT, MIN_PANEL_HEIGHT, PANEL_CHROME_PX, PANEL_COLLAPSED_KEY, PANEL_HEIGHT_KEY, readVisibleSeries, unwrapChart, } from "./B06_Section_UI_Section_View_Panel"; import { calculateYScale, CROSS_GRID_GAP, CROSS_GRID_MIN_WIDTH, CROSS_WIDTH, type CrossDesignChange, type DesignChangeHandler, designElevationAt, emptyView, inferStationInterval, L, longitudinalMaxChainage, LONG_PAD, type YScaleOptions, } from "./B06_Section_UI_Section_Common"; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; /** * 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**. * * 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면 * 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는 * 출발값이고, 최소 패널 높이 계산의 기준이기도 하다. */ export interface SectionViewController { root: HTMLElement; render: ( detail: SectionDetailResponse, verticalExaggeration: number, crossHalfWidth?: number, stationInterval?: number, earthworkConversion?: EarthworkConversion, haulEquipmentLimits?: HaulEquipmentLimit[], /** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */ balloonScope?: string, /** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */ naturalSpoilMinSlope?: number, ) => void; /** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */ refreshCard: (chainageM: number) => void; /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */ focusStation: (stationId: string) => void; /** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을 * 올린다(2026-08-29 일원화). null = 선택 해제. */ setStationSelectListener: (listener: (stationId: string | null) => void) => void; clear: () => void; dispose: () => void; } export function createSectionView( onDesignChange?: DesignChangeHandler, rockBoundary?: RockBoundaryControl, /** 측점 개별 표시 반폭(2026-08-06)·기슭막이 X 자리(2026-08-21) 제어. */ stationWidth?: StationWidthControl, revetOffset?: RevetOffsetControl, inletStructure?: InletStructureControl, // 유입측 구조물 형식(2026-08-22) extraWalls?: ExtraWallControl, // 유출측 추가 기슭막이 단 수(2026-08-22) /** 구간값·연동(2026-08-24), 세월교 측벽·BOX암거 구체(2026-08-25) 제어. */ structureSpan?: StructureSpanControl, revetLink?: RevetLinkControl, ford?: FordControl, box?: BoxControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; let currentDetail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; /** 선택 카드에서 강조 중인 면적. 카드를 다시 그려도 그대로 살아나야 하므로 여기 둔다. */ let activeAreaKey: CrossAreaKey | null = null; let currentExaggeration = 1; let currentCrossHalfWidth: number | undefined; let currentStationInterval: number | undefined; let currentConversion: EarthworkConversion | undefined; let currentHaulLimits: HaulEquipmentLimit[] | undefined; let currentNaturalSpoilSlope: number | undefined; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; // 유토곡선 표시 곡선 집합. 토글해도 Y 스케일은 전체 곡선 기준이라 축이 움직이지 않는다. const visibleSeries = readVisibleSeries(); // 그래프 상자의 마지막 실측 높이와 보정 예약 플래그(빈 공간·세로 스크롤 둘 다 막는다). let lastChartAvailable = 0; let chartFitScheduled = false; // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) let cachedYScale: YScaleOptions | undefined; let cachedStationInterval = 1; let cachedCardWidth = CROSS_WIDTH; // 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용). const cachedRowHeight = new Map(); /* ── 상단 슬라이드 패널(종단면도 + 유토곡선) ───────────────────────── * 패널·리사이저는 한 번만 만들고 draw()에서는 차트 내용만 갈아 끼운다. * 매 렌더마다 새로 만들면 리사이저 핸들과 접힘 상태가 초기화된다. */ const panel = document.createElement("section"); // 접기/펴기는 B05 하단 패널과 같은 방식이다 — 전역 `ui-collapsible` 규칙에 기대지 않고 // `is-collapsed`에 걸 자체 CSS로 높이를 0으로 접고 자식을 숨긴다(손잡이만 남는다). panel.className = "b06-section__panel"; // 최소 높이는 TS 상수 하나로만 정의하되 **접힘 상태에는 걸리지 않아야** 한다 — 인라인 // min-height를 두면 접어도 그 높이만큼 빈 배경이 남는다. CSS 변수로 넘겨 펼친 상태에만 건다. panel.style.setProperty("--b06-profile-min-height", `${MIN_PANEL_HEIGHT}px`); // 제목 문구는 없앴다(2026-08-02 사용자 지시). 헤더 행은 상태 문구만 담고, 접기/펼치기는 // 패널 하단 가운데 손잡이가 맡는다. const panelHeader = document.createElement("header"); panelHeader.className = "b06-section__panel-status"; 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); // 접기 손잡이는 패널 **하단 가운데**에 둔다(2026-08-02 사용자 지시). 모양은 좌측 사이드 // 패널과 같은 삼각형 손잡이를 90° 돌린 것이며, 위로 접히는 패널이라 `collapseToward: "up"`이다. // // 공용 `ui-collapsible__title`은 **쓰지 않는다**. 그 클래스를 달면 전역 규칙이 캐럿(▾/▸)을 // 하나 더 그리는데, 접힘 상태 전역 선택자(`.ui-collapsible.is-collapsed > .ui-collapsible__title`)가 // B06 쪽 무력화 규칙보다 특정도가 높아 캐럿이 삼각형 아이콘과 겹쳐 보였다. 토글은 여기서 직접 한다. const panelToggle = createWorkflowPanelHandle("bottom", "up", "종단/유토곡선"); panelToggle.root.classList.add("b06-section__panel-toggle"); panel.append(panelHeader, panelBody, panelToggle.root); // 접힘 상태는 세션에만 남긴다(리사이저와 같은 규칙). if (sessionStorage.getItem(PANEL_COLLAPSED_KEY) === "true") panel.classList.add("is-collapsed"); panelToggle.setOpen(!panel.classList.contains("is-collapsed")); panelToggle.root.addEventListener("click", () => { const collapsed = panel.classList.toggle("is-collapsed"); sessionStorage.setItem(PANEL_COLLAPSED_KEY, String(collapsed)); panelToggle.setOpen(!collapsed); // 펼칠 때는 레이아웃이 잡힌 **다음 프레임**에 그린다 — 그래프 몫을 실측으로 잡기 때문에 // 같은 프레임에 그리면 접혀 있던 0 높이를 읽는다(B05 하단 패널과 같은 처리). if (!collapsed) requestAnimationFrame(drawPanel); }); 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; // 브라우저 확대 제스처는 건드리지 않는다 event.preventDefault(); const room = chartWrap.scrollWidth - chartWrap.clientWidth; const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; if (room <= 1 || !delta) return; chartWrap.scrollLeft = Math.min(Math.max(chartWrap.scrollLeft + delta, 0), room); }, { passive: false, capture: true }, ); /** * 가운데 버튼 드래그로 그래프를 좌우로 민다(2026-08-02 사용자 지시). 좌클릭은 측점 선택 몫이다. * 가운데 버튼을 누르면 브라우저가 자동 스크롤을 켜는데, 그건 `mousedown` 기본동작이라 * `pointerdown`에서는 못 막는다 — `mousedown`/`auxclick`에서 막아야 팬만 남는다. */ let panPointerId: number | null = null; let panLastX = 0; chartWrap.addEventListener("mousedown", (event) => { if (event.button === 1) event.preventDefault(); }); chartWrap.addEventListener("auxclick", (event) => { if (event.button === 1) event.preventDefault(); }); chartWrap.addEventListener("pointerdown", (event) => { if (event.button !== 1) return; event.preventDefault(); panPointerId = event.pointerId; panLastX = event.clientX; chartWrap.classList.add("is-panning"); chartWrap.setPointerCapture(event.pointerId); }); chartWrap.addEventListener("pointermove", (event) => { if (panPointerId !== event.pointerId) return; chartWrap.scrollLeft -= event.clientX - panLastX; panLastX = event.clientX; }); const endChartPan = (event: PointerEvent): void => { if (panPointerId !== event.pointerId) return; panPointerId = null; chartWrap.classList.remove("is-panning"); try { chartWrap.releasePointerCapture(event.pointerId); } catch { /* 이미 해제됨 */ } }; chartWrap.addEventListener("pointerup", endChartPan); chartWrap.addEventListener("pointercancel", endChartPan); 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라 카드 스크롤 정지선은 `scroll-margin-top`(패널 높이 + 손잡이 몫)이다. * 패널은 리사이저·접기 손잡이로 수시로 높이가 변하고, 접을 때는 drawPanel이 돌지 않으므로 * **스크롤 직전에 실측**해야 정지선이 현재 패널과 맞는다. */ const syncScrollMargin = (): void => { // 접기 손잡이가 패널 아래 24px 밖으로 나와 있고, 선택 강조(빨간 테두리 + 2px ring)가 // 잘리지 않도록 8px 여유를 더한다. const panelPx = panel.getBoundingClientRect().height || panelHeight(); root.style.setProperty("--b06-cross-scroll-margin", `${Math.round(panelPx + 32)}px`); }; /** * 선택 카드를 화면에 남긴다. `nearest`는 카드가 아래에 있을 때 카드 **하단**을 브라우저 * 바닥에 맞춰 멈추는데, 그러면 카드 상단(측점 라벨)이 화면 밖이다(2026-08-04 사용자 지적). * `start`로 카드 **상단**을 접기 손잡이 바로 아래 정지선에 붙인다. 목록 맨 끝 카드는 * 스크롤이 바닥에 걸려 그 자리까지 못 올라오며, 그대로 둔다(브라우저가 알아서 멈춘다). */ const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => { if (!stationId) return; syncScrollMargin(); document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "start" }); }; /** 카드 하나의 선택 표시를 갱신한다 — **다시 만들지 않는다**(줌·팬이 살아남아야 한다). */ const markCard = (stationId: string | null): void => { if (!stationId) return; const card = document.getElementById(`cross-${stationId}`) as CrossCardElement | null; card?.applySelection?.(stationId === selectedStationId, activeAreaKey); }; /** * 연동으로 이어진 카드들의 **벽 강조**를 다시 칠한다(2026-08-30 사용자: 한 기슭막이는 * 그 연장 안 모든 횡단도에서 같이 강조된다). 조정창은 고른 카드 하나에만 남는다. * 카드는 다시 만들지 않고 클래스만 갈아 끼운다 — 줌·팬 보존. */ const syncRevetHighlights = (): void => { if (!currentDetail || !revetOffset) return; for (const section of currentDetail.cross_sections) { const card = document.getElementById( `cross-${section.station_id}`, ) as CrossCardElement | null; card?.applyRevetHighlight?.(revetOffset.highlightFor(section)); } }; if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights; /** * 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**. * 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다 * (2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이고, 그 둘도 클래스와 * 강조 상태만 갈아 끼운다. */ /** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */ let stationSelectListener: ((stationId: string | null) => void) | null = null; const applySelection = (previousStationId: string | null): void => { markCard(previousStationId); if (selectedStationId !== previousStationId) markCard(selectedStationId); drawPanel(); if (selectedStationId !== previousStationId) stationSelectListener?.(selectedStationId); }; /** 같은 측점을 다시 고르면 선택을 푼다(2026-08-02 사용자 지시) — 카드·측점선 어느 쪽이든. */ const selectStation = (stationId: string, scroll: boolean): void => { const previous = selectedStationId; const deselect = selectedStationId === stationId; selectedStationId = deselect ? null : stationId; // 측점이 바뀌면 면적 강조는 따라가지 않는다(다른 측점의 강조를 물려받으면 오해를 부른다). activeAreaKey = null; applySelection(previous); if (scroll && !deselect) revealCard(stationId, "smooth"); }; /** * 횡단 카드의 면적 값을 눌렀을 때. **선택되지 않은 카드에서 눌러도** 그 카드를 고르면서 * 누른 면적까지 한 번에 강조한다(2026-08-02 사용자 지시). * 이미 선택된 카드라면 카드가 자기 DOM에서 강조를 켜고 끄므로 여기서는 상태만 받아 둔다 * (다시 그릴 때 그대로 되살리기 위해). */ const selectArea = (stationId: string, key: CrossAreaKey | null): void => { if (selectedStationId === stationId) { activeAreaKey = key; return; } const previous = selectedStationId; selectedStationId = stationId; activeAreaKey = key; applySelection(previous); revealCard(stationId, "smooth"); }; /** 카드 바깥(그리드 빈 자리)을 누르면 선택을 푼다(2026-08-02 사용자 지시). */ const clearSelection = (): void => { if (!selectedStationId && !activeAreaKey) return; const previous = selectedStationId; selectedStationId = null; activeAreaKey = null; applySelection(previous); }; 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, section.station_id === selectedStationId ? activeAreaKey : null, selectArea, stationWidth, revetOffset, inletStructure, extraWalls, culvertLinkFor(section), structureSpan, revetLink, ford, box, ); /** * 옆 측점 구조물이 이 측점까지 넘어왔는지 — 기슭막이 기본 연장 10m(전 5·후 5)면 * 배수관 측점 하나로 끝나지 않는다(2026-08-24 사용자). 3D는 이미 그 구간을 소유 * 측점 단면으로 채우므로 횡단도도 같은 규칙으로 링크한다. */ const culvertLinkFor = (section: CrossSection): CulvertLink | undefined => { const detail = currentDetail; if (!detail) return undefined; return culvertLink( section, detail.cross_sections, (chainageM) => designElevationAt(detail.longitudinal.design_profiles, chainageM) ?? null, ); }; /** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */ const toggleSeries = (key: string): void => { // 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준으로 계산된 그래프만 보인다. const next = applyLegendToggle(visibleSeries, key); visibleSeries.clear(); next.forEach((entry) => visibleSeries.add(entry)); 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; // 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가 // 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다. // 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다. const measured = chartWrap.clientHeight; lastChartAvailable = measured > 0 ? measured : Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT); const heights = chartHeights(lastChartAvailable); const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); // 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다. cachedYScale = calculateYScale(detail, heights.long); // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). let longAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; const nodes: Element[] = [ unwrapChart( createLongitudinalProfile( detail.longitudinal, selectedStationId, currentExaggeration, cachedYScale, (stationId) => selectStation(stationId, true), cachedStationInterval, chartWidth, heights.long, minWidth, detail.longitudinal.design_profiles ?? [], 0, (axis) => { longAxis = axis; }, ), ), ]; const series: MassHaulSeries[] = currentConversion ? computeMassHaulSeries( detail.longitudinal, detail.cross_sections, currentConversion, currentNaturalSpoilSlope, ) : []; // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); const haulPlan = bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY) ? computeHaulPlan(bandedSeries.result, currentHaulLimits) : null; if (series.length) { // 유토곡선 SVG는 종단면도와 **같은 부모의 형제**여야 한다. 감싸는 상자를 하나라도 끼우면 // 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점 세로선이 어긋난다. nodes.push( createMassHaulChart( series, visibleSeries, detail.longitudinal, { maxChainageM: longitudinalMaxChainage(detail.longitudinal), padLeft: LONG_PAD.left, padRight: LONG_PAD.right, }, selectedStationId, cachedStationInterval, chartWidth, heights.mass, minWidth, (stationId) => selectStation(stationId, true), haulPlan, (axis) => { massAxis = axis; }, ), ); } chartWrap.replaceChildren(...nodes); // 고정 Y축 오버레이 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). 유토곡선 축은 종단 높이만큼 // 안쪽(inner)을 내려 자기 그래프 구간만 덮는다 — 불투명 배경이 지나간 눈금 노출을 막는다. if (massAxis) { const overlay = buildStickyYAxis(massAxis, heights.mass); (overlay.firstElementChild as HTMLElement).style.top = `${heights.long}px`; chartWrap.prepend(overlay); } if (longAxis) chartWrap.prepend(buildStickyYAxis(longAxis, heights.long)); panel.querySelector(".b06-masshaul__legend")?.remove(); panel.querySelector(".b06-masshaul__summary")?.remove(); // 범례는 유토곡선 우측 상단에 겹쳐 놓는다(2026-08-02 사용자 지시). 다만 **스크롤 컨테이너 // 밖**에서 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에 끼어들어 두 그래프의 // 측점 세로선이 어긋난다(1차 수정에서 겪은 문제). 세로 위치는 종단면도 높이로 잡는다. if (series.length) { const legend = createMassHaulLegend(series, visibleSeries, toggleSeries, () => { resetBalloonOffsets(); drawPanel(); }); legend.style.top = `${heights.long + 6}px`; panelBody.append(legend); } // 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다. // (선택 측점의 구간 물량은 곡선 위 말풍선이 맡는다.) const summarySeries = bandedSeries; if (summarySeries) { panelCount.textContent = ""; panelBody.append(createMassHaulSummary(summarySeries, haulPlan)); } else { panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); } chartWrap.scrollLeft = keepScrollLeft; // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. syncScrollMargin(); // 첫 렌더(detached)나 패널 높이 변경 직후에는 위에서 잰 값이 아직 옛 레이아웃일 수 있다. // 다음 프레임에 한 번만 다시 재서 어긋나면 고쳐 그린다. `chartWrap` 높이는 내용과 무관하니 // 되먹임 없이 한 번에 수렴한다. if (!chartFitScheduled) { chartFitScheduled = true; requestAnimationFrame(() => { chartFitScheduled = false; const now = chartWrap.clientHeight; if (now > 0 && Math.abs(now - lastChartAvailable) > 2) drawPanel(); }); } } const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; const detail = currentDetail; cachedStationInterval = currentStationInterval ?? inferStationInterval(detail.longitudinal.stations); drawPanel(); // 횡단면도 제목·개수 표기는 두지 않는다(2026-08-02 사용자 지시) — 상단 패널과 카드 사이 // 공간만 먹고, 카드마다 측점이 적혀 있어 무엇을 보는지는 이미 분명하다. const grid = document.createElement("div"); grid.className = "b06-section__grid"; // 카드 사이 빈 자리를 누르면 선택 해제 — 카드 안 클릭은 카드가 먼저 처리한다. grid.addEventListener("click", (event) => { if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection(); }); 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, // 개별 표시 반폭이 있으면 그 폭 기준으로 높이를 재야 카드·행 높이가 맞는다. stationWidth?.widthFor(section) ?? 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, 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, haulEquipmentLimits, balloonScope, naturalSpoilMinSlope, ) { 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; if (haulEquipmentLimits?.length) currentHaulLimits = haulEquipmentLimits; if (Number.isFinite(naturalSpoilMinSlope)) currentNaturalSpoilSlope = naturalSpoilMinSlope; // balloon 위치는 **영구저장소 값이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로 받는다. configureBalloonOffsets(balloonScope ?? "default", detail.balloon_offsets ?? undefined); // 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로 // 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다. renderWidth = contentWidth(); draw(); if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root)); }, refreshCard, focusStation(stationId) { if (selectedStationId !== stationId) selectStation(stationId, true); else revealCard(stationId, "smooth"); }, setStationSelectListener(listener) { stationSelectListener = listener; }, clear() { currentDetail = null; selectedStationId = null; root.replaceChildren(); }, dispose() { window.clearTimeout(resizeTimer); window.clearTimeout(panelResizeTimer); panelResizer.dispose(); resizeObserver.disconnect(); }, }; }