fix(B05,B06): 유토곡선 세로창 버티기 + 부드럽게 이동

사용자 확정(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>
This commit is contained in:
2026-09-04 22:40:41 +09:00
co-authored by Claude Opus 5
parent 331be116a9
commit 2b1fe118d1
3 changed files with 168 additions and 15 deletions
+51 -7
View File
@@ -45,7 +45,9 @@ import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulSummary,
createMassHaulWindowState,
MASS_HAUL_MIN_HEIGHT,
scheduleMassHaulSettle,
} from "@util/common_util_mass_haul_view";
import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
@@ -306,7 +308,48 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
bar.append(empty);
}
/** 곡선 계산 아낌 — 입력 객체가 그대로면 다시 계산하지 않는다. */
let seriesCache: {
longitudinal: RouteMassHaulDrawParams["longitudinal"];
crossSections: RouteMassHaulDrawParams["crossSections"];
context: RouteMassHaulContext;
series: MassHaulSeries[];
} | null = null;
function cachedSeries(
params: RouteMassHaulDrawParams,
current: RouteMassHaulContext,
): MassHaulSeries[] {
if (
seriesCache &&
seriesCache.longitudinal === params.longitudinal &&
seriesCache.crossSections === params.crossSections &&
seriesCache.context === current
) {
return seriesCache.series;
}
const series = computeMassHaulSeries(
params.longitudinal,
params.crossSections,
current.conversion,
current.naturalSpoilMinSlope,
);
seriesCache = {
longitudinal: params.longitudinal,
crossSections: params.crossSections,
context: current,
series,
};
return series;
}
// 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04).
const windowState = createMassHaulWindowState();
/** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */
let lastParams: RouteMassHaulDrawParams | null = null;
function draw(params: RouteMassHaulDrawParams): void {
lastParams = params;
clearSelection = params.onClearSelection;
overlay.hidden = !open;
syncHandlePosition();
@@ -329,12 +372,9 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
}
pendingChip.hidden = true;
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
const series: MassHaulSeries[] = computeMassHaulSeries(
params.longitudinal,
params.crossSections,
context.conversion,
context.naturalSpoilMinSlope,
);
// 가로로 스크롤할 때는 **입력이 그대로**인데 그릴 때마다 다시 계산하면 프레임을 잡아먹는다
// (2026-09-04 실측: 세로창 이동 중 33ms 넘는 프레임 다수). 같은 입력이면 아껴 둔 것을 쓴다.
const series: MassHaulSeries[] = cachedSeries(params, context);
if (!series.length) {
note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
return;
@@ -365,7 +405,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
series,
visible,
params.stationSource,
params.axis,
{ ...params.axis, window: windowState },
params.selectedStationId,
params.stationInterval,
params.widthPx,
@@ -396,6 +436,10 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
}),
);
drawnOnce = true;
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
scheduleMassHaulSettle(windowState, () => {
if (lastParams) draw(lastParams);
});
}
return {
@@ -26,6 +26,9 @@ import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulSummary,
createMassHaulWindowState,
scheduleMassHaulSettle,
type MassHaulWindowState,
} from "@util/common_util_mass_haul_view";
import {
L,
@@ -59,6 +62,8 @@ export interface MassHaulPanelInput {
redraw: () => void;
/** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */
viewRange?: { fromM: number; toM: number };
/** 세로창 버티기·부드러운 이동 상태(2026-09-04). */
windowState?: MassHaulWindowState;
}
export interface MassHaulPanelResult {
@@ -77,18 +82,37 @@ export interface MassHaulPanelResult {
* 컨테이너 폭 계산이 어긋나 측점 세로선이 밀린다) 범례는 스크롤 컨테이너 **밖**이라
* 붙일 자리가 서로 다르기 때문이다.
*/
/** 곡선 계산 아낌 — 스크롤 중에는 입력이 그대로다. 그릴 때마다 다시 계산하면 프레임을
* 잡아먹는다(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[] =
input.conversion && !pendingRecalc
cached ??
(conversion && !pendingRecalc
? computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
input.conversion,
conversion,
input.naturalSpoilSlope,
)
: [];
: []);
if (!cached) seriesCache = { detail, conversion, slope: input.naturalSpoilSlope, series };
// 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을
// 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다.
const bandedSeries = series.find((entry) => visibleSeries.has(entry.key));
@@ -108,6 +132,7 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu
padLeft: LONG_PAD.left,
padRight: LONG_PAD.right,
viewRange: input.viewRange,
window: input.windowState,
},
input.selectedStationId,
input.stationInterval,
@@ -213,7 +238,15 @@ export function createMassHaulRenderer(
targets: MassHaulMountTargets,
): (fromM: number, toM: number) => void {
let mounted: MountedMassHaul = { chart: null, axis: null };
return (fromM, toM) => {
mounted = mountMassHaulPanel({ ...base, viewRange: { fromM, toM } }, targets, mounted);
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;
}
+79 -3
View File
@@ -49,8 +49,52 @@ export interface MassHaulAxis {
* 전 구간 기준 ±200㎥ 고정이다.
*/
viewRange?: { fromM: number; toM: number };
/** 세로창 버티기·부드러운 이동 상태. 넘기면 창이 한 칸에 확 튀지 않는다(2026-09-04). */
window?: MassHaulWindowState;
}
/**
* 세로창이 스크롤 한 칸에 몇 배씩 튀는 것을 막는 상태(2026-09-04 사용자 확정: 「버티기 +
* 부드럽게」). 누가토량 곡선은 표고와 달리 가팔라, 급한 구간이 창에 들어오면 폭이 한 번에
* 4~5배로 바뀌었다(실측 228 → 1,019㎥).
*
* 규칙 둘.
* ① **버티기** — 곡선이 지금 창 안에 들어오고 창을 절반 넘게 채우면 **그대로 둔다**.
* ② **부드럽게** — 그래도 바꿔야 하면 한 번에 안 가고 다시 그릴 때마다 남은 만큼 좁힌다.
*/
export interface MassHaulWindowState {
/** 지금 쓰고 있는 창. */
held: { min: number; max: number } | null;
/** 목표에 도착했는가 — 아니면 부르는 쪽이 다음 프레임에 다시 그린다. */
settled: boolean;
/** 예약해 둔 다음 프레임(중복 예약 방지). */
frame: number;
}
export function createMassHaulWindowState(): MassHaulWindowState {
return { held: null, settled: true, frame: 0 };
}
/** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */
export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void {
if (state.frame) cancelAnimationFrame(state.frame);
state.frame = 0;
if (state.settled) return;
state.frame = requestAnimationFrame(() => {
state.frame = 0;
redraw();
});
}
/** 새 창에 두는 여유(위아래 각각). 종단 그래프와 같은 20%. */
const WINDOW_PAD_RATIO = 0.2;
/** 곡선이 창을 이만큼 채우고 있으면 창을 그대로 둔다(버티기). */
const WINDOW_KEEP_FILL = 0.55;
/** 다시 그릴 때마다 목표까지 남은 거리에서 좁히는 비율. 0.2면 약 0.2초에 걸쳐 미끄러진다 —
* 0.4는 0.08초 만에 끝나 여전히 「툭」 바뀌어 보였고, 0.15는 그리는 횟수가 늘어 프레임을
* 더 먹었다(2026-09-04 실측). */
const WINDOW_EASE = 0.2;
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
export const MASS_HAUL_MIN_HEIGHT = 90;
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
@@ -137,6 +181,7 @@ const VOLUME_RANGE_BASE_M3 = 200;
function volumeRange(
series: MassHaulSeries[],
viewRange?: { fromM: number; toM: number },
state?: MassHaulWindowState,
): { min: number; max: number } {
let rawMin = 0;
let rawMax = 0;
@@ -168,8 +213,35 @@ function volumeRange(
}
if (found) {
// 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다.
const padding = Math.max((rawMax - rawMin) * 0.05, 1);
return { min: rawMin - padding, max: rawMax + padding };
const padding = Math.max((rawMax - rawMin) * WINDOW_PAD_RATIO, 1);
const target = { min: rawMin - padding, max: rawMax + padding };
if (!state) return target;
const held = state.held;
if (held) {
// 버티기 — 곡선이 지금 창 안에 들어오고 절반 넘게 채우면 아예 건드리지 않는다.
const heldSpan = held.max - held.min;
const inside = rawMin >= held.min && rawMax <= held.max;
const fills = rawMax - rawMin >= heldSpan * WINDOW_KEEP_FILL;
if (inside && fills) {
state.settled = true;
return held;
}
// 부드럽게 — 남은 만큼씩 좁혀 간다(다시 그릴 때마다 한 걸음).
const next = {
min: held.min + (target.min - held.min) * WINDOW_EASE,
max: held.max + (target.max - held.max) * WINDOW_EASE,
};
const tolerance = Math.max((target.max - target.min) * 0.01, 0.5);
const done =
Math.abs(next.min - target.min) <= tolerance &&
Math.abs(next.max - target.max) <= tolerance;
state.held = done ? target : next;
state.settled = done;
return state.held;
}
state.held = target;
state.settled = true;
return target;
}
}
for (const entry of series) {
@@ -365,7 +437,11 @@ export function createMassHaulChart(
// 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 —
// 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백.
const rangeSource = series.filter((entry) => visibleKeys.has(entry.key));
const { min, max } = volumeRange(rangeSource.length ? rangeSource : series, axis.viewRange);
const { min, max } = volumeRange(
rangeSource.length ? rangeSource : series,
axis.viewRange,
axis.window,
);
const span = Math.max(max - min, 1e-6);
const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight;