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/src/wick-chart.js CHANGED
@@ -31,7 +31,9 @@ import {
31
31
  windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
32
32
  compileScript, predicateTrueSeries, scriptAlertStep,
33
33
  AI_TOOLS, aiPromptText, applyChartOps,
34
- calcVolCone, normalizeScenario,
34
+ calcVolCone, normalizeScenario, normalizeRiskPlan, PresenceTracker,
35
+ narrateWindow, brushStats,
36
+ easeInOutCubic, sceneList,
35
37
  } from './core.js';
36
38
 
37
39
  /* ------------------------------------------------------------------ *
@@ -48,7 +50,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
48
50
 
49
51
  class WickChart extends HTMLElementBase {
50
52
  static get observedAttributes() {
51
- return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'sonify'];
53
+ return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'co-view-name', 'brush', 'sonify'];
52
54
  }
53
55
 
54
56
  constructor() {
@@ -205,6 +207,11 @@ class WickChart extends HTMLElementBase {
205
207
  this._coviewLast = 0;
206
208
  this._ghost = null;
207
209
  this._ghostTimer = 0;
210
+ // presence: peer viewports (who is looking where)
211
+ this._coviewLabel = null; // display name from the co-view-name attribute
212
+ this._presence = new PresenceTracker();
213
+ this._coviewBeat = 0;
214
+ this._coviewViewLast = 0;
208
215
 
209
216
  // sonification state
210
217
  this._sonify = false;
@@ -213,6 +220,15 @@ class WickChart extends HTMLElementBase {
213
220
  this._playToken = 0;
214
221
  this._measure = null; // { iA, pA, iB, pB, done }
215
222
  this._measuring = false;
223
+ // bar-walk narrator state
224
+ this._walkTimer = 0;
225
+ // story mode state: token cancels stale async runs
226
+ this._storyToken = 0;
227
+ this._story = null;
228
+ // delta brush state: mode flag + current/finished selection
229
+ this._brush = false;
230
+ this._brushSel = null; // { i0, i1, stats } — the committed selection
231
+ this._brushDrag = null; // { i0, i1 } — while the pointer is down
216
232
  this._ind = { overlays: [], panes: [], volume: true };
217
233
 
218
234
  this._pointers = new Map();
@@ -233,6 +249,8 @@ class WickChart extends HTMLElementBase {
233
249
 
234
250
  // scenario projection (ghost path + vol cone)
235
251
  this._scenario = null;
252
+ // risk plan (R-multiple grid)
253
+ this._riskPlan = null;
236
254
 
237
255
  this._onResize = () => this._invalidate();
238
256
  this._onPointerDown = (e) => this._pointerDown(e);
@@ -246,7 +264,10 @@ class WickChart extends HTMLElementBase {
246
264
  }
247
265
  };
248
266
  this._onWheel = (e) => this._wheel(e);
249
- this._onDbl = () => this.fit();
267
+ this._onDbl = () => {
268
+ this._stopPlayback();
269
+ this.fit();
270
+ };
250
271
  this._onKey = (e) => this._keydown(e);
251
272
  }
252
273
 
@@ -280,12 +301,22 @@ class WickChart extends HTMLElementBase {
280
301
 
281
302
  disconnectedCallback() {
282
303
  this._connected = false;
304
+ this.stopWalk(true);
305
+ this.stopStory(true);
283
306
  if (this._coviewCh) {
307
+ this._coviewSend({ type: 'bye' });
284
308
  try {
285
309
  this._coviewCh.close();
286
310
  } catch (_) {}
287
311
  this._coviewCh = null;
288
312
  }
313
+ clearInterval(this._coviewBeat);
314
+ this._coviewBeat = 0;
315
+ if (this._presence && this._presence.peers.size) {
316
+ const left = this._presence.list();
317
+ this._presence = new PresenceTracker();
318
+ this._fire('peers', { peers: [], joined: [], left });
319
+ }
289
320
  clearTimeout(this._ghostTimer);
290
321
  if (this._ro) this._ro.disconnect();
291
322
  const cv = this._canvas;
@@ -355,6 +386,16 @@ class WickChart extends HTMLElementBase {
355
386
  this._coviewName = val || null;
356
387
  this._setupCoView();
357
388
  break;
389
+ case 'co-view-name':
390
+ // display name travels with every presence message; no re-render
391
+ this._coviewLabel = val || null;
392
+ break;
393
+ case 'brush':
394
+ this._brush = val != null && val !== 'false';
395
+ this._brushSel = null;
396
+ this._brushDrag = null;
397
+ this._invalidate();
398
+ break;
358
399
  case 'sonify':
359
400
  this._sonify = val != null && val !== 'false';
360
401
  this._lastToneIdx = -1;
@@ -424,6 +465,8 @@ class WickChart extends HTMLElementBase {
424
465
  this.clearData();
425
466
  return;
426
467
  }
468
+ // selection indices are data-bound; a replacement invalidates them
469
+ if (this._brushSel || this._brushDrag) this.clearBrush();
427
470
  const norm = [];
428
471
  for (const b of bars) {
429
472
  const nb = WickChart._normBar(b);
@@ -910,6 +953,41 @@ class WickChart extends HTMLElementBase {
910
953
  return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
911
954
  }
912
955
 
956
+ /**
957
+ * Risk plan: an R-multiple grid anchored at entry/stop. 1R = |entry −
958
+ * stop| (the risk unit); reward lines are drawn at kR beyond the entry
959
+ * with the risk/reward zones shaded, so sizing and take-profit choices
960
+ * read directly off the chart.
961
+ *
962
+ * chart.setRiskPlan({ entry: 64500, stop: 63800, multiples: [1, 2, 3] });
963
+ * chart.setRiskPlan({ entry: 64500, stop: 63800, targets: [65900, 67300] });
964
+ *
965
+ * Direction is derived (stop below entry ⇒ long). Targets convert to
966
+ * their R multiple; `multiples` win when both are given. Invalid specs
967
+ * clear the plan (replace semantics, like setScenario); excluded from
968
+ * getState/setState — it is app state, not chart state.
969
+ * @param {object} spec
970
+ * @returns {object|null} the normalized plan, or null when invalid
971
+ */
972
+ setRiskPlan(spec) {
973
+ this._riskPlan = normalizeRiskPlan(spec);
974
+ this._invalidate();
975
+ return this._riskPlan;
976
+ }
977
+
978
+ clearRiskPlan() {
979
+ if (this._riskPlan) {
980
+ this._riskPlan = null;
981
+ this._invalidate();
982
+ }
983
+ }
984
+
985
+ /** @returns {object|null} a copy of the active risk plan */
986
+ get riskPlan() {
987
+ if (!this._riskPlan) return null;
988
+ return { ...this._riskPlan, levels: this._riskPlan.levels.map((l) => ({ ...l })) };
989
+ }
990
+
913
991
  /** σ-cone for the active scenario, cached per data version. */
914
992
  _scenarioConeCache() {
915
993
  if (!this._scenario || !this._data.length) return null;
@@ -1799,6 +1877,67 @@ class WickChart extends HTMLElementBase {
1799
1877
  }
1800
1878
  }
1801
1879
 
1880
+ /* risk plan: R-multiple grid — risk/reward shading + kR lines */
1881
+ if (this._riskPlan && d.length) {
1882
+ const rp = this._riskPlan;
1883
+ const fP = numberFmt(this._prec(scale.rawHi || 1));
1884
+ const yE = yOf(rp.entry);
1885
+ const yS = yOf(rp.stop);
1886
+ ctx.fillStyle = hexToRgba(pal.down, 0.06);
1887
+ ctx.fillRect(0, Math.min(yE, yS), plotRight, Math.abs(yS - yE));
1888
+ const yTop = yOf(rp.levels[rp.levels.length - 1].price);
1889
+ ctx.fillStyle = hexToRgba(pal.up, 0.05);
1890
+ ctx.fillRect(0, Math.min(yE, yTop), plotRight, Math.abs(yTop - yE));
1891
+ const line = (p, col, dash) => {
1892
+ const y = yOf(p);
1893
+ if (y < main.y0 || y > main.y1) return null;
1894
+ ctx.strokeStyle = col;
1895
+ ctx.lineWidth = 1.5;
1896
+ if (dash) ctx.setLineDash([5, 4]);
1897
+ ctx.beginPath();
1898
+ ctx.moveTo(0, Math.round(y) + 0.5);
1899
+ ctx.lineTo(plotRight, Math.round(y) + 0.5);
1900
+ ctx.stroke();
1901
+ ctx.setLineDash([]);
1902
+ ctx.lineWidth = 1;
1903
+ return y;
1904
+ };
1905
+ const pills = [];
1906
+ for (let i = rp.levels.length - 1; i >= 0; i--) {
1907
+ const lv = rp.levels[i];
1908
+ const y = line(lv.price, pal.up, true);
1909
+ if (y != null) {
1910
+ const kk = lv.k % 1 === 0 ? lv.k : +lv.k.toFixed(2);
1911
+ pills.push({ y, text: `${kk}R ${fP.format(lv.price)}`, bg: pal.up });
1912
+ }
1913
+ }
1914
+ const yStop = line(rp.stop, pal.down, false);
1915
+ if (yStop != null) pills.push({ y: yStop, text: `STOP ${fP.format(rp.stop)}`, bg: pal.down });
1916
+ const yEnt = line(rp.entry, pal.accent, false);
1917
+ if (yEnt != null) {
1918
+ pills.push({ y: yEnt, text: `${rp.direction === 'long' ? 'LONG' : 'SHORT'} ${fP.format(rp.entry)}`, bg: pal.accent });
1919
+ }
1920
+ // stack right-edge pills instead of letting close lines overlap
1921
+ pills.sort((a, b) => a.y - b.y);
1922
+ let lastY = -Infinity;
1923
+ for (const p of pills) {
1924
+ const y = Math.max(p.y, lastY + 20);
1925
+ lastY = y;
1926
+ ctx.font = pillFont();
1927
+ const tw = ctx.measureText(p.text).width + 12;
1928
+ this._pill(plotRight - tw - 8, y, p.text, p.bg, pal.pillText, 'left', tw);
1929
+ }
1930
+ if (rp.label) {
1931
+ ctx.font = pillFont();
1932
+ ctx.fillStyle = pal.accent;
1933
+ ctx.globalAlpha = 0.9;
1934
+ ctx.textAlign = 'left';
1935
+ ctx.textBaseline = 'bottom';
1936
+ ctx.fillText(rp.label, 8, clamp(yE, main.y0 + 14, main.y1) - 3);
1937
+ ctx.globalAlpha = 1;
1938
+ }
1939
+ }
1940
+
1802
1941
  /* volume profile (behind the series) */
