wickchart 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -2
- package/package.json +3 -3
- package/src/core.js +320 -0
- package/src/wick-chart.js +831 -5
- package/types/core.d.ts +156 -0
- package/types/wick-chart.d.ts +257 -0
package/README.md
CHANGED
|
@@ -416,6 +416,88 @@ multipliers (default `[1, 2]`). Like overlays, scenarios are analysis data —
|
|
|
416
416
|
excluded from shareable state, and the same shape a server-side model could
|
|
417
417
|
push. `calcVolCone` / `normalizeScenario` are exported from `wickchart/core`.
|
|
418
418
|
|
|
419
|
+
### Risk planner — R-multiple grid
|
|
420
|
+
|
|
421
|
+
Plan the trade on the chart: entry + stop define **1R** (the risk unit) and
|
|
422
|
+
reward lines are drawn at kR beyond the entry, with the risk/reward zones
|
|
423
|
+
shaded. Direction is derived from the stop side.
|
|
424
|
+
|
|
425
|
+
```js
|
|
426
|
+
chart.setRiskPlan({ entry: 64500, stop: 63800, multiples: [1, 2, 3] });
|
|
427
|
+
chart.setRiskPlan({ entry: 64500, stop: 63800, targets: [65900, 67300] }); // prices → kR
|
|
428
|
+
chart.clearRiskPlan();
|
|
429
|
+
chart.riskPlan; // { entry, stop, risk, direction, levels: [{ k, price }], maxK, label }
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Explicit `targets` convert to their R multiple (wrong-side prices drop);
|
|
433
|
+
`multiples` win when both are given. At most 8 levels, each ≤ 20R; invalid
|
|
434
|
+
specs clear the plan, never throw. `normalizeRiskPlan` is exported from
|
|
435
|
+
`wickchart/core`.
|
|
436
|
+
|
|
437
|
+
### Bar-walk narrator — history as a story
|
|
438
|
+
|
|
439
|
+
`narrate()` builds the timeline of a window (pivot highs/lows, volume
|
|
440
|
+
spikes, gaps, RSI divergences, plus derived legs — the move between
|
|
441
|
+
opposite pivots); `walk()` replays the chart through it while `wick:walk`
|
|
442
|
+
events announce each step, so a caption bar can narrate the replay.
|
|
443
|
+
|
|
444
|
+
```js
|
|
445
|
+
chart.narrate(); // [{ i, time, type, note, legPct?, legBars? }]
|
|
446
|
+
chart.walk({ from: 0, to: 500, speed: 120, step: 10 });
|
|
447
|
+
chart.addEventListener('wick:walk', (e) => {
|
|
448
|
+
// { phase: 'step' | 'end' | 'stop', index, events: [...], from, to }
|
|
449
|
+
});
|
|
450
|
+
chart.stopWalk(); // any pointer/wheel/key input stops it too
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
`narrateWindow` (the analyzer) is exported from `wickchart/core`.
|
|
454
|
+
|
|
455
|
+
### Delta brush — drag-select with stats
|
|
456
|
+
|
|
457
|
+
`<wick-chart brush>` makes a plain drag **select bars** instead of panning:
|
|
458
|
+
a live band follows the pointer with a delta chip (Δ% · bars · high · low ·
|
|
459
|
+
Σvol); on release the selection commits and fires `wick:brush` with the
|
|
460
|
+
range statistics. Esc (or `clearBrush()`) clears it.
|
|
461
|
+
|
|
462
|
+
```html
|
|
463
|
+
<wick-chart brush></wick-chart>
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
```js
|
|
467
|
+
chart.addEventListener('wick:brush', (e) => {
|
|
468
|
+
// { bars, from: {index, time}, to: {index, time}, delta, deltaPct,
|
|
469
|
+
// firstOpen, lastClose, high, low, volume }
|
|
470
|
+
});
|
|
471
|
+
chart.brushSelection; // { i0, i1, stats } | null
|
|
472
|
+
chart.clearBrush();
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Brush mode replaces plain-drag panning (shift-drag still measures);
|
|
476
|
+
replacing the dataset clears a committed selection. `brushStats` is
|
|
477
|
+
exported from `wickchart/core`.
|
|
478
|
+
|
|
479
|
+
### Story mode — guided tours of chart state
|
|
480
|
+
|
|
481
|
+
A **story** is an array of **scenes** (view range, type, indicators,
|
|
482
|
+
overlays, scenario, risk plan + title/note). `playStory()` applies each
|
|
483
|
+
scene, eases the camera to its range, holds for `dwell`, and narrates
|
|
484
|
+
through `wick:story`. Record scenes with `captureScene()` while you
|
|
485
|
+
arrange the chart, or generate them from an analysis.
|
|
486
|
+
|
|
487
|
+
```js
|
|
488
|
+
const story = [chart.captureScene('Overview', 'the full picture')];
|
|
489
|
+
story.push({ title: 'The breakout', range: { from, to }, indicators: 'sma:20' });
|
|
490
|
+
chart.playStory(story, { dwell: 2200, panMs: 900, loop: false });
|
|
491
|
+
chart.addEventListener('wick:story', (e) => {
|
|
492
|
+
// { phase: 'scene' | 'end' | 'stop', index, total, scene, title, note }
|
|
493
|
+
});
|
|
494
|
+
chart.stopStory(); chart.getStory();
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Any user interaction stops the tour. Scenes are plain data — serialize
|
|
498
|
+
or share them. `normalizeScene` / `sceneList` / `easeInOutCubic` are
|
|
499
|
+
exported from `wickchart/core`.
|
|
500
|
+
|
|
419
501
|
### AI-ready data window — `getDataWindow()`
|
|
420
502
|
|
|
421
503
|
One call turns whatever is on screen into a compact, LLM-pasteable summary.
|
|
@@ -483,13 +565,13 @@ range as a ~4-second pitch sequence, riding the crosshair along for sighted
|
|
|
483
565
|
users. Audio starts lazily within the enabling user gesture (autoplay-policy
|
|
484
566
|
safe).
|
|
485
567
|
|
|
486
|
-
### Cross-tab co-view
|
|
568
|
+
### Cross-tab co-view & presence
|
|
487
569
|
|
|
488
570
|
Tag charts with the same channel and they share pointers — across browser
|
|
489
571
|
tabs, or between multiple charts on one page:
|
|
490
572
|
|
|
491
573
|
```html
|
|
492
|
-
<wick-chart co-view="btc-room"></wick-chart>
|
|
574
|
+
<wick-chart co-view="btc-room" co-view-name="ben"></wick-chart>
|
|
493
575
|
```
|
|
494
576
|
|
|
495
577
|
Hovering in one tab draws a ghost crosshair (accent, dotted, with the time
|
|
@@ -498,6 +580,20 @@ different history depths still line up. Ghosts fade ~2.5 s after the peer
|
|
|
498
580
|
stops moving. Same-origin only (BroadcastChannel); the connection follows the
|
|
499
581
|
`co-view` attribute and closes with the element.
|
|
500
582
|
|
|
583
|
+
Peers also see **where everyone is looking**: each peer's viewport renders
|
|
584
|
+
as a colored band (with name) along the top of the plot, updated live as
|
|
585
|
+
they pan or zoom and swept away ~12 s after they go quiet.
|
|
586
|
+
|
|
587
|
+
```js
|
|
588
|
+
chart.getPeers(); // [{ id, name, range: { from, to }, at }]
|
|
589
|
+
chart.addEventListener('wick:peers', (e) => {
|
|
590
|
+
// { peers, joined, left } — membership changes only
|
|
591
|
+
});
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
`PresenceTracker` (the TTL bookkeeping) is exported from `wickchart/core`
|
|
595
|
+
for apps that sync presence over their own transport instead.
|
|
596
|
+
|
|
501
597
|
### Smart annotations
|
|
502
598
|
|
|
503
599
|
`<wick-chart annotations>` marks notable events on the visible range — volume
|
|
@@ -537,6 +633,60 @@ WickChart.registerIndicator('vwap', {
|
|
|
537
633
|
chart.indicators = 'vwap';
|
|
538
634
|
```
|
|
539
635
|
|
|
636
|
+
### Plugin layers — extend without forking
|
|
637
|
+
|
|
638
|
+
`addLayer()` is the whole extension surface: an external draw hook that paints
|
|
639
|
+
into the render pipeline (above chart content, under the crosshair) and can
|
|
640
|
+
claim pointer gestures so drags reach your code instead of panning the chart.
|
|
641
|
+
Four public coordinate transforms — `timeToX`/`xToTime` (extrapolating past
|
|
642
|
+
the last bar into future space) and `priceToY`/`yToPrice` — anchor your
|
|
643
|
+
content in data space so it rides along with zoom and pan:
|
|
644
|
+
|
|
645
|
+
```js
|
|
646
|
+
chart.addLayer({
|
|
647
|
+
id: 'flags',
|
|
648
|
+
draw(api) {
|
|
649
|
+
const x = api.timeToX(t), y = api.priceToY(p); // anchors, not pixels
|
|
650
|
+
api.ctx.fillStyle = api.palette.accent;
|
|
651
|
+
// …paint in CSS pixels
|
|
652
|
+
},
|
|
653
|
+
onPointer(ev) {
|
|
654
|
+
if (ev.type === 'down' && hitsMyContent(ev)) return true; // claim the drag
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
chart.requestDraw(); // repaint hook for interactive layers
|
|
658
|
+
chart.removeLayer('flags'); // detach by handle or id
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
A claimed gesture delivers `move`/`up` (and `cancel` on Escape) to the layer
|
|
662
|
+
while the chart suppresses pan/brush/measure. Markers, watermarks, signal
|
|
663
|
+
badges — or a whole drawing toolkit — plug in without the core growing a
|
|
664
|
+
single tool. The main entry is covered by a CI gzip budget (64 KB) so it
|
|
665
|
+
stays that way.
|
|
666
|
+
|
|
667
|
+
### Drawings — the `wickchart-draw` plugin
|
|
668
|
+
|
|
669
|
+
The first official plugin: TradingView-style drawing tools as opt-in bytes
|
|
670
|
+
(~8 KB gz, own CI budget). Trendlines (segment/ray), horizontal levels,
|
|
671
|
+
rectangles, fibonacci retracements and text — all plain `{ time, price }`
|
|
672
|
+
data that rides zoom & pan, survives reloads, extrapolates into future
|
|
673
|
+
space, and serializes to JSON. Anchors magnet-snap to bar times and OHLC.
|
|
674
|
+
|
|
675
|
+
```js
|
|
676
|
+
import { attachDrawings } from 'wickchart-draw';
|
|
677
|
+
|
|
678
|
+
const draw = attachDrawings(chart);
|
|
679
|
+
draw.setTool('trendline'); // drag to draw; setTool(null) = select/move mode
|
|
680
|
+
draw.getDrawings(); // → JSON array (save it); setDrawings(saved)
|
|
681
|
+
draw.undo(); draw.clear();
|
|
682
|
+
chart.addEventListener('wick:drawings', (e) => save(e.detail.drawings));
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
Select mode: click a drawing to select it, drag to move, drag the square
|
|
686
|
+
handles to re-anchor, `Delete` removes, `Esc` cancels a gesture; clicks on
|
|
687
|
+
empty space fall through to the chart. Peer dependency: wickchart ≥ 1.4.
|
|
688
|
+
See the live playground in the docs (Drawings section).
|
|
689
|
+
|
|
540
690
|
## Methods
|
|
541
691
|
|
|
542
692
|
| Method | Description |
|
|
@@ -551,6 +701,10 @@ chart.indicators = 'vwap';
|
|
|
551
701
|
| `getDataWindow()` | → AI-ready summary of the visible window (see below) |
|
|
552
702
|
| `getState()` | → serializable snapshot (type, indicators, view, positions, alerts) |
|
|
553
703
|
| `setState(state)` | Apply a snapshot; a pending view applies after the next `setData()` |
|
|
704
|
+
| `addLayer(layer)` / `removeLayer(idOrHandle)` | Register/detach a plugin layer (draw hook + optional pointer claim) |
|
|
705
|
+
| `requestDraw()` | Repaint on the next frame (interactive layers) |
|
|
706
|
+
| `timeToX(t)` / `xToTime(x)` | Bar time ⇄ x-pixel; extrapolates into future space |
|
|
707
|
+
| `priceToY(p)` / `yToPrice(y)` | Price ⇄ y-pixel in the main pane (log-aware) |
|
|
554
708
|
|
|
555
709
|
### Infinite history (`loadMore`)
|
|
556
710
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wickchart",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "<wick-chart> — a modern, dependency-free financial charting web component. Candles, line & area charts, crosshair, zoom/pan, indicators (incl. a safe expression mini-language), live streaming via <wick-feed>, theming.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/wick-chart.js",
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"dev": "npx --yes serve . -l 5173",
|
|
45
|
-
"test": "node --test \"tests/*.test.mjs\"",
|
|
45
|
+
"test": "node --test \"tests/*.test.mjs\" \"plugins/draw/tests/*.test.mjs\"",
|
|
46
46
|
"build:types": "node -e \"require('fs').rmSync('types', { recursive: true, force: true });\" && tsc -p tsconfig.json",
|
|
47
47
|
"prepack": "npm run build:types",
|
|
48
|
-
"ci": "npm run build:types && npm test && node --check src/wick-chart.js && node --check src/wick-feed.js && node --check src/core.js && node --check src/react.js && node --check src/react-core.js && node --check demo/app.js"
|
|
48
|
+
"ci": "npm run build:types && npm test && node --check src/wick-chart.js && node --check src/wick-feed.js && node --check src/core.js && node --check src/react.js && node --check src/react-core.js && node --check demo/app.js && node --check plugins/draw/core.mjs && node --check plugins/draw/draw.mjs"
|
|
49
49
|
},
|
|
50
50
|
"keywords": [
|
|
51
51
|
"chart",
|
package/src/core.js
CHANGED
|
@@ -1807,6 +1807,326 @@ export function normalizeScenario(spec) {
|
|
|
1807
1807
|
};
|
|
1808
1808
|
}
|
|
1809
1809
|
|
|
1810
|
+
/* ------------------------------------------------------------------ *
|
|
1811
|
+
* Risk planner — R-multiple grid
|
|
1812
|
+
* ------------------------------------------------------------------ */
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Validate a risk plan: entry + stop define 1R (the risk unit per trade);
|
|
1816
|
+
* reward levels are drawn at R multiples beyond the entry. Invalid input
|
|
1817
|
+
* is dropped, never thrown — same contract as setOverlays/setScenario.
|
|
1818
|
+
*
|
|
1819
|
+
* { entry: 64500, stop: 63800, // stop < entry ⇒ long; else short
|
|
1820
|
+
* multiples: [1, 2, 3], // R-multiple levels (default [1,2,3])
|
|
1821
|
+
* targets: [65900, 67300], // alternative: explicit prices → kR
|
|
1822
|
+
* label: 'breakout plan' } // ≤ 40 chars
|
|
1823
|
+
*
|
|
1824
|
+
* Explicit `targets` are converted to their (signed) R multiple; levels on
|
|
1825
|
+
* the wrong side of the entry (negative or ~zero R) are dropped. `multiples`
|
|
1826
|
+
* win when both are given. At most 8 levels, each ≤ 20R.
|
|
1827
|
+
*
|
|
1828
|
+
* @param {any} spec
|
|
1829
|
+
* @returns {null|{entry: number, stop: number, risk: number,
|
|
1830
|
+
* direction: 'long'|'short', levels: {k: number, price: number}[],
|
|
1831
|
+
* maxK: number, label: string}}
|
|
1832
|
+
*/
|
|
1833
|
+
export function normalizeRiskPlan(spec) {
|
|
1834
|
+
if (!spec || typeof spec !== 'object') return null;
|
|
1835
|
+
const entry = +spec.entry;
|
|
1836
|
+
const stop = +spec.stop;
|
|
1837
|
+
if (
|
|
1838
|
+
!Number.isFinite(entry) || !Number.isFinite(stop) ||
|
|
1839
|
+
entry <= 0 || stop <= 0 || entry === stop
|
|
1840
|
+
) return null;
|
|
1841
|
+
const risk = Math.abs(entry - stop);
|
|
1842
|
+
const sign = stop < entry ? 1 : -1;
|
|
1843
|
+
let ks = null;
|
|
1844
|
+
if (Array.isArray(spec.multiples)) {
|
|
1845
|
+
ks = spec.multiples.map((k) => +k).filter((k) => Number.isFinite(k) && k > 0 && k <= 20);
|
|
1846
|
+
} else if (Array.isArray(spec.targets)) {
|
|
1847
|
+
ks = [];
|
|
1848
|
+
for (const t of spec.targets) {
|
|
1849
|
+
const p = +t;
|
|
1850
|
+
if (!Number.isFinite(p) || p <= 0) continue;
|
|
1851
|
+
const k = ((p - entry) / risk) * sign;
|
|
1852
|
+
if (k > 0.005) ks.push(Math.round(k * 100) / 100);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
if (!ks || !ks.length) ks = [1, 2, 3];
|
|
1856
|
+
const levels = [...new Set(ks)]
|
|
1857
|
+
.sort((a, b) => a - b)
|
|
1858
|
+
.slice(0, 8)
|
|
1859
|
+
.map((k) => ({ k, price: entry + sign * k * risk }));
|
|
1860
|
+
return {
|
|
1861
|
+
entry,
|
|
1862
|
+
stop,
|
|
1863
|
+
risk,
|
|
1864
|
+
direction: sign > 0 ? 'long' : 'short',
|
|
1865
|
+
levels,
|
|
1866
|
+
maxK: levels.length ? levels[levels.length - 1].k : 0,
|
|
1867
|
+
label: spec.label != null ? String(spec.label).slice(0, 40) : '',
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/* ------------------------------------------------------------------ *
|
|
1872
|
+
* Bar-walk narrator — a timeline of what happened
|
|
1873
|
+
* ------------------------------------------------------------------ */
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* Turn a bar window into an ordered story: the annotation events (pivot
|
|
1877
|
+
* highs/lows, volume spikes, gaps, RSI divergences) plus derived **legs** —
|
|
1878
|
+
* the move between consecutive opposite pivots ("+12.4% over 38 bars").
|
|
1879
|
+
* The timeline drives the bar-walk player and any caption UI.
|
|
1880
|
+
*
|
|
1881
|
+
* @param {Bar[]} bars full dataset
|
|
1882
|
+
* @param {number} i0 first index of the window
|
|
1883
|
+
* @param {number} i1 last index of the window
|
|
1884
|
+
* @param {{pivot?: number, volMult?: number, gapMult?: number, rsiPeriod?: number}} [opts]
|
|
1885
|
+
* pivot window defaults to 8 (denser than the annotations overlay's 20)
|
|
1886
|
+
* @returns {{i: number, time: number, type: string, side: string, note: string,
|
|
1887
|
+
* legPct?: number, legBars?: number}[]} sorted by index, capped at 60
|
|
1888
|
+
*/
|
|
1889
|
+
export function narrateWindow(bars, i0, i1, opts = {}) {
|
|
1890
|
+
if (!bars.length || i0 < 0 || i1 < i0 || i1 >= bars.length) return [];
|
|
1891
|
+
const rsi = calcRSI(bars.map((b) => b.close), Math.min(50, Math.max(2, +opts.rsiPeriod || 14)));
|
|
1892
|
+
const ann = detectAnnotations(bars, i0, i1, rsi, {
|
|
1893
|
+
pivot: opts.pivot ?? 8,
|
|
1894
|
+
volMult: opts.volMult,
|
|
1895
|
+
gapMult: opts.gapMult,
|
|
1896
|
+
});
|
|
1897
|
+
// legs: the move between consecutive opposite pivots, stamped at the
|
|
1898
|
+
// ending pivot so a walk player can speak it as it arrives
|
|
1899
|
+
const pivots = ann
|
|
1900
|
+
.filter((a) => a.type === 'pivothigh' || a.type === 'pivotlow')
|
|
1901
|
+
.sort((a, b) => a.i - b.i);
|
|
1902
|
+
const legs = [];
|
|
1903
|
+
for (let k = 1; k < pivots.length; k++) {
|
|
1904
|
+
const a = pivots[k - 1];
|
|
1905
|
+
const b = pivots[k];
|
|
1906
|
+
if (a.type === b.type) continue;
|
|
1907
|
+
const pa = a.type === 'pivothigh' ? bars[a.i].high : bars[a.i].low;
|
|
1908
|
+
const pb = b.type === 'pivothigh' ? bars[b.i].high : bars[b.i].low;
|
|
1909
|
+
if (!(pa > 0) || !Number.isFinite(pb)) continue;
|
|
1910
|
+
const pct = ((pb - pa) / pa) * 100;
|
|
1911
|
+
legs.push({
|
|
1912
|
+
type: 'leg',
|
|
1913
|
+
side: pct >= 0 ? 'high' : 'low',
|
|
1914
|
+
i: b.i,
|
|
1915
|
+
note: `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}% over ${b.i - a.i} bars`,
|
|
1916
|
+
legPct: Math.round(pct * 100) / 100,
|
|
1917
|
+
legBars: b.i - a.i,
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
return [...ann, ...legs]
|
|
1921
|
+
.sort((a, b) => a.i - b.i)
|
|
1922
|
+
.slice(0, 60)
|
|
1923
|
+
.map((e) => ({ ...e, time: bars[e.i].time }));
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/* ------------------------------------------------------------------ *
|
|
1927
|
+
* Delta brush — selection statistics
|
|
1928
|
+
* ------------------------------------------------------------------ */
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* Stats for a brushed bar range: net move (open of the first bar → close
|
|
1932
|
+
* of the last), extremes, and summed volume. Powers the brush-selection
|
|
1933
|
+
* overlay and its `wick:brush` event.
|
|
1934
|
+
*
|
|
1935
|
+
* @param {Bar[]} bars full dataset
|
|
1936
|
+
* @param {number} i0 first selected index
|
|
1937
|
+
* @param {number} i1 last selected index
|
|
1938
|
+
* @returns {null|{bars: number, from: {index: number, time: number},
|
|
1939
|
+
* to: {index: number, time: number}, firstOpen: number,
|
|
1940
|
+
* lastClose: number, delta: number, deltaPct: number,
|
|
1941
|
+
* high: number, low: number, volume: number}}
|
|
1942
|
+
*/
|
|
1943
|
+
export function brushStats(bars, i0, i1) {
|
|
1944
|
+
if (!bars.length || i0 < 0 || i1 < i0 || i1 >= bars.length) return null;
|
|
1945
|
+
const first = bars[i0];
|
|
1946
|
+
const last = bars[i1];
|
|
1947
|
+
let high = -Infinity;
|
|
1948
|
+
let low = Infinity;
|
|
1949
|
+
let vol = 0;
|
|
1950
|
+
for (let i = i0; i <= i1; i++) {
|
|
1951
|
+
const b = bars[i];
|
|
1952
|
+
if (b.high > high) high = b.high;
|
|
1953
|
+
if (b.low < low) low = b.low;
|
|
1954
|
+
vol += b.volume || 0;
|
|
1955
|
+
}
|
|
1956
|
+
const delta = last.close - first.open;
|
|
1957
|
+
return {
|
|
1958
|
+
bars: i1 - i0 + 1,
|
|
1959
|
+
from: { index: i0, time: first.time },
|
|
1960
|
+
to: { index: i1, time: last.time },
|
|
1961
|
+
firstOpen: first.open,
|
|
1962
|
+
lastClose: last.close,
|
|
1963
|
+
delta,
|
|
1964
|
+
deltaPct: first.open ? (delta / first.open) * 100 : 0,
|
|
1965
|
+
high,
|
|
1966
|
+
low,
|
|
1967
|
+
volume: vol,
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/* ------------------------------------------------------------------ *
|
|
1972
|
+
* Story mode — guided tours of chart state
|
|
1973
|
+
* ------------------------------------------------------------------ */
|
|
1974
|
+
|
|
1975
|
+
/** Smoothest cheap easing for viewport pans: slow in, slow out. */
|
|
1976
|
+
export function easeInOutCubic(t) {
|
|
1977
|
+
const x = clamp(+t || 0, 0, 1);
|
|
1978
|
+
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* Validate one story scene. Every field is optional except that a scene
|
|
1983
|
+
* must be an object; omitted fields simply don't change that aspect of
|
|
1984
|
+
* the chart when played. `scenario`/`riskPlan` use a 'clear' sentinel for
|
|
1985
|
+
* explicit "remove it" (null input means clear too when the KEY is present).
|
|
1986
|
+
*
|
|
1987
|
+
* { title: 'The breakout', note: 'What happened…',
|
|
1988
|
+
* range: { from, to }, // times (s or ms) — the camera pans there
|
|
1989
|
+
* indicators: 'sma:20 rsi:14', // optional indicator string
|
|
1990
|
+
* type: 'candles', // optional series type
|
|
1991
|
+
* overlays: [...], // optional zones/levels (normalizeOverlays)
|
|
1992
|
+
* scenario: {...} | null, // set / clear a scenario
|
|
1993
|
+
* riskPlan: {...} | null, // set / clear a risk plan
|
|
1994
|
+
* dwell: 2200 } // ms to hold after the pan (500–30000)
|
|
1995
|
+
*
|
|
1996
|
+
* @returns {object|null} normalized scene, or null for non-objects
|
|
1997
|
+
*/
|
|
1998
|
+
export function normalizeScene(scene) {
|
|
1999
|
+
if (!scene || typeof scene !== 'object') return null;
|
|
2000
|
+
const out = {
|
|
2001
|
+
title: scene.title != null ? String(scene.title).slice(0, 60) : '',
|
|
2002
|
+
note: scene.note != null ? String(scene.note).slice(0, 200) : '',
|
|
2003
|
+
dwell: clamp(Math.round(+scene.dwell || 2200), 500, 30000),
|
|
2004
|
+
};
|
|
2005
|
+
if (scene.range && Number.isFinite(+scene.range.from) && Number.isFinite(+scene.range.to)) {
|
|
2006
|
+
out.range = { from: +scene.range.from, to: +scene.range.to };
|
|
2007
|
+
}
|
|
2008
|
+
if (scene.indicators != null) {
|
|
2009
|
+
const s = String(scene.indicators).trim();
|
|
2010
|
+
if (s) out.indicators = s.slice(0, 200);
|
|
2011
|
+
}
|
|
2012
|
+
if (scene.type != null && SERIES_TYPES.includes(scene.type)) out.type = scene.type;
|
|
2013
|
+
if (scene.overlays != null) {
|
|
2014
|
+
const ovs = normalizeOverlays(scene.overlays);
|
|
2015
|
+
if (ovs.length) out.overlays = ovs;
|
|
2016
|
+
}
|
|
2017
|
+
if ('scenario' in scene) {
|
|
2018
|
+
if (scene.scenario == null) out.scenario = 'clear';
|
|
2019
|
+
else {
|
|
2020
|
+
const sc = normalizeScenario(scene.scenario);
|
|
2021
|
+
if (sc) out.scenario = sc;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
if ('riskPlan' in scene) {
|
|
2025
|
+
if (scene.riskPlan == null) out.riskPlan = 'clear';
|
|
2026
|
+
else {
|
|
2027
|
+
const rp = normalizeRiskPlan(scene.riskPlan);
|
|
2028
|
+
if (rp) out.riskPlan = rp;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
return out;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Validate a whole story: normalize each scene, drop junk, cap at 20.
|
|
2036
|
+
* @returns {object[]} possibly empty
|
|
2037
|
+
*/
|
|
2038
|
+
export function sceneList(story) {
|
|
2039
|
+
if (!Array.isArray(story)) return [];
|
|
2040
|
+
const out = [];
|
|
2041
|
+
for (const s of story) {
|
|
2042
|
+
const n = normalizeScene(s);
|
|
2043
|
+
if (n) out.push(n);
|
|
2044
|
+
if (out.length >= 20) break;
|
|
2045
|
+
}
|
|
2046
|
+
return out;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/* ------------------------------------------------------------------ *
|
|
2050
|
+
* Co-view presence — peer viewport tracking with TTL expiry
|
|
2051
|
+
* ------------------------------------------------------------------ */
|
|
2052
|
+
|
|
2053
|
+
/**
|
|
2054
|
+
* Tracks other charts viewing the same room: last-sighting timestamps per
|
|
2055
|
+
* peer plus the viewport each one is looking at. Pure bookkeeping — the
|
|
2056
|
+
* transport (BroadcastChannel, WebSocket, …) lives in the component/app.
|
|
2057
|
+
*
|
|
2058
|
+
* Peers expire `ttl` ms after their last sighting, so a closed tab fades
|
|
2059
|
+
* out of the room without an explicit goodbye.
|
|
2060
|
+
*/
|
|
2061
|
+
export class PresenceTracker {
|
|
2062
|
+
/** @param {number} [ttl=12000] ms a peer survives without a sighting */
|
|
2063
|
+
constructor(ttl = 12000) {
|
|
2064
|
+
this.ttl = Math.max(1000, +ttl || 12000);
|
|
2065
|
+
/** @type {Map<string, {id: string, name: string|null, range: {from:number,to:number}|null, at: number}>} */
|
|
2066
|
+
this.peers = new Map();
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* Record a sighting. `patch.range` ({from,to} times) is validated and
|
|
2071
|
+
* normalized; a sighting without a range keeps the previous one.
|
|
2072
|
+
* @returns {boolean} true when this sighting is a join (new peer)
|
|
2073
|
+
*/
|
|
2074
|
+
track(id, patch = {}, now = Date.now()) {
|
|
2075
|
+
if (!id || typeof id !== 'string') return false;
|
|
2076
|
+
const existing = this.peers.get(id);
|
|
2077
|
+
if (existing) {
|
|
2078
|
+
if (patch && patch.range) {
|
|
2079
|
+
const f = +patch.range.from;
|
|
2080
|
+
const t = +patch.range.to;
|
|
2081
|
+
if (Number.isFinite(f) && Number.isFinite(t)) {
|
|
2082
|
+
existing.range = { from: Math.min(f, t), to: Math.max(f, t) };
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
if (patch && patch.name != null) existing.name = String(patch.name).slice(0, 24) || null;
|
|
2086
|
+
existing.at = now;
|
|
2087
|
+
return false;
|
|
2088
|
+
}
|
|
2089
|
+
const f = patch && patch.range ? +patch.range.from : NaN;
|
|
2090
|
+
const t = patch && patch.range ? +patch.range.to : NaN;
|
|
2091
|
+
this.peers.set(id, {
|
|
2092
|
+
id,
|
|
2093
|
+
name: patch && patch.name != null ? (String(patch.name).slice(0, 24) || null) : null,
|
|
2094
|
+
range: Number.isFinite(f) && Number.isFinite(t)
|
|
2095
|
+
? { from: Math.min(f, t), to: Math.max(f, t) }
|
|
2096
|
+
: null,
|
|
2097
|
+
at: now,
|
|
2098
|
+
});
|
|
2099
|
+
return true;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/** @returns {object|null} the removed peer entry, or null when unknown */
|
|
2103
|
+
drop(id) {
|
|
2104
|
+
const p = this.peers.get(id);
|
|
2105
|
+
this.peers.delete(id);
|
|
2106
|
+
return p || null;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/** Expire peers not seen within the ttl.
|
|
2110
|
+
* @returns {object[]} the peer entries that left */
|
|
2111
|
+
sweep(now = Date.now()) {
|
|
2112
|
+
const left = [];
|
|
2113
|
+
for (const [id, p] of this.peers) {
|
|
2114
|
+
if (now - p.at > this.ttl) {
|
|
2115
|
+
this.peers.delete(id);
|
|
2116
|
+
left.push(p);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
return left;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/** @returns {{id: string, name: string|null, range: object|null, at: number}[]} copies, oldest sighting first */
|
|
2123
|
+
list() {
|
|
2124
|
+
return [...this.peers.values()]
|
|
2125
|
+
.sort((a, b) => a.at - b.at)
|
|
2126
|
+
.map((p) => ({ ...p, range: p.range ? { ...p.range } : null }));
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
|
|
1810
2130
|
/* ------------------------------------------------------------------ *
|
|
1811
2131
|
* AI-ready window summary
|
|
1812
2132
|
* ------------------------------------------------------------------ */
|