-
Notifications
You must be signed in to change notification settings - Fork 18
refactor: hookify min zoom, use in all charts #446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
de5d910
refactor: hookify min zoom, use in all charts
johallar d2a5d5b
Horizontal scroll for gantt chart
johallar da631a6
refactor: capture all mouse wheel interaction in hook
johallar f9305e6
chore: better types
johallar d7ca267
chore: coderabbit suggestions pretty good
johallar 94a11d7
fix: horizontal scroll + shift pans not zoom
johallar 127accd
refactor: remove callback from onReady function in operator gantt
johallar cc5a2d1
chore: unit test for multiple calls to hook result
johallar da9ac48
Cleanup when instance is removed, but the component is not unmounted
johallar 6865ef0
chore: lint fix
johallar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
23 changes: 23 additions & 0 deletions
23
ui/packages/@quent/components/src/lib/useMinZoomSpanPct.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
13 changes: 13 additions & 0 deletions
13
ui/packages/@quent/components/src/lib/useMinZoomSpanPct.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]); | ||
| } |
232 changes: 232 additions & 0 deletions
232
ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| // 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 { 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: CHART_WIDTH, | ||
| height: 100, | ||
| top: 0, | ||
| right: CHART_WIDTH, | ||
| bottom: 100, | ||
| left: 0, | ||
| x: 0, | ||
| y: 0, | ||
| toJSON: () => undefined, | ||
| }); | ||
|
|
||
| const dispatchAction = vi.fn(); | ||
| const instance = { | ||
| getDom: () => dom, | ||
| getOption: () => ({ dataZoom: [dataZoom] }), | ||
| dispatchAction, | ||
| isDisposed: () => false, | ||
| } as unknown as EChartsType; | ||
|
|
||
| return { instance, dom, dispatchAction }; | ||
| } | ||
|
|
||
| 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)); | ||
|
|
||
| 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: expectedStart, | ||
| end: expectedStart + spanPct, | ||
| }); | ||
| }); | ||
|
|
||
| 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)); | ||
| 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('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, rerender } = renderHook( | ||
| ({ minZoomSpanPct }) => useTimelineWheelNavigation(minZoomSpanPct), | ||
| { initialProps: { minZoomSpanPct: 5 } } | ||
| ); | ||
| act(() => result.current(chart.instance)); | ||
| rerender({ minZoomSpanPct: 10 }); | ||
|
|
||
| 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.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 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('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)); | ||
| act(() => result.current(chart.instance)); | ||
| unmount(); | ||
|
|
||
| act(() => { | ||
| chart.dom.dispatchEvent( | ||
| new WheelEvent('wheel', { deltaX: 100, bubbles: true, cancelable: true }) | ||
| ); | ||
| }); | ||
|
|
||
| expect(chart.dispatchAction).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
97 changes: 97 additions & 0 deletions
97
ui/packages/@quent/components/src/lib/useTimelineWheelNavigation.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| // 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 { 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; | ||
| } | ||
|
|
||
| /** | ||
| * Adds native vertical scrolling, horizontal trackpad panning, and minimum-zoom guarding. | ||
| * Returns a cleanup handle for charts removed while the hook remains mounted. | ||
| */ | ||
| export function useTimelineWheelNavigation(minZoomSpanPct: number) { | ||
| const minZoomSpanPctRef = useRef(minZoomSpanPct); | ||
| minZoomSpanPctRef.current = minZoomSpanPct; | ||
| const cleanupRef = useRef<(() => void) | null>(null); | ||
|
|
||
| const attachWheelNavigation = useCallback( | ||
| (instance: EChartsType, wheelTarget: HTMLElement = instance.getDom()) => { | ||
| cleanupRef.current?.(); | ||
|
|
||
| const isAtZoomLimit = () => { | ||
| if (instance.isDisposed?.()) return false; | ||
| const dataZoom = getDataZoomState(instance); | ||
| if (!dataZoom) return false; | ||
| const spanPct = (dataZoom.end ?? 100) - (dataZoom.start ?? 0); | ||
| return spanPct <= minZoomSpanPctRef.current * ZOOM_LIMIT_FLOAT_TOLERANCE; | ||
| }; | ||
|
|
||
| const handleWheel = (event: WheelEvent) => { | ||
| 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(); | ||
| } | ||
| return; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| event.stopPropagation(); | ||
| if (!isHorizontalScroll) 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, | ||
| }); | ||
| }; | ||
|
|
||
| wheelTarget.addEventListener('wheel', handleWheel, { capture: true, passive: false }); | ||
|
|
||
| const cleanup = () => { | ||
| wheelTarget.removeEventListener('wheel', handleWheel, { capture: true }); | ||
| if (cleanupRef.current === cleanup) cleanupRef.current = null; | ||
| }; | ||
| cleanupRef.current = cleanup; | ||
| return cleanup; | ||
| }, | ||
| [] | ||
| ); | ||
|
|
||
| useEffect( | ||
| () => () => { | ||
| cleanupRef.current?.(); | ||
| cleanupRef.current = null; | ||
| }, | ||
| [] | ||
| ); | ||
|
|
||
| return attachWheelNavigation; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.