diff --git a/packages/app/src/components/inference/ui/GPUGraph.tsx b/packages/app/src/components/inference/ui/GPUGraph.tsx index 407e7256..b0fb2e06 100644 --- a/packages/app/src/components/inference/ui/GPUGraph.tsx +++ b/packages/app/src/components/inference/ui/GPUGraph.tsx @@ -652,17 +652,24 @@ const GPUGraph = React.memo( ]} zoom={{ enabled: true, - axes: 'both', + // X-only: GPU time-series is a date-axis exploration. Vertical pan + // caused the y-axis to drift on drag, making tick labels look wrong + // relative to the visible data. + axes: 'x', scaleExtent: [1, 20], resetEventName: `gpu_timeseries_zoom_reset_${chartId}`, onReset: () => { track('interactivity_zoom_reset'); }, onZoom: (_event, ctx: ZoomContext) => { + // y-axis is locked with axes: 'x' (newYScale === yScale), but the + // framework re-renders axes on every zoom with the configured + // tickFormat. For log scale the tickFormat is `undefined` in this + // component, so re-apply `logTickFormat` here to preserve labels. if (logScale) { - const newYScale = ctx.newYScale as d3.ScaleLogarithmic; + const yScale = ctx.yScale as d3.ScaleLogarithmic; ctx.layout.yAxisGroup.call( - d3.axisLeft(newYScale).ticks(10).tickFormat(logTickFormat(newYScale)) as any, + d3.axisLeft(yScale).ticks(10).tickFormat(logTickFormat(yScale)) as any, ); } }, diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index f9a73aa8..b95c3614 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -592,7 +592,11 @@ const ScatterGraph = React.memo( const zoomConfig = useMemo( () => ({ enabled: true, - axes: 'both' as const, + // X-only: the scatter chart is fundamentally an X-axis exploration + // (Interactivity / TTFT). Allowing vertical pan caused the y-axis tick + // labels to drift on drag ("sliding around"), making them look wrong + // relative to the visible data. Matches the TrendChart zoom behavior. + axes: 'x' as const, scaleExtent: [0.7, 20] as [number, number], resetEventName: zoomResetEventName, onReset: () => { @@ -600,17 +604,13 @@ const ScatterGraph = React.memo( }, constrain: (transform: d3.ZoomTransform, extent: [[number, number], [number, number]]) => { const width = extent[1][0]; - const height = extent[1][1]; let tx = transform.x; - let ty = transform.y; const k = transform.k; const maxTx = 0; const minTx = Math.min(0, width - width * k); - const minTy = height * (1 - k); - const maxTy = Math.max(minTy, 0); tx = Math.max(minTx, Math.min(maxTx, tx)); - ty = Math.max(minTy, Math.min(maxTy, ty)); - return d3.zoomIdentity.translate(tx, ty).scale(k); + // Lock y-translation to 0 — y-axis stays fixed during pan/zoom. + return d3.zoomIdentity.translate(tx, 0).scale(k); }, onZoom: (_event: d3.D3ZoomEvent, ctx: ZoomContext) => { if (xScaleConfig._isLog) { @@ -619,10 +619,14 @@ const ScatterGraph = React.memo( d3.axisBottom(newXS).ticks(10).tickFormat(logTickFormat(newXS)) as any, ); } + // With axes: 'x' the y-scale doesn't change, but the framework still + // re-renders the y-axis on each zoom with the configured tickFormat. + // For log scale, tickFormat is `undefined` (see yAxisConfig above), + // so re-apply `logTickFormat` here to preserve the labels. if (yScaleConfig.type === 'log') { - const newYS = ctx.newYScale as d3.ScaleLogarithmic; + const yScale = ctx.yScale as d3.ScaleLogarithmic; ctx.layout.yAxisGroup.call( - d3.axisLeft(newYS).ticks(10).tickFormat(logTickFormat(newYS)) as any, + d3.axisLeft(yScale).ticks(10).tickFormat(logTickFormat(yScale)) as any, ); } },