diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx
index a68d5aac..f9a73aa8 100644
--- a/packages/app/src/components/inference/ui/ScatterGraph.tsx
+++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx
@@ -112,6 +112,8 @@ const ScatterGraph = React.memo(
showAllHardwareTypes = false,
hardwareConfigOverride,
overlayData,
+ transitionDuration = 750,
+ niceAxes = true,
}: ScatterGraphProps) => {
const {
activeHwTypes,
@@ -447,10 +449,10 @@ const ScatterGraph = React.memo(
return {
type: (useLog ? 'log' : 'linear') as 'log' | 'linear',
domain,
- nice: true,
+ nice: niceAxes,
_isLog: useLog,
};
- }, [visiblePoints, isInputTputMetric, xLabel, scaleType]);
+ }, [visiblePoints, isInputTputMetric, xLabel, scaleType, niceAxes]);
const yScaleConfig = useMemo(() => {
const ext =
@@ -472,9 +474,9 @@ const ScatterGraph = React.memo(
return {
type: (useLog ? 'log' : 'linear') as 'log' | 'linear',
domain: [yMin, ext[1] * 1.05] as [number, number],
- nice: true,
+ nice: niceAxes,
};
- }, [visiblePoints, isInputTputMetric, logScale]);
+ }, [visiblePoints, isInputTputMetric, logScale, niceAxes]);
// --- Axis configs ---
const xAxisConfig = useMemo(
@@ -1909,7 +1911,7 @@ const ScatterGraph = React.memo(
layers={layers}
zoom={zoomConfig}
tooltip={tooltipConfig}
- transitionDuration={750}
+ transitionDuration={transitionDuration}
onRender={onRender}
noDataOverlay={
filteredData.length === 0 && processedOverlayData.length === 0 ? (
diff --git a/packages/app/src/components/ui/chart-buttons.tsx b/packages/app/src/components/ui/chart-buttons.tsx
index 3f878e2f..14c0e2c0 100644
--- a/packages/app/src/components/ui/chart-buttons.tsx
+++ b/packages/app/src/components/ui/chart-buttons.tsx
@@ -1,7 +1,7 @@
'use client';
import { track } from '@/lib/analytics';
-import { Download, FileSpreadsheet, Image, RotateCcw } from 'lucide-react';
+import { Download, FileSpreadsheet, Image, RotateCcw, Video } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { useChartExport } from '@/hooks/useChartExport';
@@ -24,6 +24,8 @@ interface ChartButtonsProps {
hideImageExport?: boolean;
/** Optional callback to export chart data as CSV */
onExportCsv?: () => void;
+ /** Optional callback to open the MP4 export preview (e.g., replay modal) */
+ onExportMp4?: () => void;
/** Human-readable base name for exported files (e.g. "DeepSeek-R1_throughput_interactivity"). Falls back to chartId. */
exportFileName?: string;
/**
@@ -51,6 +53,7 @@ export function ChartButtons({
hideZoomReset,
hideImageExport,
onExportCsv,
+ onExportMp4,
exportFileName,
leadingControls,
className,
@@ -77,6 +80,12 @@ export function ChartButtons({
window.dispatchEvent(new CustomEvent('inferencex:action'));
};
+ const handleExportMp4 = () => {
+ setPopoverOpen(false);
+ track(`${analyticsPrefix}_mp4_preview_opened`);
+ onExportMp4?.();
+ };
+
return (
{leadingControls}
- {onExportCsv ? (
+ {onExportCsv || onExportMp4 ? (
Download PNG
-
+ {onExportCsv && (
+
+ )}
+ {onExportMp4 && (
+
+ )}
) : (
diff --git a/packages/app/src/lib/d3-chart/D3Chart/D3Chart.tsx b/packages/app/src/lib/d3-chart/D3Chart/D3Chart.tsx
index 5d5b6d2b..2a18b9cd 100644
--- a/packages/app/src/lib/d3-chart/D3Chart/D3Chart.tsx
+++ b/packages/app/src/lib/d3-chart/D3Chart/D3Chart.tsx
@@ -1,12 +1,16 @@
'use client';
import React, { useImperativeHandle, useRef } from 'react';
+import * as d3 from 'd3';
import { D3ChartWrapper } from '@/components/ui/d3-chart-wrapper';
import { useChartTooltipHandlers } from '@/hooks/useChartTooltipHandlers';
import { useChartZoom } from '@/hooks/useChartZoom';
import { useResponsiveChartDimensions } from '@/hooks/useResponsiveChartDimensions';
+import type { ContinuousScale } from '../types';
+
+import { isBandScale, type BuiltScale } from './scale-builders';
import type { D3ChartHandle, D3ChartProps } from './types';
import { useD3ChartRenderer } from './useD3ChartRenderer';
@@ -38,6 +42,7 @@ function D3ChartInner
(
) {
const svgRef = useRef(null);
const tooltipRef = useRef(null);
+ const scalesRef = useRef<{ xScale: BuiltScale; yScale: BuiltScale } | null>(null);
const { dimensions, setContainerRef } = useResponsiveChartDimensions({ height });
@@ -73,6 +78,19 @@ function D3ChartInner(
pinTooltip: pinTooltip as (point: unknown, isOverlay?: boolean) => void,
getSvgElement: () => svgRef.current,
getTooltipElement: () => tooltipRef.current,
+ getScales: () => scalesRef.current,
+ refreshDataPositions: () => {
+ const svg = svgRef.current;
+ const scales = scalesRef.current;
+ if (!svg || !scales) return;
+ if (isBandScale(scales.xScale) || isBandScale(scales.yScale)) return;
+ const transform = d3.zoomTransform(svg);
+ const curX = transform.rescaleX(scales.xScale as ContinuousScale);
+ const curY = transform.rescaleY(scales.yScale as ContinuousScale);
+ d3.select(svg)
+ .selectAll('.dot-group')
+ .attr('transform', (d) => `translate(${curX(d.x)},${curY(d.y)})`);
+ },
}),
[dismissTooltip, hideTooltipElements, pinnedPoint, pinnedPointIsOverlay, isPinned, pinTooltip],
);
@@ -104,6 +122,7 @@ function D3ChartInner(
svgRef,
tooltipRef,
dimensions,
+ scalesRef,
setupZoom,
zoomTransformRef,
isPinned,
diff --git a/packages/app/src/lib/d3-chart/D3Chart/types.ts b/packages/app/src/lib/d3-chart/D3Chart/types.ts
index c753ea84..3062784e 100644
--- a/packages/app/src/lib/d3-chart/D3Chart/types.ts
+++ b/packages/app/src/lib/d3-chart/D3Chart/types.ts
@@ -2,6 +2,7 @@ import type * as d3 from 'd3';
import type { ChartLayout, ChartMargin, ContinuousScale } from '../types';
import type { AnyScale } from '../chart-update';
+import type { BuiltScale } from './scale-builders';
import type { BarConfig } from '../layers/bars';
import type { HorizontalBarConfig } from '../layers/horizontal-bars';
import type { PointConfig } from '../layers/points';
@@ -224,6 +225,15 @@ export interface D3ChartHandle {
pinTooltip: (point: unknown, isOverlay?: boolean) => void;
getSvgElement: () => SVGSVGElement | null;
getTooltipElement: () => HTMLDivElement | null;
+ /** Current x/y scales (post-render). Null before the first render. */
+ getScales: () => { xScale: BuiltScale; yScale: BuiltScale } | null;
+ /**
+ * Re-apply `.dot-group` transforms from currently-bound datum `x`/`y` values using
+ * current scales (zoom-aware). Use to drive imperative position updates outside the
+ * normal React render path (e.g. replay animation frames). No-op when scales are
+ * unavailable or when neither scale is continuous.
+ */
+ refreshDataPositions: () => void;
}
// ---------------------------------------------------------------------------
diff --git a/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts b/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts
index 884dfa15..c25944ca 100644
--- a/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts
+++ b/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts
@@ -14,6 +14,8 @@ interface RendererDeps {
svgRef: React.RefObject;
tooltipRef: React.RefObject;
dimensions: { width: number; height: number };
+ /** Owned by D3Chart so the imperative handle can read current scales. */
+ scalesRef: React.MutableRefObject<{ xScale: BuiltScale; yScale: BuiltScale } | null>;
setupZoom: (
svg: d3.Selection,
width: number,
@@ -75,6 +77,7 @@ export function useD3ChartRenderer(props: D3ChartProps, deps: RendererDeps
svgRef,
tooltipRef,
dimensions,
+ scalesRef,
setupZoom,
zoomTransformRef,
isPinned,
@@ -84,8 +87,8 @@ export function useD3ChartRenderer(props: D3ChartProps, deps: RendererDeps
attachHandlers,
} = deps;
- // Store scales in ref so zoom handler can read them without stale closures
- const scalesRef = useRef<{ xScale: BuiltScale; yScale: BuiltScale } | null>(null);
+ // scalesRef is owned by D3Chart so the imperative handle can read it; the renderer
+ // writes the freshly-built scales into it on every render below.
const layoutRef = useRef(null);
const prevDataRef = useRef(data);
const prevScalesRef = useRef({ xScaleConfig, yScaleConfig });
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9f95fe60..14505e57 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -125,6 +125,9 @@ importers:
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.6)
+ mp4-muxer:
+ specifier: ^5.2.2
+ version: 5.2.2
next:
specifier: ^16.2.6
version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -2270,6 +2273,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+ '@types/dom-webcodecs@0.1.18':
+ resolution: {integrity: sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==}
+
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -2346,6 +2352,9 @@ packages:
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
+ '@types/wicg-file-system-access@2020.9.8':
+ resolution: {integrity: sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==}
+
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@@ -4424,6 +4433,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ mp4-muxer@5.2.2:
+ resolution: {integrity: sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==}
+ deprecated: This library is superseded by Mediabunny. Please migrate to it.
+
ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
@@ -7319,6 +7332,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
+ '@types/dom-webcodecs@0.1.18': {}
+
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -7397,6 +7412,8 @@ snapshots:
'@types/webxr@0.5.24': {}
+ '@types/wicg-file-system-access@2020.9.8': {}
+
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 25.6.2
@@ -9203,7 +9220,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
- '@types/node': 25.6.2
+ '@types/node': 25.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -9923,6 +9940,11 @@ snapshots:
requirejs: 2.3.8
requirejs-config-file: 4.0.0
+ mp4-muxer@5.2.2:
+ dependencies:
+ '@types/dom-webcodecs': 0.1.18
+ '@types/wicg-file-system-access': 2020.9.8
+
ms@2.1.2: {}
ms@2.1.3: {}