wickchart 1.7.0 → 2.0.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
@@ -12,8 +12,9 @@
12
12
  * Zero dependencies. Canvas-rendered. Framework-agnostic (works in React,
13
13
  * Vue, plain HTML). Themeable with --wick-* CSS custom properties.
14
14
  *
15
- * The 0.x names (<hab-chart>, --hab-*, hab:* events) still work as
16
- * deprecated aliases see the README migration notes.
15
+ * 2.0: the guided-playback (narrate/walk/sonify/story), co-view, scenario/
16
+ * risk-plan and AI-agent families live in their plugin packages; the core
17
+ * methods are warn-once stubs until a package attaches.
17
18
  *
18
19
  * MIT License.
19
20
  * ========================================================================== */
@@ -26,22 +27,19 @@ import {
26
27
  parseIndicators, normalizeIndicatorResult, BUILTIN_INDICATORS,
27
28
  positionPnl, positionPnlPct, checkAlertCross, computeStats, safeColor,
28
29
  SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
29
- calcRSI, detectAnnotations, priceToFreq,
30
+ calcRSI, detectAnnotations,
30
31
  calcRealizedVol, volRegimeBands, percentileOfSorted, parseVolShading,
31
32
  windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
32
33
  compileScript, predicateTrueSeries, scriptAlertStep,
33
- AI_TOOLS, aiPromptText, applyChartOps,
34
- calcVolCone, normalizeScenario, normalizeRiskPlan, PresenceTracker,
35
- narrateWindow, brushStats,
36
- easeInOutCubic, sceneList,
34
+ brushStats,
35
+ warnDeprecatedAlias,
37
36
  } from './core.js';
38
37
 
39
38
  /* ------------------------------------------------------------------ *
40
39
  * <wick-chart>
41
40
  * ------------------------------------------------------------------ */
42
41
 
43
- /** Indicator registry — module scope so registrations are shared by
44
- * <wick-chart> and the deprecated <hab-chart> alias element. */
42
+ /** Indicator registry — module scope, shared by every chart instance. */
45
43
  const REGISTRY = new Map(BUILTIN_INDICATORS);
46
44
 
