Keep chart overlays aligned with plot
This commit is contained in:
parent
8f71beb59f
commit
01f5cd4060
4 changed files with 111 additions and 9 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue