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); + }); + });