diff --git a/docs/implementation.md b/docs/implementation.md index fcede67..f0fbb6a 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1398,3 +1398,6 @@ HTF line, and the socket still attached 180 `future_times` (twice) plus a A forming tick of an already-seen minute is now `{type, tf, bar}` only. HTF geometry and future calendars go out when that timeframe's timestamp advances. The header quote paints the DOM directly; Vue's `price` flushes at 1 Hz. + +Schwab still prints many forming ticks per frame. Those now coalesce to one +`candles.update` on the next animation frame. A new minute flushes immediately. diff --git a/static/chart.js b/static/chart.js index 672aa4e..b5e204d 100644 --- a/static/chart.js +++ b/static/chart.js @@ -211,6 +211,8 @@ class ConfluenceChart { this.animateCurrentPrice = true; this.autoScrollLivePrice = true; this.lastCurrentPrice = null; + this.pendingBar = null; + this.pendingBarFrame = null; this.previewLine = null; this.lineBridgeLayer = null; this.bars = []; @@ -805,6 +807,30 @@ class ConfluenceChart { } updateBar(bar) { + const last = this.bars[this.bars.length - 1]; + if (last && bar.t < last.t) return; + if (!last || bar.t > last.t) { + this.flushPendingBar(); + this.applyBar(bar); + return; + } + this.pendingBar = bar; + if (this.pendingBarFrame == null) { + this.pendingBarFrame = requestAnimationFrame(() => this.flushPendingBar()); + } + } + + flushPendingBar() { + if (this.pendingBarFrame != null) { + cancelAnimationFrame(this.pendingBarFrame); + this.pendingBarFrame = null; + } + const bar = this.pendingBar; + this.pendingBar = null; + if (bar) this.applyBar(bar); + } + + applyBar(bar) { // update() throws on anything older than the series' last point, which // takes the whole app down rather than dropping one stale bar. Ticks made // this reachable often enough to matter, so refuse it here as well as at @@ -893,6 +919,10 @@ class ConfluenceChart { updateCurrentPrice(price) { if (price == null) return; + if (this.currentPriceLine && price === this.lastCurrentPrice) { + this.syncCurrentPricePulse(); + return; + } const options = { price, color: '#b7771d', diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 61e0523..18d8427 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -498,6 +498,30 @@ test('repeating the same future times does not rebuild the time scale', }); }); +test('forming ticks coalesce to one candle update per frame', + { timeout: 180000 }, async () => { + await withChart(async page => { + const result = await page.evaluate(async () => { + const c = window.__chart; + const last = c.bars.at(-1); + let updates = 0; + const original = c.candles.update.bind(c.candles); + c.candles.update = data => { + updates += 1; + return original(data); + }; + for (let index = 0; index < 12; index += 1) { + c.updateBar({ ...last, c: last.c + index * 0.25, closed: false }); + } + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { updates }; + }); + assert.ok(result.updates <= 2, + `twelve forming ticks painted ${result.updates} candle updates`); + assertNoPageErrors(page, assert); + }); + }); + test('painted canvas pixels keep the same slope through the live edge', { timeout: 180000 }, async () => { await withChart(async page => {