fix(trace-charts): drop glitchy custom wheel/pan, restore uPlot default mouse control

The hand-rolled wheel-zoom + drag-pan (wheelZoomPlugin) was hakelig/glitchy —
uPlot is not built to be driven that way. Remove the plugin and the cursor.drag
override so uPlot's own defaults govern the mouse: drag = box-zoom on x,
double-click = reset to full range, with the legend value-readout and crosshair
unchanged.

Everything else from the Tier-1 pass stays: the ordinal (gap-collapsed) x-axis
default + continuous toggle, the ordinal-label honesty (ticks/legend report the
real xs[i]), and panel cursor-sync (cursor.sync key retained; only the drag
override and the plugin are dropped). The x-mode guard is flipped to pin the
revert — overlay carries no custom plugin and no cursor.drag override, panels
carry no plugin — and stays green alongside the other three chart guards.

refs #103
This commit is contained in:
2026-06-19 23:15:39 +02:00
parent 87e6e8992d
commit 08a272c73d
2 changed files with 14 additions and 47 deletions
+5 -41
View File
@@ -6,49 +6,14 @@
(function () {
var PALETTE = ["#89b4fa", "#f38ba8", "#a6e3a1", "#f9e2af", "#cba6f7", "#94e2d5"];
// wheelZoomPlugin: a pure factory (safe to construct headless — its hooks run only in
// a browser). On init it wires wheel-zoom (x, cursor-anchored), drag-pan, and
// dblclick-reset onto u.over. uPlot's default drag-box-zoom is disabled in buildCharts
// (cursor.drag off) so drag pans instead.
function wheelZoomPlugin() {
var ZOOM = 0.9; // one wheel notch shrinks/grows the visible x-range by this factor
return { hooks: { init: function (u) {
var over = u.over;
over.addEventListener("wheel", function (e) {
e.preventDefault();
var rect = over.getBoundingClientRect();
var xVal = u.posToVal(e.clientX - rect.left, "x");
var lo = u.scales.x.min, hi = u.scales.x.max;
var f = e.deltaY < 0 ? ZOOM : 1 / ZOOM; // wheel up = zoom in
u.setScale("x", { min: xVal - (xVal - lo) * f, max: xVal + (hi - xVal) * f });
}, { passive: false });
over.addEventListener("mousedown", function (d) {
if (d.button !== 0) return;
var x0 = d.clientX, lo = u.scales.x.min, hi = u.scales.x.max;
var unitsPerPx = (hi - lo) / over.clientWidth;
function move(m) {
var dx = (m.clientX - x0) * unitsPerPx;
u.setScale("x", { min: lo - dx, max: hi - dx });
}
function up() {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
}
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
});
over.addEventListener("dblclick", function () {
u.setScale("x", { min: u.data[0][0], max: u.data[0][u.data[0].length - 1] });
});
} } };
}
// {xs, series:[{name, y_scale_id, points}]} + mode + xMode -> array of {opts, data}.
// xMode in {"ordinal","continuous"}, default "ordinal". ordinal: data[0] is the index
// spine [0..n-1] (gaps collapse) and the x-axis/legend map an index back to the real
// xs[i]; continuous: data[0] is xs. series points (data[1..]) are untouched either way
// (null gaps preserved verbatim). overlay -> one chart; panels -> one chart per series,
// sharing a cursor.sync key so the crosshair lines up.
// sharing a cursor.sync key so the crosshair lines up. Mouse interaction is uPlot's
// default (drag = box-zoom on x, double-click = reset); we add no custom wheel/pan
// (the hand-rolled version was glitchy — uPlot is not built for it).
function buildCharts(traces, mode, xMode) {
xMode = xMode || "ordinal";
var xs = traces.xs;
@@ -77,8 +42,7 @@
scales: { x: { time: false }, [s.y_scale_id]: {} },
series: [xSeriesCfg(), { label: s.name, scale: s.y_scale_id, stroke: PALETTE[i % PALETTE.length] }],
axes: [xAxisCfg(), { scale: s.y_scale_id }],
cursor: { sync: { key: SYNC }, drag: { x: false, y: false } },
plugins: [wheelZoomPlugin()],
cursor: { sync: { key: SYNC } },
},
data: [xData, s.points],
};
@@ -97,7 +61,7 @@
if (i < 2) axes.push({ scale: s.y_scale_id, side: i === 0 ? 3 : 1 });
});
return [{
opts: { scales: scales, series: uSeries, axes: axes, cursor: { drag: { x: false, y: false } }, plugins: [wheelZoomPlugin()] },
opts: { scales: scales, series: uSeries, axes: axes },
data: [xData].concat(traces.series.map(function (s) { return s.points; })),
}];
}
+9 -6
View File
@@ -2,8 +2,9 @@
// Properties: (1) the DEFAULT x spine is ORDINAL — data[0] is [0..n-1], not the raw
// timestamps, so non-trading gaps collapse; the x-axis carries an index->label values
// formatter. (2) "continuous" mode restores data[0] === xs verbatim. (3) panels carry a
// shared cursor.sync.key so the crosshair lines up. (4) every config attaches an
// interaction plugin with an init hook. Drives the pure buildCharts; no DOM/uPlot.
// shared cursor.sync.key so the crosshair lines up. (4) no custom interaction plugin and
// no cursor.drag override — uPlot's default drag-box-zoom / dblclick-reset governs the
// mouse (the hand-rolled wheel/pan was glitchy). Drives the pure buildCharts; no DOM/uPlot.
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -29,9 +30,10 @@ const ov = buildCharts(traces, "overlay");
sameArr(ov[0].data[0], idx, "default overlay x is the ordinal index spine [0..n-1]");
if (typeof ov[0].opts.axes[0].values !== "function") fail("ordinal overlay x-axis lacks a values formatter");
// (4) interaction wired: a plugin with an init hook.
if (!Array.isArray(ov[0].opts.plugins) || ov[0].opts.plugins.length < 1) fail("no interaction plugin attached");
if (typeof ov[0].opts.plugins[0].hooks.init !== "function") fail("interaction plugin has no init hook");
// (4) interaction is uPlot's default: overlay carries no custom plugin and no
// cursor.drag override (so drag = box-zoom, dblclick = reset, both uPlot built-ins).
if (ov[0].opts.plugins && ov[0].opts.plugins.length) fail("overlay should carry no custom interaction plugin (uPlot defaults)");
if (ov[0].opts.cursor && ov[0].opts.cursor.drag) fail("overlay should not override cursor.drag (uPlot default box-zoom)");
// (2) continuous mode: data[0] is xs verbatim.
const ovc = buildCharts(traces, "overlay", "continuous");
@@ -42,7 +44,8 @@ const panels = buildCharts(traces, "panels");
const keys = panels.map((p) => p.opts.cursor && p.opts.cursor.sync && p.opts.cursor.sync.key);
if (keys.some((k) => !k)) fail("a panel lacks cursor.sync.key");
if (new Set(keys).size !== 1) fail("panels do not share one cursor.sync key");
if (panels.some((p) => p.opts.plugins && p.opts.plugins.length)) fail("panels should carry no custom interaction plugin (uPlot defaults)");
sameArr(panels[0].data[0], idx, "default panel x is the ordinal index spine");
console.log("OK — ordinal default + continuous toggle + panel cursor-sync + interaction plugin.");
console.log("OK — ordinal default + continuous toggle + panel cursor-sync + uPlot-default controls.");
process.exit(0);