diff --git a/AGENTS.md b/AGENTS.md index b7dad2b..2e31982 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,8 +250,14 @@ cost on the hot path unless the screen or an alert has to change. client needs. Caps are named settings, not leftover literals. - Crosshair move is for the cursor (OHLC, drawing tooltip). Do not rebuild overlays that only depend on the viewport. -- Overlay redraws go through `scheduleOverlays`. Force when data or - size changed; let the range-key skip no-ops. +- Overlay redraws go through `scheduleOverlays`. Force when bars are + replaced, a new bar opens, or size changed. **A forming-bar tick is + not that.** `updateBar` must not `scheduleOverlays(true)` on every + tick — that walked every trendline across 5k bars at ~4 Hz and + melted the tab. We wrote this section and then shipped that anyway. +- Diagnostic `getImageData` / painted-pixel sampling is never on the + live overlay path. `?diag=1` is not a license to sync the GPU every + frame. - `window` pointermove/up attach only while a tool is armed or a drag is live. Do not leave them on for the life of the page. - Prefer one rAF over N DOM rebuilds. Read `offsetWidth` only if the diff --git a/docs/implementation.md b/docs/implementation.md index f33c8f3..de0fef4 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1182,6 +1182,23 @@ 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-26 — we wrote Stay cheap, then melted the tab anyway + +`AGENTS.md` already said: nothing extra on the hot path unless the +screen has to change. The next change put `scheduleOverlays(true)` in +`updateBar`, so every Schwab tick (~4 Hz) rebuilt handles, bridges, +comments and labels — walking every trendline across 5k bars. With +`?diag=1` that same path called `getImageData` on the chart canvases +and synced the GPU every time. + +The tab pegged a core. It looked like “the frontend is just heavy.” +It was a forming-bar tick treated as an overlay rebuild. + +A forming tick updates the last candle only. Force overlays when bars +are replaced, a new bar opens, or size changed. Painted-pixel sampling +is a capture-time measurement, not a live diagnostic. The rule is now +named in `AGENTS.md` with this failure, not a vague “when data changed.” + ### 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/app.js b/static/app.js index ecc51c2..1ea4dc9 100644 --- a/static/app.js +++ b/static/app.js @@ -695,7 +695,7 @@ createApp({ } function handleChartClick(param) { - const id = chartApi.hitTest(param); + const id = chartApi.hitTest(param, { painted: param.painted }); selectedDrawing.value = id; selectedDrawings.value = id ? [id] : []; } diff --git a/static/chart.js b/static/chart.js index a578c4b..ff7d9fa 100644 --- a/static/chart.js +++ b/static/chart.js @@ -1866,8 +1866,16 @@ class ConfluenceChart { } finishToolGesture(event) { + const tap = this.mousePan && this.mousePan.minValue == null; this.mousePan = null; if (!this.armedTool) this.detachPointers('tool'); + if (tap && this.onChartClick) { + const point = this.eventPoint(event); + if (point && this.withinPlot(point) && point.t != null) { + this.suppressClickUntil = Date.now() + 400; + this.onChartClick({ point, time: point.t, painted: true }); + } + } if (!this.armedTool || !this.gesture) return; const tool = this.armedTool; const { start, end } = this.gesture; @@ -2266,7 +2274,31 @@ class ConfluenceChart { this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`; } - hitTest(param) { + paintedLineY(level, time, x) { + const data = this.levelSeries.get(level.id)?.series.data(); + if (!data?.length) return null; + const stamp = time == null ? this.chart.timeScale().coordinateToTime(x) : time; + if (stamp == null) return null; + const t = Number(stamp); + let lo = 0; + let hi = data.length - 1; + if (t <= Number(data[0].time)) return this.candles.priceToCoordinate(data[0].value); + if (t >= Number(data[hi].time)) return this.candles.priceToCoordinate(data[hi].value); + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (Number(data[mid].time) <= t) lo = mid; + else hi = mid; + } + const left = data[lo]; + const right = data[hi]; + const span = Number(right.time) - Number(left.time); + const price = span + ? left.value + (right.value - left.value) * (t - Number(left.time)) / span + : left.value; + return this.candles.priceToCoordinate(price); + } + + hitTest(param, options = {}) { let best = null; const visible = this.visibleBarIndexes(); if (!visible) return null; @@ -2278,16 +2310,9 @@ class ConfluenceChart { } for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value) && !value.hidden)) { - const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from); - const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); - const endIndex = Math.min(naturalEnd, visible.to); - const pointIndex = this.indexAtCoordinate(param.point.x); - if (pointIndex == null || pointIndex < startIndex || pointIndex > endIndex) continue; - const linePrice = this.linePriceAtIndex(level, pointIndex); - const lineY = linePrice == null ? null : this.candles.priceToCoordinate(linePrice); - if (lineY == null) continue; - const distance = Math.abs(lineY - param.point.y); - if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; + const lineY = this.paintedLineY(level, param.time, param.point.x); + const distance = lineY == null ? Infinity : Math.abs(lineY - param.point.y); + if (distance <= 8 && (!best || distance < best.distance)) best = { id: level.id, distance }; } for (const fib of this.fibs.filter(item => !item.hidden)) { const originX = this.coordinateAtTime(fib.anchor_t); @@ -2469,9 +2494,18 @@ class ConfluenceChart { let points; if (this.trendlineGeometryMode === 'source_tf') { const cutoff = level.cutoff_t ?? Infinity; - points = this.bars - .filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff) - .map(bar => ({ time: bar.t, value: this.sourceLinePriceAt(level, bar.t) })) + const stamps = new Set(); + const source = this.sourceSeries(level); + for (const time of ConfluenceChart.sourceTimes(source) || []) { + if (time >= level.anchor_t && time <= cutoff) stamps.add(time); + } + stamps.add(level.anchor_t); + stamps.add(level.last_t); + const lastBar = this.bars[this.bars.length - 1]?.t; + if (lastBar != null && lastBar >= level.anchor_t && lastBar <= cutoff) stamps.add(lastBar); + points = [...stamps] + .sort((a, b) => a - b) + .map(time => ({ time, value: this.sourceLinePriceAt(level, time) })) .filter(point => point.value != null); } else { const start = this.indexAt(level.anchor_t);