diff --git a/static/chart.js b/static/chart.js index bff4a9b..81a1ef7 100644 --- a/static/chart.js +++ b/static/chart.js @@ -32,6 +32,7 @@ class ConfluenceChart { this.toolDownListener = null; this.toolMoveListener = null; this.toolUpListener = null; + this.pendingView = null; } static TICK = 0.25; @@ -154,10 +155,11 @@ class ConfluenceChart { const last = bars[bars.length - 1]; // 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; - this.chart.timeScale().setVisibleRange({ + this.pendingView = { from: bars[Math.max(0, bars.length - 160)].t, to: last.t + step * 5, - }); + }; + this.chart.timeScale().setVisibleRange(this.pendingView); } 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) { this.levels = levels; this.syncPriceLines(levels); @@ -238,7 +276,7 @@ class ConfluenceChart { } let data; 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 latestValue = data[data.length - 1]?.value; if (latestTime != null && latestValue != null && latestTime > data[data.length - 1].time) { @@ -249,6 +287,16 @@ class ConfluenceChart { } 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(); }