Keep level overlays on the candle grid so bars keep their width

The hourly chart drew 160 candles as unreadable slivers. Level points are
sampled on their own timeframe — VWAP every minute, the daily averages once a
session — and every distinct timestamp claims a slot on the chart's shared
scale. A minute-resolution VWAP on an hourly chart therefore spread 160 candles
across 908 slots, five to six times wider than the candles they belonged to:

  before   1h  inView 160  slots 908  ratio 5.46
  after    1h  inView 160  slots 161  ratio 1.01

Snapping ma and vwap points onto the candle grid fixes the density without
changing the line: points collapse onto the candle at or after them, and the
newest wins. Trendlines keep the raw path — lineData interpolates between two
anchors, and snapping those would move the geometry the user drew.

Two details worth keeping. Points past the final candle are clamped onto it
rather than passed through: on a daily chart every one of VWAP's ~760 minute
points falls after the last candle's session open, and letting them keep their
own times put all 760 straight back on the scale. And the viewport is re-applied
once after the level series have loaded, because setBars runs before them and
the chart holds the width it derived from the previous timeframe's density —
consumed rather than reapplied, so the minutely VWAP resync cannot yank the view
back from wherever it has been panned.

The method is snapPointsToBars, not snapToBars: this.snapToBars already exists
as the "Snap to highs/lows" boolean, and the assignment silently replaced the
prototype method with true.

Verified in a browser across all six timeframes — 1m, 5m, 15m, 30m, 1h and 1d
each show 160 candles at ratio 0.99–1.01 with no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 06:07:44 -05:00
parent 9d63e6482e
commit a15ea00c03

View file

@ -32,6 +32,7 @@ class ConfluenceChart {
this.toolDownListener = null; this.toolDownListener = null;
this.toolMoveListener = null; this.toolMoveListener = null;
this.toolUpListener = null; this.toolUpListener = null;
this.pendingView = null;
} }
static TICK = 0.25; static TICK = 0.25;
@ -154,10 +155,11 @@ class ConfluenceChart {
const last = bars[bars.length - 1]; const last = bars[bars.length - 1];
// Keep the old five bars of right-hand breathing room, in seconds. // Keep the old five bars of right-hand breathing room, in seconds.
const step = bars.length > 1 ? last.t - bars[bars.length - 2].t : 60; const step = bars.length > 1 ? last.t - bars[bars.length - 2].t : 60;
this.chart.timeScale().setVisibleRange({ this.pendingView = {
from: bars[Math.max(0, bars.length - 160)].t, from: bars[Math.max(0, bars.length - 160)].t,
to: last.t + step * 5, to: last.t + step * 5,
}); };
this.chart.timeScale().setVisibleRange(this.pendingView);
} }
requestAnimationFrame(() => this.renderAnchorHandles()); requestAnimationFrame(() => this.renderAnchorHandles());
} }
@ -201,6 +203,42 @@ class ConfluenceChart {
} }
} }
// Level points are sampled on their own timeframe — VWAP every minute, the
// daily averages once a session — and every distinct timestamp claims its own
// slot on the chart's shared scale. Left raw, a minute-resolution VWAP spread
// 160 hourly candles across 908 slots and drew them as unreadable slivers.
// Snapping onto the candle grid preserves the line's shape while keeping the
// scale one slot per candle, which is what makes the bars their proper width.
snapPointsToBars(points) {
if (!this.bars.length || !points.length) return points;
const times = this.bars.map(bar => bar.t);
const last = times[times.length - 1];
const byTime = new Map();
for (const point of points) {
// Clamped, not passed through: on a daily chart every one of VWAP's ~760
// minute points falls after the last candle's session open, and letting
// them keep their own times put all 760 back on the scale. Projection to
// the right of the last bar is handled by the caller instead.
if (point.time >= last) {
byTime.set(last, point.value);
continue;
}
let lo = 0;
let hi = times.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (times[mid] < point.time) lo = mid + 1;
else hi = mid;
}
// Points older than the window collapse onto the first candle; the newest
// of them wins, which is the value in force when the window opens.
byTime.set(times[lo], point.value);
}
return [...byTime.entries()]
.sort((a, b) => a[0] - b[0])
.map(([time, value]) => ({ time, value }));
}
syncLevels(levels) { syncLevels(levels) {
this.levels = levels; this.levels = levels;
this.syncPriceLines(levels); this.syncPriceLines(levels);
@ -238,7 +276,7 @@ class ConfluenceChart {
} }
let data; let data;
if (hasPoints) { if (hasPoints) {
data = (level.points || []).map(([time, value]) => ({ time, value })); data = this.snapPointsToBars((level.points || []).map(([time, value]) => ({ time, value })));
const latestTime = this.bars[this.bars.length - 1]?.t; const latestTime = this.bars[this.bars.length - 1]?.t;
const latestValue = data[data.length - 1]?.value; const latestValue = data[data.length - 1]?.value;
if (latestTime != null && latestValue != null && latestTime > data[data.length - 1].time) { if (latestTime != null && latestValue != null && latestTime > data[data.length - 1].time) {
@ -249,6 +287,16 @@ class ConfluenceChart {
} }
entry.series.setData(data); entry.series.setData(data);
} }
// Re-anchor once, after the level series have reshaped the scale. setBars
// runs before them, so the width it asked for was derived from the previous
// timeframe's point density and the chart holds that width as new series
// arrive. Consumed rather than reapplied every time: VWAP resyncs a level
// every minute, and re-anchoring on each would yank the view back from
// wherever the user had panned it.
if (this.pendingView) {
this.chart.timeScale().setVisibleRange(this.pendingView);
this.pendingView = null;
}
this.renderAnchorHandles(); this.renderAnchorHandles();
} }