diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index e3a56afd..2bf11752 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -41,23 +41,10 @@ import { 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 { applyLegendToggle } from "@util/common_util_mass_haul"; +import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; +import { MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; +import { buildMassHaulPanel } from "./B06_Section_UI_Section_View_MassHaul"; import { BASE_PANEL_HEIGHT, chartHeights, @@ -82,9 +69,6 @@ import { emptyView, inferStationInterval, L, - longitudinalMaxChainage, - LONG_PAD, - staleDesignChainages, type YScaleOptions, } from "./B06_Section_UI_Section_Common"; @@ -495,7 +479,6 @@ export function createSectionView( // 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( @@ -517,58 +500,31 @@ export function createSectionView( ), ]; - // 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 **옛 계획고로 만든 값**이다. 진입 - // 직후 `reconcileStaleDesigns()`가 다시 계산해 갈아 끼우므로, 그 사이 옛 값으로 곡선을 - // 그리면 사용자가 옛 그림을 본다(2026-09-03 사용자 확정: 「새 값만 보여주기」). - const pendingRecalc = staleDesignChainages(detail).length > 0; - const series: MassHaulSeries[] = - currentConversion && !pendingRecalc - ? 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; - }, - ), - ); - } + // 유토곡선 몫은 전용 모듈이 만든다(2026-09-03 · 700줄 제한) — 곡선 계산부터 + // 범례·요약줄까지 한 덩어리라 붙이는 자리만 여기서 정한다. + const massHaul = buildMassHaulPanel({ + detail, + conversion: currentConversion, + haulLimits: currentHaulLimits, + naturalSpoilSlope: currentNaturalSpoilSlope, + visibleSeries, + selectedStationId, + stationInterval: cachedStationInterval, + chartWidth, + minWidth, + massHeight: heights.mass, + longHeight: heights.long, + selectStation: (stationId) => selectStation(stationId, true), + toggleSeries, + redraw: drawPanel, + }); + if (massHaul.chart) nodes.push(massHaul.chart); chartWrap.replaceChildren(...nodes); // 고정 Y축 오버레이 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). 유토곡선 축은 종단 높이만큼 // 안쪽(inner)을 내려 자기 그래프 구간만 덮는다 — 불투명 배경이 지나간 눈금 노출을 막는다. - if (massAxis) { - const overlay = buildStickyYAxis(massAxis, heights.mass); + if (massHaul.axis) { + const overlay = buildStickyYAxis(massHaul.axis, heights.mass); (overlay.firstElementChild as HTMLElement).style.top = `${heights.long}px`; chartWrap.prepend(overlay); } @@ -576,28 +532,10 @@ export function createSectionView( 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 = pendingRecalc - ? L("B06_MassHaul_Recalculating") - : L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); - } + // 범례는 스크롤 컨테이너 **밖**에 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에 + // 끼어들어 두 그래프의 측점 세로선이 어긋난다(1차 수정에서 겪은 문제). + panelBody.append(...massHaul.overlays); + panelCount.textContent = massHaul.statusText; chartWrap.scrollLeft = keepScrollLeft; // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. syncScrollMargin(); diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts new file mode 100644 index 00000000..edf02dfa --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -0,0 +1,141 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_MassHaul.ts + * B06 상단 패널의 **유토곡선 몫**만 떼어 낸 조립기 (2026-09-03 · 700줄 제한). + * + * 뷰 컨트롤러(`_UI_Section_View`)가 종단면도와 카드 그리드를 맡고, 곡선 계산 → 차트 → + * 범례 → 요약줄까지의 한 덩어리는 여기서 만든다. 계산·판정은 전부 공용 모듈 + * (`common_util_mass_haul*`)이 하고 여기서는 **어디에 무엇을 붙일지**만 정한다. + * + * 낡음 판정(`staleDesignChainages`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금 + * 계획선과 어긋나면 곡선을 그리지 않고 안내만 띄운다(2026-09-03 사용자 확정: + * 「새 값만 보여주기」). 옛 계획고로 만든 면적을 잠깐 보여 주고 정본으로 갈아 끼우면 + * 사용자가 옛 그림을 본다. + * ========================================================================== */ + +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, +} from "@util/common_util_mass_haul_view"; +import { + L, + longitudinalMaxChainage, + LONG_PAD, + staleDesignChainages, +} 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; +} + +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 = staleDesignChainages(detail).length > 0; + 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, + }, + 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 }; +}