Sample trendlines on their own timeframe and hit-test the painted line.

1m was putting a point on every bar for 30m/1d lines, which kinked at
tape holes and made hover/select miss. Clicks that were eaten by the
vertical pan gesture now select on pointer-up.
This commit is contained in:
Chris Amow 2026-08-26 01:55:47 -05:00
parent c2ad2e8d7d
commit becbc0e0b4
4 changed files with 74 additions and 17 deletions

View file

@ -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. client needs. Caps are named settings, not leftover literals.
- Crosshair move is for the cursor (OHLC, drawing tooltip). Do not - Crosshair move is for the cursor (OHLC, drawing tooltip). Do not
rebuild overlays that only depend on the viewport. rebuild overlays that only depend on the viewport.
- Overlay redraws go through `scheduleOverlays`. Force when data or - Overlay redraws go through `scheduleOverlays`. Force when bars are
size changed; let the range-key skip no-ops. 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 - `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. 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 - Prefer one rAF over N DOM rebuilds. Read `offsetWidth` only if the

View file

@ -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 one rAF and skip if the logical/price range did not change. Pointermoves
attach only for an armed tool or an in-progress drag. 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 ### 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

@ -695,7 +695,7 @@ createApp({
} }
function handleChartClick(param) { function handleChartClick(param) {
const id = chartApi.hitTest(param); const id = chartApi.hitTest(param, { painted: param.painted });
selectedDrawing.value = id; selectedDrawing.value = id;
selectedDrawings.value = id ? [id] : []; selectedDrawings.value = id ? [id] : [];
} }

View file

@ -1866,8 +1866,16 @@ class ConfluenceChart {
} }
finishToolGesture(event) { finishToolGesture(event) {
const tap = this.mousePan && this.mousePan.minValue == null;
this.mousePan = null; this.mousePan = null;
if (!this.armedTool) this.detachPointers('tool'); 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; if (!this.armedTool || !this.gesture) return;
const tool = this.armedTool; const tool = this.armedTool;
const { start, end } = this.gesture; const { start, end } = this.gesture;
@ -2266,7 +2274,31 @@ class ConfluenceChart {
this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`; 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; let best = null;
const visible = this.visibleBarIndexes(); const visible = this.visibleBarIndexes();
if (!visible) return null; if (!visible) return null;
@ -2278,16 +2310,9 @@ class ConfluenceChart {
} }
for (const level of this.levels.filter(value => for (const level of this.levels.filter(value =>
value.kind === 'manual' && !ConfluenceChart.isFlat(value) && !value.hidden)) { value.kind === 'manual' && !ConfluenceChart.isFlat(value) && !value.hidden)) {
const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from); const lineY = this.paintedLineY(level, param.time, param.point.x);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); const distance = lineY == null ? Infinity : Math.abs(lineY - param.point.y);
const endIndex = Math.min(naturalEnd, visible.to); if (distance <= 8 && (!best || distance < best.distance)) best = { id: level.id, distance };
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 };
} }
for (const fib of this.fibs.filter(item => !item.hidden)) { for (const fib of this.fibs.filter(item => !item.hidden)) {
const originX = this.coordinateAtTime(fib.anchor_t); const originX = this.coordinateAtTime(fib.anchor_t);
@ -2469,9 +2494,18 @@ class ConfluenceChart {
let points; let points;
if (this.trendlineGeometryMode === 'source_tf') { if (this.trendlineGeometryMode === 'source_tf') {
const cutoff = level.cutoff_t ?? Infinity; const cutoff = level.cutoff_t ?? Infinity;
points = this.bars const stamps = new Set();
.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff) const source = this.sourceSeries(level);
.map(bar => ({ time: bar.t, value: this.sourceLinePriceAt(level, bar.t) })) 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); .filter(point => point.value != null);
} else { } else {
const start = this.indexAt(level.anchor_t); const start = this.indexAt(level.anchor_t);