1803
1942
  if (this._profile) {
1804
1943
  const pkey = `${i0}:${i1}:${this._version}`;
@@ -2458,6 +2597,34 @@ class WickChart extends HTMLElementBase {
2458
2597
  );
2459
2598
  }
2460
2599
 
2600
+ /* co-view presence: peer viewport bands along the top of the plot */
2601
+ if (this._presence && this._presence.peers.size && d.length) {
2602
+ const peers = this._presence.list().slice(0, 4);
2603
+ ctx.save();
2604
+ ctx.font = pillFont();
2605
+ for (let row = 0; row < peers.length; row++) {
2606
+ const p = peers[row];
2607
+ if (!p.range) continue;
2608
+ const cols = pal.overlay || [];
2609
+ const col = cols[(row + 1) % Math.max(cols.length, 1)] || pal.accent;
2610
+ const i0 = WickChart._indexForTime(this._data, p.range.from);
2611
+ const i1 = WickChart._indexForTime(this._data, p.range.to);
2612
+ const x0 = clamp(this._xFor(i0), 0, plotRight);
2613
+ const x1 = clamp(this._xFor(i1), 0, plotRight);
2614
+ const y = main.y0 + 2 + row * 5;
2615
+ ctx.globalAlpha = 0.8;
2616
+ ctx.fillStyle = col;
2617
+ ctx.fillRect(x0, y, Math.max(x1 - x0, 3), 3);
2618
+ if (x1 - x0 > 44) {
2619
+ ctx.globalAlpha = 0.95;
2620
+ ctx.textAlign = 'left';
2621
+ ctx.textBaseline = 'top';
2622
+ ctx.fillText(p.name || p.id, x0 + 3, y + 4);
2623
+ }
2624
+ }
2625
+ ctx.restore();
2626
+ }
2627
+
2461
2628
  /* co-view ghost crosshair (peer pointer from another tab/chart) */
2462
2629
  if (this._ghost) {
2463
2630
  const g = this._ghost;
@@ -2544,6 +2711,50 @@ class WickChart extends HTMLElementBase {
2544
2711
  );
2545
2712
  }
2546
2713
 
