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:
parent
c2ad2e8d7d
commit
becbc0e0b4
4 changed files with 74 additions and 17 deletions
10
AGENTS.md
10
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.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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] : [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue