wickchart 1.0.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
@@ -28,7 +28,12 @@ import {
28
28
  SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
29
29
  calcRSI, detectAnnotations, priceToFreq,
30
30
  calcRealizedVol, volRegimeBands, percentileOfSorted, parseVolShading,
31
- windowSummary,
31
+ windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
32
+ compileScript, predicateTrueSeries, scriptAlertStep,
33
+ AI_TOOLS, aiPromptText, applyChartOps,
34
+ calcVolCone, normalizeScenario, normalizeRiskPlan, PresenceTracker,
35
+ narrateWindow, brushStats,
36
+ easeInOutCubic, sceneList,
32
37
  } from './core.js';
33
38
 
34
39
  /* ------------------------------------------------------------------ *
@@ -45,7 +50,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
45
50
 
46
51
  class WickChart extends HTMLElementBase {
47
52
  static get observedAttributes() {
48
- return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'co-view', 'sonify'];
53
+ return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'co-view-name', 'brush', 'sonify'];
49
54
  }
50
55
 
51
56
  constructor() {
@@ -202,6 +207,11 @@ class WickChart extends HTMLElementBase {
202
207
  this._coviewLast = 0;
203
208
  this._ghost = null;
204
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;
205
215
 
206
216
  // sonification state
207
217
  this._sonify = false;
@@ -210,6 +220,15 @@ class WickChart extends HTMLElementBase {
210
220
  this._playToken = 0;
211
221
  this._measure = null; // { iA, pA, iB, pB, done }
212
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
213
232
  this._ind = { overlays: [], panes: [], volume: true };
214
233
 
215
234
  this._pointers = new Map();
@@ -225,6 +244,14 @@ class WickChart extends HTMLElementBase {
225
244
  this._alerts = [];
226
245
  this._seq = 0;
227
246
 
247
+ // server-side overlays (zones & levels)
248
+ this._overlays = [];
249
+
250
+ // scenario projection (ghost path + vol cone)
251
+ this._scenario = null;
252
+ // risk plan (R-multiple grid)
253
+ this._riskPlan = null;
254
+
228
255
  this._onResize = () => this._invalidate();
229
256
  this._onPointerDown = (e) => this._pointerDown(e);
230
257
  this._onPointerMove = (e) => this._pointerMove(e);
@@ -237,7 +264,10 @@ class WickChart extends HTMLElementBase {
237
264
  }
238
265
  };
239
266
  this._onWheel = (e) => this._wheel(e);
240
- this._onDbl = () => this.fit();
267
+ this._onDbl = () => {
268
+ this._stopPlayback();
269
+ this.fit();
270
+ };
241
271
  this._onKey = (e) => this._keydown(e);
242
272
  }
243
273
 
@@ -271,12 +301,22 @@ class WickChart extends HTMLElementBase {
271
301
 
272
302
  disconnectedCallback() {
273
303
  this._connected = false;
304
+ this.stopWalk(true);
305
+ this.stopStory(true);
274
306
  if (this._coviewCh) {
307
+ this._coviewSend({ type: 'bye' });
275
308
  try {
276
309
  this._coviewCh.close();
277
310
  } catch (_) {}
278
311
  this._coviewCh = null;
279
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
+ }
280
320
  clearTimeout(this._ghostTimer);
281
321
  if (this._ro) this._ro.disconnect();
282
322
  const cv = this._canvas;
@@ -330,10 +370,32 @@ class WickChart extends HTMLElementBase {
330
370
  this._volshade = val != null && val !== 'false' ? parseVolShading(val) : null;
331
371
  this._legendKey = '';
332
372
  break;
373
+ case 'overlays': {
374
+ let ovs = [];
375
+ if (val != null && val !== '') {
376
+ try {
377
+ ovs = normalizeOverlays(JSON.parse(val));
378
+ } catch (err) {
379
+ ovs = [];
380
+ }
381
+ }
382
+ this._overlays = ovs;
383
+ break;
384
+ }
333
385
  case 'co-view':
334
386
  this._coviewName = val || null;
335
387
  this._setupCoView();
336
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;
337
399
  case 'sonify':
338
400
  this._sonify = val != null && val !== 'false';
339
401
  this._lastToneIdx = -1;
@@ -403,6 +465,8 @@ class WickChart extends HTMLElementBase {
403
465
  this.clearData();
404
466
  return;
405
467
  }
468
+ // selection indices are data-bound; a replacement invalidates them
469
+ if (this._brushSel || this._brushDrag) this.clearBrush();
406
470
  const norm = [];
407
471
  for (const b of bars) {
408
472
  const nb = WickChart._normBar(b);
@@ -629,7 +693,11 @@ class WickChart extends HTMLElementBase {
629
693
  })),
630
694
  alerts: this._alerts
631
695
  .filter((a) => !a.fired)
632
- .map((a) => ({ id: a.id, price: a.price, direction: a.direction, once: a.once })),
696
+ .map((a) =>
697
+ a.when != null
698
+ ? { id: a.id, when: a.when, once: a.once }
699
+ : { id: a.id, price: a.price, direction: a.direction, once: a.once }
700
+ ),
633
701
  };
634
702
  }
635
703
 
@@ -667,14 +735,31 @@ class WickChart extends HTMLElementBase {
667
735
  }
668
736
  if (Array.isArray(state.alerts)) {
669
737
  this._alerts = state.alerts
670
- .filter((a) => a && isNum(a.price))
671
- .map((a) => ({
672
- id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
673
- price: a.price,
674
- direction: a.direction || 'cross',
675
- once: a.once !== false,
676
- fired: false,
677
- }));
738
+ .filter((a) => a && (isNum(a.price) || typeof a.when === 'string'))
739
+ .map((a) => {
740
+ if (typeof a.when === 'string' && a.when.trim()) {
741
+ try {
742
+ return {
743
+ id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
744
+ when: a.when.trim(),
745
+ compiled: compileScript(a.when),
746
+ once: a.once !== false,
747
+ fired: false,
748
+ armed: true,
749
+ };
750
+ } catch (err) {
751
+ return null;
752
+ }
753
+ }
754
+ return {
755
+ id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
756
+ price: a.price,
757
+ direction: a.direction || 'cross',
758
+ once: a.once !== false,
759
+ fired: false,
760
+ };
761
+ })
762
+ .filter(Boolean);
678
763
  }
679
764
  if (state.view && state.view.from != null && state.view.to != null) {
680
765
  if (this._ly && this._data.length > 1) {
@@ -727,22 +812,46 @@ class WickChart extends HTMLElementBase {
727
812
  }
728
813
 
729
814
  /**
730
- * Price alert. Fires `wick:alert` ({id, price, bar}) on an edge crossing
731
- * (plus the deprecated `hab:alert` alias)
732
- * during streaming updates.
733
- * @param {{id?: string, price: number, direction?: 'above'|'below'|'cross',
734
- * once?: boolean}} alert
735
- * @returns {string|null} the alert id
815
+ * Price or scripted alert. Price alerts fire `wick:alert`
816
+ * ({id, price, bar}) on an edge crossing; scripted alerts evaluate a
817
+ * WickScript predicate (`when`) on every streamed bar and fire on its
818
+ * false→true edge e.g. `when: 'crossup(rsi(close,14), 30)'` or
819
+ * `when: 'volume > sma(volume,20) * 3'`. Scripted events carry the
820
+ * triggering close as `price` plus the `when` source (deprecated
821
+ * `hab:alert` alias still dispatched).
822
+ * @param {{id?: string, price?: number, direction?: 'above'|'below'|'cross',
823
+ * when?: string, once?: boolean}} alert
824
+ * @returns {string|null} the alert id (null when no valid price/when,
825
+ * or the predicate fails to compile)
736
826
  */
