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