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

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

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

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

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.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,13 +1817,14 @@ 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);
@ -1821,17 +1834,25 @@ class ConfluenceChart {
const indexes = [start];
if (anchorEnd > start && anchorEnd < end) indexes.push(anchorEnd);
if (end > indexes[indexes.length - 1]) indexes.push(end);
return indexes.map(index => ({
points = 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);
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);
}
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 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 });
}

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

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');
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;