From fa577bacb740721c375fa3f28c59265eb0a87a42 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Mon, 10 Aug 2026 23:27:52 -0500 Subject: [PATCH] Stop the snap leaping to the live edge outside the plot area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/IMPLEMENTATION_PLAN.md | 26 ++++++++++++++++++++++++ static/chart.js | 37 +++++++++++++++++++++++++++++++++- tests/e2e/trendline.test.mjs | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index b5e0056..21130cc 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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 requires every position to land on the low of the bar beneath it — 115 of 115. 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. diff --git a/static/chart.js b/static/chart.js index f0cdc6e..7a5fe13 100644 --- a/static/chart.js +++ b/static/chart.js @@ -595,6 +595,14 @@ class ConfluenceChart { if (!this.armedTool) return; const point = this.eventPoint(event); 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.gesture) { this.gesture.end = point; @@ -739,8 +747,35 @@ class ConfluenceChart { * 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. */ + /** 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) { - 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 }; if (!this.snapToBars || !this.bars.length || fallbackT == null) return base; // An already-snapped point carries no cursor position; return it untouched diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 8a66f52..82eeece 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -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'); }); }); + +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); + }); + });