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 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.2.0",
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
  * ------------------------------------------------------------------ */