47
45
  /** Bars from which the worker compute path engages — below it, sync wins
@@ -70,7 +68,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
70
68
 
71
69
  class WickChart extends HTMLElementBase {
72
70
  static get observedAttributes() {
73
- return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'co-view-name', 'brush', 'sonify', 'alert-evaluate', 'timezone', 'vwap-anchor', 'worker'];
71
+ return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'brush', 'alert-evaluate', 'timezone', 'vwap-anchor', 'worker'];
74
72
  }
75
73
 
76
74
  /**
@@ -97,7 +95,7 @@ class WickChart extends HTMLElementBase {
97
95
  container-type: inline-size;
98
96
  }
99
97
  :host(:focus-visible) {
100
- outline: 2px solid var(--wick-accent, var(--hab-accent, #4c8dff));
98
+ outline: 2px solid var(--wick-accent, #4c8dff);
101
99
  outline-offset: -2px;
102
100
  }
103
101
  .wrap { position: absolute; inset: 0; overflow: hidden; }
@@ -127,26 +125,26 @@ class WickChart extends HTMLElementBase {
127
125
  }
128
126
  .legend .row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
129
127
  .legend .sym {
130
- color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3));
128
+ color: var(--wick-text-strong, #e6edf3);
131
129
  font-weight: 700;
132
130
  font-size: 13px;
133
131
  letter-spacing: 0.02em;
134
132
  }
135
133
  .legend .kv { display: inline-flex; gap: 5px; align-items: baseline; white-space: nowrap; }
136
- .legend .k { color: var(--wick-text, var(--hab-text, #8b949e)); font-size: 11px; }
137
- .legend .v { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
134
+ .legend .k { color: var(--wick-text, #8b949e); font-size: 11px; }
135
+ .legend .v { color: var(--wick-text-strong, #e6edf3); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
138
136
  .legend .pct { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
139
- .legend .up { color: var(--wick-up, var(--hab-up, #16c784)); }
140
- .legend .dn { color: var(--wick-down, var(--hab-down, #ea3943)); }
137
+ .legend .up { color: var(--wick-up, #16c784); }
138
+ .legend .dn { color: var(--wick-down, #ea3943); }
141
139
  .legend .ind {
142
140
  display: inline-flex; align-items: center; gap: 6px;
143
- color: var(--wick-text, var(--hab-text, #8b949e)); font-size: 11.5px; white-space: nowrap;
141
+ color: var(--wick-text, #8b949e); font-size: 11.5px; white-space: nowrap;
144
142
  }
145
143
  .legend .ind i { width: 8px; height: 2.5px; border-radius: 2px; display: inline-block; }
146
144
  .legend .ind .v { font-size: 12px; }
147
145
  .legend .insight {
148
- color: var(--wick-accent, var(--hab-accent, #4c8dff));
149
- background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
146
+ color: var(--wick-accent, #4c8dff);
147
+ background: var(--wick-chip, rgba(127, 137, 153, 0.12));
150
148
  border-radius: 6px;
151
149
  padding: 1px 8px;
152
150
  font-size: 11.5px;
@@ -155,7 +153,7 @@ class WickChart extends HTMLElementBase {
155
153
  .nodata {
156
154
  position: absolute; inset: 0;
157
155
  display: flex; align-items: center; justify-content: center;
158
- color: var(--wick-text, var(--hab-text, #8b949e));
156
+ color: var(--wick-text, #8b949e);
159
157
  font: 500 13px ${FONT_STACK};
160
158
  pointer-events: none;
161
159
  }
@@ -168,25 +166,25 @@ class WickChart extends HTMLElementBase {
168
166
  }
169
167
  .hud .pos {
170
168
  display: inline-flex; gap: 9px; align-items: baseline; white-space: nowrap;
171
- background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
172
- border: 1px solid var(--wick-border, var(--hab-border, rgba(148, 163, 184, 0.2)));
169
+ background: var(--wick-chip, rgba(127, 137, 153, 0.12));
170
+ border: 1px solid var(--wick-border, rgba(148, 163, 184, 0.2));
173
171
  border-radius: 7px;
174
172
  padding: 3px 9px;
175
173
  }
176
174
  .hud .statsrow {
177
175
  display: inline-flex; gap: 12px; white-space: nowrap;
178
- background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
179
- border: 1px solid var(--wick-border, var(--hab-border, rgba(148, 163, 184, 0.2)));
176
+ background: var(--wick-chip, rgba(127, 137, 153, 0.12));
177
+ border: 1px solid var(--wick-border, rgba(148, 163, 184, 0.2));
180
178
  border-radius: 7px;
181
179
  padding: 3px 10px;
182
- color: var(--wick-text, var(--hab-text, #8b949e));
180
+ color: var(--wick-text, #8b949e);
183
181
  font-variant-numeric: tabular-nums;
184
182
  }
185
- .hud .statsrow b { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-weight: 600; }
186
- .hud .k { color: var(--wick-text, var(--hab-text, #8b949e)); font-weight: 500; }
187
- .hud .v { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-variant-numeric: tabular-nums; }
188
- .hud .up { color: var(--wick-up, var(--hab-up, #16c784)); }
189
- .hud .dn { color: var(--wick-down, var(--hab-down, #ea3943)); }
183
+ .hud .statsrow b { color: var(--wick-text-strong, #e6edf3); font-weight: 600; }
184
+ .hud .k { color: var(--wick-text, #8b949e); font-weight: 500; }
185
+ .hud .v { color: var(--wick-text-strong, #e6edf3); font-variant-numeric: tabular-nums; }
186
+ .hud .up { color: var(--wick-up, #16c784); }
187
+ .hud .dn { color: var(--wick-down, #ea3943); }
190
188
 
191
189
  /* Narrow charts: the legend (top-left) and the HUD (top-right) are
192
190
  both pinned to the top, so on a phone they land on top of each
@@ -263,31 +261,21 @@ class WickChart extends HTMLElementBase {
263
261
  this._annoList = null;
264
262
  this._volshade = null;
265
263
 
266
- // cross-tab co-view state
267
- this._coviewName = null;
264
+ // co-view seams (2.0: driven by the wickchart-coview plugin — the
265
+ // layer/state contract its presence bands and ghost crosshair use,
266
+ // and disconnectedCallback cleans up)
268
267
  this._coviewCh = null;
269
- this._coviewPeer = '';
270
- this._coviewLast = 0;
271
268
  this._ghost = null;
272
269
  this._ghostTimer = 0;
273
- // presence: peer viewports (who is looking where)
274
- this._coviewLabel = null; // display name from the co-view-name attribute
275
- this._presence = new PresenceTracker();
270
+ this._presence = null;
276
271
  this._coviewBeat = 0;
277
- this._coviewViewLast = 0;
272
+ // scenario/risk seams (2.0: driven by the wickchart-scenario plugin;
273
+ // _rightMargin reserves future space from the active scenario)
274
+ this._scenario = null;
275
+ this._riskPlan = null;
278
276
 
279
- // sonification state
280
- this._sonify = false;
281
- this._actx = null;
282
- this._lastToneIdx = -1;
283
- this._playToken = 0;
284
277
  this._measure = null; // { iA, pA, iB, pB, done }
285
278
  this._measuring = false;
286
- // bar-walk narrator state
287
- this._walkTimer = 0;
288
- // story mode state: token cancels stale async runs
289
- this._storyToken = 0;
290
- this._story = null;
291
279
  // delta brush state: mode flag + current/finished selection
292
280
  this._brush = false;
293
281
  this._brushSel = null; // { i0, i1, stats } — the committed selection
@@ -339,11 +327,6 @@ class WickChart extends HTMLElementBase {
339
327
  // server-side overlays (zones & levels)
340
328
  this._overlays = [];
341
329
 
342
- // scenario projection (ghost path + vol cone)
343
- this._scenario = null;
344
- // risk plan (R-multiple grid)
345
- this._riskPlan = null;
346
-
347
330
  this._onResize = () => this._invalidate();
348
331
  this._onPointerDown = (e) => this._pointerDown(e);
349
332
  this._onPointerMove = (e) => this._pointerMove(e);
@@ -357,7 +340,6 @@ class WickChart extends HTMLElementBase {
357
340
  };
358
341
  this._onWheel = (e) => this._wheel(e);
359
342
  this._onDbl = () => {
360
- this._stopPlayback();
361
343
  this.fit();
362
344
  };
363
345
  this._onKey = (e) => this._keydown(e);
@@ -414,18 +396,16 @@ class WickChart extends HTMLElementBase {
414
396
  if (document.fonts && document.fonts.ready) {
415
397
  document.fonts.ready.then(() => this._invalidate()).catch(() => {});
416
398
  }
417
- if (this._coviewName) this._setupCoView();
418
399
  this._watchDpr();
419
400
  this._invalidate();
420
401
  }
421
402
 
422
403
  disconnectedCallback() {
423
404
  this._connected = false;
424
- this.stopWalk(true);
425
- this.stopStory(true);
426
405
  this._layerClaim = null;
406
+ // co-view seams (driven by the wickchart-coview plugin): close the
407
+ // room cleanly and stop its timers so a removed chart goes quiet
427
408
  if (this._coviewCh) {
428
- this._coviewSend({ type: 'bye' });
429
409
  try {
430
410
  this._coviewCh.close();
431
411
  } catch (_) {}
@@ -433,12 +413,9 @@ class WickChart extends HTMLElementBase {
433
413
  }
434
414
  clearInterval(this._coviewBeat);
435
415
  this._coviewBeat = 0;
436
- if (this._presence && this._presence.peers.size) {
437
- const left = this._presence.list();
438
- this._presence = new PresenceTracker();
439
- this._fire('peers', { peers: [], joined: [], left });
440
- }
441
416
  clearTimeout(this._ghostTimer);
417
+ this._ghost = null;
418
+ this._presence = null;
442
419
  if (this._ro) this._ro.disconnect();
443
420
  this._unwatchDpr();
444
421
  const cv = this._canvas;
@@ -526,24 +503,12 @@ class WickChart extends HTMLElementBase {
526
503
  this._overlays = ovs;
527
504
  break;
528
505
  }
529
- case 'co-view':
530
- this._coviewName = val || null;
531
- this._setupCoView();
532
- break;
533
- case 'co-view-name':
534
- // display name travels with every presence message; no re-render
535
- this._coviewLabel = val || null;
536
- break;
537
506
  case 'brush':
538
507
  this._brush = val != null && val !== 'false';
539
508
  this._brushSel = null;
540
509
  this._brushDrag = null;
541
510
  this._invalidate();
542
511
  break;
543
- case 'sonify':
544
- this._sonify = val != null && val !== 'false';
545
- this._lastToneIdx = -1;
546
- break;
547
512
  // Default evaluation mode for alerts added without one. Anything
548
513
  // other than "close" means live, so a typo cannot silently mute
549
514
  // signals — it degrades to today's behaviour.
@@ -607,8 +572,7 @@ class WickChart extends HTMLElementBase {
607
572
  });
608
573
  }
609
574
 
610
- /** The tag this class registers as. (<hab-chart> is a deprecated alias
611
- * registered from the HabChart subclass, not this name.) */
575
+ /** The tag this class registers as. */
612
576
  static get elementName() {
613
577
  return 'wick-chart';
614
578
  }
