diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index a4d3d7e..969c7ae 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1665,7 +1665,7 @@ first thing that ever disagreed with itself. # Handoff: the snap indicator is ~10px left of where it belongs -**Status: diagnosed, not fixed.** Everything below is measured, not inferred. +**Status: fixed.** Everything below is measured, not inferred. ## The symptom @@ -1759,3 +1759,18 @@ against `element.getBoundingClientRect()`, or painted pixels. the element", which fixed the 66px case this 10px residue survived. - One e2e test, "clicking a comment collapses it", is flaky (roughly one run in three) and unrelated. Worth fixing before trusting the suite. + +## Resolution + +`ConfluenceChart` now attaches a `ResizeObserver` to the plot canvas itself. +Unlike the outer chart element, that canvas changes width when Lightweight +Charts finishes sizing the left price scale or a longer price label appears. +The observer repositions the shared overlay layer and redraws its anchored DOM +content after each such change. + +The trendline suite now compares the snap dot and cursor in page pixels, then +forces a six-digit left-scale label and repeats the assertion. This catches the +stale coordinate frame that the chart API's self-consistent coordinates could +not. The comment-collapse flake was also fixed: a broad CSS rule had re-enabled +pointer events on the full-size handles SVG, which intermittently covered a +comment. Overlay surfaces now ignore pointers unless the actual control opts in. diff --git a/static/chart.js b/static/chart.js index 1c9bba4..b5c92ba 100644 --- a/static/chart.js +++ b/static/chart.js @@ -3,6 +3,8 @@ class ConfluenceChart { this.chart = null; this.candles = null; this.resizeObserver = null; + this.plotResizeObserver = null; + this.observedPlotCanvas = null; this.levelSeries = new Map(); this.priceLines = new Map(); this.previewLine = null; @@ -155,7 +157,7 @@ class ConfluenceChart { this.resizeObserver = new ResizeObserver(() => { this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); requestAnimationFrame(() => { - this.syncOverlayLayer(); + this.observePlotCanvas(); this.renderAnchorHandles(); this.renderComments(); }); @@ -267,7 +269,7 @@ class ConfluenceChart { } this.updateLineTooltip(param); }); - requestAnimationFrame(() => this.syncOverlayLayer()); + requestAnimationFrame(() => this.observePlotCanvas()); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.renderAnchorHandles(); this.renderComments(); @@ -615,8 +617,7 @@ class ConfluenceChart { /** Put the overlay container exactly over the plot area. */ syncOverlayLayer() { if (!this.overlayLayer || !this.chartEl) return; - const canvas = [...this.chartEl.querySelectorAll('canvas')] - .sort((a, b) => (b.width * b.height) - (a.width * a.height))[0]; + const canvas = this.plotCanvas(); if (!canvas) return; const chartRect = this.chartEl.getBoundingClientRect(); const plot = canvas.getBoundingClientRect(); @@ -626,10 +627,40 @@ class ConfluenceChart { this.overlayLayer.style.height = `${Math.round(plot.height)}px`; } + /** + * Keep overlays aligned as Lightweight Charts sizes its internal price scales. + * + * The chart element itself does not resize when a price label makes the left + * scale wider; the plot canvas does. Observing the element therefore leaves a + * stale offset from the first layout pass, while observing this canvas catches + * both initial settling and later digit-width changes. + */ + observePlotCanvas() { + const canvas = this.plotCanvas(); + if (!canvas) return; + if (canvas !== this.observedPlotCanvas) { + if (this.plotResizeObserver) this.plotResizeObserver.disconnect(); + this.observedPlotCanvas = canvas; + this.plotResizeObserver = new ResizeObserver(() => { + this.syncOverlayLayer(); + this.renderAnchorHandles(); + this.renderComments(); + if (this.armedTool) this.renderGesture(); + }); + this.plotResizeObserver.observe(canvas); + } + this.syncOverlayLayer(); + } + + plotCanvas() { + if (!this.chartEl) return null; + return [...this.chartEl.querySelectorAll('canvas')] + .sort((a, b) => (b.width * b.height) - (a.width * a.height))[0] || null; + } + plotOffsetX() { if (!this.chartEl) return 0; - const canvas = [...this.chartEl.querySelectorAll('canvas')] - .sort((a, b) => (b.width * b.height) - (a.width * a.height))[0]; + const canvas = this.plotCanvas(); if (!canvas) return 0; return canvas.getBoundingClientRect().left - this.chartEl.getBoundingClientRect().left; } @@ -1161,6 +1192,7 @@ class ConfluenceChart { destroy() { if (this.resizeObserver) this.resizeObserver.disconnect(); + if (this.plotResizeObserver) this.plotResizeObserver.disconnect(); if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener); if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); diff --git a/static/style.css b/static/style.css index 813e462..833ce9a 100644 --- a/static/style.css +++ b/static/style.css @@ -14,7 +14,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; } -.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } +.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } .sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } @@ -80,4 +80,4 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; .chart-snap-dot { position:absolute; width:9px; height:9px; margin:-5px 0 0 -5px; border-radius:50%; border:2px solid var(--accent); background:var(--chart-bg); pointer-events:none; z-index:5; }.chart-snap-dot[data-side=resistance] { border-color:#bd4545; }.chart-snap-dot[data-side=support] { border-color:#27825c; } .chart-snap-label { position:absolute; padding:2px 5px; border-radius:3px; pointer-events:none; z-index:6; font-size:10px; white-space:nowrap; background:var(--chart-bg); border:1px solid var(--muted); color:var(--fg); }.chart-snap-label[data-side=resistance] { border-color:#bd4545; }.chart-snap-label[data-side=support] { border-color:#27825c; } .chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; } -.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; } +.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; } diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index a932ff0..d4d6869 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -134,6 +134,61 @@ test('hovering shows where the anchor will land', { timeout: 180000 }, async () }); }); +test('the snap dot stays under the cursor when the left scale changes width', + { timeout: 180000 }, async () => { + await withChart(async page => { + await armTool(page, 'Trendline'); + + const probe = async () => { + const target = await page.evaluate(() => { + const c = window.__chart; + const bar = c.bars[Math.max(0, c.bars.length - 80)]; + const chartRect = c.chartEl.getBoundingClientRect(); + const plotX = c.chart.timeScale().timeToCoordinate(bar.t); + return { + x: chartRect.left + c.plotOffsetX() + plotX, + y: chartRect.top + c.candles.priceToCoordinate(bar.l) - 6, + offset: c.plotOffsetX(), + }; + }); + await page.mouse.move(target.x, target.y); + await page.waitForTimeout(350); + const dotX = await page.evaluate(() => { + const dot = document.querySelector('.chart-snap-dot'); + if (!dot || dot.hidden) return null; + const rect = dot.getBoundingClientRect(); + return rect.left + rect.width / 2; + }); + assert.notEqual(dotX, null, 'the snap dot was not painted'); + assert.ok(Math.abs(dotX - target.x) <= 2, + `snap dot was ${(dotX - target.x).toFixed(1)}px from the cursor`); + return target.offset; + }; + + const initialOffset = await probe(); + await page.evaluate(() => { + const c = window.__chart; + c.__scaleProbe = c.chart.addSeries(LightweightCharts.LineSeries, { + priceScaleId: 'left', lastValueVisible: true, priceLineVisible: false, + }); + c.__scaleProbe.setData([{ + time: c.bars[c.bars.length - 1].t, + value: 123456.75, + }]); + }); + await page.waitForTimeout(600); + const widenedOffset = await probe(); + assert.ok(widenedOffset > initialOffset + 2, + `left scale did not widen: ${initialOffset}px -> ${widenedOffset}px`); + await page.evaluate(() => { + const c = window.__chart; + c.chart.removeSeries(c.__scaleProbe); + delete c.__scaleProbe; + }); + assertNoPageErrors(page, assert); + }); + }); + test('sweeping the bottom traces the low of each bar under the cursor', { timeout: 180000 }, async () => { await withChart(async page => {