2714
+ /* delta brush selection: band + live delta chip */
2715
+ {
2716
+ const sel = this._brushDrag || this._brushSel;
2717
+ if (sel && d.length) {
2718
+ const bi0 = Math.min(sel.i0, sel.i1);
2719
+ const bi1 = Math.max(sel.i0, sel.i1);
2720
+ const xa = this._xFor(bi0) - this._view.spacing / 2;
2721
+ const xb = this._xFor(bi1) + this._view.spacing / 2;
2722
+ const bx0 = clamp(Math.min(xa, xb), 0, plotRight);
2723
+ const bx1 = clamp(Math.max(xa, xb), 0, plotRight);
2724
+ if (bx1 - bx0 > 1) {
2725
+ const live = this._brushDrag ? brushStats(this._data, bi0, bi1) : sel.stats;
2726
+ ctx.save();
2727
+ ctx.fillStyle = hexToRgba(pal.accent, this._brushDrag ? 0.13 : 0.08);
2728
+ ctx.fillRect(bx0, main.y0, bx1 - bx0, main.h);
2729
+ ctx.globalAlpha = 0.55;
2730
+ ctx.strokeStyle = pal.accent;
2731
+ ctx.lineWidth = 1;
2732
+ if (!this._brushDrag) ctx.setLineDash([4, 3]);
2733
+ ctx.strokeRect(bx0 + 0.5, main.y0 + 0.5, bx1 - bx0 - 1, main.h - 1);
2734
+ ctx.setLineDash([]);
2735
+ ctx.restore();
2736
+ if (live) {
2737
+ const fP = numberFmt(this._prec(Math.abs(live.lastClose) || 1));
2738
+ const sign = live.delta >= 0 ? '+' : '';
2739
+ const txt =
2740
+ `${sign}${live.deltaPct.toFixed(2)}% · ${live.bars} bars · ` +
2741
+ `H ${fP.format(live.high)} · L ${fP.format(live.low)} · Σvol ${fmtCompact(live.volume)}`;
2742
+ ctx.font = pillFont();
2743
+ const tw = ctx.measureText(txt).width + 12;
2744
+ this._pill(
2745
+ clamp((bx0 + bx1) / 2 - tw / 2, 2, plotRight - tw - 2),
2746
+ main.y0 + 11,
2747
+ txt,
2748
+ live.delta >= 0 ? pal.up : pal.down,
2749
+ pal.pillText,
2750
+ 'left',
2751
+ tw
2752
+ );
2753
+ }
2754
+ }
2755
+ }
2756
+ }
2757
+
2547
2758
  /* visible-range stats chip */