@@ -1018,8 +982,7 @@ class WickChart extends HTMLElementBase {
1018
982
  * WickScript predicate (`when`) on every streamed bar and fire on its
1019
983
  * false→true edge — e.g. `when: 'crossup(rsi(close,14), 30)'` or
1020
984
  * `when: 'volume > sma(volume,20) * 3'`. Scripted events carry the
1021
- * triggering close as `price` plus the `when` source (deprecated
1022
- * `hab:alert` alias still dispatched).
985
+ * triggering close as `price` plus the `when` source.
1023
986
  * @param {{id?: string, price?: number, direction?: 'above'|'below'|'cross',
1024
987
  * when?: string, once?: boolean}} alert
1025
988
  * @returns {string|null} the alert id (null when no valid price/when,
@@ -1123,157 +1086,21 @@ class WickChart extends HTMLElementBase {
1123
1086
  this._invalidate();
1124
1087
  }
1125
1088
 
1126
- /**
1127
- * Scenario projection into future space: a ghost path of future prices
1128
- * plus optional σ-bands (vol cone) from realized volatility.
1129
- *
1130
- * chart.setScenario({ path: [64000, 65500, 68000], label: 'bull case' });
1131
- * chart.setScenario({ horizon: 48, cone: true }); // cone-only
1132
- *
1133
- * The path is an array of prices (or {price} objects) for future bars
1134
- * 1..N; horizon defaults to the path length (1–500). `cone` (default
1135
- * true) draws ±levels·σ bands widening with √h from the current realized
1136
- * vol; `color` accepts up|down|accent or safe CSS colors. Setting a
1137
- * scenario reserves future space on the right; analysis data — excluded
1138
- * from getState/setState.
1139
- * @param {object} spec
1140
- * @returns {object|null} the normalized scenario, or null when invalid
1141
- */
1142
- setScenario(spec) {
1143
- this._scenario = normalizeScenario(spec);
1144
- this._invalidate();
1145
- return this._scenario;
1146
- }
1147
-
1148
- clearScenario() {
1149
- this._scenario = null;
1150
- this._invalidate();
1151
- }
1152
-
1153
- /** @returns {object|null} a copy of the active scenario */
1089
+ /** @returns {object|null} a copy of the active scenario (set through the
1090
+ * `_scenario` seam by the wickchart-scenario plugin; reserves future
1091
+ * space via _rightMargin) */
1154
1092
  get scenario() {
1155
1093
  if (!this._scenario) return null;
1156
1094
  return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
1157
1095
  }
1158
1096
 
1159
- /**
1160
- * Risk plan: an R-multiple grid anchored at entry/stop. 1R = |entry −
1161
- * stop| (the risk unit); reward lines are drawn at kR beyond the entry
1162
- * with the risk/reward zones shaded, so sizing and take-profit choices
1163
- * read directly off the chart.
1164
- *
1165
- * chart.setRiskPlan({ entry: 64500, stop: 63800, multiples: [1, 2, 3] });
1166
- * chart.setRiskPlan({ entry: 64500, stop: 63800, targets: [65900, 67300] });
1167
- *
1168
- * Direction is derived (stop below entry ⇒ long). Targets convert to
1169
- * their R multiple; `multiples` win when both are given. Invalid specs
1170
- * clear the plan (replace semantics, like setScenario); excluded from
1171
- * getState/setState — it is app state, not chart state.
1172
- * @param {object} spec
1173
- * @returns {object|null} the normalized plan, or null when invalid
1174
- */
1175
- setRiskPlan(spec) {
1176
- this._riskPlan = normalizeRiskPlan(spec);
1177
- this._invalidate();
1178
- return this._riskPlan;
1179
- }
1180
-
1181
- clearRiskPlan() {
1182
- if (this._riskPlan) {
1183
- this._riskPlan = null;
1184
- this._invalidate();
1185
- }
1186
- }
1187
-
1188
- /** @returns {object|null} a copy of the active risk plan */
1097
+ /** @returns {object|null} a copy of the active risk plan (set through
1098
+ * the `_riskPlan` seam by the wickchart-scenario plugin) */
1189
1099
  get riskPlan() {
1190
1100
  if (!this._riskPlan) return null;
1191
1101
  return { ...this._riskPlan, levels: this._riskPlan.levels.map((l) => ({ ...l })) };
1192
1102
  }
1193
1103
 
1194
- /** σ-cone for the active scenario, cached per data version. */
1195
- _scenarioConeCache() {
1196
- if (!this._scenario || !this._data.length) return null;
1197
- if (this._cache.v !== this._version) {
1198
- this._cache = { v: this._version, map: {} };
1199
- }
1200
- if (!this._cache.map.__scenario) {
1201
- const d = this._data;
1202
- const vol = calcRealizedVol(d.map((b) => b.close), 20);
1203
- let v = NaN;
1204
- for (let i = vol.length - 1; i >= 0; i--) {
1205
- if (Number.isFinite(vol[i])) { v = vol[i]; break; }
1206
- }
1207
- this._cache.map.__scenario = calcVolCone(
1208
- d[d.length - 1].close,
1209
- v,
1210
- this._scenario.horizon,
1211
- this._scenario.levels
1212
- );
1213
- }
1214
- return this._cache.map.__scenario;
1215
- }
1216
-
1217
- /* ------------------------------------------------------------ *
1218
- * AI agent interface — the chart as a tool surface
1219
- * ------------------------------------------------------------ */
1220
-
1221
- /** Tool manifest for LLM control — JSON-safe copy of AI_TOOLS. */
1222
- aiTools() {
1223
- return JSON.parse(JSON.stringify(AI_TOOLS));
1224
- }
1225
-
1226
- /** System prompt for agent control — paste into any LLM alongside aiTools(). */
1227
- aiPrompt() {
1228
- return aiPromptText();
1229
- }
1230
-
1231
- /** Grounding context for a model: current state + visible-window summary. */
1232
- aiContext() {
1233
- return { state: this.getState(), window: this.getDataWindow() };
1234
- }
1235
-
1236
- /**
1237
- * Apply a list of {tool, args} ops (typically LLM output) through the
1238
- * validated dispatcher in core. Never throws — each op resolves
1239
- * {ok, tool, result} or {ok: false, tool, error} so an agent can
1240
- * self-correct.
1241
- * @param {any} ops
1242
- * @returns {Array<object>}
1243
- */
1244
- applyAI(ops) {
1245
- return applyChartOps(this, ops);
1246
- }
1247
-
1248
- /**
1249
- * Ask an AI to operate the chart. Builds the payload {system,
1250
- * instruction, chart, tools}; with a `run` async function (your model
1251
- * call — the chart itself never touches the network), applies the
1252
- * returned ops and resolves {payload, ops, results}. Without `run`,
1253
- * returns the payload for manual wiring — send it anywhere, then call
1254
- * chart.applyAI(ops) with the model's answer.
1255
- *
1256
- * const { results } = await chart.ask('add RSI and mark the demand zone', {
1257
- * run: async (payload) => (await callMyLLM(payload)).ops,
1258
- * });
1259
- *
1260
- * @param {string} instruction natural-language request
1261
- * @param {{run?: (payload: object) => Promise<any>}} [opts]
1262
- */
1263
- async ask(instruction, opts = {}) {
1264
- const payload = {
1265
- system: aiPromptText(),
1266
- instruction: String(instruction == null ? '' : instruction),
1267
- chart: this.aiContext(),
1268
- tools: this.aiTools(),
1269
- };
1270
- if (typeof opts.run !== 'function') {
1271
- return { payload, ops: null, results: null };
1272
- }
1273
- const ops = await opts.run(payload);
1274
- const results = this.applyAI(ops);
1275
- return { payload, ops, results };
1276
- }
1277
1104
 
1278
1105
  /** Check alerts against an incoming bar (prev close → new close).
1279
1106
  * Scripted (`when`) alerts evaluate their predicate series, cached per
@@ -1491,11 +1318,7 @@ class WickChart extends HTMLElementBase {
1491
1318
  if (this._pal && this._palKey === this._theme) return this._pal;
1492
1319
  const base = THEMES[this._theme] || THEMES.dark;
1493
1320
  const cs = getComputedStyle(this);
1494
- // --wick-* is canonical; --hab-* still honored as the 0.x fallback
1495
- const get = (name, fallback) => {
1496
- const v = cs.getPropertyValue('--wick-' + name).trim() || cs.getPropertyValue('--hab-' + name).trim();
1497
- return v || fallback;
1498
- };
1321
+ const get = (name, fallback) => cs.getPropertyValue('--wick-' + name).trim() || fallback;
1499
1322
  const pal = {};
1500
1323
  for (const k of Object.keys(base)) {
1501
1324
  if (k === 'overlay') {
@@ -1504,8 +1327,7 @@ class WickChart extends HTMLElementBase {
1504
1327
  o.push(get('overlay-' + i, base.overlay[i]));
1505
1328
  }
1506
1329
  // allow single overlay color
1507
- const single =
1508
- cs.getPropertyValue('--wick-overlay').trim() || cs.getPropertyValue('--hab-overlay').trim();
1330
+ const single = get('overlay', '');
1509
1331
  pal.overlay = single ? base.overlay.map(() => single) : o;
1510
1332
  } else {
1511
1333
  pal[k] = get(k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), base[k]);
@@ -2225,82 +2047,7 @@ class WickChart extends HTMLElementBase {
2225
2047
  }
2226
2048
  }
2227
2049
 
2228
- /* scenario projection: ghost path + σ-cone in future space (clipped to
2229
- * the main plot so out-of-range bands never bleed into the axis) */
2230
- if (this._scenario && d.length) {
2231
- const sc = this._scenario;
2232
- const col = resolveOverlayColor(sc.color, pal);
2233
- const baseIdx = d.length - 1;
2234
- const xAt = (h) => this._xFor(baseIdx + h);
2235
- ctx.save();
2236
- ctx.beginPath();
2237
- ctx.rect(0, main.y0, plotRight, main.h);
2238
- ctx.clip();
2239
- if (sc.cone) {
2240
- const cone = this._scenarioConeCache();
2241
- if (cone) {
2242
- for (let li = cone.levels.length - 1; li >= 0; li--) {
2243
- const b = cone.bands[cone.levels[li]];
2244
- ctx.globalAlpha = li === 0 ? 0.1 : 0.05;
2245
- ctx.fillStyle = col;
2246
- ctx.beginPath();
2247
- ctx.moveTo(xAt(0), yOf(b.up[0]));
2248
- for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(b.up[h]));
2249
- for (let h = cone.horizon; h >= 0; h--) ctx.lineTo(xAt(h), yOf(b.down[h]));
2250
- ctx.closePath();
2251
- ctx.fill();
2252
- }
2253
- const inner = cone.bands[cone.levels[0]];
2254
- ctx.globalAlpha = 0.4;
2255
- ctx.strokeStyle = col;
2256
- ctx.lineWidth = 1;
2257
- ctx.setLineDash([4, 4]);
2258
- for (const arr of [inner.up, inner.down]) {
2259
- ctx.beginPath();
2260
- ctx.moveTo(xAt(0), yOf(arr[0]));
2261
- for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(arr[h]));
2262
- ctx.stroke();
2263
- }
2264
- ctx.setLineDash([]);
2265
- }
2266
- }
2267
- if (sc.path.length) {
2268
- ctx.globalAlpha = 0.9;
2269
- ctx.strokeStyle = col;
2270
- ctx.lineWidth = 1.5;
2271
- ctx.setLineDash([6, 4]);
2272
- ctx.beginPath();
2273
- ctx.moveTo(xAt(0), yOf(d[baseIdx].close));
2274
- for (const p of sc.path) ctx.lineTo(xAt(p.h), yOf(p.price));
2275
- ctx.stroke();
2276
- ctx.setLineDash([]);
2277
- ctx.fillStyle = col;
2278
- for (const p of sc.path) {
2279
- const y = yOf(p.price);
2280
- if (y >= main.y0 && y <= main.y1) {
2281
- ctx.beginPath();
2282
- ctx.arc(xAt(p.h), y, 2.5, 0, Math.PI * 2);
2283
- ctx.fill();
2284
- }
2285
- }
2286
- if (sc.label) {
2287
- const p = sc.path[sc.path.length - 1];
2288
- ctx.font = pillFont();
2289
- ctx.globalAlpha = 0.95;
2290
- ctx.fillStyle = col;
2291
- ctx.textAlign = 'left';
2292
- ctx.textBaseline = 'middle';
2293
- ctx.fillText(
2294
- sc.label,
2295
- Math.min(xAt(p.h) + 8, plotRight - 4),
2296
- clamp(yOf(p.price), main.y0 + 8, main.y1 - 8)
2297
- );
2298
- }
2299
- }
2300
- ctx.restore();
2301
- }
2302
-
2303
- /* position zones (under series) */
2050
+ /* position zones (under series) */
2304
2051
  for (const pos of this._positions) {
2305
2052
  const yE = clamp(yOf(pos.entry), main.y0, main.y1);
2306
2053
  if (isNum(pos.target)) {
@@ -2315,68 +2062,7 @@ class WickChart extends HTMLElementBase {
2315
2062
  }
2316
2063
  }
2317
2064
 
2318
- /* risk plan: R-multiple grid risk/reward shading + kR lines */
2319
- if (this._riskPlan && d.length) {
2320
- const rp = this._riskPlan;
2321
- const fP = numberFmt(this._prec(scale.rawHi || 1));
2322
- const yE = yOf(rp.entry);
2323
- const yS = yOf(rp.stop);
2324
- ctx.fillStyle = hexToRgba(pal.down, 0.06);
2325
- ctx.fillRect(0, Math.min(yE, yS), plotRight, Math.abs(yS - yE));
2326
- const yTop = yOf(rp.levels[rp.levels.length - 1].price);
2327
- ctx.fillStyle = hexToRgba(pal.up, 0.05);
2328
- ctx.fillRect(0, Math.min(yE, yTop), plotRight, Math.abs(yTop - yE));
2329
- const line = (p, col, dash) => {
2330
- const y = yOf(p);
2331
- if (y < main.y0 || y > main.y1) return null;
2332
- ctx.strokeStyle = col;
2333
- ctx.lineWidth = 1.5;
2334
- if (dash) ctx.setLineDash([5, 4]);
2335
- ctx.beginPath();
2336
- ctx.moveTo(0, Math.round(y) + 0.5);
2337
- ctx.lineTo(plotRight, Math.round(y) + 0.5);
2338
- ctx.stroke();
2339
- ctx.setLineDash([]);
2340
- ctx.lineWidth = 1;
2341
- return y;
2342
- };
2343
- const pills = [];
2344
- for (let i = rp.levels.length - 1; i >= 0; i--) {
2345
- const lv = rp.levels[i];
2346
- const y = line(lv.price, pal.up, true);
2347
- if (y != null) {
2348
- const kk = lv.k % 1 === 0 ? lv.k : +lv.k.toFixed(2);
2349
- pills.push({ y, text: `${kk}R ${fP.format(lv.price)}`, bg: pal.up });
2350
- }
2351
- }
2352
- const yStop = line(rp.stop, pal.down, false);
2353
- if (yStop != null) pills.push({ y: yStop, text: `STOP ${fP.format(rp.stop)}`, bg: pal.down });
2354
- const yEnt = line(rp.entry, pal.accent, false);
2355
- if (yEnt != null) {
2356
- pills.push({ y: yEnt, text: `${rp.direction === 'long' ? 'LONG' : 'SHORT'} ${fP.format(rp.entry)}`, bg: pal.accent });
2357
- }
2358
- // stack right-edge pills instead of letting close lines overlap
2359
- pills.sort((a, b) => a.y - b.y);
2360
- let lastY = -Infinity;
2361
- for (const p of pills) {
2362
- const y = Math.max(p.y, lastY + 20);
2363
- lastY = y;
2364
- ctx.font = pillFont();
2365
- const tw = ctx.measureText(p.text).width + 12;
2366
- this._pill(plotRight - tw - 8, y, p.text, p.bg, pal.pillText, 'left', tw);
2367
- }
2368
- if (rp.label) {
2369
- ctx.font = pillFont();
2370
- ctx.fillStyle = pal.accent;
2371
- ctx.globalAlpha = 0.9;
2372
- ctx.textAlign = 'left';
2373
- ctx.textBaseline = 'bottom';
2374
- ctx.fillText(rp.label, 8, clamp(yE, main.y0 + 14, main.y1) - 3);
2375
- ctx.globalAlpha = 1;
2376
- }
2377
- }
2378
-
2379
- /* volume profile (behind the series) */
2065
+ /* volume profile (behind the series) */
2380
2066
  if (this._profile) {
2381
2067
  const pkey = `${i0}:${i1}:${this._version}`;
2382
2068
  if (this._profileKey !== pkey) {
@@ -3048,78 +2734,6 @@ class WickChart extends HTMLElementBase {
3048
2734
  );
3049
2735
  }
3050
2736
 
3051
- /* co-view presence: peer viewport bands along the top of the plot */
3052
- if (this._presence && this._presence.peers.size && d.length) {
3053
- const peers = this._presence.list().slice(0, 4);
3054
- ctx.save();
3055
- ctx.font = pillFont();
3056
- for (let row = 0; row < peers.length; row++) {
3057
- const p = peers[row];
3058
- if (!p.range) continue;
3059
- const cols = pal.overlay || [];
3060
- const col = cols[(row + 1) % Math.max(cols.length, 1)] || pal.accent;
3061
- const i0 = WickChart._indexForTime(this._data, p.range.from);
3062
- const i1 = WickChart._indexForTime(this._data, p.range.to);
3063
- const x0 = clamp(this._xFor(i0), 0, plotRight);
3064
- const x1 = clamp(this._xFor(i1), 0, plotRight);
3065
- const y = main.y0 + 2 + row * 5;
3066
- ctx.globalAlpha = 0.8;
3067
- ctx.fillStyle = col;
3068
- ctx.fillRect(x0, y, Math.max(x1 - x0, 3), 3);
3069
- if (x1 - x0 > 44) {
3070
- ctx.globalAlpha = 0.95;
3071
- ctx.textAlign = 'left';
3072
- ctx.textBaseline = 'top';
3073
- ctx.fillText(p.name || p.id, x0 + 3, y + 4);
3074
- }
3075
- }
3076
- ctx.restore();
3077
- }
3078
-
3079
- /* co-view ghost crosshair (peer pointer from another tab/chart) */
3080
- if (this._ghost) {
3081
- const g = this._ghost;
3082
- const gx = this._xFor(g.index);
3083
- const gxVisible = gx >= 0 && gx <= plotRight;
3084
- ctx.save();
3085
- ctx.strokeStyle = pal.accent;
3086
- ctx.globalAlpha = 0.7;
3087
- ctx.setLineDash([2, 3]);
3088
- ctx.beginPath();
3089
- if (gxVisible) {
3090
- const cx = Math.round(gx) + 0.5;
3091
- ctx.moveTo(cx, 0);
3092
- ctx.lineTo(cx, plotBottom);
3093
- }
3094
- if (g.yFrac != null) {
3095
- const gy = Math.round(main.y0 + g.yFrac * main.h) + 0.5;
3096
- ctx.moveTo(0, gy);
3097
- ctx.lineTo(plotRight, gy);
3098
- if (gxVisible) {
3099
- ctx.fillStyle = pal.accent;
3100
- ctx.beginPath();
3101
- ctx.arc(gx, main.y0 + g.yFrac * main.h, 3, 0, Math.PI * 2);
3102
- ctx.fill();
3103
- }
3104
- }
3105
- ctx.stroke();
3106
- ctx.restore();
3107
- if (gxVisible && this._data[g.index]) {
3108
- const tLabel = fmtFull(this._zt(this._data[g.index].time));
3109
- ctx.font = pillFont();
3110
- const tw = ctx.measureText(tLabel).width + 12;
3111
- this._pill(
3112
- clamp(gx - tw / 2, 2, plotRight - tw - 2),
3113
- plotBottom + 2,
3114
- tLabel,
3115
- pal.accent,
3116
- pal.pillText,
3117
- 'left',
3118
- tw
3119
- );
3120
- }
3121
- }
3122
-
3123
2737
  /* measure tool overlay */
3124
2738
  if (this._measure && this._measure.pA != null && this._measure.pB != null) {
3125
2739
  const m = this._measure;
@@ -3603,7 +3217,6 @@ class WickChart extends HTMLElementBase {
3603
3217
  if (!this._ly || !this._data.length) return;
3604
3218
  const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
3605
3219
  this._hover = { index: idx, x: this._xFor(idx), y: pt.y };
3606
- this._maybeSonify(idx);
3607
3220
  this._emitCrosshair(this._hover);
3608
3221
  this._invalidate();
3609
3222
  }
@@ -3650,7 +3263,6 @@ class WickChart extends HTMLElementBase {
3650
3263
 
3651
3264
  _pointerDown(e) {
3652
3265
  if (e.button !== 0) return;
3653
- this._stopPlayback(); // any touch interrupts the story
3654
3266
  this._canvas.setPointerCapture(e.pointerId);
3655
3267
  const pt = this._localPoint(e);
3656
3268
  this._pointers.set(e.pointerId, pt);
@@ -3847,10 +3459,14 @@ class WickChart extends HTMLElementBase {
3847
3459
  return useLog ? Math.pow(10, t) : t;
3848
3460
  }
3849
3461
 
3462
+ /** Dispatch a `wick:name` event on the element. */
3463
+ _fire(name, detail) {
3464
+ this.dispatchEvent(new CustomEvent('wick:' + name, { detail }));
3465
+ }
3466
+
3850
3467
  _wheel(e) {
3851
3468
  const ly = this._ly;
3852
3469
  if (!ly || !this._data.length) return;
3853
- this._stopPlayback();
3854
3470
  e.preventDefault();
3855
3471
  const pt = this._localPoint(e);
3856
3472
  const dx = e.deltaX;
@@ -3884,7 +3500,6 @@ class WickChart extends HTMLElementBase {
3884
3500
  _keydown(e) {
3885
3501
  const ly = this._ly;
3886
3502
  if (!ly || !this._data.length) return;
3887
- this._stopPlayback();
3888
3503
  if (e.key === 'Escape' && this._layerClaim) {
3889
3504
  const claim = this._layerClaim;
3890
3505
  this._layerClaim = null;
@@ -3905,7 +3520,6 @@ class WickChart extends HTMLElementBase {
3905
3520
  const cur = this._hover ? this._hover.index : d.length - 1;
3906
3521
  const idx = clamp(cur + (key === 'ArrowRight' ? step : -step), 0, d.length - 1);
3907
3522
  this._hover = { index: idx, x: this._xFor(idx), y: this._hover ? this._hover.y : ly.main.y1 * 0.5 };
3908
- this._maybeSonify(idx);
3909
3523
  this._emitCrosshair(this._hover);
3910
3524
  this._invalidate();
3911
3525
  } else if (key === 'Home') {
@@ -3943,103 +3557,9 @@ class WickChart extends HTMLElementBase {
3943
3557
  }
3944
3558
 
3945
3559
  /* ------------------------------------------------------------ *
3946
- * Sonification — the chart by ear (a11y)
3560
+ * Crosshair events
3947
3561
  * ------------------------------------------------------------ */
3948
3562
 
3949
- /** Lazily-created shared AudioContext (enable within a user gesture). */
3950
- _audio() {
3951
- if (this._actx) return this._actx;
3952
- const AC = window.AudioContext || window.webkitAudioContext;
3953
- if (!AC) return null;
3954
- try {
3955
- this._actx = new AC();
3956
- } catch (_) {
3957
- this._actx = null;
3958
- }
3959
- return this._actx;
3960
- }
3961
-
3962
- /** Short sine blip; `when` schedules against AudioContext time. */
3963
- _tone(freq, dur = 0.14, when = 0) {
3964
- const ctx = this._audio();
3965
- if (!ctx) return;
3966
- if (ctx.state === 'suspended') ctx.resume().catch(() => {});
3967
- const t0 = when || ctx.currentTime;
3968
- const osc = ctx.createOscillator();
3969
- const gain = ctx.createGain();
3970
- osc.type = 'sine';
3971
- osc.frequency.value = freq;
3972
- gain.gain.setValueAtTime(0.0001, t0);
3973
- gain.gain.exponentialRampToValueAtTime(0.18, t0 + 0.012);
3974
- gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
3975
- osc.connect(gain).connect(ctx.destination);
3976
- osc.start(t0);
3977
- osc.stop(t0 + dur + 0.03);
3978
- }
3979
-
3980
- /** One tone for a bar's close, pitched by its position on the y-scale. */
3981
- _sonifyBar(i) {
3982
- if (!this._sonify || !this._data.length || !this._lastScale) return;
3983
- const d = this._renderBars();
3984
- const b = d[clamp(i, 0, d.length - 1)];
3985
- if (!b) return;
3986
- this._tone(priceToFreq(b.close, this._lastScale));
3987
- }
3988
-
3989
- /** One tone per crosshair bar change (dedupes y-only moves). */
3990
- _maybeSonify(idx) {
3991
- if (!this._sonify) return;
3992
- if (this._lastToneIdx === idx) return;
3993
- this._lastToneIdx = idx;
3994
- this._sonifyBar(idx);
3995
- }
3996
-
3997
- /**
3998
- * Play the visible range as a pitch sweep (~4s), riding the crosshair —
3999
- * the audible equivalent of running your eye along the price line.
4000
- */
4001
- playRange() {
4002
- if (!this._data.length || !this._ly) return;
4003
- const ctx = this._audio();
4004
- if (!ctx) return;
4005
- if (ctx.state === 'suspended') ctx.resume().catch(() => {});
4006
- const d = this._renderBars();
4007
- const count = Math.max(2, Math.round(this._ly.plotRight / this._view.spacing));
4008
- const i0 = clamp(Math.floor(this._view.rightIndex - count) - 1, 0, d.length - 1);
4009
- const i1 = clamp(Math.ceil(this._view.rightIndex), 0, d.length - 1);
4010
- if (i1 - i0 < 2) return;
4011
- const N = Math.min(120, i1 - i0 + 1);
4012
- const stepMs = Math.min(70, Math.max(24, 4000 / N));
4013
- const t0 = ctx.currentTime + 0.05;
4014
- for (let k = 0; k < N; k++) {
4015
- const i = Math.round(i0 + ((i1 - i0) * k) / (N - 1));
4016
- const b = d[i];
4017
- if (!b) continue;
4018
- this._tone(priceToFreq(b.close, this._lastScale), stepMs / 1000 * 0.9, t0 + (k * stepMs) / 1000);
4019
- }
4020
- // ride the crosshair along the sweep for sighted users
4021
- this._playToken++;
4022
- const token = this._playToken;
4023
- let k = 0;
4024
- const timer = setInterval(() => {
4025
- if (token !== this._playToken || !this._connected) {
4026
- clearInterval(timer);
4027
- return;
4028
- }
4029
- if (k >= N) {
4030
- clearInterval(timer);
4031
- this._hover = null;
4032
- this._emitCrosshair(null);
4033
- this._invalidate();
4034
- return;
4035
- }
4036
- const i = Math.round(i0 + ((i1 - i0) * k) / (N - 1));
4037
- this._hover = { index: i, x: this._xFor(i), y: this._ly ? this._ly.main.h * 0.5 : 0 };
4038
- this._invalidate();
4039
- k++;
4040
- }, stepMs);
4041
- }
4042
-
4043
3563
  _emitCrosshair(hover) {
4044
3564
  let detail = null;
4045
3565
  if (hover && this._data[hover.index]) {
@@ -4052,250 +3572,6 @@ class WickChart extends HTMLElementBase {
4052
3572
  };
4053
3573
  }
4054
3574
  this._fire('crosshair', detail);
4055
-
4056
- // co-view: share the pointer with peer charts (leave events bypass throttle)
4057
- if (this._coviewCh) {
4058
- if (!detail) {
4059
- this._coviewSend({ type: 'cross', time: null, yFrac: null });
4060
- } else {
4061
- const now = performance.now();
4062
- if (now - this._coviewLast > 40) {
4063
- this._coviewLast = now;
4064
- const ly = this._ly;
4065
- this._coviewSend({
4066
- type: 'cross',
4067
- time: detail.bar.time,
4068
- yFrac: ly && isNum(detail.y) ? clamp(detail.y / ly.main.h, 0, 1) : null,
4069
- });
4070
- }
4071
- }
4072
- }
4073
- }
4074
-
4075
- /* ------------------------------------------------------------ *
4076
- * Cross-tab co-view (BroadcastChannel)
4077
- * ------------------------------------------------------------ */
4078
-
4079
- /** Join/leave the co-view channel named by the `co-view` attribute. */
4080
- _setupCoView() {
4081
- if (this._coviewCh) {
4082
- try {
4083
- this._coviewCh.close();
4084
- } catch (_) {}
4085
- this._coviewCh = null;
4086
- }
4087
- clearInterval(this._coviewBeat);
4088
- this._coviewBeat = 0;
4089
- clearTimeout(this._ghostTimer);
4090
- if (this._ghost) {
4091
- this._ghost = null;
4092
- this._invalidate();
4093
- }
4094
- if (this._presence && this._presence.peers.size) {
4095
- const left = this._presence.list();
4096
- this._presence = new PresenceTracker();
4097
- this._fire('peers', { peers: [], joined: [], left });
4098
- }
4099
- const name = this._coviewName;
4100
- if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
4101
- if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
4102
- try {
4103
- const ch = new BroadcastChannel('wick-co-view:' + name);
4104
- ch.onmessage = (ev) => this._onCoMessage(ev.data);
4105
- this._coviewCh = ch;
4106
- } catch (_) {}
4107
- // presence: announce immediately, then heartbeat so idle peers stay
4108
- // warm (and stale ones sweep) without waiting for a pan/zoom
4109
- this._coviewSendView(true);
4110
- this._coviewBeat = setInterval(() => {
4111
- this._coviewSendView(true);
4112
- const left = this._presence.sweep();
4113
- if (left.length) {
4114
- this._fire('peers', { peers: this._presence.list(), joined: [], left });
4115
- this._invalidate();
4116
- }
4117
- }, 4000);
4118
- }
4119
-
4120
- /** Broadcast our visible range for presence; throttled unless forced. */
4121
- _coviewSendView(force) {
4122
- if (!this._coviewCh) return;
4123
- const r = this.getVisibleRange();
4124
- if (!r) return;
4125
- const now = performance.now();
4126
- if (!force && now - this._coviewViewLast < 120) return;
4127
- this._coviewViewLast = now;
4128
- this._coviewSend({
4129
- type: 'view',
4130
- from: r.from,
4131
- to: r.to,
4132
- name: this._coviewLabel || null,
4133
- });
4134
- }
4135
-
4136
- _coviewSend(msg) {
4137
- if (!this._coviewCh) return;
4138
- try {
4139
- this._coviewCh.postMessage({ v: 1, peer: this._coviewPeer, ...msg });
4140
- } catch (_) {}
4141
- }
4142
-
4143
- _onCoMessage(m) {
4144
- if (!m || m.v !== 1 || m.peer === this._coviewPeer) return;
4145
- if (m.type === 'view') {
4146
- const joined = this._presence.track(m.peer, {
4147
- range: { from: m.from, to: m.to },
4148
- name: m.name,
4149
- });
4150
- this._invalidate();
4151
- if (joined) {
4152
- const p = this._presence.peers.get(m.peer);
4153
- this._fire('peers', {
4154
- peers: this._presence.list(),
4155
- joined: [p ? { ...p, range: p.range && { ...p.range } } : { id: m.peer }],
4156
- left: [],
4157
- });
4158
- }
4159
- return;
4160
- }
4161
- if (m.type === 'bye') {
4162
- const left = this._presence.drop(m.peer);
4163
- if (left) this._fire('peers', { peers: this._presence.list(), joined: [], left: [left] });
4164
- this._invalidate();
4165
- return;
4166
- }
4167
- if (m.type !== 'cross') return;
4168
- if (m.time == null) {
4169
- if (this._ghost) {
4170
- this._ghost = null;
4171
- clearTimeout(this._ghostTimer);
4172
- this._invalidate();
4173
- }
4174
- return;
4175
- }
4176
- if (!isNum(m.time) || !this._data.length) return;
4177
- this._ghost = {
4178
- index: WickChart._indexForTime(this._data, m.time),
4179
- yFrac: isNum(m.yFrac) ? clamp(m.yFrac, 0, 1) : null,
4180
- at: Date.now(),
4181
- };
4182
- clearTimeout(this._ghostTimer);
4183
- this._ghostTimer = setTimeout(() => {
4184
- this._ghost = null;
4185
- this._invalidate();
4186
- }, 2500);
4187
- this._invalidate();
4188
- }
4189
-
4190
- /** Dispatch `wick:name` (canonical) plus the deprecated `hab:name` alias,
4191
- * so 0.x listeners keep working until 2.0. */
4192
- _fire(name, detail) {
4193
- this.dispatchEvent(new CustomEvent('wick:' + name, { detail }));
4194
- this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
4195
- }
4196
-
4197
- /**
4198
- * Live co-view peers: who else is in the room and the time window each
4199
- * one is looking at — [{ id, name, range: {from, to}, at }], oldest
4200
- * sighting first. Peers fade out ~12 s after their last sighting.
4201
- * @returns {object[]}
4202
- */
4203
- getPeers() {
4204
- return this._presence ? this._presence.list() : [];
4205
- }
4206
-
4207
- /**
4208
- * Narrated timeline for a window (default: the visible range) — pivot
4209
- * highs/lows, volume spikes, gaps, RSI divergences plus derived legs
4210
- * ("+12.4% over 38 bars"), sorted by index. Pure data, perfect for
4211
- * caption UIs or the walk player.
4212
- * chart.narrate(); // visible range
4213
- * chart.narrate({ from, to }); // times in ms (s accepted)
4214
- * @param {{from?: number, to?: number}} [range]
4215
- * @returns {{i: number, time: number, type: string, side: string, note: string,
4216
- * legPct?: number, legBars?: number}[]}
4217
- */
4218
- narrate(range) {
4219
- const d = this._data;
4220
- if (!d.length) return [];
4221
- let i0 = 0;
4222
- let i1 = d.length - 1;
4223
- if (range && isNum(range.from) && isNum(range.to)) {
4224
- i0 = WickChart._indexForTime(d, WickChart._timeToMs(range.from));
4225
- i1 = WickChart._indexForTime(d, WickChart._timeToMs(range.to));
4226
- if (i0 > i1) [i0, i1] = [i1, i0];
4227
- }
4228
- return narrateWindow(d, i0, i1);
4229
- }
4230
-
4231
- /**
4232
- * Walk the chart through history like a story: the viewport slides
4233
- * from `from` to `to` while `wick:walk` events announce every step and
4234
- * the narrator's events (spikes, gaps, pivots, legs) as they're crossed.
4235
- * Any user interaction — pointer, wheel, keys, double-click — stops it.
4236
- * chart.walk({ from: 0, to: 500, speed: 120, step: 10 });
4237
- * chart.addEventListener('wick:walk', (e) => showCaption(e.detail));
4238
- * // detail: { phase: 'step'|'end'|'stop', index, events: [...], from, to }
4239
- * @param {{from?: number, to?: number, speed?: number, step?: number}} [opts]
4240
- * from/to are bar indices (default: last ~500 bars → the end)
4241
- * @returns {boolean} true when the walk started
4242
- */
4243
- walk(opts = {}) {
4244
- this.stopWalk(true);
4245
- const d = this._data;
4246
- if (!d.length || !this._connected) return false;
4247
- const to = clamp(Math.round(+opts.to || d.length - 1), 0, d.length - 1);
4248
- const from = clamp(Math.round(opts.from != null ? +opts.from : Math.max(0, to - 500)), 0, to);
4249
- const span = to - from + 1;
4250
- // window width: the current viewport, but never more than ~⅓ of the
4251
- // span (a fully zoomed-out chart would otherwise start at `to`)
4252
- const widthBars = clamp(
4253
- Math.min(
4254
- this._ly ? Math.round(this._ly.plotRight / this._view.spacing) : 120,
4255
- Math.max(10, Math.ceil(span / 3))
4256
- ),
4257
- 10,
4258
- span
4259
- );
4260
- const events = narrateWindow(d, from, to, { pivot: 8 });
4261
- const speed = clamp(Math.round(+opts.speed || 120), 16, 2000);
4262
- const step = clamp(Math.round(+opts.step || Math.max(1, Math.round(widthBars / 12))), 1, 500);
4263
- let cursor = Math.min(from + widthBars - 1, to);
4264
- let ev = 0;
4265
- let ended = false;
4266
- const tick = () => {
4267
- if (ended) return;
4268
- this._auto = false;
4269
- this._view.rightIndex = cursor;
4270
- this._clampView();
4271
- this._invalidate();
4272
- this._emitRange();
4273
- const hits = [];
4274
- while (ev < events.length && events[ev].i <= cursor) hits.push(events[ev++]);
4275
- this._fire('walk', { phase: 'step', index: cursor, events: hits, from, to });
4276
- if (cursor >= to) {
4277
- ended = true;
4278
- clearInterval(this._walkTimer);
4279
- this._walkTimer = 0;
4280
- this._fire('walk', { phase: 'end', index: cursor, events: [], from, to });
4281
- } else {
4282
- cursor = Math.min(cursor + step, to);
4283
- }
4284
- };
4285
- this._walkTimer = setInterval(tick, speed);
4286
- tick(); // first step lands immediately
4287
- return true;
4288
- }
4289
-
4290
- /**
4291
- * Stop the running walk (if any). Fires a final `wick:walk`
4292
- * { phase: 'stop' } unless called internally.
4293
- */
4294
- stopWalk(silent) {
4295
- if (!this._walkTimer) return;
4296
- clearInterval(this._walkTimer);
4297
- this._walkTimer = 0;
4298
- if (!silent) this._fire('walk', { phase: 'stop' });
4299
3575
  }
4300
3576
 
4301
3577
  /**
@@ -4333,171 +3609,47 @@ class WickChart extends HTMLElementBase {
4333
3609
  return { i0, i1, stats: { ...stats, from: { ...stats.from }, to: { ...stats.to } } };
4334
3610
  }
4335
3611
 
4336
- /**
4337
- * Capture the current chart state as a story scene: view, series type,
4338
- * indicators, overlays, scenario and risk plan, plus a title/note.
4339
- * Build guided tours by capturing several and playing them back.
4340
- * const story = [
4341
- * chart.captureScene('Overview', 'The full picture'),
4342
- * { title: 'The breakout', range: { from, to }, indicators: 'sma:20' },
4343
- * ];
4344
- * chart.playStory(story);
4345
- * @param {string} [title]
4346
- * @param {string} [note]
4347
- * @returns {object} scene (plain data — snapshot of the moment)
4348
- */
4349
- captureScene(title, note) {
4350
- const scene = {
4351
- title: title != null ? String(title).slice(0, 60) : '',
4352
- note: note != null ? String(note).slice(0, 200) : '',
4353
- range: this.getVisibleRange() || undefined,
4354
- type: this.getAttribute('type') || 'candles',
4355
- indicators: this.getAttribute('indicators') || null,
4356
- };
4357
- const ovs = this.overlays;
4358
- if (ovs.length) scene.overlays = ovs;
4359
- const sc = this.scenario;
4360
- if (sc) scene.scenario = sc;
4361
- const rp = this.riskPlan;
4362
- if (rp) scene.riskPlan = rp;
4363
- return scene;
4364
- }
4365
-
4366
- /** @returns {object[]|null} a copy of the last played story */
4367
- getStory() {
4368
- return this._story ? this._story.map((s) => ({ ...s })) : null;
4369
- }
4370
-
4371
- /**
4372
- * Play a story: each scene applies its state (type / indicators /
4373
- * overlays / scenario / risk plan — set or clear), the camera eases
4374
- * to its range, then holds for its dwell. `wick:story` events narrate:
4375
- * { phase: 'scene' | 'end' | 'stop', index, total, scene, title, note }
4376
- * Any user interaction — pointer, wheel, keys, double-click — stops it.
4377
- * @param {object[]} story scenes (invalid entries dropped, max 20)
4378
- * @param {{dwell?: number, panMs?: number, loop?: boolean}} [opts]
4379
- * panMs clamps 100–5000 (default 900); loop replays forever
4380
- * @returns {boolean} true when playback started
4381
- */
4382
- playStory(story, opts = {}) {
4383
- this.stopStory(true);
4384
- const scenes = sceneList(story);
4385
- if (!scenes.length || !this._data.length || !this._connected) return false;
4386
- const token = ++this._storyToken;
4387
- this._story = scenes;
4388
- const panMs = clamp(Math.round(+opts.panMs || 900), 100, 5000);
4389
- const loop = opts.loop === true;
4390
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
4391
- const run = async () => {
4392
- let idx = 0;
4393
- while (token === this._storyToken) {
4394
- const sc = scenes[idx];
4395
- this._fire('story', {
4396
- phase: 'scene', index: idx, total: scenes.length,
4397
- scene: sc, title: sc.title, note: sc.note,
4398
- });
4399
- this._applyScene(sc);
4400
- const target = this._sceneTarget(sc);
4401
- if (target) await this._storyTween(target, panMs, token);
4402
- if (token !== this._storyToken) return;
4403
- await wait(sc.dwell);
4404
- if (token !== this._storyToken) return;
4405
- idx++;
4406
- if (idx >= scenes.length) {
4407
- if (loop) idx = 0;
4408
- else {
4409
- this._fire('story', { phase: 'end', index: idx - 1, total: scenes.length });
4410
- return;
4411
- }
4412
- }
4413
- }
4414
- };
4415
- run();
4416
- return true;
4417
- }
4418
-
4419
- /**
4420
- * Stop story playback (if running). Fires a final `wick:story`
4421
- * { phase: 'stop' } unless called internally.
4422
- */
4423
- stopStory(silent) {
4424
- if (!this._storyToken) return;
4425
- this._storyToken = 0;
4426
- if (!silent) this._fire('story', { phase: 'stop' });
4427
- }
4428
-
4429
- /** Apply a scene's state (only the fields it carries). */
4430
- _applyScene(sc) {
4431
- if (sc.type) this.setAttribute('type', sc.type);
4432
- if (sc.indicators != null) this.setAttribute('indicators', sc.indicators);
4433
- if (sc.overlays) this.setOverlays(sc.overlays);
4434
- if (sc.scenario === 'clear') this.clearScenario();
4435
- else if (sc.scenario) this.setScenario(sc.scenario);
4436
- if (sc.riskPlan === 'clear') this.clearRiskPlan();
4437
- else if (sc.riskPlan) this.setRiskPlan(sc.riskPlan);
4438
- }
4439
-
4440
- /** Map a scene's time range to bar indices (null when not applicable). */
4441
- _sceneTarget(sc) {
4442
- if (!sc.range || !this._data.length) return null;
4443
- let i0 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.from));
4444
- let i1 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.to));
4445
- if (i0 > i1) [i0, i1] = [i1, i0];
4446
- return i1 - i0 >= 2 ? { i0, i1 } : null;
4447
- }
4448
-
4449
- /** Ease the viewport to { i0, i1 } over `ms`; resolves early if the
4450
- * token changes (superseded or stopped). rAF when available. */
4451
- _storyTween(target, ms, token) {
4452
- const ly = this._ly;
4453
- const d = this._data;
4454
- if (!ly || !d.length) return Promise.resolve();
4455
- const sp1 = clamp(ly.plotRight / (target.i1 - target.i0), this._minSpacing(), WickChart._MAX_SP);
4456
- const from = { right: this._view.rightIndex, sp: this._view.spacing };
4457
- const to = { right: target.i1, sp: sp1 };
4458
- const t0 = performance.now();
4459
- this._auto = false;
4460
- return new Promise((resolve) => {
4461
- const step = () => {
4462
- if (token !== this._storyToken) return resolve();
4463
- const e = easeInOutCubic(Math.min(1, (performance.now() - t0) / ms));
4464
- this._view.rightIndex = from.right + (to.right - from.right) * e;
4465
- this._view.spacing = from.sp + (to.sp - from.sp) * e;
4466
- this._clampView();
4467
- this._invalidate();
4468
- this._emitRange();
4469
- if (e >= 1) resolve();
4470
- else if (typeof requestAnimationFrame === 'function') requestAnimationFrame(step);
4471
- else setTimeout(step, 16);
4472
- };
4473
- step();
4474
- });
4475
- }
4476
-
4477
- /** Interrupt narrated playback (walk / story) on user input. */
4478
- _stopPlayback() {
4479
- if (this._walkTimer) this.stopWalk();
4480
- if (this._storyToken) this.stopStory();
4481
- }
4482
-
4483
3612
  _emitRange() {
4484
3613
  const r = this.getVisibleRange();
4485
3614
  if (!r) return;
4486
3615
  this._fire('range', r);
4487
- if (this._coviewCh) this._coviewSendView();
4488
3616
  }
4489
3617
  }
4490
3618
 
3619
+ /* 2.0: the guided-playback, planning, co-view and agent families moved to
3620
+ * their packages. Until one attaches, the familiar methods warn once and
3621
+ * no-op (deleted in 3.0); attaching installs the real thing as an own
3622
+ * property that shadows these. */
3623
+ for (const [name, pkg] of [
3624
+ ['narrate', 'wickchart-narrator'],
3625
+ ['walk', 'wickchart-narrator'],
3626
+ ['stopWalk', 'wickchart-narrator'],
3627
+ ['playRange', 'wickchart-narrator'],
3628
+ ['captureScene', 'wickchart-narrator'],
3629
+ ['getStory', 'wickchart-narrator'],
3630
+ ['playStory', 'wickchart-narrator'],
3631
+ ['stopStory', 'wickchart-narrator'],
3632
+ ['getPeers', 'wickchart-coview'],
3633
+ ['setScenario', 'wickchart-scenario'],
3634
+ ['clearScenario', 'wickchart-scenario'],
3635
+ ['setRiskPlan', 'wickchart-scenario'],
3636
+ ['clearRiskPlan', 'wickchart-scenario'],
3637
+ ['aiTools', 'wickchart-ai'],
3638
+ ['aiPrompt', 'wickchart-ai'],
3639
+ ['aiContext', 'wickchart-ai'],
3640
+ ['applyAI', 'wickchart-ai'],
3641
+ ['ask', 'wickchart-ai'],
3642
+ ]) {
3643
+ WickChart.prototype[name] = function () {
3644
+ warnDeprecatedAlias(`${name}() moved to the ${pkg} package in 2.0`);
3645
+ return undefined;
3646
+ };
3647
+ }
3648
+
4491
3649
  if (typeof customElements !== 'undefined') {
4492
3650
  if (!customElements.get('wick-chart')) {
4493
3651
  customElements.define('wick-chart', WickChart);
4494
3652
  }
4495
- // 0.x alias: same element under its old tag name (deprecated, removed in 2.0)
4496
- if (!customElements.get('hab-chart')) {
4497
- /** @deprecated use <wick-chart> */
4498
- class HabChart extends WickChart {}
4499
- customElements.define('hab-chart', HabChart);
4500
- }
4501
3653
  }
4502
3654
 
4503
3655
  export default WickChart;