From 73570d29049067e8f2e0b6d65b6929b3b365c621 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Sat, 15 Aug 2026 03:40:34 -0500 Subject: [PATCH] line fix --- docs/implementation.md | 13 ++++++++++ static/chart.js | 24 +++++++++++++++--- tests/e2e/trendline.test.mjs | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/implementation.md b/docs/implementation.md index 39c7b48..48bd379 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1056,3 +1056,16 @@ or cutoff to the nearest canvas sample, never for the live-edge extension. Browser coverage requires the canvas series to own an already-existing future slot at the canonical source-space price, to extend its future tail on a new 5m bar, and to have no SVG future-projection layer. + +The production-only bend that remained was not renderer disagreement. Diagnostic +mode showed #12 changing 0.139 points over the last historical display slot and +0.015 over the first future slot, with screen slopes differing by the same factor. +The final real 1m candles were nine minutes apart but Lightweight Charts placed +them in adjacent logical slots; the future axis then resumed one-minute slots. +The canonical source-space prices were right and the canvas point existed. + +`futureSpace` now also owns whitespace timestamps inside short intraday data gaps, +so nine elapsed minutes occupy nine logical slots and the line remains straight. +Gaps over 30 minutes stay compressed, preserving the established treatment of the +65-minute settlement break and weekends. A browser regression pins both the +nine-slot spacing and equal historical/future screen slope. diff --git a/static/chart.js b/static/chart.js index 74725af..3f91356 100644 --- a/static/chart.js +++ b/static/chart.js @@ -96,6 +96,10 @@ class ConfluenceChart { // convergence a few sessions out without dominating a zoomed-out view. static FUTURE_SLOTS = 180; + // Preserve short intraday gaps as empty slots. Longer closures remain + // compressed, including the 65-minute CME settlement break and weekends. + static MAX_INTRADAY_GAP_SECONDS = 30 * 60; + /** * The timeframe's spacing, measured rather than assumed. * @@ -422,7 +426,7 @@ class ConfluenceChart { this.candles.setData(candleData); this.updateCurrentPrice(bars[bars.length - 1]?.c); this.volume.setData(bars.map(ConfluenceChart.toVolume)); - this.futureSpace.setData(this.futureTimes(bars)); + this.futureSpace.setData(this.timeScaleSlots(bars)); // Anchored by time, not by logical index. A logical index addresses the // chart's *shared* scale — the union of every series' time points — not // this array. The daily MAs land straight after with hundreds of points @@ -532,6 +536,20 @@ class ConfluenceChart { return slots; } + timeScaleSlots(bars) { + const step = ConfluenceChart.barInterval(bars); + if (!(step > 0)) return []; + const slots = []; + for (let index = 1; index < bars.length; index += 1) { + const gap = bars[index].t - bars[index - 1].t; + if (gap <= step || gap > ConfluenceChart.MAX_INTRADAY_GAP_SECONDS) continue; + for (let time = bars[index - 1].t + step; time < bars[index].t; time += step) { + slots.push({ time }); + } + } + return [...slots, ...this.futureTimes(bars)]; + } + updateBar(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 @@ -558,8 +576,8 @@ class ConfluenceChart { if (!(step > 0)) return; // A session gap invalidates the old future horizon. Rebuild only the small // manual-line series; ordinary bars extend their already-owned future tail. - if (previous && bar.t - previous.t > step * 2) { - this.futureSpace.setData(this.futureTimes(this.bars)); + if (previous && bar.t - previous.t > step) { + this.futureSpace.setData(this.timeScaleSlots(this.bars)); for (const level of this.levels) { if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)) continue; this.levelSeries.get(level.id)?.series.setData(this.seriesLineData(level)); diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 688ffed..1fe7d69 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -164,6 +164,54 @@ test('future 5m projections keep their slope after the settlement break', }); }); +test('a short final data gap does not kink a future trendline', + { timeout: 180000 }, async () => { + await withChart(async page => { + const result = await page.evaluate(() => { + const c = window.__chart; + const minute = 60; + const bars = Array.from({ length: 20 }, (_, index) => ({ + tf: '1m', t: 100000 + index * minute, o: 100, h: 101, l: 99, c: 100, + v: 1, closed: true, + })); + bars.push({ ...bars.at(-1), t: bars.at(-1).t + 9 * minute }); + const line = { + id: 'sparse-tail', kind: 'manual', tf: '30m', side: 'support', label: 'probe', + anchor_t: bars[0].t, anchor_p: 100, last_t: bars[0].t + 30 * minute, + slope: 3 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf', + line_width: 2, provisional: false, + }; + const sourceTimes = Array.from({ length: 4 }, (_, index) => bars[0].t + index * 30 * minute); + c.setBars(bars); + c.setTrendlineGeometry({ + mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: 30 * minute } }, + }); + c.syncLevels([line]); + const previousTime = bars.at(-2).t; + const currentTime = bars.at(-1).t; + const futureTime = currentTime + minute; + const point = time => ({ + x: c.chart.timeScale().timeToCoordinate(time), + y: c.candles.priceToCoordinate(c.linePriceAtIndex(line, c.indexAt(time))), + }); + const previous = point(previousTime); + const current = point(currentTime); + const future = point(futureTime); + return { + historicalSlope: (current.y - previous.y) / (current.x - previous.x), + futureSlope: (future.y - current.y) / (future.x - current.x), + historicalSlots: (current.x - previous.x) / (future.x - current.x), + }; + }); + + assert.ok(Math.abs(result.historicalSlots - 9) < 0.01, + `a nine-minute data gap occupied ${result.historicalSlots} slots`); + assert.ok(Math.abs(result.historicalSlope - result.futureSlope) < 1e-9, + `line kinked from ${result.historicalSlope} to ${result.futureSlope}`); + assertNoPageErrors(page, assert); + }); + }); + test('a manual line series owns its future 5m slots without an SVG projection', { timeout: 180000 }, async () => { await withChart(async page => {