From 796e6cdeb8774c40309609675d877fd11626ec70 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Sat, 15 Aug 2026 00:58:19 -0500 Subject: [PATCH] superior extended trendlines --- AGENTS.md | 3 +- docs/NEXT_STEPS.md | 6 + docs/implementation.md | 34 +++--- docs/plan.md | 20 +-- docs/plan_diagnostics_improvements.md | 61 ++++++++++ docs/plan_light_dark_themes.md | 126 +++++++++++++++++++ static/chart.js | 169 ++++++++++++-------------- static/style.css | 2 +- tests/e2e/trendline.test.mjs | 54 ++++---- 9 files changed, 337 insertions(+), 138 deletions(-) create mode 100644 docs/plan_diagnostics_improvements.md create mode 100644 docs/plan_light_dark_themes.md diff --git a/AGENTS.md b/AGENTS.md index bec7413..19afbc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,8 @@ turned out to be another. Then [`docs/NEXT_STEPS.md`](docs/NEXT_STEPS.md) for current recommendations and known deferred fixes. Mobile interaction work also has its own detailed plan in [`docs/mobile_enhance.md`](docs/mobile_enhance.md). The CDN-to-Vite move is -[`docs/vite_build.md`](docs/vite_build.md). +[`docs/vite_build.md`](docs/vite_build.md). Light/dark theme constraints are +[`docs/plan_light_dark_themes.md`](docs/plan_light_dark_themes.md). ## Tests earn their place by catching a real bug diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md index 70fd504..f852758 100644 --- a/docs/NEXT_STEPS.md +++ b/docs/NEXT_STEPS.md @@ -16,6 +16,12 @@ and transport failure by carrying exact trade time, the last settled minute, source receipt time and an application heartbeat over the existing WebSocket. Any stale threshold must account for Yahoo's declared delay. +## Light / dark themes + +Do not invert the cream palette. Chrome tokens flip cheaply; stored drawing +hexes and the dark palette stops do not. The constraints are in +`docs/plan_light_dark_themes.md`. + ## Frontend build The CDN-to-Vite plan is in `docs/vite_build.md`. First tranche is a production diff --git a/docs/implementation.md b/docs/implementation.md index 2f1a9cd..39c7b48 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -975,9 +975,10 @@ bar carries the server ring buffer's first timestamp so long-lived tabs trim evicted history too. Each delta also names its predecessor; a gap caused by queue pressure or a sleeping tab makes the browser request a full replacement instead of silently losing one logical bar. The browser evaluates the same -source-space line and samples it only onto displayed candle timestamps, so -manual lines cannot add points to Lightweight Charts' shared scale. Historical -series, future projection, hit testing and the selected-line hit polyline all +source-space line and samples it onto displayed candle timestamps. It also +samples onto future-whitespace timestamps already owned by `futureSpace`, so +manual lines do not add points to Lightweight Charts' shared scale. Historical +rendering, future extension, hit testing and the selected-line hit polyline all call that price function. Whole-line dragging, horizontal keyboard nudging and duplication shift timestamps in source bars rather than whatever timeframe is currently displayed. Endpoint dragging can still choose a finer-timeframe @@ -1038,17 +1039,20 @@ interpolate between the surrounding displayed candles' own pixel coordinates, which puts `:30` halfway between the hourly candles and remains correct even when another series has inserted foreign timestamps into the shared scale. -### 2026-08-14 — a crease at the live edge was two renderers disagreeing +### 2026-08-14 — remove the live-edge renderer seam -The canvas `LineSeries` holds the historical portion of a manual trendline; an -SVG segment projects it through future whitespace. `updateBar()` updated the -candle but did not extend the canvas line when a new displayed bar opened. The -SVG correctly started at that new bar, producing a visible crease at the live -edge even after its slope was corrected. +Appending the missing canvas sample fixed stale historical data, but production +still showed a crease at the live edge. The underlying design was the defect: +the visible line switched from a Lightweight Charts canvas series to an SVG +projection exactly where the chart was most scrutinized. -New displayed bars now append one source-space sample to each visible sloped -manual series. This is deliberately not a full `syncLevels()` call: the source -geometry and all previous line points are unchanged, and a per-line -`series.update()` keeps the canvas and SVG endpoints identical without adding -per-tick redraw work. Browser coverage opens a new 5m candle and asserts that -the canvas line reaches the same time and price as the projection. +Manual lines now use one canvas `LineSeries` across history and future. Its +future points reuse the timestamps already carried by `futureSpace`, so the line +does not reshape the shared time scale. New bars advance that future tail by one +point; a session gap rebuilds only the small manual-line series. SVG remains for +hit targets, handles, and the rare short bridge from an off-grid source anchor +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. diff --git a/docs/plan.md b/docs/plan.md index a0f3f78..8ac4bd7 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -36,8 +36,9 @@ See [§14](#14-why-execution-is-out-of-scope) for why, and for the seam left beh > **For current work, start with `AGENTS.md`**, which every agent loads > automatically. It points at the live planning documents: > `docs/NEXT_STEPS.md` for the short list, `docs/async_refactor.md`, -> `docs/multi_user.md`, `docs/feature_undo.md`, `docs/mobile_enhance.md` and -> `docs/vite_build.md` for designs not yet built. +> `docs/multi_user.md`, `docs/feature_undo.md`, `docs/mobile_enhance.md`, +> `docs/vite_build.md` and `docs/plan_light_dark_themes.md` for designs not +> yet built. > > **`main` deploys to production.** A push triggers a Forgejo webhook and > Coolify rebuild of . That is the intended workflow now, @@ -602,12 +603,13 @@ evaluated in the logical bar space of its attributed timeframe, however: charts compress a weekend to one slot, so wall-clock `price_at(t)` would advance through 49 hours in which no bars exist. The stored `slope` recovers the second endpoint price; the source-timeframe bar sequence determines interpolation and projection. The -browser samples that canonical geometry only at displayed candle timestamps, and the -server uses the same source series for clusters and alerts. A source history that no -longer reaches an anchor leaves the drawing visible but unresolved and unable to -cluster or alert rather than silently extrapolating from the edge of a shorter -window. `TRENDLINE_SOURCE_GEOMETRY=false` restores the previous displayed/1m-grid -behavior without changing persisted data. +browser samples that canonical geometry at displayed candle timestamps and at +the existing future-whitespace timestamps. The server uses the same source series +for clusters and alerts. A source history that no longer reaches an anchor leaves +the drawing visible but unresolved and unable to cluster or alert rather than +silently extrapolating from the edge of a shorter window. +`TRENDLINE_SOURCE_GEOMETRY=false` restores the previous displayed/1m-grid behavior +without changing persisted data. **Timeframe attribution.** Tag the line with the timeframe that was *displayed when it was drawn*. A line drawn on the 4h chart is a 4h line and carries weight 8. This is the @@ -624,7 +626,7 @@ scored. `weight = TIMEFRAME_WEIGHT[tf]`. | Place endpoints | `chart.subscribeClick(handler)` → two clicks | | Pixel → price | `series.coordinateToPrice(param.point.y)` | | Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` | -| Render | Sample source-timeframe geometry onto displayed candles; extend right in an SVG overlay | +| Render | One `LineSeries`, sampled onto displayed candles and existing future-whitespace slots; SVG only bridges off-grid endpoints and provides interaction overlays | | Select | Click within ~6px of the canonical price at that displayed bar | | Delete | `Delete`/`Backspace` on selection, plus a button | | Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars | diff --git a/docs/plan_diagnostics_improvements.md b/docs/plan_diagnostics_improvements.md new file mode 100644 index 0000000..ca2c917 --- /dev/null +++ b/docs/plan_diagnostics_improvements.md @@ -0,0 +1,61 @@ +# Diagnostic Access Improvements + +## Goal + +Make it practical for an agent on a separate SSH machine to diagnose browser and +production failures without granting broad production control or asking the user +to paste console output. + +## Browser Captures + +Diagnostic mode (`?diag=1`) offers **Capture diagnostic**. Upload and metadata +remain authenticated. The PNG URL at `/api/debug/captures/{id}` is public by its +72-bit id, which is the explicit handoff capability a user shares with an agent. + +After inspecting a user-shared capture, the agent must immediately call: + +``` +DELETE /api/debug/captures/{id} +``` + +The 24-hour expiry and 50-capture cap remain a backstop. Do not inspect capture +URLs that the user has not explicitly supplied. + +## Production Diagnostics + +Do not grant an agent a general production shell or Docker-group membership. +Docker access is effectively root access, and arbitrary shell access can expose +environment variables, OAuth tokens, and mounted volumes. + +Instead create a dedicated `chart-debug` production account with a forced-command +SSH wrapper. It accepts only a small, read-oriented command set: + +``` +logs --since +status +container-state +recent-deploy +capture-read +capture-delete +``` + +The wrapper must reject arbitrary commands and paths. It should cap output, +redact known secret patterns, and log every request. Use a dedicated SSH key that +can be revoked without affecting deployment or normal administration. + +Expected agent usage: + +``` +ssh chart-debug@production logs --since 20m +``` + +## Observability + +Keep browser performance telemetry separate from production access. A future +frontend recorder should locally aggregate frame timing, long tasks, tick rate, +visible bars, and rendered line/level counts, then periodically upload compact, +authenticated summaries. Pair it with server timing for bar handling, level +rebuilds, WebSocket serialization, and the existing loop-lag measure. + +This separates rendering, feed, transport, and backend pressure without logging +prices, drawing text, cursor positions, screenshots, or per-tick event history. diff --git a/docs/plan_light_dark_themes.md b/docs/plan_light_dark_themes.md new file mode 100644 index 0000000..09f490f --- /dev/null +++ b/docs/plan_light_dark_themes.md @@ -0,0 +1,126 @@ +# Light and dark themes + +**Status: tracked, not started.** A direction to refactor toward, not a project +with a date. Recorded 2026-08-14 from a contrast pass over the colours that +are actually in the tree. + +The page is a warm paper theme. A dark theme that just inverts it will look +broken. Chrome is cheap. The chart colours were picked for cream, and many +of them are stored as hex on drawings, so a CSS flip does not restyle lines +already placed. + +## What is already factored + +`:root` in `static/style.css` owns the chrome: + +```css +:root { + color-scheme: light; + --bg: #e8dfcf; + --panel: #f7f1e6; + --chart-bg: #fbf7ef; + --fg: #2c2924; + --muted: #746c60; + --line: #d2c5b2; + --accent: #b7771d; + --green: #27825c; + --red: #bd4545; +} +``` + +Lightweight Charts already reads `--chart-bg` and `--muted` at `create()`. +Sidebar, comments, tooltips and most chrome follow the tokens. Flipping +those variables restyles the frame. It does not restyle the series. + +## What is hardcoded for cream paper + +**Candles** — `#20b8a6` / `#ef5b3f` with borders and wicks `#087f76` / +`#b9342d`. Mid-luminance, already the usual trading pair. Fine on dark. + +**Timeframe and kind colours** (`ConfluenceChart.tfColors`, +`kindColors`, and the layer swatches): + +| Role | Hex | On dark | +|---|---|---| +| 1m | `#82909f` | usable | +| 5m / manual | `#65b7cf` | fine | +| 1h | `#efb643` | fine | +| 1d MA | `#d96073` | fine | +| VWAP | `#b07ad6` | fine | +| prior-day H/L/C | `#9fb0c4` | fine | + +**Grid** — `rgba(128,128,128,.10)`. Invisible on a dark plot. Needs a +higher alpha, or a token. + +**Accent** — `#b7771d` is a mid amber. On cream it reads as gold. On dark +it goes muddy/brown. Dark chrome wants a brighter accent, not the same hex. + +**Price tag** — `#e0a34a` on `#1a1206`. Fine on both. + +**Snap support/resistance** — `#27825c` / `#bd4545`. Fine. + +**Current-price pulse** — `color-mix(..., var(--accent), white)`. On dark, +mix toward the chart background or a lighter accent, not white. + +**Shadows** — `rgba(0,0,0,.16)`. Vanish on dark. Drop them or invert. + +**`button.active`** — `color: var(--bg)` on `background: var(--accent)`. +Survives a token flip (dark text on gold instead of cream text on gold). + +## The drawing palette is the real problem + +Four stops per family, named `green1`–`green4` and so on, persisted as hex +on each drawing: + +| | 1 (pastel) | 2 | 3 | 4 (dark) | +|---|---|---|---|---| +| green | `#A6D8AA` | `#4DB155` | `#258F33` | `#006D09` | +| red | `#FAADBC` | `#F45B78` | `#D13F62` | `#AF2850` | +| blue | `#A3CCFF` | `#4699FE` | `#1E76D8` | `#0054B3` | +| orange | `#F8C592` | `#F08A24` | `#D66B12` | `#B94E08` | +| teal | `#80D8D8` | `#00B0B1` | `#008E8F` | `#006C6E` | +| purple | `#DDBCEB` | `#BB79D7` | `#9858B3` | `#763790` | + +Stop 1 pops on dark and fades into cream. Stop 4 reads on cream and +disappears on dark. A theme switch does not rewrite stored `#006D09` +lines. Invert-and-ship leaves those lines looking like they vanished. + +## Colours that work on both + +Stay in the middle of each row. Mid-luminance, reasonably saturated hues +contrast with both `#fbf7ef` and a warm dark plot (roughly OKLCH lightness +0.55–0.70). That is why the candles already work, and why stops 2–3 work: + +`#4DB155` `#F45B78` `#4699FE` `#F08A24` `#00B0B1` `#BB79D7` + +You cannot keep a four-stop range that is readable on both backgrounds. +Two ways out, pick one when this is built: + +1. **Store the name, resolve the hex.** The labels already exist + (`green2`). Light and dark each map the name to a hex. Identity + survives; appearance follows the theme. New drawings and a one-shot + migration of known palette hexes onto names. Custom hexes stay hexes. +2. **A 1px opposite-luma hairline** on every series. The fill can then be + anything, including saved `#006D09`. Escape hatch for lines that never + get a name. + +Do not invent a third palette that claims all eight extremes work on both. +They do not. + +## What a dark theme is, and is not + +It is a contrast pass: chrome tokens, grid alpha, accent, pulse mix, +shadows, and a decision about named vs hex drawings. It is not a +restyle of the product and not a reason to rebuild the chart. + +Do not invert the cream hexes and ship. Do not delete stop 1 and stop 4 +from the picker without deciding how existing drawings render. Do not +treat `prefers-color-scheme` as enough on its own — this is a trading +desk, not a marketing page; the choice has to be sticky and explicit, +with the system preference as a default only. + +## When this is built + +Change `docs/plan.md` in the same commit if any colour table or “paper +theme” description becomes false. This file then records the reasoning, +not the live tokens. diff --git a/static/chart.js b/static/chart.js index 4c83622..ff00979 100644 --- a/static/chart.js +++ b/static/chart.js @@ -13,7 +13,7 @@ class ConfluenceChart { this.autoScrollLivePrice = true; this.lastCurrentPrice = null; this.previewLine = null; - this.projectionLayer = null; + this.lineBridgeLayer = null; this.bars = []; this.levels = []; this.trendlineGeometryMode = 'legacy'; @@ -238,7 +238,6 @@ class ConfluenceChart { requestAnimationFrame(() => { this.observePlotCanvas(); this.renderAnchorHandles(); - this.renderTrendlineProjections(); this.renderComments(); this.renderContextLabels(); }); @@ -270,10 +269,10 @@ class ConfluenceChart { this.previewLine.setAttribute('hidden', ''); preview.appendChild(this.previewLine); this.overlayLayer.appendChild(preview); - this.projectionLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - this.projectionLayer.classList.add('chart-projections'); - this.projectionLayer.setAttribute('aria-hidden', 'true'); - this.overlayLayer.appendChild(this.projectionLayer); + this.lineBridgeLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.lineBridgeLayer.classList.add('chart-line-bridges'); + this.lineBridgeLayer.setAttribute('aria-hidden', 'true'); + this.overlayLayer.appendChild(this.lineBridgeLayer); const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); handles.classList.add('chart-handles'); handles.setAttribute('aria-hidden', 'true'); @@ -401,7 +400,7 @@ class ConfluenceChart { requestAnimationFrame(() => this.observePlotCanvas()); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.renderAnchorHandles(); - this.renderTrendlineProjections(); + this.renderLineBridges(); this.renderComments(); this.renderContextLabels(); }); @@ -438,7 +437,7 @@ class ConfluenceChart { // The bar grid just changed underneath every pinned comment. this.renderComments(); this.renderContextLabels(); - this.renderTrendlineProjections(); + this.renderLineBridges(); } setTrendlineGeometry(geometry) { @@ -540,20 +539,33 @@ class ConfluenceChart { this.volume.update(ConfluenceChart.toVolume(bar)); if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; else this.bars.push(bar); - if (advanced) this.appendManualLineSamples(bar); + if (advanced) this.extendManualLineSeries(last, bar); this.renderAnchorHandles(); + if (advanced || this.lineBridgeLayer?.childElementCount) this.renderLineBridges(); this.renderContextLabels(); - this.renderTrendlineProjections(); } - appendManualLineSamples(bar) { - const index = this.bars.length - 1; + extendManualLineSeries(previous, bar) { + const step = ConfluenceChart.barInterval(this.bars); + 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)); + 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)); + } + return; + } + const futureTime = bar.t + ConfluenceChart.FUTURE_SLOTS * step; + this.futureSpace.update({ time: futureTime }); for (const level of this.levels) { if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level) - || bar.t < level.anchor_t || bar.t > (level.cutoff_t ?? Infinity)) continue; + || futureTime < level.anchor_t || futureTime > (level.cutoff_t ?? Infinity)) continue; const entry = this.levelSeries.get(level.id); - const value = this.linePriceAtIndex(level, index); - if (entry && value != null) entry.series.update({ time: bar.t, value }); + const value = this.linePriceAtIndex(level, this.indexAt(futureTime)); + if (entry && value != null) entry.series.update({ time: futureTime, value }); } } @@ -1060,7 +1072,7 @@ class ConfluenceChart { this.pendingView = null; } this.renderAnchorHandles(); - this.renderTrendlineProjections(); + this.renderLineBridges(); this.renderContextLabels(); } @@ -1164,7 +1176,7 @@ class ConfluenceChart { this.plotResizeObserver = new ResizeObserver(() => { this.syncOverlayLayer(); this.renderAnchorHandles(); - this.renderTrendlineProjections(); + this.renderLineBridges(); this.renderComments(); this.renderContextLabels(); if (this.armedTool) this.renderGesture(); @@ -1805,33 +1817,42 @@ class ConfluenceChart { seriesLineData(level) { if (!this.bars.length) return []; + let points; if (this.trendlineGeometryMode === 'source_tf') { const cutoff = level.cutoff_t ?? Infinity; - return this.bars + points = this.bars .filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff) .map(bar => ({ time: bar.t, value: this.sourceLinePriceAt(level, bar.t) })) .filter(point => point.value != null); + } else { + const start = this.indexAt(level.anchor_t); + const lastBar = this.bars.length - 1; + const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t); + const end = Math.min(lastBar, cutoff); + if (start > end) return []; + const anchorEnd = this.indexAt(level.last_t); + const indexes = [start]; + if (anchorEnd > start && anchorEnd < end) indexes.push(anchorEnd); + if (end > indexes[indexes.length - 1]) indexes.push(end); + points = indexes.map(index => ({ + time: this.timeAtIndex(index), + value: this.linePriceAtIndex(level, index), + })); } - const start = this.indexAt(level.anchor_t); - const lastBar = this.bars.length - 1; - const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t); - const end = Math.min(lastBar, cutoff); - if (start > end) return []; - const anchorEnd = this.indexAt(level.last_t); - const indexes = [start]; - if (anchorEnd > start && anchorEnd < end) indexes.push(anchorEnd); - if (end > indexes[indexes.length - 1]) indexes.push(end); - return indexes.map(index => ({ - time: this.timeAtIndex(index), - value: this.linePriceAtIndex(level, index), - })); + const cutoff = level.cutoff_t ?? Infinity; + // futureSpace already owns these timestamps. Giving values to this same + // LineSeries keeps the historical line and extension in one renderer. + for (const slot of this.futureTimes(this.bars)) { + if (slot.time < level.anchor_t || slot.time > cutoff) continue; + const value = this.linePriceAtIndex(level, this.indexAt(slot.time)); + if (value != null) points.push({ time: slot.time, value }); + } + return points; } - renderTrendlineProjections() { - if (!this.projectionLayer || !this.bars.length) return; - const visible = this.visibleBarIndexes(); - if (!visible) return; - const lastBar = this.bars.length - 1; + renderLineBridges() { + if (!this.lineBridgeLayer || !this.bars.length) return; + const firstDisplayed = this.bars[0].t; const segment = (level, t1, p1, t2, p2) => { const x1 = this.coordinateAtTime(t1); const x2 = this.coordinateAtTime(t2); @@ -1839,7 +1860,6 @@ class ConfluenceChart { const y2 = p2 == null ? null : this.candles.priceToCoordinate(p2); if ([x1, x2, y1, y2].some(value => value == null)) return null; const node = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - node.dataset.lineId = level.id; node.setAttribute('x1', x1); node.setAttribute('y1', y1); node.setAttribute('x2', x2); @@ -1848,58 +1868,27 @@ class ConfluenceChart { node.setAttribute('stroke-width', level.line_width || 2); return node; }; - const lines = this.levels - .filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level)) - .map(level => { - const start = Math.max(lastBar, this.indexAt(level.anchor_t), visible.from); - const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); - const end = Math.min(naturalEnd, visible.to); - if (end <= start) return null; - const t1 = this.timeAtIndex(start); - const t2 = this.timeAtIndex(end); - const p1 = this.linePriceAtIndex(level, start); - const p2 = this.linePriceAtIndex(level, end); - return segment(level, t1, p1, t2, p2); - }) - .filter(Boolean); - if (this.trendlineGeometryMode === 'source_tf') { - const firstDisplayed = this.bars[0].t; - const lastDisplayed = this.bars[lastBar].t; - for (const level of this.levels.filter(value => - value.kind === 'manual' && !value.hidden && !ConfluenceChart.isFlat(value))) { - const cutoff = level.cutoff_t ?? Infinity; - const sampled = this.bars.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff - && this.sourceLinePriceAt(level, bar.t) != null); - if (!sampled.length) { - if (Number.isFinite(cutoff) - && level.anchor_t >= firstDisplayed && cutoff <= lastDisplayed) { - const node = segment( - level, level.anchor_t, level.anchor_p, - cutoff, this.sourceLinePriceAt(level, cutoff), - ); - if (node) lines.push(node); - } - continue; - } - const first = sampled[0]; - if (first.t > level.anchor_t && level.anchor_t >= firstDisplayed) { - const node = segment( - level, level.anchor_t, level.anchor_p, - first.t, this.sourceLinePriceAt(level, first.t), - ); - if (node) lines.push(node); - } - const last = sampled[sampled.length - 1]; - if (Number.isFinite(cutoff) && last.t < cutoff && cutoff <= lastDisplayed) { - const node = segment( - level, last.t, this.sourceLinePriceAt(level, last.t), - cutoff, this.sourceLinePriceAt(level, cutoff), - ); - if (node) lines.push(node); - } + const bridges = []; + for (const level of this.levels) { + if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)) continue; + const points = this.seriesLineData(level); + if (!points.length) continue; + const priceAt = time => this.trendlineGeometryMode === 'source_tf' + ? this.sourceLinePriceAt(level, time) + : this.linePriceAtIndex(level, this.indexAt(time)); + const first = points[0]; + if (level.anchor_t >= firstDisplayed && first.time > level.anchor_t) { + const node = segment(level, level.anchor_t, priceAt(level.anchor_t), first.time, first.value); + if (node) bridges.push(node); + } + const cutoff = level.cutoff_t; + const last = points[points.length - 1]; + if (cutoff != null && last.time < cutoff) { + const node = segment(level, last.time, last.value, cutoff, priceAt(cutoff)); + if (node) bridges.push(node); } } - this.projectionLayer.replaceChildren(...lines); + this.lineBridgeLayer.replaceChildren(...bridges); } lineData(level) { @@ -2058,7 +2047,7 @@ class ConfluenceChart { const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.seriesLineData(level)); this.renderAnchorHandles(); - this.renderTrendlineProjections(); + this.renderLineBridges(); } finishLineDrag(event) { @@ -2118,7 +2107,7 @@ class ConfluenceChart { const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.seriesLineData(level)); this.renderAnchorHandles(); - this.renderTrendlineProjections(); + this.renderLineBridges(); } finishAnchorDrag(event) { @@ -2168,7 +2157,7 @@ class ConfluenceChart { level.cutoff_t = this.contextCutoff; const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.seriesLineData(level)); - this.renderTrendlineProjections(); + this.renderLineBridges(); this.hideContextMenu(); if (this.onLineEnd) this.onLineEnd({ ...level }); } diff --git a/static/style.css b/static/style.css index f9f1c0d..784b009 100644 --- a/static/style.css +++ b/static/style.css @@ -13,7 +13,7 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } -#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; } +#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; } .current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } } @media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; } } @keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } } diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 1f09101..688ffed 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -93,8 +93,11 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho 'the rollback path no longer restores displayed-grid geometry'); assert.equal(result.shifted, result.sourceSecondBar, 'a source-timeframe nudge did not move by one 30m bar'); - assert.deepEqual(result.sampledTimes, result.displayedTimes, - 'manual line samples introduced timestamps outside the displayed candle grid'); + assert.deepEqual(result.sampledTimes.slice(0, result.displayedTimes.length), result.displayedTimes, + 'manual line samples no longer match the displayed candle grid'); + assert.ok(result.sampledTimes.slice(result.displayedTimes.length) + .every(time => time > result.displayedTimes.at(-1)), + 'manual-line extension mixed future points into candle history'); assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes, 'source timestamps evicted by the server remained in the browser'); assert.equal(result.gapAccepted, false, @@ -161,7 +164,7 @@ test('future 5m projections keep their slope after the settlement break', }); }); -test('a new 5m candle extends the line before its future projection', +test('a manual line series owns its future 5m slots without an SVG projection', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { @@ -181,22 +184,33 @@ test('a new 5m candle extends the line before its future projection', c.setTrendlineGeometry({ mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step } }, }); + const futureTime = bars.at(-1).t + step; + const futureSlotBeforeLine = c.chart.timeScale().timeToCoordinate(futureTime) != null; c.syncLevels([line]); + const before = c.levelSeries.get(line.id).series.data(); + const futurePoint = before.find(point => Number(point.time) === futureTime); const next = { ...bars.at(-1), t: bars.at(-1).t + step }; c.updateBar(next); - const data = c.levelSeries.get(line.id).series.data(); + const after = c.levelSeries.get(line.id).series.data(); return { - seriesLastTime: Number(data.at(-1).time), - seriesLastPrice: data.at(-1).value, - expectedTime: next.t, - expectedPrice: c.linePriceAtIndex(line, c.bars.length - 1), + futurePoint, + expectedFuturePrice: c.linePriceAtIndex(line, c.indexAt(futureTime)), + futureSlotBeforeLine, + tailTime: Number(after.at(-1).time), + expectedTailTime: next.t + ConfluenceChart.FUTURE_SLOTS * step, + visibleProjection: Boolean(document.querySelector('.chart-projections')), }; }); - assert.equal(result.seriesLastTime, result.expectedTime, - 'the canvas line stopped before the projection began'); - assert.equal(result.seriesLastPrice, result.expectedPrice, - 'the canvas line and projection disagreed at the live edge'); + assert.ok(result.futurePoint, 'the canvas line stopped at the final real candle'); + assert.equal(result.futurePoint.value, result.expectedFuturePrice, + 'the first canvas future point has the wrong source-space price'); + assert.equal(result.futureSlotBeforeLine, true, + 'the manual line added a timestamp the future axis does not already own'); + assert.equal(result.tailTime, result.expectedTailTime, + 'a new bar did not extend the canvas future tail'); + assert.equal(result.visibleProjection, false, + 'the old SVG projection renderer is still visible'); assertNoPageErrors(page, assert); }); }); @@ -466,24 +480,20 @@ test('future whitespace accepts endpoint and whole-line movement without enterin const c = window.__chart; const line = c.levels.find(level => level.id === id); const data = c.levelSeries.get(id).series.data(); - const lastBar = c.bars[c.bars.length - 1]; - const projection = document.querySelector(`.chart-projections [data-line-id="${id}"]`); return { anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t), lastBarIndex: c.bars.length - 1, - maxSeriesTime: Math.max(...data.map(point => Number(point.time))), - lastBarTime: lastBar.t, - projectionEnd: Number(projection?.getAttribute('x2')), - plotWidth: c.plotCanvas().getBoundingClientRect().width, + endpointInSeries: data.some(point => Number(point.time) === line.last_t), + endpointCoordinate: c.chart.timeScale().timeToCoordinate(line.last_t), }; }, future.id); assert.ok(projected.endIndex > projected.lastBarIndex, 'the endpoint was clamped to the newest candle'); - assert.ok(projected.maxSeriesTime <= projected.lastBarTime, - 'future drawing geometry entered the shared time scale'); - assert.ok(projected.projectionEnd >= projected.plotWidth - 2, - 'the active line did not project through the visible whitespace'); + assert.equal(projected.endpointInSeries, true, + 'the active line omitted its future endpoint from the canvas series'); + assert.notEqual(projected.endpointCoordinate, null, + 'the future endpoint is absent from the shared future time scale'); const drag = await page.evaluate(() => { const c = window.__chart;