refactor(B06): 유토곡선 패널 조립을 전용 모듈로 분리 (700줄 제한)

`_UI_Section_View` 751줄 → 689줄. 곡선 계산 → 차트 → 범례 → 요약줄까지 한 덩어리를
`_UI_Section_View_MassHaul` 로 옮기고, 뷰 컨트롤러는 **붙일 자리**만 정한다(차트는 종단면도와
같은 부모의 형제, 범례는 스크롤 컨테이너 밖). 낡음 판정·계산은 옮긴 자리에서도 공용 모듈
그대로라 동작이 바뀌지 않는다.

검증 — 공용 브라우저 B06 실측: 요약줄 문자열 동일
(`절토(자연) 4,526.8㎥ … 최종 누가토량 −10,213.3㎥`), 범례 1·차트 1·카드 65·상태줄 빈 문자열.
pytest 383 passed·17 skipped, typecheck·prettier 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 13:50:53 +09:00
co-authored by Claude Opus 5
parent 451ed97279
commit d6d211ef61
2 changed files with 170 additions and 91 deletions
+29 -91
View File
@@ -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();
@@ -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<string>;
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 };
}