Stop the snap leaping to the live edge outside the plot area

Diagnostic mode found in one hover what four local hypotheses could not.

coordinateToTime answers null over the right-hand price axis, over the
whitespace past the last bar, and anywhere outside the chart. snapPoint read
that as "the newest bar":

    const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;

so the indicator jumped to the live edge from wherever the cursor actually was —
hundreds of points away, on a bar the user was nowhere near. It matches the
screenshot exactly: the crosshair read 22:42 while the snap label read 22:58,
which was the last bar.

Two faults behind it. The tool's pointer listener is on window, so it processes
moves over the sidebar — a real sample reported x=1409 on a chart 1280 wide. And
a null time meant a default instead of no answer.

A pointer outside the plot now hides the indicator, and a null time resolves to
the bar nearest in pixels rather than the newest. An e2e test hovers a bar, the
price axis, the sidebar and back, asserting the indicator never sits at the live
edge from mid-chart and disappears once the cursor leaves.

Worth recording that device pixel ratio, viewport size, resize desynchronisation
and the chart scrolling under the gesture were each measured and ruled out
before this, none of which was the cause. With the browser on another machine,
instrumenting it should have come first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 23:27:52 -05:00
parent 3222ce03f4
commit fa577bacb7
3 changed files with 101 additions and 1 deletions

View file

@ -1600,3 +1600,29 @@ feel broken, not any inherent difficulty:
An e2e test sweeps the cursor along the bottom of a zoomed-out 1h chart and An e2e test sweeps the cursor along the bottom of a zoomed-out 1h chart and
requires every position to land on the low of the bar beneath it — 115 of 115. requires every position to land on the low of the bar beneath it — 115 of 115.
That test is the rule, executable. That test is the rule, executable.
**The snap leapt to the live edge — found by diagnostic mode.** Reported from
the user's own browser, which no headless run had reproduced:
```
cursor_x 1409.0 chart_w 1280.0 -> cursor_t None -> snapped to the last bar
cursor_x 1161.0 chart_w 1280.0 -> cursor_t None -> snapped to the last bar
cursor_x 1128.0 -> valid time, drift 0 bars
```
`coordinateToTime` answers `null` over the right-hand price axis, over the
whitespace past the last bar, and anywhere outside the chart — and `snapPoint`
read that as "the newest bar", so the dot jumped to the live edge from wherever
the cursor was. Two faults behind it: the tool's pointer listener is on `window`
and therefore fires over the sidebar (x=1409 on a 1280-wide chart), and a null
time meant a default rather than no answer.
Now a pointer outside the plot hides the indicator entirely, and a null time
resolves to the bar nearest in *pixels* rather than the newest one. Covered by
an e2e test that hovers a bar, the axis, the sidebar, and back.
The lesson is about method rather than geometry: four hypotheses were tested and
killed by measurement here — device pixel ratio, viewport size, resize
desynchronisation, and the chart scrolling under the gesture — while the actual
cause was visible in one line of the client's own numbers. When the browser is
on another machine, instrument it early instead of reproducing locally.

View file

@ -595,6 +595,14 @@ class ConfluenceChart {
if (!this.armedTool) return; if (!this.armedTool) return;
const point = this.eventPoint(event); const point = this.eventPoint(event);
if (!point) return; if (!point) return;
// The listener is on window, so it fires over the sidebar and the price
// axis too. Reported from a real session: x=1409 on a 1280-wide chart, which
// has no time under it — and the snap fell back to the newest bar, throwing
// the dot to the right-hand edge.
if (!this.withinPlot(point)) {
this.hideSnapDot();
return;
}
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point), point); if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point), point);
if (this.gesture) { if (this.gesture) {
this.gesture.end = point; this.gesture.end = point;
@ -739,8 +747,35 @@ class ConfluenceChart {
* control you have to remember to set. An already-snapped point carries no * control you have to remember to set. An already-snapped point carries no
* cursor y, so it is returned untouched rather than re-snapped against NaN. * cursor y, so it is returned untouched rather than re-snapped against NaN.
*/ */
/** Is this point over the plotted area, rather than an axis or the sidebar? */
withinPlot(point) {
if (!this.chartEl) return true;
const width = this.chartEl.clientWidth;
const height = this.chartEl.clientHeight;
return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height;
}
/**
* The bar nearest a pixel, when the time scale will not name one.
*
* coordinateToTime answers null over the right-hand whitespace and the price
* axis. Treating that as "the newest bar" put the anchor at the live edge
* however far away the cursor was.
*/
barNearestX(x) {
let best = null;
const timeScale = this.chart.timeScale();
for (const bar of this.bars) {
const coordinate = timeScale.timeToCoordinate(bar.t);
if (coordinate == null) continue;
const distance = Math.abs(coordinate - x);
if (!best || distance < best.distance) best = { distance, t: bar.t };
}
return best ? best.t : null;
}
snapPoint(point) { snapPoint(point) {
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null; const fallbackT = point.t ?? (point.x == null ? null : this.barNearestX(point.x));
const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null }; const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base; if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
// An already-snapped point carries no cursor position; return it untouched // An already-snapped point carries no cursor position; return it untouched

View file

@ -178,3 +178,42 @@ test('the crosshair never magnets to the close', { timeout: 180000 }, async () =
assert.equal(mode, 0, 'crosshair is not in Normal mode'); assert.equal(mode, 0, 'crosshair is not in Normal mode');
}); });
}); });
test('the snap never leaps to the live edge when the cursor leaves the plot',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await armTool(page, 'Trendline');
const box = await chartBox(page);
const dotState = async () => page.evaluate(() => {
const dot = document.querySelector('.chart-snap-dot');
if (!dot || dot.hidden) return { hidden: true };
const chart = document.querySelector('#chart');
const left = dot.getBoundingClientRect().left - chart.getBoundingClientRect().left;
const c = window.__chart;
const lastX = c.chart.timeScale().timeToCoordinate(c.bars[c.bars.length - 1].t);
return { hidden: false, atLiveEdge: Math.abs(left - lastX) < 6 };
});
// Reported from a real session: coordinateToTime answers null over the
// price axis, the whitespace past the last bar, and anywhere outside the
// chart — and the fallback was "the newest bar", which threw the anchor
// to the live edge from wherever the cursor actually was.
await page.mouse.move(box.x + box.w * 0.5, box.y + box.h * 0.4);
await page.waitForTimeout(400);
const onBar = await dotState();
assert.equal(onBar.hidden, false, 'no snap indicator over a bar');
assert.equal(onBar.atLiveEdge, false, 'snapped to the live edge from mid-chart');
// The listener is on window, so it sees the sidebar too.
await page.mouse.move(box.x + box.w + 120, box.y + box.h * 0.4);
await page.waitForTimeout(400);
assert.equal((await dotState()).hidden, true, 'the dot survived leaving the chart');
await page.mouse.move(box.x + box.w * 0.35, box.y + box.h * 0.5);
await page.waitForTimeout(400);
const back = await dotState();
assert.equal(back.hidden, false, 'the dot did not come back');
assert.equal(back.atLiveEdge, false, 'snapped to the live edge after returning');
assertNoPageErrors(page, assert);
});
});