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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user