wickchart 1.2.0 → 1.3.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 +98 -2
- package/package.json +1 -1
- package/src/core.js +320 -0
- package/src/wick-chart.js +587 -5
- package/types/core.d.ts +156 -0
- package/types/wick-chart.d.ts +182 -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wickchart",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
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
|
* ------------------------------------------------------------------ */
|