Compare commits

..

2 commits

Author SHA1 Message Date
1ee2a96fc0 more performance 2026-08-17 01:03:28 -05:00
90f8a1b567 performance fixes 2026-08-17 00:50:20 -05:00
4 changed files with 220 additions and 54 deletions

View file

@ -19,12 +19,13 @@ passes after gap slots are inserted. The transparent-point production fix did
not visibly settle the report, so do not add another geometry patch from the not visibly settle the report, so do not add another geometry patch from the
current theory. current theory.
Next investigation should compare the painted canvas pixels around the join Painted-pixel diagnostics are now implemented: diagnostic mode samples the
against the chart API's expected coordinates in the user's production viewport. internal canvases around each join by the line's RGB colour and reports painted
This is the same class of problem where self-consistent API coordinates once historical/future slopes beside canonical and API-coordinate slopes. A browser
missed a real overlay offset. Capture the asset commit, final three bar times, regression confirms actual canvas pixels stay continuous in the deterministic
their logical/x coordinates, the first two future coordinates, canonical line sparse-tail case. The remaining step is a production capture with those painted
prices, and page-pixel samples from the rendered line before changing code. values; do not change geometry again until it says whether production pixels or
production coordinates diverge.
### Load bars from the visible window ### Load bars from the visible window

View file

@ -1075,6 +1075,15 @@ shared scale. The time-scale owner now uses transparent zero-valued points with
`autoscaleInfoProvider` that returns null. They remain invisible and cannot affect `autoscaleInfoProvider` that returns null. They remain invisible and cannot affect
price range, but Lightweight Charts retains their timestamps as real series data. price range, but Lightweight Charts retains their timestamps as real series data.
The next diagnostic step measures painted output rather than trusting the chart
API's coordinate system. In diagnostic mode the readout now samples pixels from
the internal canvases on both sides of the live-edge join, matching each manual
line's configured RGB colour near its expected path. It reports those painted
slopes alongside canonical price changes and API-coordinate slopes. This is
deliberate: a prior overlay bug produced internally consistent coordinates while
the pixels were visibly displaced. A deterministic browser test requires actual
historical and future line pixels to exist and keep the same slope.
### 2026-08-15 — 1m zoom-out died at ~1am because the socket sent 1,000 bars ### 2026-08-15 — 1m zoom-out died at ~1am because the socket sent 1,000 bars
Compressing the 1m time scale stopped around 01:00, then looked empty. Compressing the 1m time scale stopped around 01:00, then looked empty.
@ -1112,6 +1121,17 @@ made `.price` undefined and axis/price drag stayed dead. Use
the scale is still auto; a mostly-vertical mouse drag now sets the scale is still auto; a mostly-vertical mouse drag now sets
`manualPriceRange` the same way two-finger pan does. `manualPriceRange` the same way two-finger pan does.
### 2026-08-16 — frontend CPU was overlay work on every mouse pixel
`subscribeCrosshairMove` rebuilt context-label DOM every move; those labels
do not depend on the cursor. `visibleLogicalRangeChange` and every tick
redrew handles, bridges, comments and labels without coalescing. Three
`window` pointermove listeners ran while idle.
Context labels now update only with the view. All overlay redraws share
one rAF and skip if the logical/price range did not change. Pointermoves
attach only for an armed tool or an in-progress drag.
### 2026-08-16 — the rest of the same session, none of it subtle ### 2026-08-16 — the rest of the same session, none of it subtle
Written because they were skipped the first time and then asked for. Written because they were skipped the first time and then asked for.

View file

