Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 24 additions & 23 deletions ui/packages/@quent/components/src/dag/DagPlayhead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
Expand Down Expand Up @@ -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(
Expand All @@ -92,12 +93,15 @@ export function DagPlayhead({ className }: DagPlayheadProps) {
[applyClientX]
);

const handlePointerEnd = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
hideSyncedPointer();
}, []);
const handlePointerEnd = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setPlayheadLineTimeMs(null);
},
[setPlayheadLineTimeMs]
);

const stepBy = useCallback(
(bins: number) => {
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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;

Expand Down
58 changes: 58 additions & 0 deletions ui/packages/@quent/components/src/lib/usePlayheadLinePixel.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>(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;
}
24 changes: 24 additions & 0 deletions ui/packages/@quent/components/src/timeline/PlayheadLine.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="absolute top-0 bottom-0 w-px pointer-events-none z-[10] bg-primary/70"
style={{ left: pixelX }}
/>
);
}
12 changes: 10 additions & 2 deletions ui/packages/@quent/components/src/timeline/ResourceTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -269,6 +271,10 @@ export function ResourceTimeline({
// across the loading / error / data render branches.
const ownerId = useId();
const setTimelineHover = useSetTimelineHover();
const [chartInstance, setChartInstance] = useState<EChartsInstance | null>(null);
const handleChartReady = useCallback((instance: EChartsInstance) => {
setChartInstance(instance);
}, []);
const handleHoverChange = useCallback(
(position: TimelineHoverPosition | null) => {
if (position == null) {
Expand Down Expand Up @@ -301,7 +307,7 @@ export function ResourceTimeline({
const effectiveYAxisLabel = yAxisLabel ?? fsmTypeName;

return (
<div className="h-full w-full">
<div className="relative h-full w-full">
<Suspense fallback={<TimelineSkeleton />}>
<Timeline
series={series}
Expand All @@ -312,6 +318,7 @@ export function ResourceTimeline({
isDark={isDark}
yAxisLabel={effectiveYAxisLabel}
onHoverChange={handleHoverChange}
onReady={handleChartReady}
/>
{showTooltip && (
<TimelineTooltipPortal
Expand All @@ -322,6 +329,7 @@ export function ResourceTimeline({
/>
)}
</Suspense>
<PlayheadLine instance={chartInstance} />
</div>
);
}
4 changes: 4 additions & 0 deletions ui/packages/@quent/components/src/timeline/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
27 changes: 11 additions & 16 deletions ui/packages/@quent/components/src/timeline/TimelineController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -319,60 +320,53 @@ 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<EChartsInstance | null>(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,
});

// 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 (
<div ref={containerRef} style={containerDims}>
<div ref={containerRef} style={containerDims} className="relative">
<EChartsReactCore
echarts={echarts}
theme={themeName}
Expand All @@ -385,6 +379,7 @@ export function TimelineController({
opts={opts}
autoResize={false}
/>
<PlayheadLine instance={chartInstance} />
</div>
);
}
9 changes: 9 additions & 0 deletions ui/packages/@quent/hooks/src/atoms/dataFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,12 @@ export const dataFlowMetaAtom = atom<DataFlowMeta | null>(null);
* panel) subscribe to this — a scrub tick must not re-render DAG nodes.
*/
export const dataFlowFrameAtom = atom<DataFlowFrame | null>(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<number | null>(null);
4 changes: 4 additions & 0 deletions ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
import {
dataFlowEnabledAtom,
playheadTimeSAtom,
playheadLineTimeMsAtom,
selectedDataFlowMeasureAtom,
dataFlowLabelMeasureAtom,
dataFlowSelectedDimensionsAtom,
Expand All @@ -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);
2 changes: 2 additions & 0 deletions ui/packages/@quent/hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export {
useSetDataFlowSelectedDimensions,
useDataFlowMeta,
useDataFlowFrame,
usePlayheadLineTimeMs,
useSetPlayheadLineTimeMs,
} from './dataFlow/dataFlowSelectors';
export { useDataFlowSync } from './dataFlow/useDataFlowSync';
export {
Expand Down
Loading