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.
- 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

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
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.

View file

@ -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] : [];
}

View file

@ -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);