사용자 확정(2026-09-04) — 「버티기 + 부드럽게」. 누가토량 곡선은 표고와 달리 가팔라, 급한 구간이 창에 들어오면 세로 폭이 한 칸에 228 → 1,019㎥(4.5배)로 튀었음(실측). - **버티기** — 곡선이 지금 창 안에 들어오고 창을 55% 넘게 채우면 창을 아예 안 건드림. - **부드럽게** — 바꿔야 할 때도 한 번에 안 가고 다시 그릴 때마다 남은 만큼 20%씩 좁힘 (약 0.15초에 걸쳐 미끄러짐). 아직 안 끝났으면 다음 프레임에 한 걸음 더. - 여유를 5% → **20%** 로 넓혀 곡선이 위아래 끝에 딱 붙지 않게 함(종단과 같은 값). - 곡선 계산(`computeMassHaulSeries`)을 입력 객체 단위로 아껴 둠 — 스크롤 중에는 입력이 그대로인데 그릴 때마다 다시 계산해 프레임을 먹었음. 자체검증(공용 브라우저 임시 탭): ① 한 칸씩 8번 굴려 세로 폭 257·257·257·257 → 1,031 … : **4칸은 아예 그대로**(버티기) ② 창이 바뀌는 순간 궤적 257 → 711(42ms) → 882(80ms) → 1,030(151ms): 계단이 아니라 미끄러짐 ③ 휠 20칸 기준 최대 프레임 27.4 → **24.0ms**, 33ms 초과 **0건**(고치기 전보다 나아짐) ④ B06 최대 프레임 21.2ms, 33ms 초과 0건 ⑤ 전체 시험 381 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* 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<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;
|
|
/** 화면에 보이는 누가거리 구간(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 에 붙이는 것은 호출한 쪽 몫이다 —
|
|
* 차트는 종단면도와 **같은 부모의 형제**여야 하고(감싸는 상자가 하나라도 끼면 스크롤
|
|
* 컨테이너 폭 계산이 어긋나 측점 세로선이 밀린다) 범례는 스크롤 컨테이너 **밖**이라
|
|
* 붙일 자리가 서로 다르기 때문이다.
|
|
*/
|
|
/** 곡선 계산 아낌 — 스크롤 중에는 입력이 그대로다. 그릴 때마다 다시 계산하면 프레임을
|
|
* 잡아먹는다(2026-09-04 실측). */
|
|
let seriesCache: {
|
|
detail: SectionDetailResponse;
|
|
conversion: EarthworkConversion | null;
|
|
slope: number | undefined;
|
|
series: MassHaulSeries[];
|
|
} | null = null;
|
|
|
|
export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult {
|
|
const { detail, visibleSeries } = input;
|
|
const pendingRecalc = hasStaleDesigns(detail);
|
|
const conversion = input.conversion ?? null;
|
|
const cached =
|
|
seriesCache &&
|
|
seriesCache.detail === detail &&
|
|
seriesCache.conversion === conversion &&
|
|
seriesCache.slope === input.naturalSpoilSlope
|
|
? seriesCache.series
|
|
: null;
|
|
const series: MassHaulSeries[] =
|
|
cached ??
|
|
(conversion && !pendingRecalc
|
|
? computeMassHaulSeries(
|
|
detail.longitudinal,
|
|
detail.cross_sections,
|
|
conversion,
|
|
input.naturalSpoilSlope,
|
|
)
|
|
: []);
|
|
if (!cached) seriesCache = { detail, conversion, slope: input.naturalSpoilSlope, series };
|
|
// 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을
|
|
// 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다.
|
|
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<MassHaulPanelInput, "viewRange">,
|
|
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;
|
|
}
|