diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md index 0433660..316cd2e 100644 --- a/docs/NEXT_STEPS.md +++ b/docs/NEXT_STEPS.md @@ -19,12 +19,13 @@ passes after gap slots are inserted. The transparent-point production fix did not visibly settle the report, so do not add another geometry patch from the current theory. -Next investigation should compare the painted canvas pixels around the join -against the chart API's expected coordinates in the user's production viewport. -This is the same class of problem where self-consistent API coordinates once -missed a real overlay offset. Capture the asset commit, final three bar times, -their logical/x coordinates, the first two future coordinates, canonical line -prices, and page-pixel samples from the rendered line before changing code. +Painted-pixel diagnostics are now implemented: diagnostic mode samples the +internal canvases around each join by the line's RGB colour and reports painted +historical/future slopes beside canonical and API-coordinate slopes. A browser +regression confirms actual canvas pixels stay continuous in the deterministic +sparse-tail case. The remaining step is a production capture with those painted +values; do not change geometry again until it says whether production pixels or +production coordinates diverge. ### Load bars from the visible window diff --git a/docs/implementation.md b/docs/implementation.md index 2f19cda..3e235a0 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1075,6 +1075,15 @@ shared scale. The time-scale owner now uses transparent zero-valued points with `autoscaleInfoProvider` that returns null. They remain invisible and cannot affect price range, but Lightweight Charts retains their timestamps as real series data. +The next diagnostic step measures painted output rather than trusting the chart +API's coordinate system. In diagnostic mode the readout now samples pixels from +the internal canvases on both sides of the live-edge join, matching each manual +line's configured RGB colour near its expected path. It reports those painted +slopes alongside canonical price changes and API-coordinate slopes. This is +deliberate: a prior overlay bug produced internally consistent coordinates while +the pixels were visibly displaced. A deterministic browser test requires actual +historical and future line pixels to exist and keep the same slope. + ### 2026-08-15 — 1m zoom-out died at ~1am because the socket sent 1,000 bars Compressing the 1m time scale stopped around 01:00, then looked empty. diff --git a/static/chart.js b/static/chart.js index 755d492..75521f4 100644 --- a/static/chart.js +++ b/static/chart.js @@ -10,6 +10,7 @@ class ConfluenceChart { this.currentPriceLine = null; this.currentPricePulse = null; this.projectionDiagnostic = null; + this.projectionDiagnosticFrame = null; this.animateCurrentPrice = true; this.autoScrollLivePrice = true; this.lastCurrentPrice = null; @@ -2146,10 +2147,70 @@ class ConfluenceChart { .find(point => Number(point.time) === future.time)?.value; return { level, previous, current, next, series, historical: slope(previous, current), future: slope(current, next) }; }).filter(row => row.current.y != null && row.current.y >= 0 && row.current.y <= plotHeight); - this.projectionDiagnostic.textContent = lines.map(row => - `#${row.level.number} ${row.level.tf} dp ${((row.current.p ?? 0) - (row.previous.p ?? 0)).toFixed(3)}/${((row.next.p ?? 0) - (row.current.p ?? 0)).toFixed(3)} px ${(row.historical ?? 0).toFixed(3)}/${(row.future ?? 0).toFixed(3)} ${row.series == null ? 'MISSING' : 'OK'}`, - ).join('\n'); + const text = (row, painted = null) => + `#${row.level.number} ${row.level.tf} dp ${((row.current.p ?? 0) - (row.previous.p ?? 0)).toFixed(3)}/${((row.next.p ?? 0) - (row.current.p ?? 0)).toFixed(3)} px ${(row.historical ?? 0).toFixed(3)}/${(row.future ?? 0).toFixed(3)} paint ${painted ? `${painted.historical?.toFixed(3) ?? '?'}/${painted.future?.toFixed(3) ?? '?'}` : '?/?'} ${row.series == null ? 'MISSING' : 'OK'}`; + this.projectionDiagnostic.textContent = lines.map(row => text(row)).join('\n'); this.projectionDiagnostic.hidden = !lines.length; + if (this.projectionDiagnosticFrame != null) cancelAnimationFrame(this.projectionDiagnosticFrame); + this.projectionDiagnosticFrame = requestAnimationFrame(() => { + this.projectionDiagnosticFrame = null; + this.projectionDiagnostic.textContent = lines.map(row => text(row, { + historical: this.paintedSlopeBetween(row.previous, row.current, ConfluenceChart.levelColor(row.level)), + future: this.paintedSlopeBetween(row.current, row.next, ConfluenceChart.levelColor(row.level)), + })).join('\n'); + }); + } + + paintedSlopeBetween(first, second, color) { + if ([first.x, first.y, second.x, second.y].some(value => value == null)) return null; + const at = fraction => ({ + x: first.x + (second.x - first.x) * fraction, + y: first.y + (second.y - first.y) * fraction, + }); + const a = at(0.25); + const b = at(0.75); + const y1 = this.paintedYAt(a.x, a.y, color); + const y2 = this.paintedYAt(b.x, b.y, color); + return y1 == null || y2 == null ? null : (y2 - y1) / (b.x - a.x); + } + + paintedYAt(x, expectedY, color) { + const match = /^#([0-9a-f]{6})$/i.exec(color || ''); + if (!match) return null; + const value = Number.parseInt(match[1], 16); + const target = [(value >> 16) & 255, (value >> 8) & 255, value & 255]; + const plot = this.plotCanvas()?.getBoundingClientRect(); + if (!plot) return null; + const pageX = plot.left + x; + const candidates = []; + for (const canvas of this.chartEl.querySelectorAll('canvas')) { + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height || pageX < rect.left || pageX > rect.right) continue; + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const pixelX = Math.round((pageX - rect.left) * scaleX); + const top = Math.max(0, Math.floor((plot.top + expectedY - 8 - rect.top) * scaleY)); + const bottom = Math.min(canvas.height - 1, Math.ceil((plot.top + expectedY + 8 - rect.top) * scaleY)); + if (bottom < top) continue; + try { + const image = canvas.getContext('2d').getImageData( + Math.max(0, pixelX - 1), top, Math.min(3, canvas.width - Math.max(0, pixelX - 1)), bottom - top + 1, + ); + for (let offset = 0; offset < image.data.length; offset += 4) { + if (image.data[offset + 3] < 80) continue; + const distance = Math.hypot( + image.data[offset] - target[0], image.data[offset + 1] - target[1], + image.data[offset + 2] - target[2], + ); + if (distance > 55) continue; + const row = Math.floor((offset / 4) / image.width); + candidates.push(rect.top - plot.top + (top + row) / scaleY); + } + } catch { /* An unreadable internal canvas contributes no measurement. */ } + } + if (!candidates.length) return null; + candidates.sort((a, b) => Math.abs(a - expectedY) - Math.abs(b - expectedY)); + return candidates[0]; } lineData(level) { diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index fdc75a6..2b67ed9 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -216,6 +216,57 @@ test('a short final data gap does not kink a future trendline', }); }); +test('painted canvas pixels keep the same slope through the live edge', + { timeout: 180000 }, async () => { + await withChart(async page => { + await page.evaluate(() => { + const c = window.__chart; + const minute = 60; + const bars = Array.from({ length: 20 }, (_, index) => ({ + tf: '1m', t: 200000 + 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: 'painted-tail', kind: 'manual', tf: '30m', side: 'support', label: 'paint', + anchor_t: bars[0].t, anchor_p: 99, last_t: bars[0].t + 30 * minute, + slope: 2 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf', + line_width: 3, provisional: false, + }; + c.setBars(bars); + c.setTrendlineGeometry({ + mode: 'source_tf', + series: { '30m': { times: Array.from({ length: 4 }, (_, i) => bars[0].t + i * 30 * minute), duration: 30 * minute } }, + }); + c.syncLevels([line]); + }); + await page.waitForTimeout(200); + const result = await page.evaluate(() => { + const c = window.__chart; + const line = c.levels[0]; + const last = c.bars.length - 1; + const futureTime = c.bars[last].t + 60; + const point = time => ({ + x: c.chart.timeScale().timeToCoordinate(time), + y: c.candles.priceToCoordinate(c.linePriceAtIndex(line, c.indexAt(time))), + }); + const previous = point(c.bars[last - 1].t); + const current = point(c.bars[last].t); + const future = point(futureTime); + return { + historical: c.paintedSlopeBetween(previous, current, line.color), + future: c.paintedSlopeBetween(current, future, line.color), + }; + }); + + assert.notEqual(result.historical, null, 'historical canvas line pixels were not found'); + assert.notEqual(result.future, null, 'future canvas line pixels were not found'); + assert.ok(Math.abs(result.historical - result.future) < 0.08, + `painted line kinked from ${result.historical} to ${result.future}`); + assertNoPageErrors(page, assert); + }); + }); + test('a manual line series owns its future 5m slots without an SVG projection', { timeout: 180000 }, async () => { await withChart(async page => {