737
827
  addAlert(alert) {
738
- if (!alert || !isNum(alert.price)) return null;
739
- const a = {
740
- id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
741
- price: alert.price,
742
- direction: alert.direction || 'cross',
743
- once: alert.once !== false,
744
- fired: false,
745
- };
828
+ if (!alert) return null;
829
+ let a;
830
+ if (typeof alert.when === 'string' && alert.when.trim()) {
831
+ let compiled;
832
+ try {
833
+ compiled = compileScript(alert.when);
834
+ } catch (err) {
835
+ return null;
836
+ }
837
+ a = {
838
+ id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
839
+ when: alert.when.trim(),
840
+ compiled,
841
+ once: alert.once !== false,
842
+ fired: false,
843
+ armed: true,
844
+ };
845
+ } else {
846
+ if (!isNum(alert.price)) return null;
847
+ a = {
848
+ id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
849
+ price: alert.price,
850
+ direction: alert.direction || 'cross',
851
+ once: alert.once !== false,
852
+ fired: false,
853
+ };
854
+ }
746
855
  const i = this._alerts.findIndex((x) => x.id === a.id);
747
856
  if (i >= 0) this._alerts[i] = a;
748
857
  else this._alerts.push(a);
@@ -760,11 +869,229 @@ class WickChart extends HTMLElementBase {
760
869
  this._invalidate();
761
870
  }
762
871
 
763
- /** Check alerts against an incoming bar (prev close → new close). */
872
+ /**
873
+ * Server-side overlays: zones & levels anchored in time × price — e.g.
874
+ * supply/demand zones from an analysis API. Zones with no `to` extend
875
+ * into future space past the last bar, like TradingView drawings.
876
+ *
877
+ * zone: { type:'zone', from?:ms, to?:ms|null, priceFrom, priceTo,
878
+ * color?, alpha?, border?, label?, id? }
879
+ * level: { type:'level', price, from?, to?, color?, width?, dash?,
880
+ * label?, id? }
881
+ *
882
+ * Invalid entries are dropped, never thrown. Colors accept hex/rgb()/CSS
883
+ * names plus the palette keys 'up' | 'down' | 'accent'.
884
+ * @param {object[]} list
885
+ * @returns {string[]} applied overlay ids
886
+ */
887
+ setOverlays(list) {
888
+ this._overlays = normalizeOverlays(list);
889
+ this._invalidate();
890
+ return this._overlays.map((o) => o.id);
891
+ }
892
+
893
+ /** @returns {object[]} a copy of the current overlays */
894
+ get overlays() {
895
+ return this._overlays.map((o) => ({ ...o }));
896
+ }
897
+
898
+ /**
899
+ * Add or replace (upsert, by id) a single overlay.
900
+ * @returns {string|null} the overlay id, or null if invalid
901
+ */
902
+ addOverlay(ov) {
903
+ const norm = normalizeOverlays([ov]);
904
+ if (!norm.length) return null;
905
+ const one = norm[0];
906
+ const i = this._overlays.findIndex((x) => x.id === one.id);
907
+ if (i >= 0) this._overlays[i] = one;
908
+ else this._overlays.push(one);
909
+ this._invalidate();
910
+ return one.id;
911
+ }
912
+
913
+ removeOverlay(id) {
914
+ this._overlays = this._overlays.filter((o) => o.id !== String(id));
915
+ this._invalidate();
916
+ }
917
+
918
+ clearOverlays() {
919
+ this._overlays = [];
920
+ this._invalidate();
921
+ }
922
+
923
+ /**
924
+ * Scenario projection into future space: a ghost path of future prices
925
+ * plus optional σ-bands (vol cone) from realized volatility.
926
+ *
927
+ * chart.setScenario({ path: [64000, 65500, 68000], label: 'bull case' });
928
+ * chart.setScenario({ horizon: 48, cone: true }); // cone-only
929
+ *
930
+ * The path is an array of prices (or {price} objects) for future bars
931
+ * 1..N; horizon defaults to the path length (1–500). `cone` (default
932
+ * true) draws ±levels·σ bands widening with √h from the current realized
933
+ * vol; `color` accepts up|down|accent or safe CSS colors. Setting a
934
+ * scenario reserves future space on the right; analysis data — excluded
935
+ * from getState/setState.
936
+ * @param {object} spec
937
+ * @returns {object|null} the normalized scenario, or null when invalid
938
+ */
939
+ setScenario(spec) {
940
+ this._scenario = normalizeScenario(spec);
941
+ this._invalidate();
942
+ return this._scenario;
943
+ }
944
+
945
+ clearScenario() {
946
+ this._scenario = null;
947
+ this._invalidate();
948
+ }
949
+
950
+ /** @returns {object|null} a copy of the active scenario */
951
+ get scenario() {
952
+ if (!this._scenario) return null;
953
+ return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
954
+ }
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
+
991
+ /** σ-cone for the active scenario, cached per data version. */
992
+ _scenarioConeCache() {
993
+ if (!this._scenario || !this._data.length) return null;
994
+ if (this._cache.v !== this._version) {
995
+ this._cache = { v: this._version, map: {} };
996
+ }
997
+ if (!this._cache.map.__scenario) {
998
+ const d = this._data;
999
+ const vol = calcRealizedVol(d.map((b) => b.close), 20);
1000
+ let v = NaN;
1001
+ for (let i = vol.length - 1; i >= 0; i--) {
1002
+ if (Number.isFinite(vol[i])) { v = vol[i]; break; }
1003
+ }
1004
+ this._cache.map.__scenario = calcVolCone(
1005
+ d[d.length - 1].close,
1006
+ v,
1007
+ this._scenario.horizon,
1008
+ this._scenario.levels
1009
+ );
1010
+ }
1011
+ return this._cache.map.__scenario;
1012
+ }
1013
+
1014
+ /* ------------------------------------------------------------ *
1015
+ * AI agent interface — the chart as a tool surface
1016
+ * ------------------------------------------------------------ */
1017
+
1018
+ /** Tool manifest for LLM control — JSON-safe copy of AI_TOOLS. */
1019
+ aiTools() {
1020
+ return JSON.parse(JSON.stringify(AI_TOOLS));
1021
+ }
1022
+
1023
+ /** System prompt for agent control — paste into any LLM alongside aiTools(). */
1024
+ aiPrompt() {
1025
+ return aiPromptText();
1026
+ }
1027
+
1028
+ /** Grounding context for a model: current state + visible-window summary. */
1029
+ aiContext() {
1030
+ return { state: this.getState(), window: this.getDataWindow() };
1031
+ }
1032
+
1033
+ /**
1034
+ * Apply a list of {tool, args} ops (typically LLM output) through the
1035
+ * validated dispatcher in core. Never throws — each op resolves
1036
+ * {ok, tool, result} or {ok: false, tool, error} so an agent can
1037
+ * self-correct.
1038
+ * @param {any} ops
1039
+ * @returns {Array<object>}
1040
+ */
1041
+ applyAI(ops) {
1042
+ return applyChartOps(this, ops);
1043
+ }
1044
+
1045
+ /**
1046
+ * Ask an AI to operate the chart. Builds the payload {system,
1047
+ * instruction, chart, tools}; with a `run` async function (your model
1048
+ * call — the chart itself never touches the network), applies the
1049
+ * returned ops and resolves {payload, ops, results}. Without `run`,
1050
+ * returns the payload for manual wiring — send it anywhere, then call
1051
+ * chart.applyAI(ops) with the model's answer.
1052
+ *
1053
+ * const { results } = await chart.ask('add RSI and mark the demand zone', {
1054
+ * run: async (payload) => (await callMyLLM(payload)).ops,
1055
+ * });
1056
+ *
1057
+ * @param {string} instruction natural-language request
1058
+ * @param {{run?: (payload: object) => Promise<any>}} [opts]
1059
+ */
1060
+ async ask(instruction, opts = {}) {
1061
+ const payload = {
1062
+ system: aiPromptText(),
1063
+ instruction: String(instruction == null ? '' : instruction),
1064
+ chart: this.aiContext(),
1065
+ tools: this.aiTools(),
1066
+ };
1067
+ if (typeof opts.run !== 'function') {
1068
+ return { payload, ops: null, results: null };
1069
+ }
1070
+ const ops = await opts.run(payload);
1071
+ const results = this.applyAI(ops);
1072
+ return { payload, ops, results };
1073
+ }
1074
+
1075
+ /** Check alerts against an incoming bar (prev close → new close).
1076
+ * Scripted (`when`) alerts evaluate their predicate series, cached per
1077
+ * data version, and fire on the false→true edge. */
764
1078
  _checkAlerts(prevClose, bar) {
765
- if (!this._alerts.length || !isNum(prevClose)) return;
1079
+ if (!this._alerts.length) return;
766
1080
  for (const a of [...this._alerts]) {
767
1081
  if (a.fired) continue;
1082
+ if (a.when != null) {
1083
+ const series = this._predicateCache(a);
1084
+ const curTrue = series.length ? series[series.length - 1] : false;
1085
+ const step = scriptAlertStep(a.armed, curTrue);
1086
+ a.armed = step.armed;
1087
+ if (step.fire) {
1088
+ a.fired = true;
1089
+ this._fire('alert', { id: a.id, price: bar.close, when: a.when, bar });
1090
+ if (a.once) this._alerts = this._alerts.filter((x) => x !== a);
1091
+ }
1092
+ continue;
1093
+ }
1094
+ if (!isNum(prevClose)) continue;
768
1095
  if (checkAlertCross(a, prevClose, bar.close)) {
769
1096
  a.fired = true;
770
1097
  this._fire('alert', { id: a.id, price: a.price, bar });
@@ -773,6 +1100,18 @@ class WickChart extends HTMLElementBase {
773
1100
  }
774
1101
  }
775
1102
 
1103
+ /** Cached boolean series for a scripted alert's predicate (per data version). */
1104
+ _predicateCache(alert) {
1105
+ if (this._cache.v !== this._version) {
1106
+ this._cache = { v: this._version, map: {} };
1107
+ }
1108
+ const k = 'pred:' + alert.when;
1109
+ if (!this._cache.map[k]) {
1110
+ this._cache.map[k] = predicateTrueSeries(alert.compiled, this._data);
1111
+ }
1112
+ return this._cache.map[k];
1113
+ }
1114
+
776
1115
  /* reflected properties */
777
1116
  get theme() { return this._theme; }
778
1117
  set theme(v) { this.setAttribute('theme', v); }
@@ -1013,7 +1352,9 @@ class WickChart extends HTMLElementBase {
1013
1352
  _rightMargin() {
1014
1353
  const ly = this._ly;
1015
1354
  const w = ly ? ly.plotRight : 600;
1016
- return Math.max(3, (w / this._view.spacing) * 0.06);
1355
+ const base = Math.max(3, (w / this._view.spacing) * 0.06);
1356
+ // a scenario projection reserves future space so the cone stays visible
1357
+ return this._scenario ? Math.max(base, this._scenario.horizon + 3) : base;
1017
1358
  }
1018
1359
 
1019
1360
  _applyFit() {
@@ -1381,6 +1722,146 @@ class WickChart extends HTMLElementBase {
1381
1722
  }
1382
1723
  }
1383
1724
 
1725
+ /* server-side overlays: zones & levels (above regime shading, under series).
1726
+ * Zones with `to == null` extend into future space past the last bar. */
1727
+ if (this._overlays.length) {
1728
+ const d = this._data;
1729
+ const idxFor = (t, fallback) => (t == null ? fallback : barIndexForTime(d, t));
1730
+ for (const ov of this._overlays) {
1731
+ const col = resolveOverlayColor(ov.color, pal);
1732
+ if (ov.type === 'zone') {
1733
+ const iA = Math.max(0, idxFor(ov.from, 0));
1734
+ const iB = ov.to == null ? null : Math.max(0, idxFor(ov.to, d.length - 1));
1735
+ const zx0 = clamp(this._xFor(iA) - sp * 0.5, 0, plotRight);
1736
+ const zx1 = iB == null ? plotRight : clamp(this._xFor(iB) + sp * 0.5, 0, plotRight);
1737
+ const zyT = clamp(yOf(ov.priceTo), main.y0, main.y1);
1738
+ const zyB = clamp(yOf(ov.priceFrom), main.y0, main.y1);
1739
+ if (zx1 - zx0 < 1 || zyB - zyT < 1) continue;
1740
+ ctx.save();
1741
+ ctx.globalAlpha = ov.alpha;
1742
+ ctx.fillStyle = col;
1743
+ ctx.fillRect(zx0, zyT, zx1 - zx0, zyB - zyT);
1744
+ if (ov.border) {
1745
+ ctx.globalAlpha = Math.min(1, ov.alpha + 0.4);
1746
+ ctx.lineWidth = 1;
1747
+ ctx.strokeStyle = col;
1748
+ ctx.strokeRect(
1749
+ Math.round(zx0) + 0.5, Math.round(zyT) + 0.5,
1750
+ Math.max(2, Math.round(zx1 - zx0) - 1), Math.max(2, Math.round(zyB - zyT) - 1)
1751
+ );
1752
+ }
1753
+ if (ov.label) {
1754
+ ctx.globalAlpha = 0.95;
1755
+ ctx.font = pillFont();
1756
+ ctx.fillStyle = col;
1757
+ ctx.textAlign = 'left';
1758
+ ctx.textBaseline = 'top';
1759
+ ctx.fillText(ov.label, zx0 + 6, Math.max(main.y0, zyT) + 4);
1760
+ }
1761
+ ctx.restore();
1762
+ } else {
1763
+ const lx0 = clamp(this._xFor(Math.max(0, idxFor(ov.from, 0))) - sp * 0.5, 0, plotRight);
1764
+ const lx1 =
1765
+ ov.to == null
1766
+ ? plotRight
1767
+ : clamp(this._xFor(Math.max(0, idxFor(ov.to, d.length - 1))) + sp * 0.5, 0, plotRight);
1768
+ const ly = Math.round(yOf(ov.price)) + 0.5;
1769
+ if (lx1 - lx0 < 1 || ly < main.y0 || ly > main.y1) continue;
1770
+ ctx.save();
1771
+ ctx.strokeStyle = col;
1772
+ ctx.lineWidth = ov.width;
1773
+ if (ov.dash) ctx.setLineDash([5, 4]);
1774
+ ctx.beginPath();
1775
+ ctx.moveTo(lx0, ly);
1776
+ ctx.lineTo(lx1, ly);
1777
+ ctx.stroke();
1778
+ if (ov.label) {
1779
+ ctx.font = pillFont();
1780
+ ctx.fillStyle = col;
1781
+ ctx.textAlign = 'right';
1782
+ ctx.textBaseline = 'bottom';
1783
+ ctx.fillText(ov.label, Math.min(lx1, plotRight) - 6, ly - 2);
1784
+ }
1785
+ ctx.restore();
1786
+ }
1787
+ }
1788
+ }
1789
+
1790
+ /* scenario projection: ghost path + σ-cone in future space (clipped to
1791
+ * the main plot so out-of-range bands never bleed into the axis) */
1792
+ if (this._scenario && d.length) {
1793
+ const sc = this._scenario;
1794
+ const col = resolveOverlayColor(sc.color, pal);
1795
+ const baseIdx = d.length - 1;
1796
+ const xAt = (h) => this._xFor(baseIdx + h);
1797
+ ctx.save();
1798
+ ctx.beginPath();
1799
+ ctx.rect(0, main.y0, plotRight, main.h);
1800
+ ctx.clip();
1801
+ if (sc.cone) {
1802
+ const cone = this._scenarioConeCache();
1803
+ if (cone) {
1804
+ for (let li = cone.levels.length - 1; li >= 0; li--) {
1805
+ const b = cone.bands[cone.levels[li]];
1806
+ ctx.globalAlpha = li === 0 ? 0.1 : 0.05;
1807
+ ctx.fillStyle = col;
1808
+ ctx.beginPath();
1809
+ ctx.moveTo(xAt(0), yOf(b.up[0]));
1810
+ for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(b.up[h]));
1811
+ for (let h = cone.horizon; h >= 0; h--) ctx.lineTo(xAt(h), yOf(b.down[h]));
1812
+ ctx.closePath();
1813
+ ctx.fill();
1814
+ }
1815
+ const inner = cone.bands[cone.levels[0]];
1816
+ ctx.globalAlpha = 0.4;
1817
+ ctx.strokeStyle = col;
1818
+ ctx.lineWidth = 1;
1819
+ ctx.setLineDash([4, 4]);
1820
+ for (const arr of [inner.up, inner.down]) {
1821
+ ctx.beginPath();
1822
+ ctx.moveTo(xAt(0), yOf(arr[0]));
1823
+ for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(arr[h]));
1824
+ ctx.stroke();
1825
+ }
1826
+ ctx.setLineDash([]);
1827
+ }
1828
+ }
1829
+ if (sc.path.length) {
1830
+ ctx.globalAlpha = 0.9;
1831
+ ctx.strokeStyle = col;
1832
+ ctx.lineWidth = 1.5;
1833
+ ctx.setLineDash([6, 4]);
1834
+ ctx.beginPath();
1835
+ ctx.moveTo(xAt(0), yOf(d[baseIdx].close));
1836
+ for (const p of sc.path) ctx.lineTo(xAt(p.h), yOf(p.price));
1837
+ ctx.stroke();
1838
+ ctx.setLineDash([]);
1839
+ ctx.fillStyle = col;
1840
+ for (const p of sc.path) {
1841
+ const y = yOf(p.price);
1842
+ if (y >= main.y0 && y <= main.y1) {
1843
+ ctx.beginPath();
1844
+ ctx.arc(xAt(p.h), y, 2.5, 0, Math.PI * 2);
1845
+ ctx.fill();
1846
+ }
1847
+ }
1848
+ if (sc.label) {
1849
+ const p = sc.path[sc.path.length - 1];
1850
+ ctx.font = pillFont();
1851
+ ctx.globalAlpha = 0.95;
1852
+ ctx.fillStyle = col;
1853
+ ctx.textAlign = 'left';
1854
+ ctx.textBaseline = 'middle';
1855
+ ctx.fillText(
1856
+ sc.label,
1857
+ Math.min(xAt(p.h) + 8, plotRight - 4),
1858
+ clamp(yOf(p.price), main.y0 + 8, main.y1 - 8)
1859
+ );
1860
+ }
1861
+ }
1862
+ ctx.restore();
1863
+ }
1864
+
1384
1865
  /* position zones (under series) */
1385
1866
  for (const pos of this._positions) {
1386
1867
  const yE = clamp(yOf(pos.entry), main.y0, main.y1);
@@ -1396,6 +1877,67 @@ class WickChart extends HTMLElementBase {
1396
1877
  }
1397
1878
  }
1398
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
+
1399
1941
  /* volume profile (behind the series) */
1400
1942
  if (this._profile) {
1401
1943
  const pkey = `${i0}:${i1}:${this._version}`;
@@ -1724,7 +2266,7 @@ class WickChart extends HTMLElementBase {
1724
2266
  ctx.setLineDash([5, 4]);
1725
2267
  ctx.strokeStyle = pal.overlay[0];
1726
2268
  for (const a of this._alerts) {
1727
- if (a.fired) continue;
2269
+ if (a.fired || !isNum(a.price)) continue; // scripted alerts have no line
1728
2270
  const y = yOf(a.price);
1729
2271
  if (y < main.y0 || y > main.y1) continue;
1730
2272
  ctx.globalAlpha = 0.8;
@@ -2055,6 +2597,34 @@ class WickChart extends HTMLElementBase {
2055
2597
  );
2056
2598
  }
2057
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
+
2058
2628
  /* co-view ghost crosshair (peer pointer from another tab/chart) */
2059
2629
  if (this._ghost) {
2060
2630
  const g = this._ghost;
@@ -2141,6 +2711,50 @@ class WickChart extends HTMLElementBase {
2141
2711
  );
2142
2712
  }
2143
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
+
2144
2758
  /* visible-range stats chip */
2145
2759
  if (this._stats) {
2146
2760
  const st = computeStats(d, i0, i1, this._dt);
@@ -2309,6 +2923,7 @@ class WickChart extends HTMLElementBase {
2309
2923
 
2310
2924
  _pointerDown(e) {
2311
2925
  if (e.button !== 0) return;
2926
+ this._stopPlayback(); // any touch interrupts the story
2312
2927
  this._canvas.setPointerCapture(e.pointerId);
2313
2928
  const pt = this._localPoint(e);
2314
2929
  this._pointers.set(e.pointerId, pt);
@@ -2323,6 +2938,15 @@ class WickChart extends HTMLElementBase {
2323
2938
  };
2324
2939
  this._pan = null;
2325
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();
2326
2950
  } else if (e.shiftKey && this._data.length) {
2327
2951
  // shift+drag → measure tool
2328
2952
  this._measuring = true;
@@ -2374,6 +2998,13 @@ class WickChart extends HTMLElementBase {
2374
2998
  return;
2375
2999
  }
2376
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
+
2377
3008
  if (this._pan && this._pointers.has(e.pointerId) && ly) {
2378
3009
  const dx = pt.x - this._pan.x;
2379
3010
  if (Math.abs(dx) > 3) this._pan.moved = true;
@@ -2399,7 +3030,11 @@ class WickChart extends HTMLElementBase {
2399
3030
  if (this._pointers.size < 2) this._pinch = null;
2400
3031
  if (this._pointers.size === 0) {
2401
3032
  this._canvas.classList.remove('grabbing');
2402
- 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) {
2403
3038
  this._measuring = false;
2404
3039
  if (this._measure) {
2405
3040
  this._measure.done = true;
@@ -2443,14 +3078,17 @@ class WickChart extends HTMLElementBase {
2443
3078
  _wheel(e) {
2444
3079
  const ly = this._ly;
2445
3080
  if (!ly || !this._data.length) return;
3081
+ this._stopPlayback();
2446
3082
  e.preventDefault();
2447
3083
  const pt = this._localPoint(e);
2448
3084
  const dx = e.deltaX;
2449
3085
  const dy = e.deltaY * (e.deltaMode === 1 ? 33 : 1);
2450
3086
 
2451
3087
  if (Math.abs(dx) > Math.abs(dy) && !e.ctrlKey) {
2452
- // trackpad horizontal scroll → pan
2453
- this._view.rightIndex -= dx / this._view.spacing;
3088
+ // trackpad horizontal scroll → pan. Wheel deltas are viewport-relative:
3089
+ // deltaX>0 means "scroll right", i.e. reveal newer bars. On natural-scroll
3090
+ // trackpads this makes the content follow the fingers, matching drag.
3091
+ this._view.rightIndex += dx / this._view.spacing;
2454
3092
  this._auto = this._atRight();
2455
3093
  this._clampView();
2456
3094
  this._invalidate();
@@ -2474,6 +3112,11 @@ class WickChart extends HTMLElementBase {
2474
3112
  _keydown(e) {
2475
3113
  const ly = this._ly;
2476
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
+ }
2477
3120
  const d = this._data;
2478
3121
  const key = e.key;
2479
3122
  const step = e.shiftKey ? 10 : 1;
@@ -2662,11 +3305,18 @@ class WickChart extends HTMLElementBase {
2662
3305
  } catch (_) {}
2663
3306
  this._coviewCh = null;
2664
3307
  }
3308
+ clearInterval(this._coviewBeat);
3309
+ this._coviewBeat = 0;
2665
3310
  clearTimeout(this._ghostTimer);
2666
3311
  if (this._ghost) {
2667
3312
  this._ghost = null;
2668
3313
  this._invalidate();
2669
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
+ }
2670
3320
  const name = this._coviewName;
2671
3321
  if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
2672
3322
  if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
@@ -2675,6 +3325,33 @@ class WickChart extends HTMLElementBase {
2675
3325
  ch.onmessage = (ev) => this._onCoMessage(ev.data);
2676
3326
  this._coviewCh = ch;
2677
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
+ });
2678
3355
  }
2679
3356
 
2680
3357
  _coviewSend(msg) {
@@ -2685,7 +3362,30 @@ class WickChart extends HTMLElementBase {
2685
3362
  }
2686
3363
 
2687
3364
  _onCoMessage(m) {
2688
- 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;
2689
3389
  if (m.time == null) {
2690
3390
  if (this._ghost) {
2691
3391
  this._ghost = null;
@@ -2715,10 +3415,297 @@ class WickChart extends HTMLElementBase {
2715
3415
  this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
2716
3416
  }
2717
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
+
2718
3704
  _emitRange() {
2719
3705
  const r = this.getVisibleRange();
2720
3706
  if (!r) return;
2721
3707
  this._fire('range', r);
3708
+ if (this._coviewCh) this._coviewSendView();
2722
3709
  }
2723
3710
  }
2724
3711