@ -10,6 +10,7 @@ class ConfluenceChart {
this.currentPriceLine = null; this.currentPriceLine = null;
this.currentPricePulse = null; this.currentPricePulse = null;
this.projectionDiagnostic = null; this.projectionDiagnostic = null;
this.projectionDiagnosticFrame = null;
this.animateCurrentPrice = true; this.animateCurrentPrice = true;
this.autoScrollLivePrice = true; this.autoScrollLivePrice = true;
this.lastCurrentPrice = null; this.lastCurrentPrice = null;
@ -66,7 +67,10 @@ class ConfluenceChart {
this.commentNodes = new Map(); this.commentNodes = new Map();
this.commentLayer = null; this.commentLayer = null;
this.contextLabelLayer = null; this.contextLabelLayer = null;
this.contextLabelFrame = null; this.overlayFrame = null;
this.overlayForce = false;
this.overlayRangeKey = '';
this.pointerAttached = { tool: false, anchor: false, line: false };
this.onCommentToggle = null; this.onCommentToggle = null;
this.onCommentMove = null; this.onCommentMove = null;
this.onCommentSelect = null; this.onCommentSelect = null;
@ -254,9 +258,7 @@ class ConfluenceChart {
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.observePlotCanvas(); this.observePlotCanvas();
this.renderAnchorHandles(); this.scheduleOverlays(true);
this.renderComments();
this.renderContextLabels();
}); });
}); });
this.resizeObserver.observe(el); this.resizeObserver.observe(el);
@ -397,16 +399,10 @@ class ConfluenceChart {
this.toolMoveListener = event => this.moveToolGesture(event); this.toolMoveListener = event => this.moveToolGesture(event);
this.toolUpListener = event => this.finishToolGesture(event); this.toolUpListener = event => this.finishToolGesture(event);
el.addEventListener('pointerdown', this.toolDownListener); el.addEventListener('pointerdown', this.toolDownListener);
window.addEventListener('pointermove', this.toolMoveListener);
window.addEventListener('pointerup', this.toolUpListener);
this.anchorMoveListener = event => this.moveAnchor(event); this.anchorMoveListener = event => this.moveAnchor(event);
this.anchorUpListener = event => this.finishAnchorDrag(event); this.anchorUpListener = event => this.finishAnchorDrag(event);
window.addEventListener('pointermove', this.anchorMoveListener);
window.addEventListener('pointerup', this.anchorUpListener);
this.lineMoveListener = event => this.moveLine(event); this.lineMoveListener = event => this.moveLine(event);
this.lineUpListener = event => this.finishLineDrag(event); this.lineUpListener = event => this.finishLineDrag(event);
window.addEventListener('pointermove', this.lineMoveListener);
window.addEventListener('pointerup', this.lineUpListener);
this.contextListener = event => this.showContextMenu(event); this.contextListener = event => this.showContextMenu(event);
el.addEventListener('contextmenu', this.contextListener); el.addEventListener('contextmenu', this.contextListener);
this.clickListener = event => { this.clickListener = event => {
@ -423,7 +419,6 @@ class ConfluenceChart {
}; };
el.addEventListener('click', this.clickListener); el.addEventListener('click', this.clickListener);
this.chart.subscribeCrosshairMove(param => { this.chart.subscribeCrosshairMove(param => {
this.scheduleContextLabels();
if (!param.point || !param.time) { if (!param.point || !param.time) {
this.tooltip.hidden = true; this.tooltip.hidden = true;
this.hideOhlc(); this.hideOhlc();
@ -434,11 +429,7 @@ class ConfluenceChart {
}); });
requestAnimationFrame(() => this.observePlotCanvas()); requestAnimationFrame(() => this.observePlotCanvas());
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles(); this.scheduleOverlays();
this.renderLineBridges();
this.renderProjectionDiagnostic();
this.renderComments();
this.renderContextLabels();
}); });
this.installPinchZoom(); this.installPinchZoom();
} }
@ -469,12 +460,7 @@ class ConfluenceChart {
}; };
this.chart.timeScale().setVisibleRange(this.pendingView); this.chart.timeScale().setVisibleRange(this.pendingView);
} }
requestAnimationFrame(() => this.renderAnchorHandles()); this.scheduleOverlays(true);
// The bar grid just changed underneath every pinned comment.
this.renderComments();
this.renderContextLabels();
this.renderLineBridges();
this.renderProjectionDiagnostic();
} }
setTrendlineGeometry(geometry) { setTrendlineGeometry(geometry) {
@ -591,10 +577,7 @@ class ConfluenceChart {
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
else this.bars.push(bar); else this.bars.push(bar);
if (advanced) this.extendManualLineSeries(last, bar); if (advanced) this.extendManualLineSeries(last, bar);
this.renderAnchorHandles(); this.scheduleOverlays(true);
if (advanced || this.lineBridgeLayer?.childElementCount) this.renderLineBridges();
this.renderContextLabels();
this.renderProjectionDiagnostic();
} }
extendManualLineSeries(previous, bar) { extendManualLineSeries(previous, bar) {
@ -623,7 +606,7 @@ class ConfluenceChart {
setComments(comments) { setComments(comments) {
this.comments = comments; this.comments = comments;
this.renderComments(); this.scheduleOverlays(true);
} }
setCurrentPriceAnimation(enabled) { setCurrentPriceAnimation(enabled) {
@ -804,9 +787,7 @@ class ConfluenceChart {
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
} }
} }
this.renderAnchorHandles(); this.scheduleOverlays(true);
this.renderComments();
this.renderContextLabels();
this.updateCurrentPrice(this.lastCurrentPrice); this.updateCurrentPrice(this.lastCurrentPrice);
}; };
this.touchEndListener = event => { this.touchEndListener = event => {
@ -1174,10 +1155,7 @@ class ConfluenceChart {
this.chart.timeScale().setVisibleRange(this.pendingView); this.chart.timeScale().setVisibleRange(this.pendingView);
this.pendingView = null; this.pendingView = null;
} }
this.renderAnchorHandles(); this.scheduleOverlays(true);
this.renderLineBridges();
this.renderContextLabels();
this.renderProjectionDiagnostic();
} }
renderContextLabels() { renderContextLabels() {
@ -1215,14 +1193,61 @@ class ConfluenceChart {
})); }));
} }
scheduleContextLabels() { overlayKey() {
if (this.contextLabelFrame != null) return; const range = this.chart?.timeScale().getVisibleLogicalRange();
this.contextLabelFrame = requestAnimationFrame(() => { if (!range) return '';
this.contextLabelFrame = null; const price = this.manualPriceRange;
return `${range.from.toFixed(4)}:${range.to.toFixed(4)}:${price?.minValue ?? ''}:${price?.maxValue ?? ''}`;
}
scheduleOverlays(force = false) {
this.overlayForce = this.overlayForce || force;
if (this.overlayFrame != null) return;
this.overlayFrame = requestAnimationFrame(() => {
this.overlayFrame = null;
const forceDraw = this.overlayForce;
this.overlayForce = false;
const key = this.overlayKey();
if (!forceDraw && key && key === this.overlayRangeKey) return;
this.overlayRangeKey = key;
this.renderAnchorHandles();
this.renderLineBridges();
this.renderProjectionDiagnostic();
this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
}); });
} }
attachPointers(kind) {
if (this.pointerAttached[kind]) return;
this.pointerAttached[kind] = true;
if (kind === 'tool') {
window.addEventListener('pointermove', this.toolMoveListener);
window.addEventListener('pointerup', this.toolUpListener);
} else if (kind === 'anchor') {
window.addEventListener('pointermove', this.anchorMoveListener);
window.addEventListener('pointerup', this.anchorUpListener);
} else if (kind === 'line') {
window.addEventListener('pointermove', this.lineMoveListener);
window.addEventListener('pointerup', this.lineUpListener);
}
}
detachPointers(kind) {
if (!this.pointerAttached[kind]) return;
this.pointerAttached[kind] = false;
if (kind === 'tool') {
window.removeEventListener('pointermove', this.toolMoveListener);
window.removeEventListener('pointerup', this.toolUpListener);
} else if (kind === 'anchor') {
window.removeEventListener('pointermove', this.anchorMoveListener);
window.removeEventListener('pointerup', this.anchorUpListener);
} else if (kind === 'line') {
window.removeEventListener('pointermove', this.lineMoveListener);
window.removeEventListener('pointerup', this.lineUpListener);
}
}
// --- tool arming and placement gestures --------------------------------- // --- tool arming and placement gestures ---------------------------------
setToolCompleteHandler(handler) { this.onToolComplete = handler; } setToolCompleteHandler(handler) { this.onToolComplete = handler; }
@ -1255,6 +1280,8 @@ class ConfluenceChart {
this.pendingAnchor = null; this.pendingAnchor = null;
this.clearGesture(); this.clearGesture();
this.clearLinePreview(); this.clearLinePreview();
if (tool) this.attachPointers('tool');
else this.detachPointers('tool');
} }
/** /**
@ -1292,11 +1319,7 @@ class ConfluenceChart {
this.observedPlotCanvas = canvas; this.observedPlotCanvas = canvas;
this.plotResizeObserver = new ResizeObserver(() => { this.plotResizeObserver = new ResizeObserver(() => {
this.syncOverlayLayer(); this.syncOverlayLayer();
this.renderAnchorHandles(); this.scheduleOverlays(true);
this.renderLineBridges();
this.renderProjectionDiagnostic();
this.renderComments();
this.renderContextLabels();
if (this.armedTool) this.renderGesture(); if (this.armedTool) this.renderGesture();
}); });
this.plotResizeObserver.observe(canvas); this.plotResizeObserver.observe(canvas);
@ -1377,7 +1400,10 @@ class ConfluenceChart {
const point = this.eventPoint(event); const point = this.eventPoint(event);
if (!point) return; if (!point) return;
if (!this.armedTool) { if (!this.armedTool) {
if (this.withinPlot(point)) this.mousePan = { start: point }; if (this.withinPlot(point)) {
this.mousePan = { start: point };
this.attachPointers('tool');
}
return; return;
} }
event.preventDefault(); event.preventDefault();
@ -1546,6 +1572,7 @@ class ConfluenceChart {
finishToolGesture(event) { finishToolGesture(event) {
this.mousePan = null; this.mousePan = null;
if (!this.armedTool) this.detachPointers('tool');
if (!this.armedTool || !this.gesture) return; if (!this.armedTool || !this.gesture) return;
const tool = this.armedTool; const tool = this.armedTool;
const { start, end } = this.gesture; const { start, end } = this.gesture;
@ -2120,10 +2147,70 @@ class ConfluenceChart {
.find(point => Number(point.time) === future.time)?.value; .find(point => Number(point.time) === future.time)?.value;
return { level, previous, current, next, series, historical: slope(previous, current), future: slope(current, next) }; return { level, previous, current, next, series, historical: slope(previous, current), future: slope(current, next) };
}).filter(row => row.current.y != null && row.current.y >= 0 && row.current.y <= plotHeight); }).filter(row => row.current.y != null && row.current.y >= 0 && row.current.y <= plotHeight);
this.projectionDiagnostic.textContent = lines.map(row => const text = (row, painted = null) =>
`#${row.level.number} ${row.level.tf} dp ${((row.current.p ?? 0) - (row.previous.p ?? 0)).toFixed(3)}/${((row.next.p ?? 0) - (row.current.p ?? 0)).toFixed(3)} px ${(row.historical ?? 0).toFixed(3)}/${(row.future ?? 0).toFixed(3)} ${row.series == null ? 'MISSING' : 'OK'}`, `#${row.level.number} ${row.level.tf} dp ${((row.current.p ?? 0) - (row.previous.p ?? 0)).toFixed(3)}/${((row.next.p ?? 0) - (row.current.p ?? 0)).toFixed(3)} px ${(row.historical ?? 0).toFixed(3)}/${(row.future ?? 0).toFixed(3)} paint ${painted ? `${painted.historical?.toFixed(3) ?? '?'}/${painted.future?.toFixed(3) ?? '?'}` : '?/?'} ${row.series == null ? 'MISSING' : 'OK'}`;
).join('\n'); this.projectionDiagnostic.textContent = lines.map(row => text(row)).join('\n');
this.projectionDiagnostic.hidden = !lines.length; this.projectionDiagnostic.hidden = !lines.length;
if (this.projectionDiagnosticFrame != null) cancelAnimationFrame(this.projectionDiagnosticFrame);
this.projectionDiagnosticFrame = requestAnimationFrame(() => {
this.projectionDiagnosticFrame = null;
this.projectionDiagnostic.textContent = lines.map(row => text(row, {
historical: this.paintedSlopeBetween(row.previous, row.current, ConfluenceChart.levelColor(row.level)),
future: this.paintedSlopeBetween(row.current, row.next, ConfluenceChart.levelColor(row.level)),
})).join('\n');
});
}
paintedSlopeBetween(first, second, color) {
if ([first.x, first.y, second.x, second.y].some(value => value == null)) return null;
const at = fraction => ({
x: first.x + (second.x - first.x) * fraction,
y: first.y + (second.y - first.y) * fraction,
});
const a = at(0.25);
const b = at(0.75);
const y1 = this.paintedYAt(a.x, a.y, color);
const y2 = this.paintedYAt(b.x, b.y, color);
return y1 == null || y2 == null ? null : (y2 - y1) / (b.x - a.x);
}
paintedYAt(x, expectedY, color) {
const match = /^#([0-9a-f]{6})$/i.exec(color || '');
if (!match) return null;
const value = Number.parseInt(match[1], 16);
const target = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
const plot = this.plotCanvas()?.getBoundingClientRect();
if (!plot) return null;
const pageX = plot.left + x;
const candidates = [];
for (const canvas of this.chartEl.querySelectorAll('canvas')) {
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height || pageX < rect.left || pageX > rect.right) continue;
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const pixelX = Math.round((pageX - rect.left) * scaleX);
const top = Math.max(0, Math.floor((plot.top + expectedY - 8 - rect.top) * scaleY));
const bottom = Math.min(canvas.height - 1, Math.ceil((plot.top + expectedY + 8 - rect.top) * scaleY));
if (bottom < top) continue;
try {
const image = canvas.getContext('2d').getImageData(
Math.max(0, pixelX - 1), top, Math.min(3, canvas.width - Math.max(0, pixelX - 1)), bottom - top + 1,
);
for (let offset = 0; offset < image.data.length; offset += 4) {
if (image.data[offset + 3] < 80) continue;
const distance = Math.hypot(
image.data[offset] - target[0], image.data[offset + 1] - target[1],
image.data[offset + 2] - target[2],
);
if (distance > 55) continue;
const row = Math.floor((offset / 4) / image.width);
candidates.push(rect.top - plot.top + (top + row) / scaleY);
}
} catch { /* An unreadable internal canvas contributes no measurement. */ }
}
if (!candidates.length) return null;
candidates.sort((a, b) => Math.abs(a - expectedY) - Math.abs(b - expectedY));
return candidates[0];
} }
lineData(level) { lineData(level) {
@ -2242,6 +2329,7 @@ class ConfluenceChart {
original: { ...level }, original: { ...level },
moved: false, moved: false,
}; };
this.attachPointers('line');
event.currentTarget.setPointerCapture?.(event.pointerId); event.currentTarget.setPointerCapture?.(event.pointerId);
} }
@ -2291,6 +2379,7 @@ class ConfluenceChart {
const level = this.levels.find(value => value.id === this.draggingLine.id); const level = this.levels.find(value => value.id === this.draggingLine.id);
const moved = this.draggingLine.moved; const moved = this.draggingLine.moved;
this.draggingLine = null; this.draggingLine = null;
this.detachPointers('line');
if (moved && level && this.onLineChange) this.onLineChange({ ...level }); if (moved && level && this.onLineChange) this.onLineChange({ ...level });
} }
@ -2303,6 +2392,7 @@ class ConfluenceChart {
anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor, anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor,
}; };
event.currentTarget.setPointerCapture?.(event.pointerId); event.currentTarget.setPointerCapture?.(event.pointerId);
this.attachPointers('anchor');
} }
moveAnchor(event) { moveAnchor(event) {
@ -2350,6 +2440,7 @@ class ConfluenceChart {
event.preventDefault(); event.preventDefault();
const level = this.levels.find(value => value.id === this.draggingAnchor.id); const level = this.levels.find(value => value.id === this.draggingAnchor.id);
this.draggingAnchor = null; this.draggingAnchor = null;
this.detachPointers('anchor');
this.hideSnapDot(); this.hideSnapDot();
if (level && this.onLineChange) this.onLineChange({ ...level }); if (level && this.onLineChange) this.onLineChange({ ...level });
} }
@ -2425,7 +2516,10 @@ class ConfluenceChart {
destroy() { destroy() {
if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.resizeObserver) this.resizeObserver.disconnect();
if (this.plotResizeObserver) this.plotResizeObserver.disconnect(); if (this.plotResizeObserver) this.plotResizeObserver.disconnect();
if (this.contextLabelFrame != null) cancelAnimationFrame(this.contextLabelFrame); if (this.overlayFrame != null) cancelAnimationFrame(this.overlayFrame);
this.detachPointers('tool');
this.detachPointers('anchor');
this.detachPointers('line');
clearTimeout(this.lineFocusTimer); clearTimeout(this.lineFocusTimer);
for (const node of this.commentNodes.values()) clearTimeout(node.focusTimer); for (const node of this.commentNodes.values()) clearTimeout(node.focusTimer);
if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener); if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);

View file

@ -216,6 +216,57 @@ test('a short final data gap does not kink a future trendline',
}); });
}); });
test('painted canvas pixels keep the same slope through the live edge',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.evaluate(() => {
const c = window.__chart;
const minute = 60;
const bars = Array.from({ length: 20 }, (_, index) => ({
tf: '1m', t: 200000 + index * minute, o: 100, h: 101, l: 99, c: 100,
v: 1, closed: true,
}));
bars.push({ ...bars.at(-1), t: bars.at(-1).t + 9 * minute });
const line = {
id: 'painted-tail', kind: 'manual', tf: '30m', side: 'support', label: 'paint',
anchor_t: bars[0].t, anchor_p: 99, last_t: bars[0].t + 30 * minute,
slope: 2 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf',
line_width: 3, provisional: false,
};
c.setBars(bars);
c.setTrendlineGeometry({
mode: 'source_tf',
series: { '30m': { times: Array.from({ length: 4 }, (_, i) => bars[0].t + i * 30 * minute), duration: 30 * minute } },
});
c.syncLevels([line]);
});
await page.waitForTimeout(200);
const result = await page.evaluate(() => {
const c = window.__chart;
const line = c.levels[0];
const last = c.bars.length - 1;
const futureTime = c.bars[last].t + 60;
const point = time => ({
x: c.chart.timeScale().timeToCoordinate(time),
y: c.candles.priceToCoordinate(c.linePriceAtIndex(line, c.indexAt(time))),
});
const previous = point(c.bars[last - 1].t);
const current = point(c.bars[last].t);
const future = point(futureTime);
return {
historical: c.paintedSlopeBetween(previous, current, line.color),
future: c.paintedSlopeBetween(current, future, line.color),
};
});
assert.notEqual(result.historical, null, 'historical canvas line pixels were not found');
assert.notEqual(result.future, null, 'future canvas line pixels were not found');
assert.ok(Math.abs(result.historical - result.future) < 0.08,
`painted line kinked from ${result.historical} to ${result.future}`);
assertNoPageErrors(page, assert);
});
});
test('a manual line series owns its future 5m slots without an SVG projection', test('a manual line series owns its future 5m slots without an SVG projection',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {