Skip to content

fix: zoom drag lifecycle + all-zero horizon rendering#42

Merged
ekacnet merged 3 commits into
ekacnet:mainfrom
jtakkala:jari/upstream-fixes
Mar 31, 2026
Merged

fix: zoom drag lifecycle + all-zero horizon rendering#42
ekacnet merged 3 commits into
ekacnet:mainfrom
jtakkala:jari/upstream-fixes

Conversation

@jtakkala

Copy link
Copy Markdown

Two fixes found while integrating cubism into a Grafana panel plugin.

Zoom drag never ends when released outside the horizon

The mouseup handler was attached to the horizon divs. Releasing the drag over the zoom overlay (a sibling element), the axis, a gap between lanes, or anywhere outside the panel never fired the handler — the selection rectangle followed the mouse forever until you happened to mouseup precisely on a horizon.

Fixed by attaching a one-shot mouseup to window on mousedown. Also:

  • pointer-events: none on the zoom overlay SVG so it doesn't swallow events
  • try/finally around the callback invocation so a throwing callback can't leave _corner1 set
  • New zoom.disable() method for consumers that reuse the context across renders and need to turn zoom off cleanly

All-zero metrics paint solid instead of transparent

When every value in a metric is zero, max === 0 gives d3's linear scale a [0, 0] domain. scale(0) then returns the range midpoint, painting the entire lane a mid-intensity band — making zero look like constant nonzero data. Now skips rendering after clearRect so zero lanes stay transparent.

Tests

+5 tests covering both regressions (zoom state clearing, disable, try/finally, window mouseup integration, listener self-removal). 86 → 91 passing.

When every value in a lane is 0, extent() returns [0, 0], so max = 0
and the scale domain becomes [0, 0]. d3's linear scale with a zero-
width domain returns the range midpoint for every input (the
normalize(x) step divides by zero and d3 clamps to 0.5). That midpoint
maps to a mid-intensity color and the loop paints a full-height band —
visually indistinguishable from "weak negative data" when the intent
is "no data here".

Early-return after clearRect when max === 0. The lane stays
transparent, showing the panel background through.
Two related bugs in the drag-to-zoom interaction:

1. mouseup was attached only to the horizon divs. Releasing over the
   axis, the rule line, a gap between lanes, or outside the panel
   never reached the handler — zoom.stop() never ran, _corner1 stayed
   set, and the drag continued indefinitely. Also, a data-driven
   re-render mid-drag (Grafana refreshes every 5-30s) destroys the
   horizon divs and their handlers with them. Fixed by attaching a
   one-shot mouseup to window on mousedown — fires no matter where
   the release happens, survives DOM churn.

2. The selection-rectangle SVG has position:absolute, which anchors
   to the nearest positioned ancestor. The scrollable container
   (canvasDiv) was position:static, so the overlay anchored to its
   grandparent instead. When scrolled, pointer() returns coords
   relative to canvasDiv's content origin (includes scroll offset),
   but the overlay positioned relative to the unscrolled grandparent
   — it appeared off-screen. The overlay also needs pointer-events:
   none so mouse events pass through to the horizons underneath.
   (The position:relative fix is in the consuming panel's CSS;
   the pointer-events fix is here.)
@codecov

codecov Bot commented Mar 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.54%. Comparing base (7aff2c9) to head (a2f5fbe).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main      #42   +/-   ##
=======================================
  Coverage   99.54%   99.54%           
=======================================
  Files          33       33           
  Lines         871      883   +12     
  Branches      193      189    -4     
=======================================
+ Hits          867      879   +12     
  Misses          4        4           
Flag Coverage Δ
integration 99.54% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The window-level mouseup handler closes over the container captured
at render time. If a re-render happens mid-drag (Grafana auto-refresh,
or the panel's width useEffect dep firing on resize), the container
gets detached via innerHTML='' but the window listener survives.

On mouseup:
- pointer() against the detached node computes via a zero-rect
  getBoundingClientRect, returning raw viewport coords
- _corner1 still holds the pre-render container-relative position
- stop() mixes the two coordinate systems → garbage range
- onChangeTimeRange / datalink navigation fires with nonsense timestamps

Two-part fix:
1. context.zoom(callback) re-registration now clears _corner1/_selection,
   same as disable() does. Any in-progress drag is abandoned on re-render.
2. The mouseup handler checks container.isConnected and bails to reset()
   if detached — belt-and-suspenders in case corner1 somehow survives.

Found by bughunt-lite workflow (5-0 adversarial vote).
@ekacnet ekacnet merged commit 4037869 into ekacnet:main Mar 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants