/* ============================================================================= * B06_Section_UI_Section_View_MassHaul.ts * B06 상단 패널의 **유토곡선 몫**만 떼어 낸 조립기 (2026-09-03 · 700줄 제한). * * 뷰 컨트롤러(`_UI_Section_View`)가 종단면도와 카드 그리드를 맡고, 곡선 계산 → 차트 → * 범례 → 요약줄까지의 한 덩어리는 여기서 만든다. 계산·판정은 전부 공용 모듈 * (`common_util_mass_haul*`)이 하고 여기서는 **어디에 무엇을 붙일지**만 정한다. * * 낡음 판정(`hasStaleDesigns`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금 * 계획선과 어긋나면 곡선을 그리지 않고 안내만 띄운다(2026-09-03 사용자 확정: * 「새 값만 보여주기」). 옛 계획고로 만든 면적을 잠깐 보여 주고 정본으로 갈아 끼우면 * 사용자가 옛 그림을 본다. * ========================================================================== */ import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul"; import type { EarthworkConversion, HaulEquipmentLimit } from "./B06_Section_Api_Fetch"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { computeMassHaulSeries, MASS_HAUL_BALANCE_KEY, type MassHaulSeries, } from "@util/common_util_mass_haul"; import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { createMassHaulChart, createMassHaulLegend, createMassHaulSummary, createMassHaulWindowState, scheduleMassHaulSettle, type MassHaulWindowState, } from "@util/common_util_mass_haul_view"; import { L, longitudinalMaxChainage, LONG_PAD, hasStaleDesigns, } from "./B06_Section_UI_Section_Common"; /** 유토곡선 Y축 눈금 — 가로 스크롤 고정 오버레이가 그대로 받는다. */ export interface MassHaulAxisTicks { padLeft: number; ticks: Array<{ y: number; label: string }>; } export interface MassHaulPanelInput { detail: SectionDetailResponse; conversion: EarthworkConversion | undefined; haulLimits: HaulEquipmentLimit[] | undefined; naturalSpoilSlope: number | undefined; visibleSeries: Set; selectedStationId: string | null; stationInterval: number; chartWidth: number; minWidth: number; /** 유토곡선 몫 높이(px)와 그 위 종단면도 높이(px) — 범례·Y축 자리를 잡는 값. */ massHeight: number; longHeight: number; selectStation: (stationId: string) => void; toggleSeries: (key: string) => void; /** 범례에서 도형 위치를 초기화한 뒤 패널을 다시 그린다. */ redraw: () => void; /** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */ viewRange?: { fromM: number; toM: number }; /** 세로창 버티기·부드러운 이동 상태(2026-09-04). */ windowState?: MassHaulWindowState; } export interface MassHaulPanelResult { /** 차트 SVG — 곡선이 없으면 null(그 자리는 비운다). */ chart: Element | null; axis: MassHaulAxisTicks | null; /** 범례·요약줄 — 패널 본문에 붙일 순서대로. */ overlays: HTMLElement[]; /** 곡선을 못 그린 이유(있으면 상태줄에 그대로 적는다). 그릴 수 있으면 빈 문자열. */ statusText: string; } /** * 유토곡선 차트·범례·요약줄을 만든다. DOM 에 붙이는 것은 호출한 쪽 몫이다 — * 차트는 종단면도와 **같은 부모의 형제**여야 하고(감싸는 상자가 하나라도 끼면 스크롤 * 컨테이너 폭 계산이 어긋나 측점 세로선이 밀린다) 범례는 스크롤 컨테이너 **밖**이라 * 붙일 자리가 서로 다르기 때문이다. */ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult { const { detail, visibleSeries } = input; const pendingRecalc = hasStaleDesigns(detail); // 계산 결과를 아껴 두지 **않는다**. 횡단 설계는 같은 객체를 제자리에서 고치므로 // (`refreshCrossDesigns`) 객체가 같은지로는 바뀐 것을 못 잰다 — 2026-09-04 에 캐시를 // 넣었다가 계획고를 조절해도 횡단 기준 곡선이 그대로였다(사용자 보고). const series: MassHaulSeries[] = input.conversion && !pendingRecalc ? computeMassHaulSeries( detail.longitudinal, detail.cross_sections, input.conversion, input.naturalSpoilSlope, ) : []; // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); const haulPlan = bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY) ? computeHaulPlan(bandedSeries.result, input.haulLimits) : null; let axis: MassHaulAxisTicks | null = null; const chart = series.length ? createMassHaulChart( series, visibleSeries, detail.longitudinal, { maxChainageM: longitudinalMaxChainage(detail.longitudinal), padLeft: LONG_PAD.left, padRight: LONG_PAD.right, viewRange: input.viewRange, window: input.windowState, }, input.selectedStationId, input.stationInterval, input.chartWidth, input.massHeight, input.minWidth, input.selectStation, haulPlan, (next) => { axis = next; }, ) : null; const overlays: HTMLElement[] = []; if (series.length) { // 범례는 유토곡선 우측 상단에 겹쳐 놓는다(2026-08-02 사용자 지시). 세로 자리는 // 종단면도 높이로 잡는다. const legend = createMassHaulLegend(series, visibleSeries, input.toggleSeries, () => { resetBalloonOffsets(); input.redraw(); }); legend.style.top = `${input.longHeight + 6}px`; overlays.push(legend); } // 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다. if (bandedSeries) { overlays.push(createMassHaulSummary(bandedSeries, haulPlan)); return { chart, axis, overlays, statusText: "" }; } const statusText = pendingRecalc ? L("B06_MassHaul_Recalculating") : L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); return { chart, axis, overlays, statusText }; } /** 유토곡선을 붙일 자리 — 뷰 컨트롤러가 들고 있는 DOM 이다. */ export interface MassHaulMountTargets { /** 차트가 들어가는 가로 스크롤 컨테이너. 종단면도와 **같은 부모의 형제**여야 한다. */ chartWrap: HTMLElement; /** 옛 범례·요약줄을 찾아 지울 뿌리. */ panel: HTMLElement; /** 새 범례·요약줄을 붙일 자리(스크롤 컨테이너 밖). */ panelBody: HTMLElement; /** 곡선을 못 그린 이유를 적는 상태줄. */ statusNode: HTMLElement; } /** 붙여 둔 유토곡선 노드 — 다음 갱신에서 **제자리 교체**하는 데 쓴다. */ export interface MountedMassHaul { chart: Element | null; axis: HTMLElement | null; } /** * 유토곡선을 만들어 붙인다. 이미 붙어 있으면 **그 자리에서 갈아 끼운다** — 종단면도는 * 건드리지 않는다(2026-09-04). 가로로 스크롤할 때마다 곡선의 세로 창이 따라와야 하는데, * 상단 패널을 통째로 다시 그리면 종단 그래프까지 새로 만들어져 화면이 한 번 끊긴다. */ export function mountMassHaulPanel( input: MassHaulPanelInput, targets: MassHaulMountTargets, previous: MountedMassHaul, ): MountedMassHaul { const built = buildMassHaulPanel(input); let chart = previous.chart; if (built.chart) { if (chart?.isConnected) chart.replaceWith(built.chart); else targets.chartWrap.append(built.chart); chart = built.chart; } else if (chart?.isConnected) { chart.remove(); chart = null; } let axis = previous.axis; if (built.axis) { // 고정 Y축은 0크기 sticky 앵커라 **첫 자식**이어야 세로 기준이 컨테이너 상단이 된다. // 안쪽(inner)은 종단 높이만큼 내려 자기 그래프 구간만 덮는다. const overlay = buildStickyYAxis(built.axis, input.massHeight); (overlay.firstElementChild as HTMLElement).style.top = `${input.longHeight}px`; if (axis?.isConnected) axis.replaceWith(overlay); else targets.chartWrap.prepend(overlay); axis = overlay; } else if (axis?.isConnected) { axis.remove(); axis = null; } targets.panel.querySelector(".b06-masshaul__legend")?.remove(); targets.panel.querySelector(".b06-masshaul__summary")?.remove(); targets.panelBody.append(...built.overlays); targets.statusNode.textContent = built.statusText; return { chart, axis }; } /** * 「보이는 구간만 바꿔 다시 그리는」 함수를 만든다 — 스크롤 갱신이 부를 것이다. * 붙여 둔 노드는 이 함수가 스스로 들고 있으므로 부르는 쪽은 구간만 넘기면 된다. */ export function createMassHaulRenderer( base: Omit, targets: MassHaulMountTargets, ): (fromM: number, toM: number) => void { let mounted: MountedMassHaul = { chart: null, axis: null }; const windowState = createMassHaulWindowState(); const render = (fromM: number, toM: number): void => { mounted = mountMassHaulPanel( { ...base, viewRange: { fromM, toM }, windowState }, targets, mounted, ); // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더(2026-09-04 「부드럽게」). scheduleMassHaulSettle(windowState, () => render(fromM, toM)); }; return render; }