From 90f8a1b5679a2c007cde10e1e35b3dbda5fd8215 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Mon, 17 Aug 2026 00:50:20 -0500 Subject: [PATCH] performance fixes --- docs/implementation.md | 11 ++++ static/chart.js | 123 ++++++++++++++++++++++++++--------------- 2 files changed, 89 insertions(+), 45 deletions(-) diff --git a/docs/implementation.md b/docs/implementation.md index 855ba0b..2f19cda 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1112,6 +1112,17 @@ made `.price` undefined and axis/price drag stayed dead. Use the scale is still auto; a mostly-vertical mouse drag now sets `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 Written because they were skipped the first time and then asked for. diff --git a/static/chart.js b/static/chart.js index c885f35..755d492 100644 --- a/static/chart.js +++ b/static/chart.js @@ -66,7 +66,10 @@ class ConfluenceChart { this.commentNodes = new Map(); this.commentLayer = 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.onCommentMove = null; this.onCommentSelect = null; @@ -254,9 +257,7 @@ class ConfluenceChart { this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); requestAnimationFrame(() => { this.observePlotCanvas(); - this.renderAnchorHandles(); - this.renderComments(); - this.renderContextLabels(); + this.scheduleOverlays(true); }); }); this.resizeObserver.observe(el); @@ -397,16 +398,10 @@ class ConfluenceChart { this.toolMoveListener = event => this.moveToolGesture(event); this.toolUpListener = event => this.finishToolGesture(event); el.addEventListener('pointerdown', this.toolDownListener); - window.addEventListener('pointermove', this.toolMoveListener); - window.addEventListener('pointerup', this.toolUpListener); this.anchorMoveListener = event => this.moveAnchor(event); this.anchorUpListener = event => this.finishAnchorDrag(event); - window.addEventListener('pointermove', this.anchorMoveListener); - window.addEventListener('pointerup', this.anchorUpListener); this.lineMoveListener = event => this.moveLine(event); this.lineUpListener = event => this.finishLineDrag(event); - window.addEventListener('pointermove', this.lineMoveListener); - window.addEventListener('pointerup', this.lineUpListener); this.contextListener = event => this.showContextMenu(event); el.addEventListener('contextmenu', this.contextListener); this.clickListener = event => { @@ -423,7 +418,6 @@ class ConfluenceChart { }; el.addEventListener('click', this.clickListener); this.chart.subscribeCrosshairMove(param => { - this.scheduleContextLabels(); if (!param.point || !param.time) { this.tooltip.hidden = true; this.hideOhlc(); @@ -434,11 +428,7 @@ class ConfluenceChart { }); requestAnimationFrame(() => this.observePlotCanvas()); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { - this.renderAnchorHandles(); - this.renderLineBridges(); - this.renderProjectionDiagnostic(); - this.renderComments(); - this.renderContextLabels(); + this.scheduleOverlays(); }); this.installPinchZoom(); } @@ -469,12 +459,7 @@ class ConfluenceChart { }; this.chart.timeScale().setVisibleRange(this.pendingView); } - requestAnimationFrame(() => this.renderAnchorHandles()); - // The bar grid just changed underneath every pinned comment. - this.renderComments(); - this.renderContextLabels(); - this.renderLineBridges(); - this.renderProjectionDiagnostic(); + this.scheduleOverlays(true); } setTrendlineGeometry(geometry) { @@ -591,10 +576,7 @@ class ConfluenceChart { 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); if (advanced) this.extendManualLineSeries(last, bar); - this.renderAnchorHandles(); - if (advanced || this.lineBridgeLayer?.childElementCount) this.renderLineBridges(); - this.renderContextLabels(); - this.renderProjectionDiagnostic(); + this.scheduleOverlays(true); } extendManualLineSeries(previous, bar) { @@ -623,7 +605,7 @@ class ConfluenceChart { setComments(comments) { this.comments = comments; - this.renderComments(); + this.scheduleOverlays(true); } setCurrentPriceAnimation(enabled) { @@ -804,9 +786,7 @@ class ConfluenceChart { this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); } } - this.renderAnchorHandles(); - this.renderComments(); - this.renderContextLabels(); + this.scheduleOverlays(true); this.updateCurrentPrice(this.lastCurrentPrice); }; this.touchEndListener = event => { @@ -1174,10 +1154,7 @@ class ConfluenceChart { this.chart.timeScale().setVisibleRange(this.pendingView); this.pendingView = null; } - this.renderAnchorHandles(); - this.renderLineBridges(); - this.renderContextLabels(); - this.renderProjectionDiagnostic(); + this.scheduleOverlays(true); } renderContextLabels() { @@ -1215,14 +1192,61 @@ class ConfluenceChart { })); } - scheduleContextLabels() { - if (this.contextLabelFrame != null) return; - this.contextLabelFrame = requestAnimationFrame(() => { - this.contextLabelFrame = null; + overlayKey() { + const range = this.chart?.timeScale().getVisibleLogicalRange(); + if (!range) return ''; + 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(); }); } + 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 --------------------------------- setToolCompleteHandler(handler) { this.onToolComplete = handler; } @@ -1255,6 +1279,8 @@ class ConfluenceChart { this.pendingAnchor = null; this.clearGesture(); this.clearLinePreview(); + if (tool) this.attachPointers('tool'); + else this.detachPointers('tool'); } /** @@ -1292,11 +1318,7 @@ class ConfluenceChart { this.observedPlotCanvas = canvas; this.plotResizeObserver = new ResizeObserver(() => { this.syncOverlayLayer(); - this.renderAnchorHandles(); - this.renderLineBridges(); - this.renderProjectionDiagnostic(); - this.renderComments(); - this.renderContextLabels(); + this.scheduleOverlays(true); if (this.armedTool) this.renderGesture(); }); this.plotResizeObserver.observe(canvas); @@ -1377,7 +1399,10 @@ class ConfluenceChart { const point = this.eventPoint(event); if (!point) return; if (!this.armedTool) { - if (this.withinPlot(point)) this.mousePan = { start: point }; + if (this.withinPlot(point)) { + this.mousePan = { start: point }; + this.attachPointers('tool'); + } return; } event.preventDefault(); @@ -1546,6 +1571,7 @@ class ConfluenceChart { finishToolGesture(event) { this.mousePan = null; + if (!this.armedTool) this.detachPointers('tool'); if (!this.armedTool || !this.gesture) return; const tool = this.armedTool; const { start, end } = this.gesture; @@ -2242,6 +2268,7 @@ class ConfluenceChart { original: { ...level }, moved: false, }; + this.attachPointers('line'); event.currentTarget.setPointerCapture?.(event.pointerId); } @@ -2291,6 +2318,7 @@ class ConfluenceChart { const level = this.levels.find(value => value.id === this.draggingLine.id); const moved = this.draggingLine.moved; this.draggingLine = null; + this.detachPointers('line'); if (moved && level && this.onLineChange) this.onLineChange({ ...level }); } @@ -2303,6 +2331,7 @@ class ConfluenceChart { anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor, }; event.currentTarget.setPointerCapture?.(event.pointerId); + this.attachPointers('anchor'); } moveAnchor(event) { @@ -2350,6 +2379,7 @@ class ConfluenceChart { event.preventDefault(); const level = this.levels.find(value => value.id === this.draggingAnchor.id); this.draggingAnchor = null; + this.detachPointers('anchor'); this.hideSnapDot(); if (level && this.onLineChange) this.onLineChange({ ...level }); } @@ -2425,7 +2455,10 @@ class ConfluenceChart { destroy() { if (this.resizeObserver) this.resizeObserver.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); for (const node of this.commentNodes.values()) clearTimeout(node.focusTimer); if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener);