Synchronizing a Crosshair Across Linked Charts
You hover one time-series chart and want the crosshair, the tooltip, and the highlighted datum to appear on every stacked chart below it at the same x position — but each chart only responds to its own pointer, so the others stay dead.
This guide is part of brushing and linked views in D3, covering the pointer-tracking half of coordination rather than the drag-selection half. The mechanism is a shared piece of state driven by a tiny event bus, plus a bisect lookup that turns a pixel x into the nearest datum without scanning the whole array — the same inversion discipline from D3 scales and axes configuration, now applied to hover instead of a brush.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: each chart handles only its own pointer, in pixels, with a linear scan.
function wireChart(svg: SVGSVGElement, data: Row[]): void {
svg.addEventListener('pointermove', (e: PointerEvent) => {
const px = e.offsetX;
// Linear scan for the nearest point — O(n) per move, per chart:
let best = data[0];
for (const d of data) if (Math.abs(d.px - px) < Math.abs(best.px - px)) best = d;
drawCrosshair(svg, best); // only THIS chart updates; others stay dead
});
}
// ✅ FIXED: broadcast the data x; each chart bisects its own sorted array.
import { bisector } from 'd3-array';
import { ScaleTime } from 'd3-scale';
interface Row { t: number; value: number; }
type HoverListener = (dataX: number | null) => void;
class HoverBus {
private listeners = new Set<HoverListener>();
subscribe(fn: HoverListener): () => void {
this.listeners.add(fn);
return () => { this.listeners.delete(fn); }; // PERF: caller unsubscribes on teardown
}
publish(dataX: number | null): void {
for (const fn of this.listeners) fn(dataX);
}
}
const bisectT = bisector<Row, number>((d) => d.t).left;
function registerChart(
bus: HoverBus,
svg: SVGSVGElement,
data: Row[], // MUST be sorted ascending by t for bisect to work
x: ScaleTime<number, number>,
): () => void {
// Source role: pointer → data x → publish (throttled to one per frame).
let queued = false;
svg.addEventListener('pointermove', (e: PointerEvent) => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
bus.publish(+x.invert(e.offsetX)); // publish DATA value, not pixels
});
});
svg.addEventListener('pointerleave', () => bus.publish(null));
// Listener role: data x → nearest datum → draw at THIS chart's pixel.
return bus.subscribe((dataX: number | null) => {
if (dataX === null) { hideCrosshair(svg); announce(''); return; }
const i = bisectT(data, dataX); // O(log n) nearest-index
const d = nearestOf(data, i, dataX);
drawCrosshairAt(svg, x(d.t), d); // A11Y: mirror value to aria-live
announce(`${new Date(d.t).toISOString()}: ${d.value}`);
});
}
function nearestOf(data: Row[], i: number, target: number): Row {
const lo = data[Math.max(0, i - 1)], hi = data[Math.min(data.length - 1, i)];
return target - lo.t <= hi.t - target ? lo : hi;
}
declare function drawCrosshairAt(svg: SVGSVGElement, px: number, d: Row): void;
declare function hideCrosshair(svg: SVGSVGElement): void;
declare function announce(text: string): void;
The pivot is what travels between charts. The broken version passes a pixel, which is nonsense to a chart of a different width or a different visible domain. The fixed version publishes the hovered data x-value, and each chart independently converts it back to its own pixel with its own scale. That decoupling is what lets a wide overview and a narrow sparkline share one crosshair.
Step-by-step fix
// bisector.center picks the closest index directly, saving the manual neighbour compare.
import { bisector } from 'd3-array';
interface Sample { t: number; v: number; }
const byTime = bisector<Sample, number>((d) => d.t).center;
function nearestSample(series: Sample[], targetT: number): Sample {
const i = byTime(series, targetT); // index of the closest element
return series[i];
}
Verification
// bisect must return the nearest datum, including at the array boundaries.
const series = [{ t: 0, v: 1 }, { t: 10, v: 2 }, { t: 20, v: 3 }];
console.assert(nearestSample(series, 9).t === 10, 'rounds up to the nearer neighbour');
console.assert(nearestSample(series, 4).t === 0, 'rounds down to the nearer neighbour');
console.assert(nearestSample(series, -5).t === 0, 'clamps below the domain');
console.assert(nearestSample(series, 99).t === 20, 'clamps above the domain');
In DevTools, hover the source chart and confirm the crosshair on every other chart moves in lockstep; then throttle the CPU 6× in the Performance panel and check the move handler still runs at most once per frame rather than piling up. A Scripting band that grows with pointer speed means the throttle is missing.
Why bisect beats a linear scan
The nearest-datum lookup runs on every pointer move across every subscribed chart, so its cost is multiplied by both frequency and chart count — exactly the place an O(n) scan hurts. d3.bisector performs a binary search over a sorted array in O(log n): for a 50,000-point series that is roughly 16 comparisons instead of 50,000, and across four linked charts at 60Hz the difference is the gap between a smooth crosshair and a janky one. The precondition is that the array is sorted by the accessor you bisect on, which time series usually already satisfy. bisector(accessor).left returns the insertion index that keeps the array sorted; .center goes one step further and returns the index of the closest element directly, which is what you want for a crosshair because you always snap to a real datum rather than interpolating between two.
There is a subtlety when series are not sampled at the same x-values. If chart A is sampled hourly and chart B every fifteen minutes, publishing A’s hovered timestamp and bisecting into B lands on B’s nearest sample, which is correct and is exactly why you broadcast the data value rather than an index. Broadcasting an array index would be meaningless across differently-sampled series; broadcasting the data x lets each chart resolve its own nearest point in its own resolution.
Event bus versus framework store
The HoverBus shown here is deliberately framework-agnostic — a Set of callbacks with publish and subscribe — because a shared crosshair frequently spans charts that a single component tree does not cleanly contain. In a React or Vue app you can swap it for the app’s existing store (a context value, a signal, a Svelte store) and the pattern is identical: one writable holding the current hovered x, charts subscribing in an effect and unsubscribing in its cleanup. The one rule that does not change is that the shared value must be plain data — a number or null — never a DOM node or a pixel, so that the store stays serializable and every consumer remains free to project it through its own scale. Keeping the bus tiny also keeps teardown honest: a chart that unmounts must call the unsubscribe function the bus returned, or its closure and its SVG stay pinned in the listener set as a detached-node leak.
Edge cases & gotchas
- Unsorted data breaks bisect silently.
bisectreturns a wrong index rather than throwing on unsorted input, producing a crosshair that snaps to the wrong point. Sort once at ingestion and treat sorted-ness as an invariant. - Sparse or gapped series. When the nearest datum is far from the pointer (a data gap), consider hiding the crosshair beyond a distance threshold rather than snapping across the gap, which misleads.
- Differing domains. If one chart is zoomed to a sub-range, a broadcast x outside its visible domain should clamp or hide, not draw off-canvas.
- Pointer versus mouse events. Use
pointermove/pointerleaveso touch and pen work;mouseleavealone misses touch cancellation and leaves a stuck crosshair. - Throttle, do not debounce, the hover. A crosshair must feel live, so coalesce with
requestAnimationFrame(throttle to one per frame); debouncing adds a trailing delay that reads as lag. - Announce sparingly. Update a single
aria-live="polite"node with the focused value, but do not fire a new announcement for every pixel — update it on the settled datum so a screen reader is not flooded.
Frequently Asked Questions
How do I share one crosshair across multiple D3 charts?
Publish the hovered data x-value to a small shared bus or store on pointermove, and have every chart subscribe. Each subscriber converts that data value back to its own pixel with its own scale and draws its crosshair there, so charts of different widths or domains all track the same logical position. Broadcast a pixel and only the source chart would be correct.
Why use d3.bisector instead of Array.find for the nearest point?
Because the lookup runs on every pointer move across every linked chart, so an O(n) find scan multiplies quickly. d3.bisector binary-searches a sorted array in O(log n) — about sixteen comparisons for fifty thousand points instead of fifty thousand — which keeps the crosshair smooth. Its .center method returns the closest index directly, which is what a snapping crosshair needs.
Should I throttle or debounce the crosshair pointer handler?
Throttle it, do not debounce it. A crosshair must feel instantaneous, so coalesce moves to at most one update per frame with requestAnimationFrame, which keeps it live while capping the work. Debouncing waits for the pointer to pause before firing, which introduces a trailing delay the user reads as the crosshair lagging behind their cursor.
Related
- Brushing and linked views in D3 — the parent topic on coordinated charts.
- Filtering a scatterplot with a D3 brush — the selection counterpart to hover tracking.
- Debouncing brush events on large selections — rate-limiting high-frequency interaction handlers.
- D3 scales and axes configuration — the invert operation behind pixel-to-data lookups.