Skip to content

Dense data-viz strips: per-mark hit targets silently dispatch to the wrong datum

Symptom

A rate-distribution strip (287 absolutely positioned 8px-wide <button> marks inside a 314px track) logged repeated PostHog $rageclick events. Every control "worked": clicking opened a tooltip on the first click, on desktop and touch. Nothing was broken by any functional test.

The bug: the tooltip showed the wrong rate. Clicking the visual centre of mark index 150 dispatched the event to mark index 283.

Root cause

When marks are packed denser than their hit width, they overlap. Measured: ~55 marks overlapped any given point (average spacing 1.09px vs 8px hit width). CSS hit-testing has no notion of "nearest" — document.elementFromPoint returns whichever overlapping element paints last (DOM order for equal z-index). So the user aims at a mark and reliably gets a different datum's tooltip, taps again, gets the same wrong one. Classic rageclick, invisible to functional tests because something always responds.

The intuitive fix (widen the hit area for WCAG 2.5.8's 24px minimum target size) makes it strictly worse: more overlap, more mis-targeting.

Diagnostic

One script tells you if you have this:

const track = document.querySelector('.density-track');
const marks = [...track.querySelectorAll('.density-line')];
const target = marks[150];
const r = target.getBoundingClientRect();
const cx = r.x + r.width / 2, cy = r.y + r.height / 2;
console.log({
  hitIsTarget: document.elementFromPoint(cx, cy) === target,
  overlappingWithin4px: marks.filter(m => {
    const b = m.getBoundingClientRect();
    return Math.abs(b.x + b.width / 2 - cx) < 4;
  }).length,
});

hitIsTarget: false with a double-digit overlap count means every click on that strip is a coin flip.

Fix: separate visuals from hit-testing

Do not try to make per-mark hit-testing work. Instead:

  1. Marks become pure decoration: pointer-events: none, tabindex="-1", aria-hidden="true".
  2. One transparent hit surface covers the whole track (position: absolute; inset: 0; z-index: 5, below any special marker like a median line at z-index: 10).
  3. That surface resolves the nearest mark from the pointer's x-fraction on both click and mousemove:
function open_nearest(event: MouseEvent, positions: { position: number }[]) {
  const { left, width } = (event.currentTarget as HTMLElement).getBoundingClientRect();
  if (!width || positions.length === 0) return;
  const pct = ((event.clientX - left) / width) * 100;
  let best = 0, best_distance = Infinity;
  for (let i = 0; i < positions.length; i++) {
    const d = Math.abs(positions[i].position - pct);
    if (d < best_distance) { best_distance = d; best = i; }
  }
  open_tooltip(best);
}
  1. Sort the data by position so index order == visual order; arrow-key stepping then falls out for free.
  2. Guard the state setter (if (open_id !== id) open_id = id;) or a pointer sweep re-renders every tooltip component per mousemove.

Why this is the right shape

  • Touch target: goes from a nominal 8px mark (in practice un-aimable) to the full strip (312x38 measured). WCAG 2.5.8 satisfied by the strip, which is the real control.
  • Correctness: the answer is the datum you pointed at, on hover and on tap.
  • A11y: removed 565 junk tab stops (two tracks of 287 and 278 focusable buttons). A keyboard user previously had to Tab 565 times to get past the chart. Now: one tab stop per track, arrow keys to step.
  • Tooltip anchoring still works: pointer-events: none does not affect layout, so a floating-ui/Popper tooltip controlled by an open prop still anchors to the correct mark element.

Generalizes to

Any dense chart where marks are individually interactive: sparkline points, histogram bins, timeline events, heatmap cells, waveform peaks, gantt ticks. Rule of thumb: if average mark spacing is less than the hit width, per-mark hit-testing is already broken — you need one hit surface plus nearest-neighbour resolution.

Bonus: why functional tests never caught it

Every assertion you'd naturally write ("click a mark, a tooltip appears") passes. You need an assertion on which datum came back, e.g. click at 10%/50%/90% of the strip and assert the returned values are monotonic and match the expected interpolation of the data range.

No signals yet