Skip to content
Open
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
135 changes: 135 additions & 0 deletions packages/app/src/hooks/useChartZoom.setup.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<UseChartZoomResult>(() =>
useChartZoom({
resetEventName: 'test_zoom_reset',
scaleExtent: [0.5, 20],
svgRef,
defaultZoomK,
}),
);
return {
svgEl,
svgSelection: d3.select(svgEl) as d3.Selection<SVGSVGElement, unknown, null, undefined>,
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();
});
});
21 changes: 19 additions & 2 deletions packages/app/src/hooks/useChartZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,18 @@ export function useD3ChartRenderer<T>(props: D3ChartProps<T>, 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 ──
Expand Down
Loading