Keep chart overlays aligned with plot

This commit is contained in:
Chris Amow 2026-08-11 02:22:38 -05:00
parent 8f71beb59f
commit 01f5cd4060
4 changed files with 111 additions and 9 deletions

View file

@ -1665,7 +1665,7 @@ first thing that ever disagreed with itself.
# Handoff: the snap indicator is ~10px left of where it belongs # 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 ## The symptom
@ -1759,3 +1759,18 @@ against `element.getBoundingClientRect()`, or painted pixels.
the element", which fixed the 66px case this 10px residue survived. 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 - One e2e test, "clicking a comment collapses it", is flaky (roughly one run in
three) and unrelated. Worth fixing before trusting the suite. 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.

View file

@ -3,6 +3,8 @@ class ConfluenceChart {
this.chart = null; this.chart = null;
this.candles = null; this.candles = null;
this.resizeObserver = null; this.resizeObserver = null;
this.plotResizeObserver = null;
this.observedPlotCanvas = null;
this.levelSeries = new Map(); this.levelSeries = new Map();
this.priceLines = new Map(); this.priceLines = new Map();
this.previewLine = null; this.previewLine = null;
@ -155,7 +157,7 @@ class ConfluenceChart {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.syncOverlayLayer(); this.observePlotCanvas();
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderComments(); this.renderComments();
}); });
@ -267,7 +269,7 @@ class ConfluenceChart {
} }
this.updateLineTooltip(param); this.updateLineTooltip(param);
}); });
requestAnimationFrame(() => this.syncOverlayLayer()); requestAnimationFrame(() => this.observePlotCanvas());
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderComments(); this.renderComments();
@ -615,8 +617,7 @@ class ConfluenceChart {
/** Put the overlay container exactly over the plot area. */ /** Put the overlay container exactly over the plot area. */
syncOverlayLayer() { syncOverlayLayer() {
if (!this.overlayLayer || !this.chartEl) return; if (!this.overlayLayer || !this.chartEl) return;
const canvas = [...this.chartEl.querySelectorAll('canvas')] const canvas = this.plotCanvas();
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
if (!canvas) return; if (!canvas) return;
const chartRect = this.chartEl.getBoundingClientRect(); const chartRect = this.chartEl.getBoundingClientRect();
const plot = canvas.getBoundingClientRect(); const plot = canvas.getBoundingClientRect();
@ -626,10 +627,40 @@ class ConfluenceChart {
this.overlayLayer.style.height = `${Math.round(plot.height)}px`; 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() { plotOffsetX() {
if (!this.chartEl) return 0; if (!this.chartEl) return 0;
const canvas = [...this.chartEl.querySelectorAll('canvas')] const canvas = this.plotCanvas();
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
if (!canvas) return 0; if (!canvas) return 0;
return canvas.getBoundingClientRect().left - this.chartEl.getBoundingClientRect().left; return canvas.getBoundingClientRect().left - this.chartEl.getBoundingClientRect().left;
} }
@ -1161,6 +1192,7 @@ class ConfluenceChart {
destroy() { destroy() {
if (this.resizeObserver) this.resizeObserver.disconnect(); 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.clickListener) this.chartEl.removeEventListener('click', this.clickListener);
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);

View file

@ -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; } .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); } .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 { 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; } .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; } 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; } .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-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-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-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; }

View file

@ -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', test('sweeping the bottom traces the low of each bar under the cursor',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {