wickchart 1.7.0 → 2.0.2

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
@@ -240,6 +238,8 @@ class WickChart extends HTMLElementBase {
240
238
  this._hover = null; // { index, x, y }
241
239
  this._dt = HOUR; // median bar interval (ms)
242
240
  this._ly = null; // last layout
241
+ this._priceW = null; // settled price-axis width (see _axisWidth)
242
+ this._pwNarrow = null; // pending axis shrink { want, t }
243
243
  this._cache = { v: -1, map: {} };
244
244
  this._pal = null; // palette cache
245
245
  this._palKey = '';
@@ -263,31 +263,21 @@ class WickChart extends HTMLElementBase {
263
263
  this._annoList = null;
264
264
  this._volshade = null;
265
265
 
266
- // cross-tab co-view state
267
- this._coviewName = null;
266
+ // co-view seams (2.0: driven by the wickchart-coview plugin — the
267
+ // layer/state contract its presence bands and ghost crosshair use,
268
+ // and disconnectedCallback cleans up)
268
269
  this._coviewCh = null;
269
- this._coviewPeer = '';
270
- this._coviewLast = 0;
271
270
  this._ghost = null;
272
271
  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();
272
+ this._presence = null;
276
273
  this._coviewBeat = 0;
277
- this._coviewViewLast = 0;
274
+ // scenario/risk seams (2.0: driven by the wickchart-scenario plugin;
275
+ // _rightMargin reserves future space from the active scenario)
276
+ this._scenario = null;
277
+ this._riskPlan = null;
278
278
 
279
- // sonification state
280
- this._sonify = false;
281
- this._actx = null;
282
- this._lastToneIdx = -1;
283
- this._playToken = 0;
284
279
  this._measure = null; // { iA, pA, iB, pB, done }
285
280
  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
281
  // delta brush state: mode flag + current/finished selection
292
282
  this._brush = false;
293
283
  this._brushSel = null; // { i0, i1, stats } — the committed selection
@@ -339,11 +329,6 @@ class WickChart extends HTMLElementBase {
339
329
  // server-side overlays (zones & levels)
340
330
  this._overlays = [];
341
331
 
342
- // scenario projection (ghost path + vol cone)
343
- this._scenario = null;
344
- // risk plan (R-multiple grid)
345
- this._riskPlan = null;
346
-
347
332
  this._onResize = () => this._invalidate();
348
333
  this._onPointerDown = (e) => this._pointerDown(e);
349
334
  this._onPointerMove = (e) => this._pointerMove(e);
@@ -357,7 +342,6 @@ class WickChart extends HTMLElementBase {
357
342
  };
358
343
  this._onWheel = (e) => this._wheel(e);
359
344
  this._onDbl = () => {
360
- this._stopPlayback();
361
345
  this.fit();
362
346
  };
363
347
  this._onKey = (e) => this._keydown(e);
@@ -414,18 +398,16 @@ class WickChart extends HTMLElementBase {
414
398
  if (document.fonts && document.fonts.ready) {
415
399
  document.fonts.ready.then(() => this._invalidate()).catch(() => {});
416
400
  }
417
- if (this._coviewName) this._setupCoView();
418
401
  this._watchDpr();
419
402
  this._invalidate();
420
403
  }
421
404
 
422
405
  disconnectedCallback() {
423
406
  this._connected = false;
424
- this.stopWalk(true);
425
- this.stopStory(true);
426
407
  this._layerClaim = null;
408
+ // co-view seams (driven by the wickchart-coview plugin): close the
409
+ // room cleanly and stop its timers so a removed chart goes quiet
427
410
  if (this._coviewCh) {
428
- this._coviewSend({ type: 'bye' });
429
411
  try {
430
412
  this._coviewCh.close();
431
413
  } catch (_) {}
@@ -433,12 +415,9 @@ class WickChart extends HTMLElementBase {
433
415
  }
434
416
  clearInterval(this._coviewBeat);
435
417
  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
418
  clearTimeout(this._ghostTimer);
419
+ this._ghost = null;
420
+ this._presence = null;
442
421
  if (this._ro) this._ro.disconnect();
443
422
  this._unwatchDpr();
444
423
  const cv = this._canvas;
@@ -526,24 +505,12 @@ class WickChart extends HTMLElementBase {
526
505
  this._overlays = ovs;
527
506
  break;
528
507
  }
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
508
  case 'brush':
538
509
  this._brush = val != null && val !== 'false';
539
510
  this._brushSel = null;
540
511
  this._brushDrag = null;
541
512
  this._invalidate();
542
513
  break;
543
- case 'sonify':
544
- this._sonify = val != null && val !== 'false';
545
- this._lastToneIdx = -1;
546
- break;
547
514
  // Default evaluation mode for alerts added without one. Anything
548
515
  // other than "close" means live, so a typo cannot silently mute
549
516
  // signals — it degrades to today's behaviour.
@@ -607,8 +574,7 @@ class WickChart extends HTMLElementBase {
607
574
  });
608
575
  }
609
576
 
610
- /** The tag this class registers as. (<hab-chart> is a deprecated alias
611
- * registered from the HabChart subclass, not this name.) */
577
+ /** The tag this class registers as. */
612
578
  static get elementName() {
613
579
  return 'wick-chart';
614
580
  }
@@ -660,6 +626,9 @@ class WickChart extends HTMLElementBase {
660
626
  this._version++;
661
627
  this._epoch++;
662
628
  this._computeDt();
629
+ // a replacement dataset is a new instrument regime — re-measure the
630
+ // axis from its own prices instead of debouncing down from the old one
631
+ this._priceW = this._pwNarrow = null;
663
632
  // history is not a live signal: re-baseline so close-mode alerts only
664
633
  // fire on candles that close from here on
665
634
  this._syncClosedIdx();
@@ -721,6 +690,7 @@ class WickChart extends HTMLElementBase {
721
690
  this._data = [];
722
691
  this._version++;
723
692
  this._epoch++;
693
+ this._priceW = this._pwNarrow = null;
724
694
  this._syncClosedIdx();
725
695
  this._hover = null;
726
696
  this._needsFit = true;
@@ -1018,8 +988,7 @@ class WickChart extends HTMLElementBase {
1018
988
  * WickScript predicate (`when`) on every streamed bar and fire on its
1019
989
  * false→true edge — e.g. `when: 'crossup(rsi(close,14), 30)'` or
1020
990
  * `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).
991
+ * triggering close as `price` plus the `when` source.
1023
992
  * @param {{id?: string, price?: number, direction?: 'above'|'below'|'cross',
1024
993
  * when?: string, once?: boolean}} alert
1025
994
  * @returns {string|null} the alert id (null when no valid price/when,
@@ -1123,157 +1092,21 @@ class WickChart extends HTMLElementBase {
1123
1092
  this._invalidate();
1124
1093
  }
1125
1094
 
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 */
1095
+ /** @returns {object|null} a copy of the active scenario (set through the
1096
+ * `_scenario` seam by the wickchart-scenario plugin; reserves future
1097
+ * space via _rightMargin) */
1154
1098
  get scenario() {
1155
1099
  if (!this._scenario) return null;
1156
1100
  return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
1157
1101
  }
1158
1102
 
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 */
1103
+ /** @returns {object|null} a copy of the active risk plan (set through
1104
+ * the `_riskPlan` seam by the wickchart-scenario plugin) */
1189
1105
  get riskPlan() {
1190
1106
  if (!this._riskPlan) return null;
1191
1107
  return { ...this._riskPlan, levels: this._riskPlan.levels.map((l) => ({ ...l })) };
1192
1108
  }
1193
1109
 
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
1110
 
1278
1111
  /** Check alerts against an incoming bar (prev close → new close).
1279
1112
  * Scripted (`when`) alerts evaluate their predicate series, cached per
@@ -1483,6 +1316,32 @@ class WickChart extends HTMLElementBase {
1483
1316
  this._raf = requestAnimationFrame(() => this._render());
1484
1317
  }
1485
1318
 
1319
+ /**
1320
+ * Price-axis width with tick-noise immunity. The width is measured from
1321
+ * the live last close, and digits in a proportional font measure
1322
+ * differently ("1" is narrower than "8") — so on every tick the ceil()
1323
+ * can flip a pixel, dragging the axis separator and with it every candle
1324
+ * sideways (visibly shaking on high-frequency pairs). Grow at once so a
1325
+ * wider label never clips; adopt a narrower width only after it has held
1326
+ * for 750ms, and only when it's a real change (≥2px) rather than
1327
+ * digit-width noise, so a flapping price can never wobble the layout.
1328
+ */
1329
+ _axisWidth(want) {
1330
+ const prev = this._priceW;
1331
+ if (prev == null || want >= prev) {
1332
+ this._priceW = want;
1333
+ this._pwNarrow = null;
1334
+ } else if (want <= prev - 2) {
1335
+ if (this._pwNarrow == null || this._pwNarrow.want !== want) {
1336
+ this._pwNarrow = { want, t: performance.now() };
1337
+ } else if (performance.now() - this._pwNarrow.t >= 750) {
1338
+ this._priceW = want;
1339
+ this._pwNarrow = null;
1340
+ }
1341
+ }
1342
+ return this._priceW;
1343
+ }
1344
+
1486
1345
  _prec(v) {
1487
1346
  return this._precision != null ? this._precision : autoPrecision(v);
1488
1347
  }
@@ -1491,11 +1350,7 @@ class WickChart extends HTMLElementBase {
1491
1350
  if (this._pal && this._palKey === this._theme) return this._pal;
1492
1351
  const base = THEMES[this._theme] || THEMES.dark;
1493
1352
  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
- };
1353
+ const get = (name, fallback) => cs.getPropertyValue('--wick-' + name).trim() || fallback;
1499
1354
  const pal = {};
1500
1355
  for (const k of Object.keys(base)) {
1501
1356
  if (k === 'overlay') {
@@ -1504,8 +1359,7 @@ class WickChart extends HTMLElementBase {
1504
1359
  o.push(get('overlay-' + i, base.overlay[i]));
1505
1360
  }
1506
1361
  // allow single overlay color
1507
- const single =
1508
- cs.getPropertyValue('--wick-overlay').trim() || cs.getPropertyValue('--hab-overlay').trim();
1362
+ const single = get('overlay', '');
1509
1363
  pal.overlay = single ? base.overlay.map(() => single) : o;
1510
1364
  } else {
1511
1365
  pal[k] = get(k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), base[k]);
@@ -2017,14 +1871,16 @@ class WickChart extends HTMLElementBase {
2017
1871
  const sample = d.length ? d[d.length - 1].close : 0;
2018
1872
  const p = this._prec(sample || 1);
2019
1873
  const f = numberFmt(p);
2020
- priceW = Math.max(
2021
- 52,
2022
- Math.ceil(
2023
- Math.max(
2024
- measure(f.format(sample || 1000)),
2025
- measure(fmtCompact(1234567))
2026
- )
2027
- ) + 16
1874
+ priceW = this._axisWidth(
1875
+ Math.max(
1876
+ 52,
1877
+ Math.ceil(
1878
+ Math.max(
1879
+ measure(f.format(sample || 1000)),
1880
+ measure(fmtCompact(1234567))
1881
+ )
1882
+ ) + 16
1883
+ )
2028
1884
  );
2029
1885
  }
2030
1886
  const timeH = 26;
@@ -2057,6 +1913,10 @@ class WickChart extends HTMLElementBase {
2057
1913
  });
2058
1914
 
2059
1915
  /* background */
1916
+ // Clear explicitly: the fill below is also the only clear, and with
1917
+ // --wick-bg: transparent it paints nothing — without this every redraw
1918
+ // stacks on the previous frame's pixels.
1919
+ ctx.clearRect(0, 0, W, H);
2060
1920
  ctx.fillStyle = pal.bg;
2061
1921
  ctx.fillRect(0, 0, W, H);
2062
1922
  this._nodata.hidden = d.length > 0;
@@ -2225,82 +2085,7 @@ class WickChart extends HTMLElementBase {
2225
2085
  }
2226
2086
  }
2227
2087
 
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) */
2088
+ /* position zones (under series) */
2304
2089
  for (const pos of this._positions) {
2305
2090
  const yE = clamp(yOf(pos.entry), main.y0, main.y1);
2306
2091
  if (isNum(pos.target)) {
@@ -2315,68 +2100,7 @@ class WickChart extends HTMLElementBase {
2315
2100
  }
2316
2101
  }
2317
2102
 
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) */
2103
+ /* volume profile (behind the series) */
2380
2104
  if (this._profile) {
2381
2105
  const pkey = `${i0}:${i1}:${this._version}`;
2382
2106
  if (this._profileKey !== pkey) {
@@ -3048,78 +2772,6 @@ class WickChart extends HTMLElementBase {
3048
2772
  );
3049
2773
  }
3050
2774
 
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
2775
  /* measure tool overlay */
3124
2776
  if (this._measure && this._measure.pA != null && this._measure.pB != null) {
3125
2777
  const m = this._measure;
@@ -3603,7 +3255,6 @@ class WickChart extends HTMLElementBase {
3603
3255
  if (!this._ly || !this._data.length) return;
3604
3256
  const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
3605
3257
  this._hover = { index: idx, x: this._xFor(idx), y: pt.y };
3606
- this._maybeSonify(idx);
3607
3258
  this._emitCrosshair(this._hover);
3608
3259
  this._invalidate();
3609
3260
  }
@@ -3650,7 +3301,6 @@ class WickChart extends HTMLElementBase {
3650
3301
 
3651
3302
  _pointerDown(e) {
3652
3303
  if (e.button !== 0) return;
3653
- this._stopPlayback(); // any touch interrupts the story
3654
3304
  this._canvas.setPointerCapture(e.pointerId);
3655
3305
  const pt = this._localPoint(e);
3656
3306
  this._pointers.set(e.pointerId, pt);
@@ -3847,10 +3497,14 @@ class WickChart extends HTMLElementBase {
3847
3497
  return useLog ? Math.pow(10, t) : t;
3848
3498
  }
3849
3499
 
3500
+ /** Dispatch a `wick:name` event on the element. */
3501
+ _fire(name, detail) {
3502
+ this.dispatchEvent(new CustomEvent('wick:' + name, { detail }));
3503
+ }
3504
+
3850
3505
  _wheel(e) {
3851
3506
  const ly = this._ly;
3852
3507
  if (!ly || !this._data.length) return;
3853
- this._stopPlayback();
3854
3508
  e.preventDefault();
3855
3509
  const pt = this._localPoint(e);
3856
3510
  const dx = e.deltaX;
@@ -3884,7 +3538,6 @@ class WickChart extends HTMLElementBase {
3884
3538
  _keydown(e) {
3885
3539
  const ly = this._ly;
3886
3540
  if (!ly || !this._data.length) return;
3887
- this._stopPlayback();
3888
3541
  if (e.key === 'Escape' && this._layerClaim) {
3889
3542
  const claim = this._layerClaim;
3890
3543
  this._layerClaim = null;
@@ -3905,7 +3558,6 @@ class WickChart extends HTMLElementBase {
3905
3558
  const cur = this._hover ? this._hover.index : d.length - 1;
3906
3559
  const idx = clamp(cur + (key === 'ArrowRight' ? step : -step), 0, d.length - 1);
3907
3560
  this._hover = { index: idx, x: this._xFor(idx), y: this._hover ? this._hover.y : ly.main.y1 * 0.5 };
3908
- this._maybeSonify(idx);
3909
3561
  this._emitCrosshair(this._hover);
3910
3562
  this._invalidate();
3911
3563
  } else if (key === 'Home') {
@@ -3943,103 +3595,9 @@ class WickChart extends HTMLElementBase {
3943
3595
  }
3944
3596
 
3945
3597
  /* ------------------------------------------------------------ *
3946
- * Sonification — the chart by ear (a11y)
3598
+ * Crosshair events
3947
3599
  * ------------------------------------------------------------ */
3948
3600
 
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
3601
  _emitCrosshair(hover) {
4044
3602
  let detail = null;
4045
3603
  if (hover && this._data[hover.index]) {
@@ -4052,250 +3610,6 @@ class WickChart extends HTMLElementBase {
4052
3610
  };
4053
3611
  }
4054
3612
  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
3613
  }
4300
3614
 
4301
3615
  /**
@@ -4333,171 +3647,47 @@ class WickChart extends HTMLElementBase {
4333
3647
  return { i0, i1, stats: { ...stats, from: { ...stats.from }, to: { ...stats.to } } };
4334
3648
  }
4335
3649
 
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
3650
  _emitRange() {
4484
3651
  const r = this.getVisibleRange();
4485
3652
  if (!r) return;
4486
3653
  this._fire('range', r);
4487
- if (this._coviewCh) this._coviewSendView();
4488
3654
  }
4489
3655
  }
4490
3656
 
3657
+ /* 2.0: the guided-playback, planning, co-view and agent families moved to
3658
+ * their packages. Until one attaches, the familiar methods warn once and
3659
+ * no-op (deleted in 3.0); attaching installs the real thing as an own
3660
+ * property that shadows these. */
3661
+ for (const [name, pkg] of [
3662
+ ['narrate', 'wickchart-narrator'],
3663
+ ['walk', 'wickchart-narrator'],
3664
+ ['stopWalk', 'wickchart-narrator'],
3665
+ ['playRange', 'wickchart-narrator'],
3666
+ ['captureScene', 'wickchart-narrator'],
3667
+ ['getStory', 'wickchart-narrator'],
3668
+ ['playStory', 'wickchart-narrator'],
3669
+ ['stopStory', 'wickchart-narrator'],
3670
+ ['getPeers', 'wickchart-coview'],
3671
+ ['setScenario', 'wickchart-scenario'],
3672
+ ['clearScenario', 'wickchart-scenario'],
3673
+ ['setRiskPlan', 'wickchart-scenario'],
3674
+ ['clearRiskPlan', 'wickchart-scenario'],
3675
+ ['aiTools', 'wickchart-ai'],
3676
+ ['aiPrompt', 'wickchart-ai'],
3677
+ ['aiContext', 'wickchart-ai'],
3678
+ ['applyAI', 'wickchart-ai'],
3679
+ ['ask', 'wickchart-ai'],
3680
+ ]) {
3681
+ WickChart.prototype[name] = function () {
3682
+ warnDeprecatedAlias(`${name}() moved to the ${pkg} package in 2.0`);
3683
+ return undefined;
3684
+ };
3685
+ }
3686
+
4491
3687
  if (typeof customElements !== 'undefined') {
4492
3688
  if (!customElements.get('wick-chart')) {
4493
3689
  customElements.define('wick-chart', WickChart);
4494
3690
  }
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
3691
  }
4502
3692
 
4503
3693
  export default WickChart;