superior extended trendlines

This commit is contained in:
Chris Amow 2026-08-15 00:58:19 -05:00
parent d523dad1be
commit 796e6cdeb8
9 changed files with 337 additions and 138 deletions

View file

@ -11,7 +11,8 @@ turned out to be another. Then
[`docs/NEXT_STEPS.md`](docs/NEXT_STEPS.md) for current recommendations and known [`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 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/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 ## Tests earn their place by catching a real bug

View file

@ -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. source receipt time and an application heartbeat over the existing WebSocket.
Any stale threshold must account for Yahoo's declared delay. 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 ## Frontend build
The CDN-to-Vite plan is in `docs/vite_build.md`. First tranche is a production The CDN-to-Vite plan is in `docs/vite_build.md`. First tranche is a production

View file

@ -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 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 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 instead of silently losing one logical bar. The browser evaluates the same
source-space line and samples it only onto displayed candle timestamps, so source-space line and samples it onto displayed candle timestamps. It also
manual lines cannot add points to Lightweight Charts' shared scale. Historical samples onto future-whitespace timestamps already owned by `futureSpace`, so
series, future projection, hit testing and the selected-line hit polyline all 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 call that price function. Whole-line dragging, horizontal keyboard nudging and
duplication shift timestamps in source bars rather than whatever timeframe is duplication shift timestamps in source bars rather than whatever timeframe is
currently displayed. Endpoint dragging can still choose a finer-timeframe 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 which puts `:30` halfway between the hourly candles and remains correct even
when another series has inserted foreign timestamps into the shared scale. 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 Appending the missing canvas sample fixed stale historical data, but production
SVG segment projects it through future whitespace. `updateBar()` updated the still showed a crease at the live edge. The underlying design was the defect:
candle but did not extend the canvas line when a new displayed bar opened. The the visible line switched from a Lightweight Charts canvas series to an SVG
SVG correctly started at that new bar, producing a visible crease at the live projection exactly where the chart was most scrutinized.
edge even after its slope was corrected.
New displayed bars now append one source-space sample to each visible sloped Manual lines now use one canvas `LineSeries` across history and future. Its
manual series. This is deliberately not a full `syncLevels()` call: the source future points reuse the timestamps already carried by `futureSpace`, so the line
geometry and all previous line points are unchanged, and a per-line does not reshape the shared time scale. New bars advance that future tail by one
`series.update()` keeps the canvas and SVG endpoints identical without adding point; a session gap rebuilds only the small manual-line series. SVG remains for
per-tick redraw work. Browser coverage opens a new 5m candle and asserts that hit targets, handles, and the rare short bridge from an off-grid source anchor
the canvas line reaches the same time and price as the projection. 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.

View file

@ -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 > **For current work, start with `AGENTS.md`**, which every agent loads
> automatically. It points at the live planning documents: > automatically. It points at the live planning documents:
> `docs/NEXT_STEPS.md` for the short list, `docs/async_refactor.md`, > `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/multi_user.md`, `docs/feature_undo.md`, `docs/mobile_enhance.md`,
> `docs/vite_build.md` for designs not yet built. > `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 > **`main` deploys to production.** A push triggers a Forgejo webhook and
> Coolify rebuild of <https://chart.amow.com>. That is the intended workflow now, > Coolify rebuild of <https://chart.amow.com>. 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 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; hours in which no bars exist. The stored `slope` recovers the second endpoint price;
the source-timeframe bar sequence determines interpolation and projection. The the source-timeframe bar sequence determines interpolation and projection. The
browser samples that canonical geometry only at displayed candle timestamps, and the browser samples that canonical geometry at displayed candle timestamps and at
server uses the same source series for clusters and alerts. A source history that no the existing future-whitespace timestamps. The server uses the same source series
longer reaches an anchor leaves the drawing visible but unresolved and unable to for clusters and alerts. A source history that no longer reaches an anchor leaves
cluster or alert rather than silently extrapolating from the edge of a shorter the drawing visible but unresolved and unable to cluster or alert rather than
window. `TRENDLINE_SOURCE_GEOMETRY=false` restores the previous displayed/1m-grid silently extrapolating from the edge of a shorter window.
behavior without changing persisted data. `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 **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 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 | | Place endpoints | `chart.subscribeClick(handler)` → two clicks |
| Pixel → price | `series.coordinateToPrice(param.point.y)` | | Pixel → price | `series.coordinateToPrice(param.point.y)` |
| Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` | | 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 | | Select | Click within ~6px of the canonical price at that displayed bar |
| Delete | `Delete`/`Backspace` on selection, plus a button | | Delete | `Delete`/`Backspace` on selection, plus a button |
| Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars | | Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars |

View file

@ -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 <duration>
status
container-state
recent-deploy
capture-read <capture-id>
capture-delete <capture-id>
```
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.

View file

@ -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.

View file

@ -13,7 +13,7 @@ class ConfluenceChart {
this.autoScrollLivePrice = true; this.autoScrollLivePrice = true;
this.lastCurrentPrice = null; this.lastCurrentPrice = null;
this.previewLine = null; this.previewLine = null;
this.projectionLayer = null; this.lineBridgeLayer = null;
this.bars = []; this.bars = [];
this.levels = []; this.levels = [];
this.trendlineGeometryMode = 'legacy'; this.trendlineGeometryMode = 'legacy';
@ -238,7 +238,6 @@ class ConfluenceChart {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.observePlotCanvas(); this.observePlotCanvas();
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections();
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
}); });
@ -270,10 +269,10 @@ class ConfluenceChart {
this.previewLine.setAttribute('hidden', ''); this.previewLine.setAttribute('hidden', '');
preview.appendChild(this.previewLine); preview.appendChild(this.previewLine);
this.overlayLayer.appendChild(preview); this.overlayLayer.appendChild(preview);
this.projectionLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.lineBridgeLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.projectionLayer.classList.add('chart-projections'); this.lineBridgeLayer.classList.add('chart-line-bridges');
this.projectionLayer.setAttribute('aria-hidden', 'true'); this.lineBridgeLayer.setAttribute('aria-hidden', 'true');
this.overlayLayer.appendChild(this.projectionLayer); this.overlayLayer.appendChild(this.lineBridgeLayer);
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
handles.classList.add('chart-handles'); handles.classList.add('chart-handles');
handles.setAttribute('aria-hidden', 'true'); handles.setAttribute('aria-hidden', 'true');
@ -401,7 +400,7 @@ class ConfluenceChart {
requestAnimationFrame(() => this.observePlotCanvas()); requestAnimationFrame(() => this.observePlotCanvas());
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections(); this.renderLineBridges();
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
}); });
@ -438,7 +437,7 @@ class ConfluenceChart {
// The bar grid just changed underneath every pinned comment. // The bar grid just changed underneath every pinned comment.
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
this.renderTrendlineProjections(); this.renderLineBridges();
} }
setTrendlineGeometry(geometry) { setTrendlineGeometry(geometry) {
@ -540,20 +539,33 @@ class ConfluenceChart {
this.volume.update(ConfluenceChart.toVolume(bar)); 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; 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); else this.bars.push(bar);
if (advanced) this.appendManualLineSamples(bar); if (advanced) this.extendManualLineSeries(last, bar);
this.renderAnchorHandles(); this.renderAnchorHandles();
if (advanced || this.lineBridgeLayer?.childElementCount) this.renderLineBridges();
this.renderContextLabels(); this.renderContextLabels();
this.renderTrendlineProjections();
} }
appendManualLineSamples(bar) { extendManualLineSeries(previous, bar) {
const index = this.bars.length - 1; 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) { for (const level of this.levels) {
if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level) 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 entry = this.levelSeries.get(level.id);
const value = this.linePriceAtIndex(level, index); const value = this.linePriceAtIndex(level, this.indexAt(futureTime));
if (entry && value != null) entry.series.update({ time: bar.t, value }); if (entry && value != null) entry.series.update({ time: futureTime, value });
} }
} }
@ -1060,7 +1072,7 @@ class ConfluenceChart {
this.pendingView = null; this.pendingView = null;
} }
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections(); this.renderLineBridges();
this.renderContextLabels(); this.renderContextLabels();
} }
@ -1164,7 +1176,7 @@ class ConfluenceChart {
this.plotResizeObserver = new ResizeObserver(() => { this.plotResizeObserver = new ResizeObserver(() => {
this.syncOverlayLayer(); this.syncOverlayLayer();
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections(); this.renderLineBridges();
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
if (this.armedTool) this.renderGesture(); if (this.armedTool) this.renderGesture();
@ -1805,13 +1817,14 @@ class ConfluenceChart {
seriesLineData(level) { seriesLineData(level) {
if (!this.bars.length) return []; if (!this.bars.length) return [];
let points;
if (this.trendlineGeometryMode === 'source_tf') { if (this.trendlineGeometryMode === 'source_tf') {
const cutoff = level.cutoff_t ?? Infinity; const cutoff = level.cutoff_t ?? Infinity;
return this.bars points = this.bars
.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff) .filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff)
.map(bar => ({ time: bar.t, value: this.sourceLinePriceAt(level, bar.t) })) .map(bar => ({ time: bar.t, value: this.sourceLinePriceAt(level, bar.t) }))
.filter(point => point.value != null); .filter(point => point.value != null);
} } else {
const start = this.indexAt(level.anchor_t); const start = this.indexAt(level.anchor_t);
const lastBar = this.bars.length - 1; const lastBar = this.bars.length - 1;
const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t); const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t);
@ -1821,17 +1834,25 @@ class ConfluenceChart {
const indexes = [start]; const indexes = [start];
if (anchorEnd > start && anchorEnd < end) indexes.push(anchorEnd); if (anchorEnd > start && anchorEnd < end) indexes.push(anchorEnd);
if (end > indexes[indexes.length - 1]) indexes.push(end); if (end > indexes[indexes.length - 1]) indexes.push(end);
return indexes.map(index => ({ points = indexes.map(index => ({
time: this.timeAtIndex(index), time: this.timeAtIndex(index),
value: this.linePriceAtIndex(level, 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() { renderLineBridges() {
if (!this.projectionLayer || !this.bars.length) return; if (!this.lineBridgeLayer || !this.bars.length) return;
const visible = this.visibleBarIndexes(); const firstDisplayed = this.bars[0].t;
if (!visible) return;
const lastBar = this.bars.length - 1;
const segment = (level, t1, p1, t2, p2) => { const segment = (level, t1, p1, t2, p2) => {
const x1 = this.coordinateAtTime(t1); const x1 = this.coordinateAtTime(t1);
const x2 = this.coordinateAtTime(t2); const x2 = this.coordinateAtTime(t2);
@ -1839,7 +1860,6 @@ class ConfluenceChart {
const y2 = p2 == null ? null : this.candles.priceToCoordinate(p2); const y2 = p2 == null ? null : this.candles.priceToCoordinate(p2);
if ([x1, x2, y1, y2].some(value => value == null)) return null; if ([x1, x2, y1, y2].some(value => value == null)) return null;
const node = document.createElementNS('http://www.w3.org/2000/svg', 'line'); const node = document.createElementNS('http://www.w3.org/2000/svg', 'line');
node.dataset.lineId = level.id;
node.setAttribute('x1', x1); node.setAttribute('x1', x1);
node.setAttribute('y1', y1); node.setAttribute('y1', y1);
node.setAttribute('x2', x2); node.setAttribute('x2', x2);
@ -1848,58 +1868,27 @@ class ConfluenceChart {
node.setAttribute('stroke-width', level.line_width || 2); node.setAttribute('stroke-width', level.line_width || 2);
return node; return node;
}; };
const lines = this.levels const bridges = [];
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level)) for (const level of this.levels) {
.map(level => { if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)) continue;
const start = Math.max(lastBar, this.indexAt(level.anchor_t), visible.from); const points = this.seriesLineData(level);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); if (!points.length) continue;
const end = Math.min(naturalEnd, visible.to); const priceAt = time => this.trendlineGeometryMode === 'source_tf'
if (end <= start) return null; ? this.sourceLinePriceAt(level, time)
const t1 = this.timeAtIndex(start); : this.linePriceAtIndex(level, this.indexAt(time));
const t2 = this.timeAtIndex(end); const first = points[0];
const p1 = this.linePriceAtIndex(level, start); if (level.anchor_t >= firstDisplayed && first.time > level.anchor_t) {
const p2 = this.linePriceAtIndex(level, end); const node = segment(level, level.anchor_t, priceAt(level.anchor_t), first.time, first.value);
return segment(level, t1, p1, t2, p2); if (node) bridges.push(node);
})
.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 cutoff = level.cutoff_t;
} const last = points[points.length - 1];
const first = sampled[0]; if (cutoff != null && last.time < cutoff) {
if (first.t > level.anchor_t && level.anchor_t >= firstDisplayed) { const node = segment(level, last.time, last.value, cutoff, priceAt(cutoff));
const node = segment( if (node) bridges.push(node);
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);
} }
} }
} this.lineBridgeLayer.replaceChildren(...bridges);
this.projectionLayer.replaceChildren(...lines);
} }
lineData(level) { lineData(level) {
@ -2058,7 +2047,7 @@ class ConfluenceChart {
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.seriesLineData(level)); if (entry) entry.series.setData(this.seriesLineData(level));
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections(); this.renderLineBridges();
} }
finishLineDrag(event) { finishLineDrag(event) {
@ -2118,7 +2107,7 @@ class ConfluenceChart {
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.seriesLineData(level)); if (entry) entry.series.setData(this.seriesLineData(level));
this.renderAnchorHandles(); this.renderAnchorHandles();
this.renderTrendlineProjections(); this.renderLineBridges();
} }
finishAnchorDrag(event) { finishAnchorDrag(event) {
@ -2168,7 +2157,7 @@ class ConfluenceChart {
level.cutoff_t = this.contextCutoff; level.cutoff_t = this.contextCutoff;
const entry = this.levelSeries.get(level.id); const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.seriesLineData(level)); if (entry) entry.series.setData(this.seriesLineData(level));
this.renderTrendlineProjections(); this.renderLineBridges();
this.hideContextMenu(); this.hideContextMenu();
if (this.onLineEnd) this.onLineEnd({ ...level }); if (this.onLineEnd) this.onLineEnd({ ...level });
} }

View file

@ -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); } 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; } .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); } .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%); } } .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; } } @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); } } @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); } }

View file

@ -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'); 'the rollback path no longer restores displayed-grid geometry');
assert.equal(result.shifted, result.sourceSecondBar, assert.equal(result.shifted, result.sourceSecondBar,
'a source-timeframe nudge did not move by one 30m bar'); 'a source-timeframe nudge did not move by one 30m bar');
assert.deepEqual(result.sampledTimes, result.displayedTimes, assert.deepEqual(result.sampledTimes.slice(0, result.displayedTimes.length), result.displayedTimes,
'manual line samples introduced timestamps outside the displayed candle grid'); '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, assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes,
'source timestamps evicted by the server remained in the browser'); 'source timestamps evicted by the server remained in the browser');
assert.equal(result.gapAccepted, false, 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 () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
const result = await page.evaluate(() => { const result = await page.evaluate(() => {
@ -181,22 +184,33 @@ test('a new 5m candle extends the line before its future projection',
c.setTrendlineGeometry({ c.setTrendlineGeometry({
mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step } }, 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]); 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 }; const next = { ...bars.at(-1), t: bars.at(-1).t + step };
c.updateBar(next); c.updateBar(next);
const data = c.levelSeries.get(line.id).series.data(); const after = c.levelSeries.get(line.id).series.data();
return { return {
seriesLastTime: Number(data.at(-1).time), futurePoint,
seriesLastPrice: data.at(-1).value, expectedFuturePrice: c.linePriceAtIndex(line, c.indexAt(futureTime)),
expectedTime: next.t, futureSlotBeforeLine,
expectedPrice: c.linePriceAtIndex(line, c.bars.length - 1), 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, assert.ok(result.futurePoint, 'the canvas line stopped at the final real candle');
'the canvas line stopped before the projection began'); assert.equal(result.futurePoint.value, result.expectedFuturePrice,
assert.equal(result.seriesLastPrice, result.expectedPrice, 'the first canvas future point has the wrong source-space price');
'the canvas line and projection disagreed at the live edge'); 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); assertNoPageErrors(page, assert);
}); });
}); });
@ -466,24 +480,20 @@ test('future whitespace accepts endpoint and whole-line movement without enterin
const c = window.__chart; const c = window.__chart;
const line = c.levels.find(level => level.id === id); const line = c.levels.find(level => level.id === id);
const data = c.levelSeries.get(id).series.data(); 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 { return {
anchorIndex: c.indexAt(line.anchor_t), anchorIndex: c.indexAt(line.anchor_t),
endIndex: c.indexAt(line.last_t), endIndex: c.indexAt(line.last_t),
lastBarIndex: c.bars.length - 1, lastBarIndex: c.bars.length - 1,
maxSeriesTime: Math.max(...data.map(point => Number(point.time))), endpointInSeries: data.some(point => Number(point.time) === line.last_t),
lastBarTime: lastBar.t, endpointCoordinate: c.chart.timeScale().timeToCoordinate(line.last_t),
projectionEnd: Number(projection?.getAttribute('x2')),
plotWidth: c.plotCanvas().getBoundingClientRect().width,
}; };
}, future.id); }, future.id);
assert.ok(projected.endIndex > projected.lastBarIndex, assert.ok(projected.endIndex > projected.lastBarIndex,
'the endpoint was clamped to the newest candle'); 'the endpoint was clamped to the newest candle');
assert.ok(projected.maxSeriesTime <= projected.lastBarTime, assert.equal(projected.endpointInSeries, true,
'future drawing geometry entered the shared time scale'); 'the active line omitted its future endpoint from the canvas series');
assert.ok(projected.projectionEnd >= projected.plotWidth - 2, assert.notEqual(projected.endpointCoordinate, null,
'the active line did not project through the visible whitespace'); 'the future endpoint is absent from the shared future time scale');
const drag = await page.evaluate(() => { const drag = await page.evaluate(() => {
const c = window.__chart; const c = window.__chart;