diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 61006117..8ab1394a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -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 { diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index d9ffc4c1..5a72fa2c 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -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; } diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index d09d8de6..4ef8c6fc 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -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;