2548
2759
  if (this._stats) {
2549
2760
  const st = computeStats(d, i0, i1, this._dt);
@@ -2712,6 +2923,7 @@ class WickChart extends HTMLElementBase {
2712
2923
 
2713
2924
  _pointerDown(e) {
2714
2925
  if (e.button !== 0) return;
2926
+ this._stopPlayback(); // any touch interrupts the story
2715
2927
  this._canvas.setPointerCapture(e.pointerId);
2716
2928
  const pt = this._localPoint(e);
2717
2929
  this._pointers.set(e.pointerId, pt);
@@ -2726,6 +2938,15 @@ class WickChart extends HTMLElementBase {
2726
2938
  };
2727
2939
  this._pan = null;
2728
2940
  this._measuring = false;
2941
+ this._brushDrag = null;
2942
+ } else if (this._brush && !e.shiftKey && this._data.length) {
2943
+ // brush mode: plain drag selects a bar range (shift still measures)
2944
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
2945
+ this._brushDrag = { i0: idx, i1: idx };
2946
+ this._brushSel = null;
2947
+ this._measuring = false;
2948
+ this._pan = null;
2949
+ this._invalidate();
2729
2950
  } else if (e.shiftKey && this._data.length) {
2730
2951
  // shift+drag → measure tool
2731
2952
  this._measuring = true;
@@ -2777,6 +2998,13 @@ class WickChart extends HTMLElementBase {
2777
2998
  return;
2778
2999
  }
2779
3000
 
3001
+ if (this._brushDrag && this._pointers.has(e.pointerId) && this._data.length) {
3002
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
3003
+ this._brushDrag.i1 = idx;
3004
+ this._invalidate();
3005
+ return;
3006
+ }
3007
+
2780
3008
  if (this._pan && this._pointers.has(e.pointerId) && ly) {
2781
3009
  const dx = pt.x - this._pan.x;
2782
3010
  if (Math.abs(dx) > 3) this._pan.moved = true;
@@ -2802,7 +3030,11 @@ class WickChart extends HTMLElementBase {
2802
3030
  if (this._pointers.size < 2) this._pinch = null;
2803
3031
  if (this._pointers.size === 0) {
2804
3032
  this._canvas.classList.remove('grabbing');
2805
- if (this._measuring) {
3033
+ if (this._brushDrag && had) {
3034
+ const b = this._brushDrag;
3035
+ this._brushDrag = null;
3036
+ this._brushFinish(Math.min(b.i0, b.i1), Math.max(b.i0, b.i1));
3037
+ } else if (this._measuring) {
2806
3038
  this._measuring = false;
2807
3039
  if (this._measure) {
2808
3040
  this._measure.done = true;
@@ -2846,6 +3078,7 @@ class WickChart extends HTMLElementBase {
2846
3078
  _wheel(e) {
2847
3079
  const ly = this._ly;
2848
3080
  if (!ly || !this._data.length) return;
3081
+ this._stopPlayback();
2849
3082
  e.preventDefault();
2850
3083
  const pt = this._localPoint(e);
2851
3084
  const dx = e.deltaX;
@@ -2879,6 +3112,11 @@ class WickChart extends HTMLElementBase {
2879
3112
  _keydown(e) {
2880
3113
  const ly = this._ly;
2881
3114
  if (!ly || !this._data.length) return;
3115
+ this._stopPlayback();
3116
+ if (e.key === 'Escape' && (this._brushSel || this._brushDrag)) {
3117
+ this.clearBrush();
3118
+ return;
3119
+ }
2882
3120
  const d = this._data;
2883
3121
  const key = e.key;
2884
3122
  const step = e.shiftKey ? 10 : 1;
@@ -3067,11 +3305,18 @@ class WickChart extends HTMLElementBase {
3067
3305
  } catch (_) {}
3068
3306
  this._coviewCh = null;
3069
3307
  }
3308
+ clearInterval(this._coviewBeat);
3309
+ this._coviewBeat = 0;
3070
3310
  clearTimeout(this._ghostTimer);
3071
3311
  if (this._ghost) {
3072
3312
  this._ghost = null;
3073
3313
  this._invalidate();
3074
3314
  }
3315
+ if (this._presence && this._presence.peers.size) {
3316
+ const left = this._presence.list();
3317
+ this._presence = new PresenceTracker();
3318
+ this._fire('peers', { peers: [], joined: [], left });
3319
+ }
3075
3320
  const name = this._coviewName;
3076
3321
  if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
3077
3322
  if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
@@ -3080,6 +3325,33 @@ class WickChart extends HTMLElementBase {
3080
3325
  ch.onmessage = (ev) => this._onCoMessage(ev.data);
3081
3326
  this._coviewCh = ch;
3082
3327
  } catch (_) {}
3328
+ // presence: announce immediately, then heartbeat so idle peers stay
3329
+ // warm (and stale ones sweep) without waiting for a pan/zoom
3330
+ this._coviewSendView(true);
3331
+ this._coviewBeat = setInterval(() => {
3332
+ this._coviewSendView(true);
3333
+ const left = this._presence.sweep();
3334
+ if (left.length) {
3335
+ this._fire('peers', { peers: this._presence.list(), joined: [], left });
3336
+ this._invalidate();
3337
+ }
3338
+ }, 4000);
3339
+ }
3340
+
3341
+ /** Broadcast our visible range for presence; throttled unless forced. */
3342
+ _coviewSendView(force) {
3343
+ if (!this._coviewCh) return;
3344
+ const r = this.getVisibleRange();
3345
+ if (!r) return;
3346
+ const now = performance.now();
3347
+ if (!force && now - this._coviewViewLast < 120) return;
3348
+ this._coviewViewLast = now;
3349
+ this._coviewSend({
3350
+ type: 'view',
3351
+ from: r.from,
3352
+ to: r.to,
3353
+ name: this._coviewLabel || null,
3354
+ });
3083
3355
  }
3084
3356
 
3085
3357
  _coviewSend(msg) {
@@ -3090,7 +3362,30 @@ class WickChart extends HTMLElementBase {
3090
3362
  }
3091
3363
 
3092
3364
  _onCoMessage(m) {
3093
- if (!m || m.v !== 1 || m.peer === this._coviewPeer || m.type !== 'cross') return;
3365
+ if (!m || m.v !== 1 || m.peer === this._coviewPeer) return;
3366
+ if (m.type === 'view') {
3367
+ const joined = this._presence.track(m.peer, {
3368
+ range: { from: m.from, to: m.to },
3369
+ name: m.name,
3370
+ });
3371
+ this._invalidate();
3372
+ if (joined) {
3373
+ const p = this._presence.peers.get(m.peer);
3374
+ this._fire('peers', {
3375
+ peers: this._presence.list(),
3376
+ joined: [p ? { ...p, range: p.range && { ...p.range } } : { id: m.peer }],
3377
+ left: [],
3378
+ });
3379
+ }
3380
+ return;
3381
+ }
3382
+ if (m.type === 'bye') {
3383
+ const left = this._presence.drop(m.peer);
3384
+ if (left) this._fire('peers', { peers: this._presence.list(), joined: [], left: [left] });
3385
+ this._invalidate();
3386
+ return;
3387
+ }
3388
+ if (m.type !== 'cross') return;
3094
3389
  if (m.time == null) {
3095
3390
  if (this._ghost) {
3096
3391
  this._ghost = null;
@@ -3120,10 +3415,297 @@ class WickChart extends HTMLElementBase {
3120
3415
  this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
3121
3416
  }
3122
3417
 
3418
+ /**
3419
+ * Live co-view peers: who else is in the room and the time window each
3420
+ * one is looking at — [{ id, name, range: {from, to}, at }], oldest
3421
+ * sighting first. Peers fade out ~12 s after their last sighting.
3422
+ * @returns {object[]}
3423
+ */
3424
+ getPeers() {
3425
+ return this._presence ? this._presence.list() : [];
3426
+ }
3427
+
3428
+ /**
3429
+ * Narrated timeline for a window (default: the visible range) — pivot
3430
+ * highs/lows, volume spikes, gaps, RSI divergences plus derived legs
3431
+ * ("+12.4% over 38 bars"), sorted by index. Pure data, perfect for
3432
+ * caption UIs or the walk player.
3433
+ * chart.narrate(); // visible range
3434
+ * chart.narrate({ from, to }); // times in ms (s accepted)
3435
+ * @param {{from?: number, to?: number}} [range]
3436
+ * @returns {{i: number, time: number, type: string, side: string, note: string,
3437
+ * legPct?: number, legBars?: number}[]}
3438
+ */
3439
+ narrate(range) {
3440
+ const d = this._data;
3441
+ if (!d.length) return [];
3442
+ let i0 = 0;
3443
+ let i1 = d.length - 1;
3444
+ if (range && isNum(range.from) && isNum(range.to)) {
3445
+ i0 = WickChart._indexForTime(d, WickChart._timeToMs(range.from));
3446
+ i1 = WickChart._indexForTime(d, WickChart._timeToMs(range.to));
3447
+ if (i0 > i1) [i0, i1] = [i1, i0];
3448
+ }
3449
+ return narrateWindow(d, i0, i1);
3450
+ }
3451
+
3452
+ /**
3453
+ * Walk the chart through history like a story: the viewport slides
3454
+ * from `from` to `to` while `wick:walk` events announce every step and
3455
+ * the narrator's events (spikes, gaps, pivots, legs) as they're crossed.
3456
+ * Any user interaction — pointer, wheel, keys, double-click — stops it.
3457
+ * chart.walk({ from: 0, to: 500, speed: 120, step: 10 });
3458
+ * chart.addEventListener('wick:walk', (e) => showCaption(e.detail));
3459
+ * // detail: { phase: 'step'|'end'|'stop', index, events: [...], from, to }
3460
+ * @param {{from?: number, to?: number, speed?: number, step?: number}} [opts]
3461
+ * from/to are bar indices (default: last ~500 bars → the end)
3462
+ * @returns {boolean} true when the walk started
3463
+ */
3464
+ walk(opts = {}) {
3465
+ this.stopWalk(true);
3466
+ const d = this._data;
3467
+ if (!d.length || !this._connected) return false;
3468
+ const to = clamp(Math.round(+opts.to || d.length - 1), 0, d.length - 1);
3469
+ const from = clamp(Math.round(opts.from != null ? +opts.from : Math.max(0, to - 500)), 0, to);
3470
+ const span = to - from + 1;
3471
+ // window width: the current viewport, but never more than ~⅓ of the
3472
+ // span (a fully zoomed-out chart would otherwise start at `to`)
3473
+ const widthBars = clamp(
3474
+ Math.min(
3475
+ this._ly ? Math.round(this._ly.plotRight / this._view.spacing) : 120,
3476
+ Math.max(10, Math.ceil(span / 3))
3477
+ ),
3478
+ 10,
3479
+ span
3480
+ );
3481
+ const events = narrateWindow(d, from, to, { pivot: 8 });
3482
+ const speed = clamp(Math.round(+opts.speed || 120), 16, 2000);
3483
+ const step = clamp(Math.round(+opts.step || Math.max(1, Math.round(widthBars / 12))), 1, 500);
3484
+ let cursor = Math.min(from + widthBars - 1, to);
3485
+ let ev = 0;
3486
+ let ended = false;
3487
+ const tick = () => {
3488
+ if (ended) return;
3489
+ this._auto = false;
3490
+ this._view.rightIndex = cursor;
3491
+ this._clampView();
3492
+ this._invalidate();
3493
+ this._emitRange();
3494
+ const hits = [];
3495
+ while (ev < events.length && events[ev].i <= cursor) hits.push(events[ev++]);
3496
+ this._fire('walk', { phase: 'step', index: cursor, events: hits, from, to });
3497
+ if (cursor >= to) {
3498
+ ended = true;
3499
+ clearInterval(this._walkTimer);
3500
+ this._walkTimer = 0;
3501
+ this._fire('walk', { phase: 'end', index: cursor, events: [], from, to });
3502
+ } else {
3503
+ cursor = Math.min(cursor + step, to);
3504
+ }
3505
+ };
3506
+ this._walkTimer = setInterval(tick, speed);
3507
+ tick(); // first step lands immediately
3508
+ return true;
3509
+ }
3510
+
3511
+ /**
3512
+ * Stop the running walk (if any). Fires a final `wick:walk`
3513
+ * { phase: 'stop' } unless called internally.
3514
+ */
3515
+ stopWalk(silent) {
3516
+ if (!this._walkTimer) return;
3517
+ clearInterval(this._walkTimer);
3518
+ this._walkTimer = 0;
3519
+ if (!silent) this._fire('walk', { phase: 'stop' });
3520
+ }
3521
+
3522
+ /**
3523
+ * Commit a brush selection over [i0, i1]: stores it (draws the band
3524
+ * and delta chip) and fires `wick:brush` with the range statistics.
3525
+ * @param {number} i0 first index
3526
+ * @param {number} i1 last index
3527
+ */
3528
+ _brushFinish(i0, i1) {
3529
+ if (!this._data.length) return;
3530
+ const stats = brushStats(this._data, i0, i1);
3531
+ if (!stats) {
3532
+ this._brushSel = null;
3533
+ this._invalidate();
3534
+ return;
3535
+ }
3536
+ this._brushSel = { i0, i1, stats };
3537
+ this._invalidate();
3538
+ this._fire('brush', stats);
3539
+ }
3540
+
3541
+ /** Clear the committed brush selection (if any). Escape does the same. */
3542
+ clearBrush() {
3543
+ if (this._brushSel || this._brushDrag) {
3544
+ this._brushSel = null;
3545
+ this._brushDrag = null;
3546
+ this._invalidate();
3547
+ }
3548
+ }
3549
+
3550
+ /** @returns {object|null} the committed selection { i0, i1, stats } */
3551
+ get brushSelection() {
3552
+ if (!this._brushSel) return null;
3553
+ const { i0, i1, stats } = this._brushSel;
3554
+ return { i0, i1, stats: { ...stats, from: { ...stats.from }, to: { ...stats.to } } };
3555
+ }
3556
+
3557
+ /**
3558
+ * Capture the current chart state as a story scene: view, series type,
3559
+ * indicators, overlays, scenario and risk plan, plus a title/note.
3560
+ * Build guided tours by capturing several and playing them back.
3561
+ * const story = [
3562
+ * chart.captureScene('Overview', 'The full picture'),
3563
+ * { title: 'The breakout', range: { from, to }, indicators: 'sma:20' },
3564
+ * ];
3565
+ * chart.playStory(story);
3566
+ * @param {string} [title]
3567
+ * @param {string} [note]
3568
+ * @returns {object} scene (plain data — snapshot of the moment)
3569
+ */
3570
+ captureScene(title, note) {
3571
+ const scene = {
3572
+ title: title != null ? String(title).slice(0, 60) : '',
3573
+ note: note != null ? String(note).slice(0, 200) : '',
3574
+ range: this.getVisibleRange() || undefined,
3575
+ type: this.getAttribute('type') || 'candles',
3576
+ indicators: this.getAttribute('indicators') || null,
3577
+ };
3578
+ const ovs = this.overlays;
3579
+ if (ovs.length) scene.overlays = ovs;
3580
+ const sc = this.scenario;
3581
+ if (sc) scene.scenario = sc;
3582
+ const rp = this.riskPlan;
3583
+ if (rp) scene.riskPlan = rp;
3584
+ return scene;
3585
+ }
3586
+
3587
+ /** @returns {object[]|null} a copy of the last played story */
3588
+ getStory() {
3589
+ return this._story ? this._story.map((s) => ({ ...s })) : null;
3590
+ }
3591
+
3592
+ /**
3593
+ * Play a story: each scene applies its state (type / indicators /
3594
+ * overlays / scenario / risk plan — set or clear), the camera eases
3595
+ * to its range, then holds for its dwell. `wick:story` events narrate:
3596
+ * { phase: 'scene' | 'end' | 'stop', index, total, scene, title, note }
3597
+ * Any user interaction — pointer, wheel, keys, double-click — stops it.
3598
+ * @param {object[]} story scenes (invalid entries dropped, max 20)
3599
+ * @param {{dwell?: number, panMs?: number, loop?: boolean}} [opts]
3600
+ * panMs clamps 100–5000 (default 900); loop replays forever
3601
+ * @returns {boolean} true when playback started
3602
+ */
3603
+ playStory(story, opts = {}) {
3604
+ this.stopStory(true);
3605
+ const scenes = sceneList(story);
3606
+ if (!scenes.length || !this._data.length || !this._connected) return false;
3607
+ const token = ++this._storyToken;
3608
+ this._story = scenes;
3609
+ const panMs = clamp(Math.round(+opts.panMs || 900), 100, 5000);
3610
+ const loop = opts.loop === true;
3611
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
3612
+ const run = async () => {
3613
+ let idx = 0;
3614
+ while (token === this._storyToken) {
3615
+ const sc = scenes[idx];
3616
+ this._fire('story', {
3617
+ phase: 'scene', index: idx, total: scenes.length,
3618
+ scene: sc, title: sc.title, note: sc.note,
3619
+ });
3620
+ this._applyScene(sc);
3621
+ const target = this._sceneTarget(sc);
3622
+ if (target) await this._storyTween(target, panMs, token);
3623
+ if (token !== this._storyToken) return;
3624
+ await wait(sc.dwell);
3625
+ if (token !== this._storyToken) return;
3626
+ idx++;
3627
+ if (idx >= scenes.length) {
3628
+ if (loop) idx = 0;
3629
+ else {
3630
+ this._fire('story', { phase: 'end', index: idx - 1, total: scenes.length });
3631
+ return;
3632
+ }
3633
+ }
3634
+ }
3635
+ };
3636
+ run();
3637
+ return true;
3638
+ }
3639
+
3640
+ /**
3641
+ * Stop story playback (if running). Fires a final `wick:story`
3642
+ * { phase: 'stop' } unless called internally.
3643
+ */
3644
+ stopStory(silent) {
3645
+ if (!this._storyToken) return;
3646
+ this._storyToken = 0;
3647
+ if (!silent) this._fire('story', { phase: 'stop' });
3648
+ }
3649
+
3650
+ /** Apply a scene's state (only the fields it carries). */
3651
+ _applyScene(sc) {
3652
+ if (sc.type) this.setAttribute('type', sc.type);
3653
+ if (sc.indicators != null) this.setAttribute('indicators', sc.indicators);
3654
+ if (sc.overlays) this.setOverlays(sc.overlays);
3655
+ if (sc.scenario === 'clear') this.clearScenario();
3656
+ else if (sc.scenario) this.setScenario(sc.scenario);
3657
+ if (sc.riskPlan === 'clear') this.clearRiskPlan();
3658
+ else if (sc.riskPlan) this.setRiskPlan(sc.riskPlan);
3659
+ }
3660
+
3661
+ /** Map a scene's time range to bar indices (null when not applicable). */
3662
+ _sceneTarget(sc) {
3663
+ if (!sc.range || !this._data.length) return null;
3664
+ let i0 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.from));
3665
+ let i1 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.to));
3666
+ if (i0 > i1) [i0, i1] = [i1, i0];
3667
+ return i1 - i0 >= 2 ? { i0, i1 } : null;
3668
+ }
3669
+
3670
+ /** Ease the viewport to { i0, i1 } over `ms`; resolves early if the
3671
+ * token changes (superseded or stopped). rAF when available. */
3672
+ _storyTween(target, ms, token) {
3673
+ const ly = this._ly;
3674
+ const d = this._data;
3675
+ if (!ly || !d.length) return Promise.resolve();
3676
+ const sp1 = clamp(ly.plotRight / (target.i1 - target.i0), this._minSpacing(), WickChart._MAX_SP);
3677
+ const from = { right: this._view.rightIndex, sp: this._view.spacing };
3678
+ const to = { right: target.i1, sp: sp1 };
3679
+ const t0 = performance.now();
3680
+ this._auto = false;
3681
+ return new Promise((resolve) => {
3682
+ const step = () => {
3683
+ if (token !== this._storyToken) return resolve();
3684
+ const e = easeInOutCubic(Math.min(1, (performance.now() - t0) / ms));
3685
+ this._view.rightIndex = from.right + (to.right - from.right) * e;
3686
+ this._view.spacing = from.sp + (to.sp - from.sp) * e;
3687
+ this._clampView();
3688
+ this._invalidate();
3689
+ this._emitRange();
3690
+ if (e >= 1) resolve();
3691
+ else if (typeof requestAnimationFrame === 'function') requestAnimationFrame(step);
3692
+ else setTimeout(step, 16);
3693
+ };
3694
+ step();
3695
+ });
3696
+ }
3697
+
3698
+ /** Interrupt narrated playback (walk / story) on user input. */
3699
+ _stopPlayback() {
3700
+ if (this._walkTimer) this.stopWalk();
3701
+ if (this._storyToken) this.stopStory();
3702
+ }
3703
+
3123
3704
  _emitRange() {
3124
3705
  const r = this.getVisibleRange();
3125
3706
  if (!r) return;
3126
3707
  this._fire('range', r);
3708
+ if (this._coviewCh) this._coviewSendView();
3127
3709
  }
3128
3710
  }
3129
3711