From 1c344a911cb00894f9e72f8c425a1b610aa14ed1 Mon Sep 17 00:00:00 2001 From: Oseltamivir <58582368+Oseltamivir@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:23:56 -0700 Subject: [PATCH] perf(charts): skip identity zoom-transform replay on chart rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setupZoom unconditionally replayed the stored zoom transform after re-attaching the zoom behavior. zoom.transform dispatches start/zoom/end synchronously, and the chart's zoom handler answers with a full axes + grid + every-layer re-render — so every chart rebuild rendered everything twice, even when the user had never zoomed (identity transform, the overwhelmingly common case). Profiling the live site shows this doubles the cost of every ~250-490ms rebuild long task. Replay now happens only when there is actually a zoom to restore (stored transform or node state non-identity). Zoom preservation across rebuilds — the reason the replay exists (docs/d3-charts.md 'Zoom Transform Preservation') — is unchanged: non-identity transforms replay exactly as before, including charts with a non-1 defaultZoomK. The identity replay had one observable side effect: its emit dismissed a pinned tooltip on every rebuild. useD3ChartRenderer now performs that dismissal explicitly when the replay is skipped, so behavior is identical. --- .../app/src/hooks/useChartZoom.setup.test.ts | 135 ++++++++++++++++++ packages/app/src/hooks/useChartZoom.ts | 21 ++- .../d3-chart/D3Chart/useD3ChartRenderer.ts | 12 ++ 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/hooks/useChartZoom.setup.test.ts diff --git a/packages/app/src/hooks/useChartZoom.setup.test.ts b/packages/app/src/hooks/useChartZoom.setup.test.ts new file mode 100644 index 00000000..54700b12 --- /dev/null +++ b/packages/app/src/hooks/useChartZoom.setup.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment jsdom +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as d3 from 'd3'; + +import { useChartZoom, type UseChartZoomResult } from './useChartZoom'; + +// Lightweight renderHook — TLR isn't installed, so we mount a 1-component root +// and capture the latest hook return value in a ref-style object. +function renderHook(hook: () => T): { result: { current: T }; unmount: () => void; root: Root } { + const result = { current: undefined as unknown as T }; + function TestComponent() { + result.current = hook(); + return null; + } + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + act(() => { + root.render(createElement(TestComponent)); + }); + return { + result, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + root, + }; +} + +function setup(defaultZoomK?: number) { + const svgEl = d3.create('svg:svg').node()! as SVGSVGElement; + document.body.append(svgEl); + const svgRef = { current: svgEl }; + const rendered = renderHook(() => + useChartZoom({ + resetEventName: 'test_zoom_reset', + scaleExtent: [0.5, 20], + svgRef, + defaultZoomK, + }), + ); + return { + svgEl, + svgSelection: d3.select(svgEl) as d3.Selection, + hook: rendered.result, + cleanup: () => { + rendered.unmount(); + svgEl.remove(); + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('setupZoom transform replay', () => { + it('does not emit a zoom event when the stored transform is identity', () => { + const { svgSelection, hook, cleanup } = setup(); + const onZoom = vi.fn(); + + hook.current.setupZoom(svgSelection, 800, 600, { onZoom }); + + // Drawing just happened at base scales; replaying identity would force the + // chart's zoom handler through a full axes + grid + layers pass for + // pixel-identical output. + expect(onZoom).not.toHaveBeenCalled(); + cleanup(); + }); + + it('replays a non-identity stored transform on re-setup (zoom preservation)', () => { + const { svgSelection, hook, cleanup } = setup(); + const firstOnZoom = vi.fn(); + const zoom = hook.current.setupZoom(svgSelection, 800, 600, { onZoom: firstOnZoom }); + + // User zooms in: 2x around an offset. + const userTransform = d3.zoomIdentity.translate(-100, -50).scale(2); + svgSelection.call(zoom.transform as any, userTransform); + expect(firstOnZoom).toHaveBeenCalledTimes(1); + expect(hook.current.zoomTransformRef.current.k).toBe(2); + + // Chart rebuilds (data change) and re-runs setupZoom: the stored zoom must + // be replayed exactly once so the freshly drawn DOM matches the zoom state. + const secondOnZoom = vi.fn(); + hook.current.setupZoom(svgSelection, 800, 600, { onZoom: secondOnZoom }); + + expect(secondOnZoom).toHaveBeenCalledTimes(1); + const replayed = secondOnZoom.mock.calls[0][0].transform; + expect(replayed.k).toBe(2); + expect(replayed.x).toBe(-100); + expect(replayed.y).toBe(-50); + cleanup(); + }); + + it('keeps zoomTransformRef in sync after the replay', () => { + const { svgSelection, hook, cleanup } = setup(); + const zoom = hook.current.setupZoom(svgSelection, 800, 600, {}); + svgSelection.call(zoom.transform as any, d3.zoomIdentity.scale(4)); + + hook.current.setupZoom(svgSelection, 800, 600, {}); + + expect(hook.current.zoomTransformRef.current.k).toBe(4); + cleanup(); + }); + + it('replays when the node state disagrees with the stored ref (defensive sync)', () => { + const { svgEl, svgSelection, hook, cleanup } = setup(); + const onZoom = vi.fn(); + + // Stored ref says identity but someone left a stale transform on the node. + (svgEl as unknown as { __zoom: d3.ZoomTransform }).__zoom = d3.zoomIdentity.scale(3); + hook.current.setupZoom(svgSelection, 800, 600, { onZoom }); + + // The replay normalizes the node back to the stored (identity) transform. + expect(onZoom).toHaveBeenCalledTimes(1); + expect(d3.zoomTransform(svgEl).k).toBe(1); + cleanup(); + }); + + it('replays a non-identity defaultZoomK on first setup', () => { + const { svgSelection, hook, cleanup } = setup(1.5); + const onZoom = vi.fn(); + + hook.current.setupZoom(svgSelection, 800, 600, { onZoom }); + + // Charts that declare a default zoom level still get their initial + // transform applied — only the no-op identity replay is skipped. + expect(onZoom).toHaveBeenCalledTimes(1); + expect(onZoom.mock.calls[0][0].transform.k).toBe(1.5); + cleanup(); + }); +}); diff --git a/packages/app/src/hooks/useChartZoom.ts b/packages/app/src/hooks/useChartZoom.ts index e82fbea9..415a49a7 100644 --- a/packages/app/src/hooks/useChartZoom.ts +++ b/packages/app/src/hooks/useChartZoom.ts @@ -181,8 +181,25 @@ export function useChartZoom(options: UseChartZoomOptions): UseChartZoomResult { // store zoom behavior in ref zoomRef.current = zoom; - // restore previous zoom transform - svg.call(zoom.transform as any, zoomTransformRef.current); + // Restore the previous zoom transform — but only when there is actually + // a zoom to restore. `zoom.transform` dispatches start/zoom/end events + // synchronously, and the chart's zoom handler answers with a full + // axes + grid + every-layer re-render. Charts call setupZoom right after + // drawing at base scales, so replaying an identity transform repeats all + // of that work to render the exact same pixels — on every rebuild. + // + // At identity nothing needs to move: attaching the behavior above + // already initialized the node's internal `__zoom` state (d3-zoom + // preserves an existing transform or defaults to identity), so internal + // state and drawn state agree. The node-state check is defensive: if the + // node somehow disagrees with our ref (it shouldn't — the `zoom.store` + // listener keeps them in sync), fall through to the replay. + const stored = zoomTransformRef.current; + const nodeTransform = d3.zoomTransform(svg.node()!); + const isIdentity = (t: d3.ZoomTransform) => t.k === 1 && t.x === 0 && t.y === 0; + if (!isIdentity(stored) || !isIdentity(nodeTransform)) { + svg.call(zoom.transform as any, stored); + } // double-click to reset zoom svg.on('dblclick.zoom', () => { diff --git a/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts b/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts index c25944ca..726ff971 100644 --- a/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts +++ b/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts @@ -446,6 +446,18 @@ export function useD3ChartRenderer(props: D3ChartProps, deps: RendererDeps }, }, ); + + // setupZoom only replays the stored transform (re-emitting a zoom + // event over the freshly drawn base-scale DOM) when it is non-identity. + // The identity replay used to dismiss a pinned tooltip as a side + // effect of that emit — keep that behavior when the replay is skipped, + // since the chart under the tooltip was just rebuilt. + const restored = zoomTransformRef.current; + if (restored.k === 1 && restored.x === 0 && restored.y === 0 && isPinned()) { + dismissTooltip(true); + tooltip.style('opacity', 0).style('display', 'none').style('pointer-events', 'none'); + renderGroup.select('.ruler-group').style('display', 'none'); + } } // ── Animate from old positions to new positions ──