superior extended trendlines
This commit is contained in:
parent
d523dad1be
commit
796e6cdeb8
9 changed files with 337 additions and 138 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
20
docs/plan.md
20
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
|
> **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 |
|
||||||
|
|
|
||||||
61
docs/plan_diagnostics_improvements.md
Normal file
61
docs/plan_diagnostics_improvements.md
Normal 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.
|
||||||
126
docs/plan_light_dark_themes.md
Normal file
126
docs/plan_light_dark_themes.md
Normal 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.
|
||||||
169
static/chart.js
169
static/chart.js
|
|
@ -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,33 +1817,42 @@ 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 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 cutoff = level.cutoff_t ?? Infinity;
|
||||||
const lastBar = this.bars.length - 1;
|
// futureSpace already owns these timestamps. Giving values to this same
|
||||||
const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t);
|
// LineSeries keeps the historical line and extension in one renderer.
|
||||||
const end = Math.min(lastBar, cutoff);
|
for (const slot of this.futureTimes(this.bars)) {
|
||||||
if (start > end) return [];
|
if (slot.time < level.anchor_t || slot.time > cutoff) continue;
|
||||||
const anchorEnd = this.indexAt(level.last_t);
|
const value = this.linePriceAtIndex(level, this.indexAt(slot.time));
|
||||||
const indexes = [start];
|
if (value != null) points.push({ time: slot.time, value });
|
||||||
if (anchorEnd > start && anchorEnd < end) indexes.push(anchorEnd);
|
}
|
||||||
if (end > indexes[indexes.length - 1]) indexes.push(end);
|
return points;
|
||||||
return indexes.map(index => ({
|
|
||||||
time: this.timeAtIndex(index),
|
|
||||||
value: this.linePriceAtIndex(level, index),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
const cutoff = level.cutoff_t;
|
||||||
if (this.trendlineGeometryMode === 'source_tf') {
|
const last = points[points.length - 1];
|
||||||
const firstDisplayed = this.bars[0].t;
|
if (cutoff != null && last.time < cutoff) {
|
||||||
const lastDisplayed = this.bars[lastBar].t;
|
const node = segment(level, last.time, last.value, cutoff, priceAt(cutoff));
|
||||||
for (const level of this.levels.filter(value =>
|
if (node) bridges.push(node);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.projectionLayer.replaceChildren(...lines);
|
this.lineBridgeLayer.replaceChildren(...bridges);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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); } }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue