From de5d9101811978f70ff9aa09a52c94f080b53409 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Thu, 23 Jul 2026 14:14:25 -0600 Subject: [PATCH 01/10] refactor: hookify min zoom, use in all charts --- .../src/lib/useMinZoomSpanPct.test.ts | 23 +++++++++++++++++++ .../components/src/lib/useMinZoomSpanPct.ts | 13 +++++++++++ .../operator-timeline/OperatorGanttChart.tsx | 14 ++++++++++- .../components/src/timeline/Timeline.tsx | 8 +++---- .../src/timeline/TimelineController.tsx | 7 ++---- 5 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 ui/packages/@quent/components/src/lib/useMinZoomSpanPct.test.ts create mode 100644 ui/packages/@quent/components/src/lib/useMinZoomSpanPct.ts diff --git a/ui/packages/@quent/components/src/lib/useMinZoomSpanPct.test.ts b/ui/packages/@quent/components/src/lib/useMinZoomSpanPct.test.ts new file mode 100644 index 000000000..6d5a86d45 --- /dev/null +++ b/ui/packages/@quent/components/src/lib/useMinZoomSpanPct.test.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useMinZoomSpanPct } from './useMinZoomSpanPct'; + +describe('useMinZoomSpanPct', () => { + it('converts the minimum zoom window to a percentage of the query duration', () => { + const { result } = renderHook(() => useMinZoomSpanPct(1)); + expect(result.current).toBe(0.005); + }); + + it('caps the minimum span at the full query duration', () => { + const { result } = renderHook(() => useMinZoomSpanPct(0.00001)); + expect(result.current).toBe(100); + }); + + it.each([0, -1])('returns zero for non-positive duration %s', duration => { + const { result } = renderHook(() => useMinZoomSpanPct(duration)); + expect(result.current).toBe(0); + }); +}); diff --git a/ui/packages/@quent/components/src/lib/useMinZoomSpanPct.ts b/ui/packages/@quent/components/src/lib/useMinZoomSpanPct.ts new file mode 100644 index 000000000..e926893ce --- /dev/null +++ b/ui/packages/@quent/components/src/lib/useMinZoomSpanPct.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react'; +import { MIN_ZOOM_WINDOW_S } from './timeline.utils'; + +/** ECharts `dataZoom.minSpan` that preserves the timeline bin-size floor. */ +export function useMinZoomSpanPct(durationSeconds: number): number { + return useMemo(() => { + if (durationSeconds <= 0) return 0; + return Math.min(100, (MIN_ZOOM_WINDOW_S / durationSeconds) * 100); + }, [durationSeconds]); +} diff --git a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx index 989019b0d..67c7d48aa 100644 --- a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx +++ b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx @@ -13,6 +13,7 @@ import { unregisterAxisPointerSync, } from '../lib/timeline.utils'; import { useChartConnect } from '../lib/useChartConnect'; +import { useMinZoomSpanPct } from '../lib/useMinZoomSpanPct'; import { echarts } from '../lib/echarts'; import { CHART_GROUP } from '../timeline/Timeline'; import { useTimelineEchartsTheme } from '../timeline/timelineEchartsTheme'; @@ -73,6 +74,7 @@ export function OperatorGanttChart({ () => startTimeMs + durationSeconds * 1_000, [startTimeMs, durationSeconds] ); + const minZoomSpanPct = useMinZoomSpanPct(durationSeconds); const { yAxisCategories, rowCount } = useMemo(() => { if (operators.length === 0) return { yAxisCategories: [] as number[], rowCount: 0 }; @@ -263,6 +265,7 @@ export function OperatorGanttChart({ realtime: true, filterMode: 'none', xAxisIndex: [0], + minSpan: minZoomSpanPct, }, { type: 'inside', @@ -281,10 +284,19 @@ export function OperatorGanttChart({ throttle: 30, filterMode: 'none', xAxisIndex: [0], + minSpan: minZoomSpanPct, }, ], }), - [gridOptions, startTimeMs, xAxisMax, yAxisCategories, customSeriesData, renderItem] + [ + gridOptions, + startTimeMs, + xAxisMax, + yAxisCategories, + customSeriesData, + renderItem, + minZoomSpanPct, + ] ); const handleClick = useMemo( diff --git a/ui/packages/@quent/components/src/timeline/Timeline.tsx b/ui/packages/@quent/components/src/timeline/Timeline.tsx index 758581870..85fa40304 100644 --- a/ui/packages/@quent/components/src/timeline/Timeline.tsx +++ b/ui/packages/@quent/components/src/timeline/Timeline.tsx @@ -19,9 +19,10 @@ import { TIMELINE_MONO_FONT, useTimelineEchartsTheme, } from './timelineEchartsTheme'; -import { MIN_ZOOM_WINDOW_S, nanosToMs } from '../lib/timeline.utils'; +import { nanosToMs } from '../lib/timeline.utils'; import { useVisibleMaxValue } from './useVisibleMaxValue'; import { useChartConnect } from '../lib/useChartConnect'; +import { useMinZoomSpanPct } from '../lib/useMinZoomSpanPct'; import { Opts } from 'echarts-for-react/lib/types'; export const CHART_GROUP = 'timeline-sync-group'; @@ -225,10 +226,7 @@ export function Timeline({ const gridOptions = useMemo(() => ({ ...TIMELINE_SPACING }), []); - const minZoomSpanPct = useMemo(() => { - if (durationSeconds <= 0) return 0; - return Math.min(100, (MIN_ZOOM_WINDOW_S / durationSeconds) * 100); - }, [durationSeconds]); + const minZoomSpanPct = useMinZoomSpanPct(durationSeconds); const minZoomSpanPctRef = useRef(minZoomSpanPct); minZoomSpanPctRef.current = minZoomSpanPct; diff --git a/ui/packages/@quent/components/src/timeline/TimelineController.tsx b/ui/packages/@quent/components/src/timeline/TimelineController.tsx index e823cd0d7..92835ee1b 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineController.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineController.tsx @@ -13,12 +13,12 @@ import { buildBinnedTimelineSeries, getAdaptiveNumBins, getTimelineXAxisIntervalMs, - MIN_ZOOM_WINDOW_S, nanosToMs, registerAxisPointerSync, unregisterAxisPointerSync, } from '../lib/timeline.utils'; import { useChartConnect } from '../lib/useChartConnect'; +import { useMinZoomSpanPct } from '../lib/useMinZoomSpanPct'; import { TIMELINE_X_AXIS_ANIMATION, TIMELINE_SPACING } from './types'; import type { SingleTimelineResponse } from '@quent/utils'; import { useTimelineEchartsTheme } from './timelineEchartsTheme'; @@ -230,10 +230,7 @@ export function TimelineController({ [controllerGridBackgroundColor] ); - const minZoomSpanPct = useMemo(() => { - if (durationSeconds <= 0) return 0; - return Math.min(100, (MIN_ZOOM_WINDOW_S / durationSeconds) * 100); - }, [durationSeconds]); + const minZoomSpanPct = useMinZoomSpanPct(durationSeconds); const eChartOptions: EChartsOption = useMemo(() => { return { From d2a5d5bb298fcf6eae15b0784543be4e9116715f Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 12:43:20 -0600 Subject: [PATCH 02/10] Horizontal scroll for gantt chart --- .../operator-timeline/OperatorGanttChart.tsx | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx index 67c7d48aa..63c5e55e5 100644 --- a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx +++ b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx @@ -354,10 +354,9 @@ export function OperatorGanttChart({ instanceRef.current = null; } }; - }, []); + }, [instanceRef]); - // Handle scrolling from the container, echarts captures wheel events and prevents the container - // from receiving. + // Keep vertical scrolling native; use horizontal trackpad scrolling to pan the time window. const wrapperRef = useRef(null); useEffect(() => { const wrapper = wrapperRef.current; @@ -365,12 +364,40 @@ export function OperatorGanttChart({ const handleWheel = (e: WheelEvent) => { if (e.shiftKey) return; e.stopPropagation(); + + if (e.deltaX === 0 || Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; + + const instance = instanceRef.current; + if (!instance || instance.isDisposed?.()) return; + + e.preventDefault(); + + const opt = instance.getOption() as { + dataZoom?: Array<{ start?: number; end?: number }>; + }; + const dz = opt.dataZoom?.[0]; + if (!dz) return; + + const currentStart = dz.start ?? 0; + const currentEnd = dz.end ?? 100; + const spanPct = currentEnd - currentStart; + const rect = instance.getDom().getBoundingClientRect(); + const usableWidth = Math.max(1, rect.width - TIMELINE_SPACING.left - TIMELINE_SPACING.right); + const deltaPct = (e.deltaX / usableWidth) * spanPct; + const newStart = Math.max(0, Math.min(100 - spanPct, currentStart + deltaPct)); + + instance.dispatchAction({ + type: 'dataZoom', + dataZoomIndex: 0, + start: newStart, + end: newStart + spanPct, + }); }; - wrapper.addEventListener('wheel', handleWheel, { capture: true, passive: true }); + wrapper.addEventListener('wheel', handleWheel, { capture: true, passive: false }); return () => { wrapper.removeEventListener('wheel', handleWheel, { capture: true }); }; - }, []); + }, [instanceRef]); if (operators.length === 0) { return ( From da631a6ebef09e45105db12b7ba3206056ee0fc6 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 12:59:25 -0600 Subject: [PATCH 03/10] refactor: capture all mouse wheel interaction in hook --- .../lib/useTimelineWheelNavigation.test.ts | 129 ++++++++++++++++++ .../src/lib/useTimelineWheelNavigation.ts | 98 +++++++++++++ .../operator-timeline/OperatorGanttChart.tsx | 56 ++------ .../components/src/timeline/Timeline.tsx | 87 +----------- 4 files changed, 243 insertions(+), 127 deletions(-) create mode 100644 ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts create mode 100644 ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts new file mode 100644 index 000000000..1229182da --- /dev/null +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { act, renderHook } from '@testing-library/react'; +import type { EChartsInstance } from 'echarts-for-react'; +import { describe, expect, it, vi } from 'vitest'; +import { useTimelineWheelNavigation } from './useTimelineWheelNavigation'; + +function createChart(dataZoom: { start: number; end: number }) { + const dom = document.createElement('div'); + vi.spyOn(dom, 'getBoundingClientRect').mockReturnValue({ + width: 1010, + height: 100, + top: 0, + right: 1010, + bottom: 100, + left: 0, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + let dataZoomListener: (() => void) | undefined; + const dispatchAction = vi.fn(); + const off = vi.fn(); + const instance = { + getDom: () => dom, + getOption: () => ({ dataZoom: [dataZoom] }), + dispatchAction, + isDisposed: () => false, + on: vi.fn((event: string, listener: () => void) => { + if (event === 'datazoom') dataZoomListener = listener; + }), + off, + } as unknown as EChartsInstance; + + return { instance, dom, dispatchAction, off, emitDataZoom: () => dataZoomListener?.() }; +} + +describe('useTimelineWheelNavigation', () => { + it('pans the visible range on horizontal wheel input', () => { + const chart = createChart({ start: 25, end: 75 }); + const { result } = renderHook(() => useTimelineWheelNavigation(10)); + act(() => result.current(chart.instance)); + + const event = new WheelEvent('wheel', { + deltaX: 100, + bubbles: true, + cancelable: true, + }); + act(() => chart.dom.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(true); + expect(chart.dispatchAction).toHaveBeenCalledWith({ + type: 'dataZoom', + dataZoomIndex: 0, + start: 30, + end: 80, + }); + }); + + it('leaves vertical wheel input to native scrolling', () => { + const chart = createChart({ start: 25, end: 75 }); + const { result } = renderHook(() => useTimelineWheelNavigation(10)); + act(() => result.current(chart.instance)); + + const event = new WheelEvent('wheel', { + deltaY: 100, + bubbles: true, + cancelable: true, + }); + act(() => chart.dom.dispatchEvent(event)); + + expect(event.defaultPrevented).toBe(false); + expect(chart.dispatchAction).not.toHaveBeenCalled(); + }); + + it('blocks zoom-in panning at the minimum span but allows zoom-out', () => { + const dataZoom = { start: 20, end: 40 }; + const chart = createChart(dataZoom); + const parent = document.createElement('div'); + parent.appendChild(chart.dom); + const parentWheel = vi.fn(); + parent.addEventListener('wheel', parentWheel); + + const { result } = renderHook(() => useTimelineWheelNavigation(10)); + act(() => result.current(chart.instance)); + dataZoom.end = 30; + act(() => chart.emitDataZoom()); + + const zoomIn = new WheelEvent('wheel', { + deltaY: -1, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + act(() => chart.dom.dispatchEvent(zoomIn)); + + expect(zoomIn.defaultPrevented).toBe(true); + expect(parentWheel).not.toHaveBeenCalled(); + + const zoomOut = new WheelEvent('wheel', { + deltaY: 1, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + act(() => chart.dom.dispatchEvent(zoomOut)); + + expect(zoomOut.defaultPrevented).toBe(false); + expect(parentWheel).toHaveBeenCalledOnce(); + }); + + it('removes chart and wheel listeners on unmount', () => { + const chart = createChart({ start: 25, end: 75 }); + const { result, unmount } = renderHook(() => useTimelineWheelNavigation(10)); + act(() => result.current(chart.instance)); + unmount(); + + act(() => { + chart.dom.dispatchEvent( + new WheelEvent('wheel', { deltaX: 100, bubbles: true, cancelable: true }) + ); + }); + + expect(chart.dispatchAction).not.toHaveBeenCalled(); + expect(chart.off).toHaveBeenCalledWith('datazoom', expect.any(Function)); + }); +}); diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts new file mode 100644 index 000000000..af93b4b2a --- /dev/null +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useRef } from 'react'; +import type { EChartsInstance } from 'echarts-for-react'; +import { TIMELINE_SPACING } from '../timeline/types'; + +type DataZoomState = { + start?: number; + end?: number; +}; + +function getDataZoomState(instance: EChartsInstance): DataZoomState | undefined { + const option = instance.getOption() as { dataZoom?: DataZoomState[] }; + return option.dataZoom?.[0]; +} + +/** + * Adds native vertical scrolling, horizontal trackpad panning, and minimum-zoom guarding. + * Call the returned function from the chart's `onReady` callback. + */ +export function useTimelineWheelNavigation(minZoomSpanPct: number) { + const minZoomSpanPctRef = useRef(minZoomSpanPct); + minZoomSpanPctRef.current = minZoomSpanPct; + const cleanupRef = useRef<(() => void) | null>(null); + + const attachWheelNavigation = useCallback( + (instance: EChartsInstance, wheelTarget: HTMLElement = instance.getDom()) => { + cleanupRef.current?.(); + + let atZoomLimit = false; + const updateZoomLimit = () => { + if (instance.isDisposed?.()) return; + const dataZoom = getDataZoomState(instance); + if (!dataZoom) return; + const spanPct = (dataZoom.end ?? 100) - (dataZoom.start ?? 0); + atZoomLimit = spanPct <= minZoomSpanPctRef.current * 1.01; + }; + + const handleWheel = (event: WheelEvent) => { + if (event.shiftKey) { + const zoomDelta = event.deltaY || event.deltaX; + if (zoomDelta < 0 && atZoomLimit) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + + event.stopPropagation(); + if (event.deltaX === 0 || Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; + if (instance.isDisposed?.()) return; + + event.preventDefault(); + const dataZoom = getDataZoomState(instance); + if (!dataZoom) return; + + const currentStart = dataZoom.start ?? 0; + const currentEnd = dataZoom.end ?? 100; + const spanPct = currentEnd - currentStart; + const rect = instance.getDom().getBoundingClientRect(); + const usableWidth = Math.max( + 1, + rect.width - TIMELINE_SPACING.left - TIMELINE_SPACING.right + ); + const deltaPct = (event.deltaX / usableWidth) * spanPct; + const newStart = Math.max(0, Math.min(100 - spanPct, currentStart + deltaPct)); + + instance.dispatchAction({ + type: 'dataZoom', + dataZoomIndex: 0, + start: newStart, + end: newStart + spanPct, + }); + }; + + updateZoomLimit(); + instance.on('datazoom', updateZoomLimit); + wheelTarget.addEventListener('wheel', handleWheel, { capture: true, passive: false }); + + cleanupRef.current = () => { + wheelTarget.removeEventListener('wheel', handleWheel, { capture: true }); + if (!instance.isDisposed?.()) instance.off('datazoom', updateZoomLimit); + }; + }, + [] + ); + + useEffect( + () => () => { + cleanupRef.current?.(); + cleanupRef.current = null; + }, + [] + ); + + return attachWheelNavigation; +} diff --git a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx index 63c5e55e5..07b3f8808 100644 --- a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx +++ b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx @@ -14,6 +14,7 @@ import { } from '../lib/timeline.utils'; import { useChartConnect } from '../lib/useChartConnect'; import { useMinZoomSpanPct } from '../lib/useMinZoomSpanPct'; +import { useTimelineWheelNavigation } from '../lib/useTimelineWheelNavigation'; import { echarts } from '../lib/echarts'; import { CHART_GROUP } from '../timeline/Timeline'; import { useTimelineEchartsTheme } from '../timeline/timelineEchartsTheme'; @@ -75,6 +76,8 @@ export function OperatorGanttChart({ [startTimeMs, durationSeconds] ); const minZoomSpanPct = useMinZoomSpanPct(durationSeconds); + const attachWheelNavigation = useTimelineWheelNavigation(minZoomSpanPct); + const wrapperRef = useRef(null); const { yAxisCategories, rowCount } = useMemo(() => { if (operators.length === 0) return { yAxisCategories: [] as number[], rowCount: 0 }; @@ -337,9 +340,13 @@ export function OperatorGanttChart({ // Join timeline-sync-group for frame-rate-level x-axis zoom sync via ECharts connect(). // The y-axis dataZoom (index 3, when present) has a unique component ID and does not // propagate to resource timelines that have no matching component. - const onChartReady = useCallback((instance: EChartsInstance) => { - registerAxisPointerSync(instance, 0, { receiveShowTip: false }); - }, []); + const onChartReady = useCallback( + (instance: EChartsInstance) => { + registerAxisPointerSync(instance, 0, { receiveShowTip: false }); + attachWheelNavigation(instance, wrapperRef.current ?? undefined); + }, + [attachWheelNavigation] + ); const { handleChartReady, instanceRef } = useChartConnect({ durationSeconds, @@ -356,49 +363,6 @@ export function OperatorGanttChart({ }; }, [instanceRef]); - // Keep vertical scrolling native; use horizontal trackpad scrolling to pan the time window. - const wrapperRef = useRef(null); - useEffect(() => { - const wrapper = wrapperRef.current; - if (!wrapper) return; - const handleWheel = (e: WheelEvent) => { - if (e.shiftKey) return; - e.stopPropagation(); - - if (e.deltaX === 0 || Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; - - const instance = instanceRef.current; - if (!instance || instance.isDisposed?.()) return; - - e.preventDefault(); - - const opt = instance.getOption() as { - dataZoom?: Array<{ start?: number; end?: number }>; - }; - const dz = opt.dataZoom?.[0]; - if (!dz) return; - - const currentStart = dz.start ?? 0; - const currentEnd = dz.end ?? 100; - const spanPct = currentEnd - currentStart; - const rect = instance.getDom().getBoundingClientRect(); - const usableWidth = Math.max(1, rect.width - TIMELINE_SPACING.left - TIMELINE_SPACING.right); - const deltaPct = (e.deltaX / usableWidth) * spanPct; - const newStart = Math.max(0, Math.min(100 - spanPct, currentStart + deltaPct)); - - instance.dispatchAction({ - type: 'dataZoom', - dataZoomIndex: 0, - start: newStart, - end: newStart + spanPct, - }); - }; - wrapper.addEventListener('wheel', handleWheel, { capture: true, passive: false }); - return () => { - wrapper.removeEventListener('wheel', handleWheel, { capture: true }); - }; - }, [instanceRef]); - if (operators.length === 0) { return (
({ ...TIMELINE_SPACING }), []); const minZoomSpanPct = useMinZoomSpanPct(durationSeconds); - - const minZoomSpanPctRef = useRef(minZoomSpanPct); - minZoomSpanPctRef.current = minZoomSpanPct; - const atZoomLimitRef = useRef(false); + const attachWheelNavigation = useTimelineWheelNavigation(minZoomSpanPct); // ECharts' built-in tooltip is reduced to crosshair only (`showContent: false`). // Tooltip content is rendered by the parent via `onHoverChange` — keeping @@ -297,7 +295,7 @@ export function Timeline({ const timestampsRef = useRef(timestamps); timestampsRef.current = timestamps; - const onChartReady = useCallback((instance: EChartsInstance) => { + const onChartReady = (instance: EChartsInstance) => { const dom = instance.getDom(); const outsideTimelineViz = (e: PointerEvent) => { const rect = dom.getBoundingClientRect(); @@ -357,81 +355,8 @@ export function Timeline({ isDraggingRef.current = false; }); - // Update atZoomLimitRef from ECharts' datazoom event, which fires synchronously - // within the same dispatch tick as the wheel handler — no React render-cycle lag. - instance.on('datazoom', () => { - const opt = instance.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> }; - const dz = opt.dataZoom?.[0]; - if (dz != null) { - const spanPct = (dz.end ?? 100) - (dz.start ?? 0); - atZoomLimitRef.current = spanPct <= minZoomSpanPctRef.current * 1.01; - } - }); - - // Two-finger horizontal trackpad scroll → pan the time window. - dom.addEventListener( - 'wheel', - e => { - // Shift+wheel is reserved for zoom; browsers remap deltaY to deltaX while shift is held. - if (e.shiftKey) return; - if (e.deltaX === 0 || Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; - if (instance.isDisposed?.()) return; - - e.preventDefault(); - - const opt = instance.getOption() as { - dataZoom?: Array<{ start?: number; end?: number }>; - }; - const dz = opt.dataZoom?.[0]; - if (!dz) return; - - const currentStart = dz.start ?? 0; - const currentEnd = dz.end ?? 100; - const spanPct = currentEnd - currentStart; - - const rect = dom.getBoundingClientRect(); - const usableWidth = Math.max( - 1, - rect.width - TIMELINE_SPACING.left - TIMELINE_SPACING.right - ); - const deltaPct = (e.deltaX / usableWidth) * spanPct; - const newStart = Math.max(0, Math.min(100 - spanPct, currentStart + deltaPct)); - - instance.dispatchAction({ - type: 'dataZoom', - dataZoomIndex: 0, - start: newStart, - end: newStart + spanPct, - }); - }, - { capture: true, passive: false } - ); - - // Pass non-shift wheel events through to the page for normal scrolling. - // Without this, ECharts' inside dataZoom calls preventDefault on all wheel events. - // When at the zoom limit, also block shift+wheel-in before ECharts sees it — - // ECharts converts a blocked zoom into a pan, so we must stop it at the source. - dom.addEventListener( - 'wheel', - e => { - if (!e.shiftKey) { - e.stopPropagation(); - } else if (e.deltaY < 0 && atZoomLimitRef.current) { - e.stopPropagation(); - } - }, - { capture: true, passive: true } - ); - - // Prevent the browser from handling shift+wheel-in when ECharts can't zoom further - dom.addEventListener( - 'wheel', - e => { - if (e.shiftKey && e.deltaY < 0) e.preventDefault(); - }, - { passive: false } - ); - }, []); + attachWheelNavigation(instance); + }; // If this Timeline is unmounted while the pointer is over it (e.g. a tree // row is virtualized away mid-hover, or ResourceTimeline swaps to a From f9305e69ad8d473a0f3a9298183bb1ae4a237bc4 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 13:07:11 -0600 Subject: [PATCH 04/10] chore: better types --- .../src/lib/useTimelineWheelNavigation.test.ts | 4 ++-- .../src/lib/useTimelineWheelNavigation.ts | 16 ++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts index 1229182da..62e3eb163 100644 --- a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { act, renderHook } from '@testing-library/react'; -import type { EChartsInstance } from 'echarts-for-react'; +import type { EChartsType } from 'echarts'; import { describe, expect, it, vi } from 'vitest'; import { useTimelineWheelNavigation } from './useTimelineWheelNavigation'; @@ -32,7 +32,7 @@ function createChart(dataZoom: { start: number; end: number }) { if (event === 'datazoom') dataZoomListener = listener; }), off, - } as unknown as EChartsInstance; + } as unknown as EChartsType; return { instance, dom, dispatchAction, off, emitDataZoom: () => dataZoomListener?.() }; } diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts index af93b4b2a..4bfdafe71 100644 --- a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts @@ -2,17 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { useCallback, useEffect, useRef } from 'react'; -import type { EChartsInstance } from 'echarts-for-react'; +import type { EChartsOption, EChartsType } from 'echarts'; +import type { DataZoomComponentOption } from 'echarts/components'; import { TIMELINE_SPACING } from '../timeline/types'; -type DataZoomState = { - start?: number; - end?: number; -}; - -function getDataZoomState(instance: EChartsInstance): DataZoomState | undefined { - const option = instance.getOption() as { dataZoom?: DataZoomState[] }; - return option.dataZoom?.[0]; +function getDataZoomState(instance: EChartsType): DataZoomComponentOption | undefined { + const dataZoom = (instance.getOption() as EChartsOption).dataZoom; + return Array.isArray(dataZoom) ? dataZoom[0] : dataZoom; } /** @@ -25,7 +21,7 @@ export function useTimelineWheelNavigation(minZoomSpanPct: number) { const cleanupRef = useRef<(() => void) | null>(null); const attachWheelNavigation = useCallback( - (instance: EChartsInstance, wheelTarget: HTMLElement = instance.getDom()) => { + (instance: EChartsType, wheelTarget: HTMLElement = instance.getDom()) => { cleanupRef.current?.(); let atZoomLimit = false; From d7ca267730cb9e103f5223710608c949fc4a5a04 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 14:15:52 -0600 Subject: [PATCH 05/10] chore: coderabbit suggestions pretty good --- .../lib/useTimelineWheelNavigation.test.ts | 55 ++++++++++++------- .../src/lib/useTimelineWheelNavigation.ts | 16 +++--- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts index 62e3eb163..bccddfe89 100644 --- a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts @@ -4,15 +4,18 @@ import { act, renderHook } from '@testing-library/react'; import type { EChartsType } from 'echarts'; import { describe, expect, it, vi } from 'vitest'; +import { TIMELINE_SPACING } from '../timeline/types'; import { useTimelineWheelNavigation } from './useTimelineWheelNavigation'; +const CHART_WIDTH = 1010; + function createChart(dataZoom: { start: number; end: number }) { const dom = document.createElement('div'); vi.spyOn(dom, 'getBoundingClientRect').mockReturnValue({ - width: 1010, + width: CHART_WIDTH, height: 100, top: 0, - right: 1010, + right: CHART_WIDTH, bottom: 100, left: 0, x: 0, @@ -20,21 +23,15 @@ function createChart(dataZoom: { start: number; end: number }) { toJSON: () => undefined, }); - let dataZoomListener: (() => void) | undefined; const dispatchAction = vi.fn(); - const off = vi.fn(); const instance = { getDom: () => dom, getOption: () => ({ dataZoom: [dataZoom] }), dispatchAction, isDisposed: () => false, - on: vi.fn((event: string, listener: () => void) => { - if (event === 'datazoom') dataZoomListener = listener; - }), - off, } as unknown as EChartsType; - return { instance, dom, dispatchAction, off, emitDataZoom: () => dataZoomListener?.() }; + return { instance, dom, dispatchAction }; } describe('useTimelineWheelNavigation', () => { @@ -50,12 +47,15 @@ describe('useTimelineWheelNavigation', () => { }); act(() => chart.dom.dispatchEvent(event)); + const spanPct = 50; + const usableWidth = CHART_WIDTH - TIMELINE_SPACING.left - TIMELINE_SPACING.right; + const expectedStart = 25 + (event.deltaX / usableWidth) * spanPct; expect(event.defaultPrevented).toBe(true); expect(chart.dispatchAction).toHaveBeenCalledWith({ type: 'dataZoom', dataZoomIndex: 0, - start: 30, - end: 80, + start: expectedStart, + end: expectedStart + spanPct, }); }); @@ -75,18 +75,19 @@ describe('useTimelineWheelNavigation', () => { expect(chart.dispatchAction).not.toHaveBeenCalled(); }); - it('blocks zoom-in panning at the minimum span but allows zoom-out', () => { - const dataZoom = { start: 20, end: 40 }; - const chart = createChart(dataZoom); + it('uses the latest minimum span when blocking zoom-in panning', () => { + const chart = createChart({ start: 20, end: 30 }); const parent = document.createElement('div'); parent.appendChild(chart.dom); const parentWheel = vi.fn(); parent.addEventListener('wheel', parentWheel); - const { result } = renderHook(() => useTimelineWheelNavigation(10)); + const { result, rerender } = renderHook( + ({ minZoomSpanPct }) => useTimelineWheelNavigation(minZoomSpanPct), + { initialProps: { minZoomSpanPct: 5 } } + ); act(() => result.current(chart.instance)); - dataZoom.end = 30; - act(() => chart.emitDataZoom()); + rerender({ minZoomSpanPct: 10 }); const zoomIn = new WheelEvent('wheel', { deltaY: -1, @@ -111,7 +112,24 @@ describe('useTimelineWheelNavigation', () => { expect(parentWheel).toHaveBeenCalledOnce(); }); - it('removes chart and wheel listeners on unmount', () => { + it.each([ + { dataZoom: { start: 0, end: 50 }, deltaX: -100, expectedStart: 0 }, + { dataZoom: { start: 50, end: 100 }, deltaX: 100, expectedStart: 50 }, + ])('clamps panning at the range edge for $dataZoom', ({ dataZoom, deltaX, expectedStart }) => { + const chart = createChart(dataZoom); + const { result } = renderHook(() => useTimelineWheelNavigation(10)); + act(() => result.current(chart.instance)); + + act(() => { + chart.dom.dispatchEvent(new WheelEvent('wheel', { deltaX, bubbles: true, cancelable: true })); + }); + + expect(chart.dispatchAction).toHaveBeenCalledWith( + expect.objectContaining({ start: expectedStart, end: expectedStart + 50 }) + ); + }); + + it('removes the wheel listener on unmount', () => { const chart = createChart({ start: 25, end: 75 }); const { result, unmount } = renderHook(() => useTimelineWheelNavigation(10)); act(() => result.current(chart.instance)); @@ -124,6 +142,5 @@ describe('useTimelineWheelNavigation', () => { }); expect(chart.dispatchAction).not.toHaveBeenCalled(); - expect(chart.off).toHaveBeenCalledWith('datazoom', expect.any(Function)); }); }); diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts index 4bfdafe71..73f92e3fc 100644 --- a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts @@ -6,6 +6,8 @@ import type { EChartsOption, EChartsType } from 'echarts'; import type { DataZoomComponentOption } from 'echarts/components'; import { TIMELINE_SPACING } from '../timeline/types'; +const ZOOM_LIMIT_FLOAT_TOLERANCE = 1.01; + function getDataZoomState(instance: EChartsType): DataZoomComponentOption | undefined { const dataZoom = (instance.getOption() as EChartsOption).dataZoom; return Array.isArray(dataZoom) ? dataZoom[0] : dataZoom; @@ -24,19 +26,18 @@ export function useTimelineWheelNavigation(minZoomSpanPct: number) { (instance: EChartsType, wheelTarget: HTMLElement = instance.getDom()) => { cleanupRef.current?.(); - let atZoomLimit = false; - const updateZoomLimit = () => { - if (instance.isDisposed?.()) return; + const isAtZoomLimit = () => { + if (instance.isDisposed?.()) return false; const dataZoom = getDataZoomState(instance); - if (!dataZoom) return; + if (!dataZoom) return false; const spanPct = (dataZoom.end ?? 100) - (dataZoom.start ?? 0); - atZoomLimit = spanPct <= minZoomSpanPctRef.current * 1.01; + return spanPct <= minZoomSpanPctRef.current * ZOOM_LIMIT_FLOAT_TOLERANCE; }; const handleWheel = (event: WheelEvent) => { if (event.shiftKey) { const zoomDelta = event.deltaY || event.deltaX; - if (zoomDelta < 0 && atZoomLimit) { + if (zoomDelta < 0 && isAtZoomLimit()) { event.preventDefault(); event.stopPropagation(); } @@ -70,13 +71,10 @@ export function useTimelineWheelNavigation(minZoomSpanPct: number) { }); }; - updateZoomLimit(); - instance.on('datazoom', updateZoomLimit); wheelTarget.addEventListener('wheel', handleWheel, { capture: true, passive: false }); cleanupRef.current = () => { wheelTarget.removeEventListener('wheel', handleWheel, { capture: true }); - if (!instance.isDisposed?.()) instance.off('datazoom', updateZoomLimit); }; }, [] From 94a11d737d88488908660923d77c407cca6587fd Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 15:47:11 -0600 Subject: [PATCH 06/10] fix: horizontal scroll + shift pans not zoom --- .../lib/useTimelineWheelNavigation.test.ts | 50 +++++++++++++++++++ .../src/lib/useTimelineWheelNavigation.ts | 10 ++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts index bccddfe89..b23abf1e4 100644 --- a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts @@ -59,6 +59,56 @@ describe('useTimelineWheelNavigation', () => { }); }); + it('pans instead of zooming on shifted horizontal wheel input', () => { + const chart = createChart({ start: 25, end: 75 }); + const wheelTarget = document.createElement('div'); + wheelTarget.appendChild(chart.dom); + const echartsWheel = vi.fn(); + chart.dom.addEventListener('wheel', echartsWheel); + const { result } = renderHook(() => useTimelineWheelNavigation(10)); + act(() => result.current(chart.instance, wheelTarget)); + + const event = new WheelEvent('wheel', { + deltaX: 100, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + act(() => chart.dom.dispatchEvent(event)); + + const spanPct = 50; + const usableWidth = CHART_WIDTH - TIMELINE_SPACING.left - TIMELINE_SPACING.right; + const expectedStart = 25 + (event.deltaX / usableWidth) * spanPct; + expect(echartsWheel).not.toHaveBeenCalled(); + expect(chart.dispatchAction).toHaveBeenCalledWith( + expect.objectContaining({ start: expectedStart, end: expectedStart + spanPct }) + ); + }); + + it('allows shifted vertical wheel input to reach ECharts', () => { + const chart = createChart({ start: 25, end: 75 }); + const wheelTarget = document.createElement('div'); + wheelTarget.appendChild(chart.dom); + const echartsWheel = vi.fn(); + chart.dom.addEventListener('wheel', echartsWheel); + const { result } = renderHook(() => useTimelineWheelNavigation(10)); + act(() => result.current(chart.instance, wheelTarget)); + + act(() => { + chart.dom.dispatchEvent( + new WheelEvent('wheel', { + deltaY: 100, + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ); + }); + + expect(echartsWheel).toHaveBeenCalledOnce(); + expect(chart.dispatchAction).not.toHaveBeenCalled(); + }); + it('leaves vertical wheel input to native scrolling', () => { const chart = createChart({ start: 25, end: 75 }); const { result } = renderHook(() => useTimelineWheelNavigation(10)); diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts index 73f92e3fc..b68b0fa1f 100644 --- a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts @@ -35,9 +35,11 @@ export function useTimelineWheelNavigation(minZoomSpanPct: number) { }; const handleWheel = (event: WheelEvent) => { - if (event.shiftKey) { - const zoomDelta = event.deltaY || event.deltaX; - if (zoomDelta < 0 && isAtZoomLimit()) { + const isHorizontalScroll = + event.deltaX !== 0 && Math.abs(event.deltaX) > Math.abs(event.deltaY); + + if (event.shiftKey && !isHorizontalScroll) { + if (event.deltaY < 0 && isAtZoomLimit()) { event.preventDefault(); event.stopPropagation(); } @@ -45,7 +47,7 @@ export function useTimelineWheelNavigation(minZoomSpanPct: number) { } event.stopPropagation(); - if (event.deltaX === 0 || Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; + if (!isHorizontalScroll) return; if (instance.isDisposed?.()) return; event.preventDefault(); From 127accd4dca781cc8abf210333daa77531a61223 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 21:19:32 -0600 Subject: [PATCH 07/10] refactor: remove callback from onReady function in operator gantt --- .../src/operator-timeline/OperatorGanttChart.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx index 07b3f8808..6b10d2542 100644 --- a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx +++ b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx @@ -340,13 +340,10 @@ export function OperatorGanttChart({ // Join timeline-sync-group for frame-rate-level x-axis zoom sync via ECharts connect(). // The y-axis dataZoom (index 3, when present) has a unique component ID and does not // propagate to resource timelines that have no matching component. - const onChartReady = useCallback( - (instance: EChartsInstance) => { - registerAxisPointerSync(instance, 0, { receiveShowTip: false }); - attachWheelNavigation(instance, wrapperRef.current ?? undefined); - }, - [attachWheelNavigation] - ); + const onChartReady = (instance: EChartsInstance) => { + registerAxisPointerSync(instance, 0, { receiveShowTip: false }); + attachWheelNavigation(instance, wrapperRef.current ?? undefined); + }; const { handleChartReady, instanceRef } = useChartConnect({ durationSeconds, From cc5a2d13279401933f1ab644107693e12c393b15 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 21:24:51 -0600 Subject: [PATCH 08/10] chore: unit test for multiple calls to hook result --- .../lib/useTimelineWheelNavigation.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts index b23abf1e4..380cb7c9f 100644 --- a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts @@ -179,6 +179,27 @@ describe('useTimelineWheelNavigation', () => { ); }); + it('removes the previous listener when attach is called again', () => { + const chart = createChart({ start: 25, end: 75 }); + const addSpy = vi.spyOn(chart.dom, 'addEventListener'); + const removeSpy = vi.spyOn(chart.dom, 'removeEventListener'); + const { result } = renderHook(() => useTimelineWheelNavigation(10)); + + act(() => result.current(chart.instance)); + act(() => result.current(chart.instance)); + + expect(addSpy.mock.calls.filter(([type]) => type === 'wheel')).toHaveLength(2); + expect(removeSpy.mock.calls.filter(([type]) => type === 'wheel')).toHaveLength(1); + + act(() => { + chart.dom.dispatchEvent( + new WheelEvent('wheel', { deltaX: 100, bubbles: true, cancelable: true }) + ); + }); + + expect(chart.dispatchAction).toHaveBeenCalledOnce(); + }); + it('removes the wheel listener on unmount', () => { const chart = createChart({ start: 25, end: 75 }); const { result, unmount } = renderHook(() => useTimelineWheelNavigation(10)); From da9ac483af21311bb5bff0eaf9846fe60bcf3319 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 21:43:34 -0600 Subject: [PATCH 09/10] Cleanup when instance is removed, but the component is not unmounted --- .../lib/useTimelineWheelNavigation.test.ts | 15 +++++++++++ .../src/lib/useTimelineWheelNavigation.ts | 7 +++-- .../operator-timeline/OperatorGanttChart.tsx | 26 +++++++++++++++---- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts index 380cb7c9f..177639f6e 100644 --- a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts @@ -200,6 +200,21 @@ describe('useTimelineWheelNavigation', () => { expect(chart.dispatchAction).toHaveBeenCalledOnce(); }); + it('returns a cleanup function for removing the listener explicitly', () => { + const chart = createChart({ start: 25, end: 75 }); + const { result } = renderHook(() => useTimelineWheelNavigation(10)); + const cleanup = result.current(chart.instance); + cleanup(); + + act(() => { + chart.dom.dispatchEvent( + new WheelEvent('wheel', { deltaX: 100, bubbles: true, cancelable: true }) + ); + }); + + expect(chart.dispatchAction).not.toHaveBeenCalled(); + }); + it('removes the wheel listener on unmount', () => { const chart = createChart({ start: 25, end: 75 }); const { result, unmount } = renderHook(() => useTimelineWheelNavigation(10)); diff --git a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts index b68b0fa1f..6ef5d7d4d 100644 --- a/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts +++ b/ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts @@ -15,7 +15,7 @@ function getDataZoomState(instance: EChartsType): DataZoomComponentOption | unde /** * Adds native vertical scrolling, horizontal trackpad panning, and minimum-zoom guarding. - * Call the returned function from the chart's `onReady` callback. + * Returns a cleanup handle for charts removed while the hook remains mounted. */ export function useTimelineWheelNavigation(minZoomSpanPct: number) { const minZoomSpanPctRef = useRef(minZoomSpanPct); @@ -75,9 +75,12 @@ export function useTimelineWheelNavigation(minZoomSpanPct: number) { wheelTarget.addEventListener('wheel', handleWheel, { capture: true, passive: false }); - cleanupRef.current = () => { + const cleanup = () => { wheelTarget.removeEventListener('wheel', handleWheel, { capture: true }); + if (cleanupRef.current === cleanup) cleanupRef.current = null; }; + cleanupRef.current = cleanup; + return cleanup; }, [] ); diff --git a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx index 6b10d2542..faeda733a 100644 --- a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx +++ b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx @@ -78,6 +78,7 @@ export function OperatorGanttChart({ const minZoomSpanPct = useMinZoomSpanPct(durationSeconds); const attachWheelNavigation = useTimelineWheelNavigation(minZoomSpanPct); const wrapperRef = useRef(null); + const chartCleanupRef = useRef<(() => void) | null>(null); const { yAxisCategories, rowCount } = useMemo(() => { if (operators.length === 0) return { yAxisCategories: [] as number[], rowCount: 0 }; @@ -341,8 +342,18 @@ export function OperatorGanttChart({ // The y-axis dataZoom (index 3, when present) has a unique component ID and does not // propagate to resource timelines that have no matching component. const onChartReady = (instance: EChartsInstance) => { + chartCleanupRef.current?.(); registerAxisPointerSync(instance, 0, { receiveShowTip: false }); - attachWheelNavigation(instance, wrapperRef.current ?? undefined); + const detachWheelNavigation = attachWheelNavigation( + instance, + wrapperRef.current ?? undefined + ); + const cleanup = () => { + unregisterAxisPointerSync(instance); + detachWheelNavigation(); + if (chartCleanupRef.current === cleanup) chartCleanupRef.current = null; + }; + chartCleanupRef.current = cleanup; }; const { handleChartReady, instanceRef } = useChartConnect({ @@ -351,12 +362,17 @@ export function OperatorGanttChart({ onReady: onChartReady, }); + // Empty data replaces the chart without unmounting this component. + useEffect(() => { + if (operators.length > 0) return; + chartCleanupRef.current?.(); + instanceRef.current = null; + }, [operators.length, instanceRef]); + useEffect(() => { return () => { - if (instanceRef.current) { - unregisterAxisPointerSync(instanceRef.current); - instanceRef.current = null; - } + chartCleanupRef.current?.(); + instanceRef.current = null; }; }, [instanceRef]); From 6865ef09e4d185dca3028c1c1538146fce24c9e6 Mon Sep 17 00:00:00 2001 From: Joe O'Hallaron Date: Mon, 27 Jul 2026 21:57:49 -0600 Subject: [PATCH 10/10] chore: lint fix --- .../components/src/operator-timeline/OperatorGanttChart.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx index faeda733a..c3637d29b 100644 --- a/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx +++ b/ui/packages/@quent/components/src/operator-timeline/OperatorGanttChart.tsx @@ -344,10 +344,7 @@ export function OperatorGanttChart({ const onChartReady = (instance: EChartsInstance) => { chartCleanupRef.current?.(); registerAxisPointerSync(instance, 0, { receiveShowTip: false }); - const detachWheelNavigation = attachWheelNavigation( - instance, - wrapperRef.current ?? undefined - ); + const detachWheelNavigation = attachWheelNavigation(instance, wrapperRef.current ?? undefined); const cleanup = () => { unregisterAxisPointerSync(instance); detachWheelNavigation();