diff --git a/ui/packages/@quent/components/src/dag/DagPlayhead.tsx b/ui/packages/@quent/components/src/dag/DagPlayhead.tsx index 867f34a33..9a5472e6f 100644 --- a/ui/packages/@quent/components/src/dag/DagPlayhead.tsx +++ b/ui/packages/@quent/components/src/dag/DagPlayhead.tsx @@ -9,8 +9,8 @@ import { useDataFlowMeta, usePlayheadTimeS, useSetPlayheadTimeS, + useSetPlayheadLineTimeMs, } from '@quent/hooks'; -import { broadcastSyncedPointer, hideSyncedPointer } from '../lib/timeline.utils'; /** Interval between play ticks; each tick advances the playhead by one bin. */ const PLAY_INTERVAL_MS = 100; @@ -38,6 +38,7 @@ export function DagPlayhead({ className }: DagPlayheadProps) { const meta = useDataFlowMeta(); const playheadTimeS = usePlayheadTimeS(); const setPlayheadTimeS = useSetPlayheadTimeS(); + const setPlayheadLineTimeMs = useSetPlayheadLineTimeMs(); const [isPlaying, setIsPlaying] = useState(false); const trackRef = useRef(null); @@ -65,9 +66,9 @@ export function DagPlayhead({ className }: DagPlayheadProps) { const t = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); const timeS = bin.startS + t * (bin.endS - bin.startS); setPlayheadTimeS(timeS); - broadcastSyncedPointer(timeS * 1000); + setPlayheadLineTimeMs(timeS * 1000); }, - [bin, setPlayheadTimeS] + [bin, setPlayheadTimeS, setPlayheadLineTimeMs] ); const handlePointerDown = useCallback( @@ -92,12 +93,15 @@ export function DagPlayhead({ className }: DagPlayheadProps) { [applyClientX] ); - const handlePointerEnd = useCallback((event: React.PointerEvent) => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - hideSyncedPointer(); - }, []); + const handlePointerEnd = useCallback( + (event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + setPlayheadLineTimeMs(null); + }, + [setPlayheadLineTimeMs] + ); const stepBy = useCallback( (bins: number) => { @@ -147,15 +151,14 @@ export function DagPlayhead({ className }: DagPlayheadProps) { }); }, [bin, setPlayheadTimeS]); - // Stop playback when the overlay is disabled or the bin metadata goes - // away: the component stays mounted while rendering null, so a live play - // interval would otherwise keep advancing the playhead and broadcasting - // the synced crosshair invisibly. + // Stop playback when the overlay is disabled or the bin metadata goes away: + // the component stays mounted while rendering null, so a live play interval + // would otherwise keep advancing the playhead invisibly. useEffect(() => { if (enabled && bin) return; setIsPlaying(false); - hideSyncedPointer(); - }, [enabled, bin]); + setPlayheadLineTimeMs(null); + }, [enabled, bin, setPlayheadLineTimeMs]); // Advance one bin per tick while playing; stop at the window end. useEffect(() => { @@ -165,24 +168,22 @@ export function DagPlayhead({ className }: DagPlayheadProps) { const current = playheadRef.current ?? startS; const next = Math.min(current + binDurationS, endS); setPlayheadTimeS(next); - broadcastSyncedPointer(next * 1000); + setPlayheadLineTimeMs(next * 1000); if (next >= endS) setIsPlaying(false); }, PLAY_INTERVAL_MS); return () => window.clearInterval(id); - }, [isPlaying, bin, setPlayheadTimeS]); + }, [isPlaying, bin, setPlayheadTimeS, setPlayheadLineTimeMs]); - // Clear the synced crosshair when playback stops. useEffect(() => { - if (!isPlaying) hideSyncedPointer(); - }, [isPlaying]); + if (!isPlaying) setPlayheadLineTimeMs(null); + }, [isPlaying, setPlayheadLineTimeMs]); - // Cleanup on unmount: pending rAF and any lingering crosshair. useEffect(() => { return () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); - hideSyncedPointer(); + setPlayheadLineTimeMs(null); }; - }, []); + }, [setPlayheadLineTimeMs]); if (!enabled || !meta || !bin) return null; diff --git a/ui/packages/@quent/components/src/lib/usePlayheadLinePixel.ts b/ui/packages/@quent/components/src/lib/usePlayheadLinePixel.ts new file mode 100644 index 000000000..a6bcd0a2f --- /dev/null +++ b/ui/packages/@quent/components/src/lib/usePlayheadLinePixel.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { EChartsInstance } from 'echarts-for-react'; +import { usePlayheadLineTimeMs } from '@quent/hooks'; + +export function usePlayheadLinePixel( + instance: EChartsInstance | null, + xAxisIndex = 0 +): number | null { + const [pixelX, setPixelX] = useState(null); + const timestampMs = usePlayheadLineTimeMs(); + const timestampMsRef = useRef(timestampMs); + timestampMsRef.current = timestampMs; + + const recompute = useCallback(() => { + const ts = timestampMsRef.current; + if (!instance || ts === null) { + setPixelX(null); + return; + } + try { + const pixel = instance.convertToPixel({ xAxisIndex }, ts); + setPixelX(typeof pixel === 'number' && Number.isFinite(pixel) ? pixel : null); + } catch { + setPixelX(null); + } + }, [instance, xAxisIndex]); + + // Recompute when the timestamp atom changes. + useEffect(() => { + recompute(); + }, [timestampMs, recompute]); + + // Re-attach ECharts listeners when the instance changes; recompute on + // zoom/resize so the overlay stays aligned with the x-axis. + // dataZoom covers zoom/pan; finished fires after resize. + useEffect(() => { + if (instance) { + instance.on('dataZoom', recompute); + instance.on('finished', recompute); + } + recompute(); + return () => { + if (instance) { + try { + instance.off('dataZoom', recompute); + instance.off('finished', recompute); + } catch { + // Instance may already be disposed on cleanup. + } + } + }; + }, [instance, recompute]); + + return pixelX; +} diff --git a/ui/packages/@quent/components/src/timeline/PlayheadLine.tsx b/ui/packages/@quent/components/src/timeline/PlayheadLine.tsx new file mode 100644 index 000000000..3c5db252f --- /dev/null +++ b/ui/packages/@quent/components/src/timeline/PlayheadLine.tsx @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { EChartsInstance } from 'echarts-for-react'; +import { usePlayheadLinePixel } from '../lib/usePlayheadLinePixel'; + +type PlayheadLineProps = { + instance: EChartsInstance | null; + xAxisIndex?: number; +}; + +/** Playhead overlay aligned to an ECharts x-axis. */ +export function PlayheadLine({ instance, xAxisIndex = 0 }: PlayheadLineProps) { + const pixelX = usePlayheadLinePixel(instance, xAxisIndex); + + if (pixelX == null) return null; + + return ( +
+ ); +} diff --git a/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx b/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx index c8c7a8dd9..37d0a9d46 100644 --- a/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx +++ b/ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx @@ -16,8 +16,10 @@ import { } from '@quent/hooks'; import { TimelineSkeleton } from './TimelineSkeleton'; import { TimelineTooltipPortal } from './TimelineTooltipPortal'; +import { PlayheadLine } from './PlayheadLine'; import type { TimelineHoverPosition } from './Timeline'; -import { useCallback, useEffect, useId, useMemo, useRef, lazy, Suspense } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState, lazy, Suspense } from 'react'; +import type { EChartsInstance } from 'echarts-for-react'; import { buildBinnedTimelineSeries, buildTimelineMarks, @@ -269,6 +271,10 @@ export function ResourceTimeline({ // across the loading / error / data render branches. const ownerId = useId(); const setTimelineHover = useSetTimelineHover(); + const [chartInstance, setChartInstance] = useState(null); + const handleChartReady = useCallback((instance: EChartsInstance) => { + setChartInstance(instance); + }, []); const handleHoverChange = useCallback( (position: TimelineHoverPosition | null) => { if (position == null) { @@ -301,7 +307,7 @@ export function ResourceTimeline({ const effectiveYAxisLabel = yAxisLabel ?? fsmTypeName; return ( -
+
}> {showTooltip && ( )} +
); } diff --git a/ui/packages/@quent/components/src/timeline/Timeline.tsx b/ui/packages/@quent/components/src/timeline/Timeline.tsx index b7f0acaa3..6839203d8 100644 --- a/ui/packages/@quent/components/src/timeline/Timeline.tsx +++ b/ui/packages/@quent/components/src/timeline/Timeline.tsx @@ -54,6 +54,7 @@ export function Timeline({ isDark, yAxisLabel, onHoverChange, + onReady, }: { /** Full query duration — used to set xAxis range so dataZoom percentages align across all connected charts */ durationSeconds: number; @@ -68,6 +69,8 @@ export function Timeline({ yAxisLabel?: string; /** Pointer-state callback. */ onHoverChange?: (position: TimelineHoverPosition | null) => void; + /** Called when the underlying ECharts instance is ready or recreated. */ + onReady?: (instance: EChartsInstance) => void; }) { const { themeName, textColor, labelBackgroundColor } = useTimelineEchartsTheme(isDark); const maxMarkCountRef = useRef(0); @@ -358,6 +361,7 @@ export function Timeline({ }); attachWheelNavigation(instance); + onReady?.(instance); }; // If this Timeline is unmounted while the pointer is over it (e.g. a tree diff --git a/ui/packages/@quent/components/src/timeline/TimelineController.tsx b/ui/packages/@quent/components/src/timeline/TimelineController.tsx index db3268c51..c706e37e5 100644 --- a/ui/packages/@quent/components/src/timeline/TimelineController.tsx +++ b/ui/packages/@quent/components/src/timeline/TimelineController.tsx @@ -23,6 +23,7 @@ import type { SingleTimelineResponse } from '@quent/utils'; import { useTimelineEchartsTheme } from './timelineEchartsTheme'; import type { PaletteTheme } from '@quent/utils'; import { Opts } from 'echarts-for-react/lib/types'; +import { PlayheadLine } from './PlayheadLine'; const CONTROLLER_HEIGHT = 50; const CONTROLLER_TOP_HEADROOM_RATIO = 0.2; @@ -319,18 +320,16 @@ export function TimelineController({ }, [onZoomChange, durationSeconds]); const selfTriggeredRef = useRef(false); - // Bumped on chart-ready so the restore effect re-runs when the instance is - // recreated (e.g. theme change disposes and rebuilds the chart at 0–100%). - const [readyTick, setReadyTick] = useState(0); + const [chartInstance, setChartInstance] = useState(null); const zoomRange = useZoomRange(); const onChartReady = useCallback((instance: EChartsInstance) => { registerAxisPointerSync(instance); - setReadyTick(t => t + 1); + setChartInstance(instance); }, []); - const { handleChartReady, instanceRef } = useChartConnect({ + const { handleChartReady } = useChartConnect({ durationSeconds, activateBrushSelect: true, onReady: onChartReady, @@ -338,41 +337,36 @@ export function TimelineController({ // Restore the persisted zoom on range change or instance (re)creation. useEffect(() => { - if (readyTick === 0) return; if (selfTriggeredRef.current) { selfTriggeredRef.current = false; return; } - const instance = instanceRef.current; - if (!instance || durationSeconds === 0) return; + if (!chartInstance || durationSeconds === 0) return; const startPct = (zoomRange.start / durationSeconds) * 100; const endPct = (zoomRange.end / durationSeconds) * 100; // Mute our own dispatch so the echoed dataZoom event doesn't overwrite the atom. selfTriggeredRef.current = true; - instance.dispatchAction({ + chartInstance.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: startPct, end: endPct, }); - }, [readyTick, zoomRange, durationSeconds, instanceRef]); + }, [chartInstance, zoomRange, durationSeconds]); useEffect(() => { return () => { - if (instanceRef.current) { - unregisterAxisPointerSync(instanceRef.current); - instanceRef.current = null; - } + if (chartInstance) unregisterAxisPointerSync(chartInstance); }; - }, [instanceRef]); + }, [chartInstance]); const opts = useMemo(() => ({ renderer: 'svg' }) as Opts, []); const containerDims = useMemo(() => ({ width: '100%', height: `${height}px` }), [height]); return ( -
+
+
); } diff --git a/ui/packages/@quent/hooks/src/atoms/dataFlow.ts b/ui/packages/@quent/hooks/src/atoms/dataFlow.ts index 54d9638bc..1c349693d 100644 --- a/ui/packages/@quent/hooks/src/atoms/dataFlow.ts +++ b/ui/packages/@quent/hooks/src/atoms/dataFlow.ts @@ -49,3 +49,12 @@ export const dataFlowMetaAtom = atom(null); * panel) subscribe to this — a scrub tick must not re-render DAG nodes. */ export const dataFlowFrameAtom = atom(null); + +/** + * Timestamp (ms relative to query epoch) for the playhead overlay line on the + * timeline charts. `null` hides the line. Kept separate from + * {@link playheadTimeSAtom} so the overlay line can be hidden (e.g. when + * paused) without losing the playhead position used to drive the data-flow + * frame. + */ +export const playheadLineTimeMsAtom = atom(null); diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts index f1c4eff32..e627beba8 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts @@ -7,6 +7,7 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { dataFlowEnabledAtom, playheadTimeSAtom, + playheadLineTimeMsAtom, selectedDataFlowMeasureAtom, dataFlowLabelMeasureAtom, dataFlowSelectedDimensionsAtom, @@ -31,3 +32,6 @@ export const useSetDataFlowSelectedDimensions = () => useSetAtom(dataFlowSelecte export const useDataFlowMeta = () => useAtomValue(dataFlowMetaAtom); export const useDataFlowFrame = () => useAtomValue(dataFlowFrameAtom); + +export const usePlayheadLineTimeMs = () => useAtomValue(playheadLineTimeMsAtom); +export const useSetPlayheadLineTimeMs = () => useSetAtom(playheadLineTimeMsAtom); diff --git a/ui/packages/@quent/hooks/src/index.ts b/ui/packages/@quent/hooks/src/index.ts index 8be8914a0..5f8b0d305 100644 --- a/ui/packages/@quent/hooks/src/index.ts +++ b/ui/packages/@quent/hooks/src/index.ts @@ -107,6 +107,8 @@ export { useSetDataFlowSelectedDimensions, useDataFlowMeta, useDataFlowFrame, + usePlayheadLineTimeMs, + useSetPlayheadLineTimeMs, } from './dataFlow/dataFlowSelectors'; export { useDataFlowSync } from './dataFlow/useDataFlowSync'; export { diff --git a/ui/src/components/DataFlowOverlay.test.tsx b/ui/src/components/DataFlowOverlay.test.tsx index 05d0705a2..3510baef2 100644 --- a/ui/src/components/DataFlowOverlay.test.tsx +++ b/ui/src/components/DataFlowOverlay.test.tsx @@ -13,14 +13,7 @@ import { useSetDataFlowSelectedDimensions, useSetSelectedNodeData, } from '@quent/hooks'; -import { - DagPlayhead, - DAGLegend, - DAGNodeInfoPanel, - NodeFlowBar, - registerAxisPointerSync, - unregisterAxisPointerSync, -} from '@quent/components'; +import { DagPlayhead, DAGLegend, DAGNodeInfoPanel, NodeFlowBar } from '@quent/components'; import type { DataFlowTimelineBinned, EntityRef, QueryBundle } from '@quent/utils'; // 4 bins of 2s over [0, 8): op-1 task totals per bin are [1, 3, 5, 0] and @@ -265,55 +258,36 @@ describe('playback while the overlay is disabled', () => { it('stops the play interval and hides the synced pointer when disabled', () => { vi.useFakeTimers(); - // Fake timeline chart: receives the showTip/hideTip actions that - // broadcastSyncedPointer/hideSyncedPointer dispatch to registered charts. - const dispatchAction = vi.fn(); - const fakeChart = { - convertToPixel: () => 42, - getHeight: () => 100, - dispatchAction, - getZr: () => ({ on: vi.fn(), off: vi.fn() }), - } as unknown as Parameters[0]; - registerAxisPointerSync(fakeChart); - try { - const { rerender } = renderOverlay(RESPONSE); - fireEvent.click(screen.getByRole('button', { name: 'Play data flow' })); - act(() => { - vi.advanceTimersByTime(100); - }); - // One tick advanced one bin (2s) and broadcast the synced crosshair. - expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '2'); - expect(dispatchAction).toHaveBeenCalledWith(expect.objectContaining({ type: 'showTip' })); - dispatchAction.mockClear(); - - // Disable the overlay mid-playback: the component renders null but - // stays mounted, so the interval must stop and the crosshair hide. - rerender( - - - - ); - expect(screen.queryByTestId('dag-playhead')).not.toBeInTheDocument(); - expect(dispatchAction).toHaveBeenCalledWith({ type: 'hideTip' }); - dispatchAction.mockClear(); - - // No further ticks: nothing is broadcast while disabled... - act(() => { - vi.advanceTimersByTime(1000); - }); - expect(dispatchAction).not.toHaveBeenCalled(); - - // ...and re-enabling shows a paused playhead that did not advance. - rerender( - - - - ); - expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '2'); - expect(screen.getByRole('button', { name: 'Play data flow' })).toBeInTheDocument(); - } finally { - unregisterAxisPointerSync(fakeChart); - } + const { rerender } = renderOverlay(RESPONSE); + fireEvent.click(screen.getByRole('button', { name: 'Play data flow' })); + act(() => { + vi.advanceTimersByTime(100); + }); + // One tick advanced one bin (2s). + expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '2'); + + // Disable the overlay mid-playback: the component renders null but + // stays mounted, so the interval must stop. + rerender( + + + + ); + expect(screen.queryByTestId('dag-playhead')).not.toBeInTheDocument(); + + // No further ticks while disabled — after 1000ms the position is unchanged. + act(() => { + vi.advanceTimersByTime(1000); + }); + + // Re-enabling shows a paused playhead that did not advance. + rerender( + + + + ); + expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '2'); + expect(screen.getByRole('button', { name: 'Play data flow' })).toBeInTheDocument(); }); });