Repository navigation
feat(panel): add the shared crosshair, shared tooltip, and drag-to-zoom #83
Description
Split out of #36. These three are one problem, not three, and they are the most intricate part of uPlot's API — keeping them in #36 would have made it unreviewable.
Why they are one problem
cursor.sync does not forward only the cursor. pubSync publishes mousedown, mousemove, mouseup and dblclick, and filters.pub/filters.sub both default to retTrue. Verified in uPlot 1.6.32. So naively enabling sync plus drag-to-zoom gives you:
- A drag propagates as a selection. On a synced
mousemovewhile dragging, the subscriber copies the source's drag origin and callssetSelXwith the selection translated throughposToVal/valToPosX. - Every synced panel zooms itself.
mouseUprunsif (drag.setScale && hasSelect && chgSelect) … _setScale(...)regardless of whether the event came from elsewhere.drag.setScaledefaults totrue. - Every synced panel fires
setSelect. So a zoom handler hung onsetSelectpushes one history entry per panel for a single drag — and feat(panel): add the time series panel #36's acceptance criterion is that the zoom is undoable with one Back. - Double-click resets every panel's axis.
dblClickcallsautoScaleX()before itse != nullguard, so a synced dblclick autoscales the subscriber to its own data extent, silently desyncing it from the dashboard range.
The shape that works
cursor: {
sync: {
key: dashboardUid, // per dashboard, not global
setSeries: false,
filters: { pub: (t) => t === 'mousemove', sub: (t) => t === 'mousemove' },
},
drag: { x: true, y: false, setScale: false },
}plus a setSelect hook reading u.select.left/width → u.posToVal → range.zoomTo(...) → clear the selection, and an explicit dblclick handler calling range.reset(). With pub filtered to mousemove, setSelect only ever fires on the panel that owns the pointer, so the N-history-entries problem is gone by construction rather than by guard.
The decision this needs
Panels with timeFrom or timeShift have their own x range (effectiveBounds in queryRunner.ts). Sync's default scales: [xScaleKey, …] syncs by value, so a week-over-week panel gets the crosshair placed at an absolute timestamp outside its own range — pinned to an edge, reading a wrong row.
uPlot documents the lever: a null scale key syncs by relative position instead. Three options, and one must be chosen deliberately:
- Panels with a time override join a different sync key, so they sync only with each other.
- The whole dashboard syncs by relative position (
scales: [null, null]). - Time-overridden panels do not sync at all.
Silently syncing a shifted panel by value is the worst of the four possibilities.
Notes
destroy()callssync.unsub(self), so panel churn does not leak subscriptions — but uPlot's module-levelsyncsregistry never deletes a key. Keying on the dashboard uid is bounded; keying on anything per-mount is not.cursor.sync.setSeriesdefaults tofalsein the source, despite the.d.tscomment saying// true. Do not trust that comment elsewhere either.- A shared tooltip needs the same event filtering, and feat(panel): add the time series panel #36 already owns a per-panel tooltip that this can extend rather than replace.
Acceptance criteria
- The crosshair tracks across every panel with no measurable lag
- Drag-to-zoom updates the dashboard range and is undoable with one browser Back
- Double-click returns to the previous range rather than autoscaling one panel
- A panel with
timeFrom/timeShiftbehaves per whichever option above is chosen, and the choice is written down
Activity
feat(panel): add the time series panel (#84)
xe-nvdk commentedon Sep 26, 2026
Closing: the dashboards feature has been dropped from Launchpad. The implemented work was removed in #87 and this work will not be picked up. Recoverable from git tag
pre-dashboards-removal-backupif the feature is ever revived.