wickchart 1.6.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
  * ========================================================================== */
@@ -21,38 +22,62 @@
21
22
  import {
22
23
  clamp, isNum, numberFmt, fmtCompact, autoPrecision, niceStep, hexToRgba,
23
24
  FONT_STACK, axisFont, pillFont, roundRectPath,
24
- TIME_STEPS, HOUR, DAY, hhmm, fmtDay, fmtMonth, fmtYear, fmtFull,
25
+ TIME_STEPS, HOUR, DAY, toMs, zoneOffset, hhmm, fmtDay, fmtMonth, fmtYear, fmtFull,
25
26
  THEMES, mergeOlderData, detectGaps,
26
27
  parseIndicators, normalizeIndicatorResult, BUILTIN_INDICATORS,
27
- positionPnl, checkAlertCross, computeStats, safeColor,
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
 
45
+ /** Bars from which the worker compute path engages — below it, sync wins
46
+ * (posting the epoch snapshot costs more than the compute it saves). */
47
+ const WORKER_MIN_BARS = 50000;
48
+ /** Returned while an off-thread compute is in flight: no lines yet, and —
49
+ * like a failed compute — every renderer draws nothing. */
50
+ const PENDING_SERIES = { lines: [], histogram: null };
51
+ /**
52
+ * Built-ins a streamed tick can update by recomputing a bounded tail with
53
+ * the SAME batch definition (window indicators exactly, recursive ones
54
+ * converge geometrically). Excluded: obv/vwap are cumulative over all
55
+ * history, supertrend is a path-dependent state machine — no tail can
56
+ * patch those; they keep the full-recompute behavior.
57
+ */
58
+ const ONLINE_SKIP = new Set(['obv', 'vwap', 'supertrend']);
59
+ /** Tail warm-up bars per tick: past the recursion decay of any realistic
60
+ * period (~10×), and still ~0.1 ms of work per indicator. */
61
+ const ONLINE_WARMUP = 400;
62
+ /** Per-chart worker session ids (see _workerCompute / src/worker-core.js). */
63
+ let CHART_SID = 0;
64
+
47
65
  /* SSR safety: importing this module under Node (Next.js/Nuxt server render)
48
66
  * must not throw — the element simply registers only in browsers. */
49
67
  const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
50
68
 
51
69
  class WickChart extends HTMLElementBase {
52
70
  static get observedAttributes() {
53
- return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'co-view-name', 'brush', 'sonify'];
71
+ return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'brush', 'alert-evaluate', 'timezone', 'vwap-anchor', 'worker'];
54
72
  }
55
73
 
74
+ /**
75
+ * Shared ChartWorkerPool for the worker compute path (set by importing
76
+ * 'wickchart/worker'; null — everything sync — until then).
77
+ * @type {object|null}
78
+ */
79
+ static _workerPool = null;
80
+
56
81
  constructor() {
57
82
  super();
58
83
  const root = this.attachShadow({ mode: 'open' });
@@ -65,9 +90,12 @@ class WickChart extends HTMLElementBase {
65
90
  height: 100%;
66
91
  min-height: 220px;
67
92
  contain: content;
93
+ /* the overlays lay themselves out against the chart's own width
94
+ (see the @container rule at the end of this sheet) */
95
+ container-type: inline-size;
68
96
  }
69
97
  :host(:focus-visible) {
70
- outline: 2px solid var(--wick-accent, var(--hab-accent, #4c8dff));
98
+ outline: 2px solid var(--wick-accent, #4c8dff);
71
99
  outline-offset: -2px;
72
100
  }
73
101
  .wrap { position: absolute; inset: 0; overflow: hidden; }
@@ -75,10 +103,17 @@ class WickChart extends HTMLElementBase {
75
103
  position: absolute; inset: 0;
76
104
  width: 100%; height: 100%;
77
105
  display: block;
78
- touch-action: none;
106
+ /* pan-y, not none: a vertical swipe belongs to the page, or a
107
+ chart embedded in a phone article becomes a dead zone the
108
+ reader cannot scroll past. Horizontal drags and pinches still
109
+ arrive as pointer events; _onTouchMove takes the gesture back
110
+ (preventDefault) once the chart owns it. */
111
+ touch-action: pan-y;
79
112
  cursor: crosshair;
80
113
  user-select: none;
81
114
  -webkit-user-select: none;
115
+ /* long-press is the scrub gesture — suppress the iOS callout */
116
+ -webkit-touch-callout: none;
82
117
  }
83
118
  canvas.grabbing { cursor: grabbing; }
84
119
  .legend {
@@ -90,26 +125,26 @@ class WickChart extends HTMLElementBase {
90
125
  }
91
126
  .legend .row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
92
127
  .legend .sym {
93
- color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3));
128
+ color: var(--wick-text-strong, #e6edf3);
94
129
  font-weight: 700;
95
130
  font-size: 13px;
96
131
  letter-spacing: 0.02em;
97
132
  }
98
133
  .legend .kv { display: inline-flex; gap: 5px; align-items: baseline; white-space: nowrap; }
99
- .legend .k { color: var(--wick-text, var(--hab-text, #8b949e)); font-size: 11px; }
100
- .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; }
101
136
  .legend .pct { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
102
- .legend .up { color: var(--wick-up, var(--hab-up, #16c784)); }
103
- .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); }
104
139
  .legend .ind {
105
140
  display: inline-flex; align-items: center; gap: 6px;
106
- 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;
107
142
  }
108
143
  .legend .ind i { width: 8px; height: 2.5px; border-radius: 2px; display: inline-block; }
109
144
  .legend .ind .v { font-size: 12px; }
110
145
  .legend .insight {
111
- color: var(--wick-accent, var(--hab-accent, #4c8dff));
112
- 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));
113
148
  border-radius: 6px;
114
149
  padding: 1px 8px;
115
150
  font-size: 11.5px;
@@ -118,7 +153,7 @@ class WickChart extends HTMLElementBase {
118
153
  .nodata {
119
154
  position: absolute; inset: 0;
120
155
  display: flex; align-items: center; justify-content: center;
121
- color: var(--wick-text, var(--hab-text, #8b949e));
156
+ color: var(--wick-text, #8b949e);
122
157
  font: 500 13px ${FONT_STACK};
123
158
  pointer-events: none;
124
159
  }
@@ -131,25 +166,51 @@ class WickChart extends HTMLElementBase {
131
166
  }
132
167
  .hud .pos {
133
168
  display: inline-flex; gap: 9px; align-items: baseline; white-space: nowrap;
134
- background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
135
- 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));
136
171
  border-radius: 7px;
137
172
  padding: 3px 9px;
138
173
  }
139
174
  .hud .statsrow {
140
175
  display: inline-flex; gap: 12px; white-space: nowrap;
141
- background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
142
- 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));
143
178
  border-radius: 7px;
144
179
  padding: 3px 10px;
145
- color: var(--wick-text, var(--hab-text, #8b949e));
180
+ color: var(--wick-text, #8b949e);
146
181
  font-variant-numeric: tabular-nums;
147
182
  }
148
- .hud .statsrow b { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-weight: 600; }
149
- .hud .k { color: var(--wick-text, var(--hab-text, #8b949e)); font-weight: 500; }
150
- .hud .v { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-variant-numeric: tabular-nums; }
151
- .hud .up { color: var(--wick-up, var(--hab-up, #16c784)); }
152
- .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); }
188
+
189
+ /* Narrow charts: the legend (top-left) and the HUD (top-right) are
190
+ both pinned to the top, so on a phone they land on top of each
191
+ other — a label plus a few indicators wraps the legend to three
192
+ rows and the stats row draws straight through it. Below 560px
193
+ they stack instead.
194
+
195
+ A container query, not a media query: what matters is how wide
196
+ the chart is, not the screen. A narrow chart in a sidebar on a
197
+ desktop has exactly the same problem.
198
+
199
+ They become position:relative rather than static so they stay in
200
+ flow *and* keep their stacking context — an unpositioned box would
201
+ paint underneath the absolutely positioned canvas. The canvas is
202
+ out of flow either way, so the flex column never moves it. */
203
+ @container (max-width: 560px) {
204
+ .wrap { display: flex; flex-direction: column; align-items: flex-start; }
205
+ .legend, .hud {
206
+ position: relative;
207
+ left: auto; right: auto; top: auto;
208
+ max-width: calc(100% - 20px);
209
+ }
210
+ .legend { margin: 8px 10px 0; }
211
+ .hud { margin: 4px 10px 0; align-items: flex-start; }
212
+ .hud .pos, .hud .statsrow { white-space: normal; }
213
+ }
153
214
  </style>
154
215
  <div class="wrap" part="wrap">
155
216
  <canvas part="canvas" role="img"></canvas>
@@ -200,40 +261,48 @@ class WickChart extends HTMLElementBase {
200
261
  this._annoList = null;
201
262
  this._volshade = null;
202
263
 
203
- // cross-tab co-view state
204
- 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)
205
267
  this._coviewCh = null;
206
- this._coviewPeer = '';
207
- this._coviewLast = 0;
208
268
  this._ghost = null;
209
269
  this._ghostTimer = 0;
210
- // presence: peer viewports (who is looking where)
211
- this._coviewLabel = null; // display name from the co-view-name attribute
212
- this._presence = new PresenceTracker();
270
+ this._presence = null;
213
271
  this._coviewBeat = 0;
214
- 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;
215
276
 
216
- // sonification state
217
- this._sonify = false;
218
- this._actx = null;
219
- this._lastToneIdx = -1;
220
- this._playToken = 0;
221
277
  this._measure = null; // { iA, pA, iB, pB, done }
222
278
  this._measuring = false;
223
- // bar-walk narrator state
224
- this._walkTimer = 0;
225
- // story mode state: token cancels stale async runs
226
- this._storyToken = 0;
227
- this._story = null;
228
279
  // delta brush state: mode flag + current/finished selection
229
280
  this._brush = false;
230
281
  this._brushSel = null; // { i0, i1, stats } — the committed selection
231
282
  this._brushDrag = null; // { i0, i1 } — while the pointer is down
232
283
  this._ind = { overlays: [], panes: [], volume: true };
233
284
 
285
+ // worker compute path (see _indicatorSeries): built-in indicators over
286
+ // big histories compute off the main thread. Results are cached per
287
+ // data *epoch* (bulk loads: setData/clearData/backfill) — streamed
288
+ // ticks bump _version, not _epoch, so they stop recomputing the full
289
+ // series per bar; the forming bar's value catches up on the next load.
290
+ this._workerOn = false;
291
+ this._epoch = 0;
292
+ this._workerCache = { epoch: -1, map: {}, pending: {}, sent: -1 };
293
+ this._sid = ++CHART_SID;
294
+ // incremental tick updates: { epoch, map: key → { v, res } } — the
295
+ // freshest series per key, patched in place by _onlineTick
296
+ this._onlineSeries = { epoch: -1, map: {} };
297
+
234
298
  this._pointers = new Map();
235
299
  this._pan = null;
236
300
  this._pinch = null;
301
+ // long-press scrub: touch has no hover, so reading a bar needs a
302
+ // gesture of its own (see _armPress)
303
+ this._scrub = false;
304
+ this._pressTimer = 0;
305
+ this._pressOrigin = null;
237
306
 
238
307
  // plugin layers: external draw hooks + pointer claims (see addLayer)
239
308
  this._layers = [];
@@ -248,15 +317,16 @@ class WickChart extends HTMLElementBase {
248
317
  this._positions = [];
249
318
  this._alerts = [];
250
319
  this._seq = 0;
320
+ // Default evaluation mode for alerts that don't pick one, and the last
321
+ // bar index known to be final — see _lastClosedIndex().
322
+ this._alertEval = 'live';
323
+ this._lastClosedIdx = -1;
324
+ this._tz = 'local';
325
+ this._vwapAnchor = 'utc';
251
326
 
252
327
  // server-side overlays (zones & levels)
253
328
  this._overlays = [];
254
329
 
255
- // scenario projection (ghost path + vol cone)
256
- this._scenario = null;
257
- // risk plan (R-multiple grid)
258
- this._riskPlan = null;
259
-
260
330
  this._onResize = () => this._invalidate();
261
331
  this._onPointerDown = (e) => this._pointerDown(e);
262
332
  this._onPointerMove = (e) => this._pointerMove(e);
@@ -270,10 +340,35 @@ class WickChart extends HTMLElementBase {
270
340
  };
271
341
  this._onWheel = (e) => this._wheel(e);
272
342
  this._onDbl = () => {
273
- this._stopPlayback();
274
343
  this.fit();
275
344
  };
276
345
  this._onKey = (e) => this._keydown(e);
346
+ // The canvas leaves vertical scrolling to the page (touch-action:
347
+ // pan-y). Once a chart gesture owns the touch — a pinch, a scrub, a
348
+ // layer drag, or a pan that has committed to a direction — the
349
+ // gesture is taken back, or the browser would hand it to the scroller
350
+ // halfway through. Must be non-passive to be allowed to.
351
+ this._onTouchMove = (e) => {
352
+ if (
353
+ this._scrub ||
354
+ this._pointers.size >= 2 ||
355
+ this._layerClaim ||
356
+ (this._pan && this._pan.moved)
357
+ ) {
358
+ e.preventDefault();
359
+ }
360
+ };
361
+ // devicePixelRatio changes without the CSS box changing size — dragging
362
+ // the window to a monitor with a different ratio, or a browser zoom
363
+ // that lands on the same layout width. ResizeObserver stays silent for
364
+ // those, so the canvas would keep its old backing store and render
365
+ // soft until something else forced a resize. A `resolution` media
366
+ // query is the only event for it; it only ever matches the ratio it
367
+ // was created with, so each change re-arms a fresh one.
368
+ this._onDprChange = () => {
369
+ this._watchDpr();
370
+ this._invalidate();
371
+ };
277
372
  }
278
373
 
279
374
  connectedCallback() {
@@ -294,23 +389,23 @@ class WickChart extends HTMLElementBase {
294
389
  cv.addEventListener('pointercancel', this._onPointerUp);
295
390
  cv.addEventListener('pointerleave', this._onPointerLeave);
296
391
  cv.addEventListener('wheel', this._onWheel, { passive: false });
392
+ cv.addEventListener('touchmove', this._onTouchMove, { passive: false });
297
393
  cv.addEventListener('dblclick', this._onDbl);
298
394
  this.addEventListener('keydown', this._onKey);
299
395
 
300
396
  if (document.fonts && document.fonts.ready) {
301
397
  document.fonts.ready.then(() => this._invalidate()).catch(() => {});
302
398
  }
303
- if (this._coviewName) this._setupCoView();
399
+ this._watchDpr();
304
400
  this._invalidate();
305
401
  }
306
402
 
307
403
  disconnectedCallback() {
308
404
  this._connected = false;
309
- this.stopWalk(true);
310
- this.stopStory(true);
311
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
312
408
  if (this._coviewCh) {
313
- this._coviewSend({ type: 'bye' });
314
409
  try {
315
410
  this._coviewCh.close();
316
411
  } catch (_) {}
@@ -318,13 +413,11 @@ class WickChart extends HTMLElementBase {
318
413
  }
319
414
  clearInterval(this._coviewBeat);
320
415
  this._coviewBeat = 0;
321
- if (this._presence && this._presence.peers.size) {
322
- const left = this._presence.list();
323
- this._presence = new PresenceTracker();
324
- this._fire('peers', { peers: [], joined: [], left });
325
- }
326
416
  clearTimeout(this._ghostTimer);
417
+ this._ghost = null;
418
+ this._presence = null;
327
419
  if (this._ro) this._ro.disconnect();
420
+ this._unwatchDpr();
328
421
  const cv = this._canvas;
329
422
  cv.removeEventListener('pointerdown', this._onPointerDown);
330
423
  cv.removeEventListener('pointermove', this._onPointerMove);
@@ -332,11 +425,29 @@ class WickChart extends HTMLElementBase {
332
425
  cv.removeEventListener('pointercancel', this._onPointerUp);
333
426
  cv.removeEventListener('pointerleave', this._onPointerLeave);
334
427
  cv.removeEventListener('wheel', this._onWheel);
428
+ cv.removeEventListener('touchmove', this._onTouchMove);
335
429
  cv.removeEventListener('dblclick', this._onDbl);
336
430
  this.removeEventListener('keydown', this._onKey);
431
+ this._disarmPress();
432
+ this._scrub = false;
337
433
  if (this._raf) cancelAnimationFrame(this._raf), (this._raf = 0);
338
434
  }
339
435
 
436
+ /** (Re)arm the devicePixelRatio watcher for the current ratio. */
437
+ _watchDpr() {
438
+ this._unwatchDpr();
439
+ if (typeof matchMedia !== 'function') return;
440
+ const dpr = window.devicePixelRatio || 1;
441
+ this._dprMq = matchMedia(`(resolution: ${dpr}dppx)`);
442
+ this._dprMq.addEventListener('change', this._onDprChange);
443
+ }
444
+
445
+ _unwatchDpr() {
446
+ if (!this._dprMq) return;
447
+ this._dprMq.removeEventListener('change', this._onDprChange);
448
+ this._dprMq = null;
449
+ }
450
+
340
451
  attributeChangedCallback(name, _old, val) {
341
452
  switch (name) {
342
453
  case 'theme':
@@ -360,6 +471,10 @@ class WickChart extends HTMLElementBase {
360
471
  case 'indicators':
361
472
  this._ind = parseIndicators(val, WickChart._registry());
362
473
  break;
474
+ case 'worker':
475
+ this._workerOn = val != null && val !== 'false';
476
+ this._invalidate();
477
+ break;
363
478
  case 'stats':
364
479
  this._stats = val != null && val !== 'false';
365
480
  this._statsKey = '';
@@ -388,23 +503,31 @@ class WickChart extends HTMLElementBase {
388
503
  this._overlays = ovs;
389
504
  break;
390
505
  }
391
- case 'co-view':
392
- this._coviewName = val || null;
393
- this._setupCoView();
394
- break;
395
- case 'co-view-name':
396
- // display name travels with every presence message; no re-render
397
- this._coviewLabel = val || null;
398
- break;
399
506
  case 'brush':
400
507
  this._brush = val != null && val !== 'false';
401
508
  this._brushSel = null;
402
509
  this._brushDrag = null;
403
510
  this._invalidate();
404
511
  break;
405
- case 'sonify':
406
- this._sonify = val != null && val !== 'false';
407
- this._lastToneIdx = -1;
512
+ // Default evaluation mode for alerts added without one. Anything
513
+ // other than "close" means live, so a typo cannot silently mute
514
+ // signals — it degrades to today's behaviour.
515
+ case 'alert-evaluate':
516
+ this._alertEval = val === 'close' ? 'close' : 'live';
517
+ break;
518
+ // Display zone for axis labels and the crosshair readout: 'local'
519
+ // (default), 'utc', or an IANA name. Deliberately does NOT re-anchor
520
+ // VWAP — the trading session is a separate question from how times
521
+ // are shown; see calcVWAP's `anchor`.
522
+ case 'timezone':
523
+ this._tz = val || 'local';
524
+ break;
525
+ // Session anchor for VWAP: 'utc' (default), 'local', an IANA zone, or
526
+ // a fixed offset in ms. Bumping the version drops the per-version
527
+ // indicator cache so the series recomputes on the next frame.
528
+ case 'vwap-anchor':
529
+ this._vwapAnchor = val || 'utc';
530
+ this._version++;
408
531
  break;
409
532
  }
410
533
  this._invalidate();
@@ -449,9 +572,9 @@ class WickChart extends HTMLElementBase {
449
572
  });
450
573
  }
451
574
 
452
- /** The custom element class (also exported implicitly for users). */
575
+ /** The tag this class registers as. */
453
576
  static get elementName() {
454
- return 'hab-chart';
577
+ return 'wick-chart';
455
578
  }
456
579
 
457
580
  /* ------------------------------------------------------------ *
@@ -487,9 +610,23 @@ class WickChart extends HTMLElementBase {
487
610
  }
488
611
  }
489
612
  if (!sorted) norm.sort((a, b) => a.time - b.time);
613
+ // One bar per timestamp. A REST history fetch and the websocket that
614
+ // takes over from it overlap, so the same final candle routinely
615
+ // arrives twice; the later copy is the corrected one and wins. In
616
+ // place and allocation-free when the data is already unique.
617
+ let dst = 0;
618
+ for (let i = 0; i < norm.length; i++) {
619
+ if (dst > 0 && norm[i].time === norm[dst - 1].time) norm[dst - 1] = norm[i];
620
+ else norm[dst++] = norm[i];
621
+ }
622
+ norm.length = dst;
490
623
  this._data = norm;
491
624
  this._version++;
625
+ this._epoch++;
492
626
  this._computeDt();
627
+ // history is not a live signal: re-baseline so close-mode alerts only
628
+ // fire on candles that close from here on
629
+ this._syncClosedIdx();
493
630
  this._needsFit = true;
494
631
  this._auto = this._autoAttr();
495
632
  this._hover = null;
@@ -508,21 +645,37 @@ class WickChart extends HTMLElementBase {
508
645
  if (!b) return;
509
646
  const d = this._data;
510
647
  const last = d[d.length - 1];
511
- this._checkAlerts(last ? last.close : NaN, b);
648
+ const prevClose = last ? last.close : NaN;
649
+ // Only the front of the series is a live signal. A historical
650
+ // correction or a backfilled candle must never be compared against the
651
+ // latest price — that would fire an alert on a stale bar.
652
+ let live = true;
512
653
  if (!last || b.time > last.time) {
513
654
  d.push(b);
514
655
  if (d.length > 1) this._computeDt();
515
656
  } else if (b.time === last.time) {
516
657
  d[d.length - 1] = b;
517
658
  } else {
518
- // out-of-order / backfill: replace matching or insert
519
- let i = d.length - 1;
520
- while (i >= 0 && d[i].time > b.time) i--;
521
- if (i >= 0 && d[i].time === b.time) d[i] = b;
522
- else d.splice(i + 1, 0, b);
659
+ // out-of-order / backfill: replace matching or insert. Binary search
660
+ // for the slot a backward scan is O(n) per bar, which turns a
661
+ // backfill of old candles into O(n·m) over a long history.
662
+ live = false;
663
+ const i = WickChart._indexForTime(d, b.time);
664
+ if (d[i] && d[i].time === b.time) d[i] = b;
665
+ else d.splice(i, 0, b);
523
666
  this._computeDt();
524
667
  }
525
668
  this._version++;
669
+ // a live tick (append / forming-bar replace) patches every
670
+ // online-capable series in O(warm-up) before the next render
671
+ if (live) this._onlineTick();
672
+ // Alerts run after the dataset AND the version are updated, so scripted
673
+ // predicates evaluate over the bar that just arrived rather than
674
+ // re-reading the previous version's memoized series.
675
+ // A historical insert shifts indices without closing anything, so the
676
+ // cursor moves with the data rather than reading as a fresh close.
677
+ if (live) this._checkAlerts(prevClose, b);
678
+ else this._syncClosedIdx();
526
679
  if (this._hover && this._hover.index >= d.length) this._hover = null;
527
680
  this._updateAria();
528
681
  this._invalidate();
@@ -531,6 +684,8 @@ class WickChart extends HTMLElementBase {
531
684
  clearData() {
532
685
  this._data = [];
533
686
  this._version++;
687
+ this._epoch++;
688
+ this._syncClosedIdx();
534
689
  this._hover = null;
535
690
  this._needsFit = true;
536
691
  this._noMore = false;
@@ -574,9 +729,11 @@ class WickChart extends HTMLElementBase {
574
729
  }
575
730
  this._data = merged;
576
731
  this._version++;
732
+ this._epoch++;
577
733
  this._computeDt();
578
734
  // keep the exact same bars on screen: every index shifts by `added`
579
735
  this._view.rightIndex += added;
736
+ this._syncClosedIdx(); // backfill shifts indices, it closes nothing
580
737
  if (this._hover) this._hover.index = Math.min(this._hover.index + added, this._data.length - 1);
581
738
  this._clampView();
582
739
  this._invalidate();
@@ -701,8 +858,8 @@ class WickChart extends HTMLElementBase {
701
858
  .filter((a) => !a.fired)
702
859
  .map((a) =>
703
860
  a.when != null
704
- ? { id: a.id, when: a.when, once: a.once }
705
- : { id: a.id, price: a.price, direction: a.direction, once: a.once }
861
+ ? { id: a.id, when: a.when, once: a.once, evaluate: a.evaluate }
862
+ : { id: a.id, price: a.price, direction: a.direction, once: a.once, evaluate: a.evaluate }
706
863
  ),
707
864
  };
708
865
  }
@@ -750,6 +907,7 @@ class WickChart extends HTMLElementBase {
750
907
  when: a.when.trim(),
751
908
  compiled: compileScript(a.when),
752
909
  once: a.once !== false,
910
+ evaluate: this._evalMode(a.evaluate),
753
911
  fired: false,
754
912
  armed: true,
755
913
  };
@@ -762,6 +920,7 @@ class WickChart extends HTMLElementBase {
762
920
  price: a.price,
763
921
  direction: a.direction || 'cross',
764
922
  once: a.once !== false,
923
+ evaluate: this._evalMode(a.evaluate),
765
924
  fired: false,
766
925
  };
767
926
  })
@@ -823,8 +982,7 @@ class WickChart extends HTMLElementBase {
823
982
  * WickScript predicate (`when`) on every streamed bar and fire on its
824
983
  * false→true edge — e.g. `when: 'crossup(rsi(close,14), 30)'` or
825
984
  * `when: 'volume > sma(volume,20) * 3'`. Scripted events carry the
826
- * triggering close as `price` plus the `when` source (deprecated
827
- * `hab:alert` alias still dispatched).
985
+ * triggering close as `price` plus the `when` source.
828
986
  * @param {{id?: string, price?: number, direction?: 'above'|'below'|'cross',
829
987
  * when?: string, once?: boolean}} alert
830
988
  * @returns {string|null} the alert id (null when no valid price/when,
@@ -845,6 +1003,7 @@ class WickChart extends HTMLElementBase {
845
1003
  when: alert.when.trim(),
846
1004
  compiled,
847
1005
  once: alert.once !== false,
1006
+ evaluate: this._evalMode(alert.evaluate),
848
1007
  fired: false,
849
1008
  armed: true,
850
1009
  };
@@ -855,6 +1014,7 @@ class WickChart extends HTMLElementBase {
855
1014
  price: alert.price,
856
1015
  direction: alert.direction || 'cross',
857
1016
  once: alert.once !== false,
1017
+ evaluate: this._evalMode(alert.evaluate),
858
1018
  fired: false,
859
1019
  };
860
1020
  }
@@ -926,182 +1086,97 @@ class WickChart extends HTMLElementBase {
926
1086
  this._invalidate();
927
1087
  }
928
1088
 
929
- /**
930
- * Scenario projection into future space: a ghost path of future prices
931
- * plus optional σ-bands (vol cone) from realized volatility.
932
- *
933
- * chart.setScenario({ path: [64000, 65500, 68000], label: 'bull case' });
934
- * chart.setScenario({ horizon: 48, cone: true }); // cone-only
935
- *
936
- * The path is an array of prices (or {price} objects) for future bars
937
- * 1..N; horizon defaults to the path length (1–500). `cone` (default
938
- * true) draws ±levels·σ bands widening with √h from the current realized
939
- * vol; `color` accepts up|down|accent or safe CSS colors. Setting a
940
- * scenario reserves future space on the right; analysis data — excluded
941
- * from getState/setState.
942
- * @param {object} spec
943
- * @returns {object|null} the normalized scenario, or null when invalid
944
- */
945
- setScenario(spec) {
946
- this._scenario = normalizeScenario(spec);
947
- this._invalidate();
948
- return this._scenario;
949
- }
950
-
951
- clearScenario() {
952
- this._scenario = null;
953
- this._invalidate();
954
- }
955
-
956
- /** @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) */
957
1092
  get scenario() {
958
1093
  if (!this._scenario) return null;
959
1094
  return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
960
1095
  }
961
1096
 
962
- /**
963
- * Risk plan: an R-multiple grid anchored at entry/stop. 1R = |entry −
964
- * stop| (the risk unit); reward lines are drawn at kR beyond the entry
965
- * with the risk/reward zones shaded, so sizing and take-profit choices
966
- * read directly off the chart.
967
- *
968
- * chart.setRiskPlan({ entry: 64500, stop: 63800, multiples: [1, 2, 3] });
969
- * chart.setRiskPlan({ entry: 64500, stop: 63800, targets: [65900, 67300] });
970
- *
971
- * Direction is derived (stop below entry ⇒ long). Targets convert to
972
- * their R multiple; `multiples` win when both are given. Invalid specs
973
- * clear the plan (replace semantics, like setScenario); excluded from
974
- * getState/setState — it is app state, not chart state.
975
- * @param {object} spec
976
- * @returns {object|null} the normalized plan, or null when invalid
977
- */
978
- setRiskPlan(spec) {
979
- this._riskPlan = normalizeRiskPlan(spec);
980
- this._invalidate();
981
- return this._riskPlan;
982
- }
983
-
984
- clearRiskPlan() {
985
- if (this._riskPlan) {
986
- this._riskPlan = null;
987
- this._invalidate();
988
- }
989
- }
990
-
991
- /** @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) */
992
1099
  get riskPlan() {
993
1100
  if (!this._riskPlan) return null;
994
1101
  return { ...this._riskPlan, levels: this._riskPlan.levels.map((l) => ({ ...l })) };
995
1102
  }
996
1103
 
997
- /** σ-cone for the active scenario, cached per data version. */
998
- _scenarioConeCache() {
999
- if (!this._scenario || !this._data.length) return null;
1000
- if (this._cache.v !== this._version) {
1001
- this._cache = { v: this._version, map: {} };
1002
- }
1003
- if (!this._cache.map.__scenario) {
1004
- const d = this._data;
1005
- const vol = calcRealizedVol(d.map((b) => b.close), 20);
1006
- let v = NaN;
1007
- for (let i = vol.length - 1; i >= 0; i--) {
1008
- if (Number.isFinite(vol[i])) { v = vol[i]; break; }
1009
- }
1010
- this._cache.map.__scenario = calcVolCone(
1011
- d[d.length - 1].close,
1012
- v,
1013
- this._scenario.horizon,
1014
- this._scenario.levels
1015
- );
1016
- }
1017
- return this._cache.map.__scenario;
1018
- }
1019
-
1020
- /* ------------------------------------------------------------ *
1021
- * AI agent interface — the chart as a tool surface
1022
- * ------------------------------------------------------------ */
1023
-
1024
- /** Tool manifest for LLM control — JSON-safe copy of AI_TOOLS. */
1025
- aiTools() {
1026
- return JSON.parse(JSON.stringify(AI_TOOLS));
1027
- }
1028
1104
 
1029
- /** System prompt for agent control paste into any LLM alongside aiTools(). */
1030
- aiPrompt() {
1031
- return aiPromptText();
1105
+ /** Check alerts against an incoming bar (prev close new close).
1106
+ * Scripted (`when`) alerts evaluate their predicate series, cached per
1107
+ * data version, and fire on the false→true edge. */
1108
+ /**
1109
+ * Index of the newest bar known to be final: any bar with a newer bar
1110
+ * behind it, plus the front bar when the feed flagged it `closed: true`
1111
+ * (Binance's `k.x`). -1 when nothing has closed yet.
1112
+ */
1113
+ _lastClosedIndex() {
1114
+ const d = this._data;
1115
+ if (!d.length) return -1;
1116
+ const last = d.length - 1;
1117
+ return d[last] && d[last].closed === true ? last : last - 1;
1032
1118
  }
1033
1119
 
1034
- /** Grounding context for a model: current state + visible-window summary. */
1035
- aiContext() {
1036
- return { state: this.getState(), window: this.getDataWindow() };
1120
+ /** Re-baseline the closed-bar cursor without firing anything. */
1121
+ _syncClosedIdx() {
1122
+ this._lastClosedIdx = this._lastClosedIndex();
1037
1123
  }
1038
1124
 
1039
1125
  /**
1040
- * Apply a list of {tool, args} ops (typically LLM output) through the
1041
- * validated dispatcher in core. Never throws each op resolves
1042
- * {ok, tool, result} or {ok: false, tool, error} so an agent can
1043
- * self-correct.
1044
- * @param {any} ops
1045
- * @returns {Array<object>}
1126
+ * Resolve an alert's evaluation mode: an explicit 'close' / 'live' on the
1127
+ * alert wins, otherwise the chart-level `alert-evaluate` default (itself
1128
+ * 'live', so 1.x behaviour is unchanged unless asked for).
1129
+ * @param {string|undefined} v
1130
+ * @returns {'live'|'close'}
1046
1131
  */
1047
- applyAI(ops) {
1048
- return applyChartOps(this, ops);
1132
+ _evalMode(v) {
1133
+ if (v === 'close' || v === 'live') return v;
1134
+ return this._alertEval === 'close' ? 'close' : 'live';
1049
1135
  }
1050
1136
 
1051
- /**
1052
- * Ask an AI to operate the chart. Builds the payload {system,
1053
- * instruction, chart, tools}; with a `run` async function (your model
1054
- * call the chart itself never touches the network), applies the
1055
- * returned ops and resolves {payload, ops, results}. Without `run`,
1056
- * returns the payload for manual wiring — send it anywhere, then call
1057
- * chart.applyAI(ops) with the model's answer.
1058
- *
1059
- * const { results } = await chart.ask('add RSI and mark the demand zone', {
1060
- * run: async (payload) => (await callMyLLM(payload)).ops,
1061
- * });
1062
- *
1063
- * @param {string} instruction natural-language request
1064
- * @param {{run?: (payload: object) => Promise<any>}} [opts]
1065
- */
1066
- async ask(instruction, opts = {}) {
1067
- const payload = {
1068
- system: aiPromptText(),
1069
- instruction: String(instruction == null ? '' : instruction),
1070
- chart: this.aiContext(),
1071
- tools: this.aiTools(),
1072
- };
1073
- if (typeof opts.run !== 'function') {
1074
- return { payload, ops: null, results: null };
1075
- }
1076
- const ops = await opts.run(payload);
1077
- const results = this.applyAI(ops);
1078
- return { payload, ops, results };
1137
+ /** Dispatch one alert, retiring it when it was a `once` alert. */
1138
+ _fireAlert(alert, detail) {
1139
+ if (alert.once) alert.fired = true;
1140
+ this._fire('alert', { id: alert.id, ...detail });
1141
+ if (alert.once) this._alerts = this._alerts.filter((x) => x !== alert);
1079
1142
  }
1080
1143
 
1081
- /** Check alerts against an incoming bar (prev close → new close).
1082
- * Scripted (`when`) alerts evaluate their predicate series, cached per
1083
- * data version, and fire on the false→true edge. */
1084
1144
  _checkAlerts(prevClose, bar) {
1145
+ const closedIdx = this._lastClosedIndex();
1146
+ // A bar closing is an edge, not a level: close-mode alerts evaluate
1147
+ // only on the update that finalizes a candle, so a candle that ticks
1148
+ // through a threshold and back never produces a signal.
1149
+ const justClosed = closedIdx > this._lastClosedIdx;
1150
+ if (justClosed) this._lastClosedIdx = closedIdx;
1085
1151
  if (!this._alerts.length) return;
1152
+ const d = this._data;
1086
1153
  for (const a of [...this._alerts]) {
1154
+ // `fired` means "spent forever" — it is only ever set on `once`
1155
+ // alerts. Repeating (once:false) alerts re-fire on every edge:
1156
+ // price alerts are edge-triggered by checkAlertCross(), scripted
1157
+ // ones by the armed/scriptAlertStep() latch.
1087
1158
  if (a.fired) continue;
1159
+
1160
+ // Close mode reads the newest final candle; live mode reads the
1161
+ // front of the series, forming or not.
1162
+ const onClose = a.evaluate === 'close';
1163
+ if (onClose && (!justClosed || closedIdx < 0)) continue;
1164
+ const idx = onClose ? closedIdx : d.length - 1;
1165
+ const cur = onClose ? d[idx] : bar;
1166
+ if (!cur) continue;
1167
+
1088
1168
  if (a.when != null) {
1089
- const series = this._predicateCache(a);
1090
- const curTrue = series.length ? series[series.length - 1] : false;
1091
- const step = scriptAlertStep(a.armed, curTrue);
1169
+ // predicates are causal, so the value at `idx` is the same whether
1170
+ // it was computed over the whole series or just the prefix
1171
+ const step = scriptAlertStep(a.armed, this._predicateCache(a)[idx] === true);
1092
1172
  a.armed = step.armed;
1093
- if (step.fire) {
1094
- a.fired = true;
1095
- this._fire('alert', { id: a.id, price: bar.close, when: a.when, bar });
1096
- if (a.once) this._alerts = this._alerts.filter((x) => x !== a);
1097
- }
1173
+ if (step.fire) this._fireAlert(a, { price: cur.close, when: a.when, bar: cur });
1098
1174
  continue;
1099
1175
  }
1100
- if (!isNum(prevClose)) continue;
1101
- if (checkAlertCross(a, prevClose, bar.close)) {
1102
- a.fired = true;
1103
- this._fire('alert', { id: a.id, price: a.price, bar });
1104
- if (a.once) this._alerts = this._alerts.filter((x) => x !== a);
1176
+ const prev = onClose ? (d[idx - 1] ? d[idx - 1].close : NaN) : prevClose;
1177
+ if (!isNum(prev)) continue;
1178
+ if (checkAlertCross(a, prev, cur.close)) {
1179
+ this._fireAlert(a, { price: a.price, bar: cur });
1105
1180
  }
1106
1181
  }
1107
1182
  }
@@ -1136,6 +1211,11 @@ class WickChart extends HTMLElementBase {
1136
1211
 
1137
1212
  static _MAX_SP = 90;
1138
1213
 
1214
+ /** Hold this long on a touchscreen to open the crosshair (ms). */
1215
+ static _PRESS_MS = 350;
1216
+ /** Finger travel that cancels the press and makes it a pan (px). */
1217
+ static _PRESS_SLOP = 10;
1218
+
1139
1219
  /**
1140
1220
  * Lowest allowed px/bar: either 0.35, or whatever fits the entire
1141
1221
  * dataset on screen — so any history can be zoomed out fully.
@@ -1147,18 +1227,19 @@ class WickChart extends HTMLElementBase {
1147
1227
  }
1148
1228
 
1149
1229
  static _timeToMs(t) {
1150
- return isNum(t) ? (t < 1e12 ? t * 1000 : t) : Date.now();
1230
+ if (t instanceof Date) return t.getTime();
1231
+ return isNum(t) ? toMs(t) : Date.now();
1151
1232
  }
1152
1233
 
1153
1234
  static _normBar(b) {
1154
1235
  if (!b) return null;
1155
1236
  const t = b.time != null ? b.time : b.t;
1156
- if (!isNum(t)) return null;
1237
+ if (!isNum(t) && !(t instanceof Date)) return null;
1157
1238
  const time = WickChart._timeToMs(t);
1158
1239
  const close = isNum(b.close) ? b.close : isNum(b.value) ? b.value : NaN;
1159
1240
  if (!isNum(close)) return null;
1160
1241
  const open = isNum(b.open) ? b.open : close;
1161
- return {
1242
+ const nb = {
1162
1243
  time,
1163
1244
  open,
1164
1245
  high: isNum(b.high) ? b.high : Math.max(open, close),
@@ -1166,6 +1247,10 @@ class WickChart extends HTMLElementBase {
1166
1247
  close,
1167
1248
  volume: isNum(b.volume) ? b.volume : isNum(b.v) ? b.v : 0,
1168
1249
  };
1250
+ // Only carried when the feed actually says the candle is final
1251
+ // (Binance `k.x`), so the bar shape is unchanged for everyone else.
1252
+ if (b.closed === true) nb.closed = true;
1253
+ return nb;
1169
1254
  }
1170
1255
 
1171
1256
  static _indexForTime(d, time) {
@@ -1233,11 +1318,7 @@ class WickChart extends HTMLElementBase {
1233
1318
  if (this._pal && this._palKey === this._theme) return this._pal;
1234
1319
  const base = THEMES[this._theme] || THEMES.dark;
1235
1320
  const cs = getComputedStyle(this);
1236
- // --wick-* is canonical; --hab-* still honored as the 0.x fallback
1237
- const get = (name, fallback) => {
1238
- const v = cs.getPropertyValue('--wick-' + name).trim() || cs.getPropertyValue('--hab-' + name).trim();
1239
- return v || fallback;
1240
- };
1321
+ const get = (name, fallback) => cs.getPropertyValue('--wick-' + name).trim() || fallback;
1241
1322
  const pal = {};
1242
1323
  for (const k of Object.keys(base)) {
1243
1324
  if (k === 'overlay') {
@@ -1246,8 +1327,7 @@ class WickChart extends HTMLElementBase {
1246
1327
  o.push(get('overlay-' + i, base.overlay[i]));
1247
1328
  }
1248
1329
  // allow single overlay color
1249
- const single =
1250
- cs.getPropertyValue('--wick-overlay').trim() || cs.getPropertyValue('--hab-overlay').trim();
1330
+ const single = get('overlay', '');
1251
1331
  pal.overlay = single ? base.overlay.map(() => single) : o;
1252
1332
  } else {
1253
1333
  pal[k] = get(k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), base[k]);
@@ -1303,22 +1383,188 @@ class WickChart extends HTMLElementBase {
1303
1383
 
1304
1384
  /** Compute (and cache per data version) an indicator entry's series. */
1305
1385
  _indicatorSeries(entry) {
1386
+ {
1387
+ // incremental path first: a series patched for this exact version
1388
+ // by _onlineTick is the freshest thing there is
1389
+ const on = this._onlineSeries;
1390
+ const cur = on.epoch === this._epoch ? on.map['ind:' + entry.key] : null;
1391
+ if (cur && cur.v === this._version) return cur.res;
1392
+ }
1306
1393
  if (this._cache.v !== this._version) {
1307
1394
  this._cache = { v: this._version, map: {} };
1308
1395
  }
1309
1396
  const k = 'ind:' + entry.key;
1310
1397
  if (!this._cache.map[k]) {
1398
+ // Worker compute path: built-in indicators over big histories run
1399
+ // off the main thread. The dataset crosses once per data epoch as
1400
+ // six transferable Float64Arrays (~25 ms/M bars — cloning objects
1401
+ // would cost ~1 s). Closures (custom/scripted defs) can't cross;
1402
+ // neither can anything below WORKER_MIN_BARS — both stay sync.
1403
+ const pool = WickChart._workerPool;
1404
+ if (pool && pool.available && this._workerOn && this._data.length >= WORKER_MIN_BARS &&
1405
+ BUILTIN_INDICATORS.get(entry.name) === entry.def) {
1406
+ const wc = this._workerCache;
1407
+ if (wc.epoch === this._epoch && wc.map[k]) return wc.map[k];
1408
+ this._workerCompute(pool, entry, k);
1409
+ return PENDING_SERIES; // the line lands when the result arrives
1410
+ }
1311
1411
  let res;
1312
1412
  try {
1313
- res = entry.def.compute(this._data, entry.params);
1413
+ // the session anchor rides along for indicators that observe one
1414
+ // (vwap); the rest ignore the extra key
1415
+ res = entry.def.compute(this._data, { ...entry.params, anchor: this._vwapAnchor });
1314
1416
  } catch (err) {
1315
1417
  res = null;
1316
1418
  }
1317
1419
  this._cache.map[k] = normalizeIndicatorResult(res);
1420
+ this._seedOnline(k, entry, this._cache.map[k]);
1318
1421
  }
1319
1422
  return this._cache.map[k];
1320
1423
  }
1321
1424
 
1425
+ /**
1426
+ * After a live stream tick (append or forming-bar replace), patch every
1427
+ * online-capable series by recomputing a bounded tail with the same
1428
+ * batch definition — O(warm-up) instead of a full-history recompute per
1429
+ * indicator per tick. Bases are seeded by the sync or worker path; bulk
1430
+ * loads (epoch changes) reseed automatically.
1431
+ */
1432
+ _onlineTick() {
1433
+ const d = this._data;
1434
+ const on = this._onlineSeries;
1435
+ if (!d.length || on.epoch !== this._epoch) return;
1436
+ for (const entry of this._ind.overlays.concat(this._ind.panes)) {
1437
+ const k = 'ind:' + entry.key;
1438
+ const cur = on.map[k];
1439
+ if (!cur || ONLINE_SKIP.has(entry.name) || BUILTIN_INDICATORS.get(entry.name) !== entry.def) {
1440
+ continue;
1441
+ }
1442
+ if (this._patchSeriesTail(cur.res, entry, d)) cur.v = this._version;
1443
+ else delete on.map[k]; // length mismatch etc. — reseed on the next compute
1444
+ }
1445
+ }
1446
+
1447
+ /** Recompute the last K bars of one series in place (K = max(400,
1448
+ * 10×period), clamped to the data). False when the series and data
1449
+ * lengths can't line up — the caller drops the base and reseeds. */
1450
+ _patchSeriesTail(res, entry, d) {
1451
+ let p = 0;
1452
+ for (const v of Object.values(entry.params || {})) {
1453
+ if (Number.isFinite(+v) && +v > p) p = +v;
1454
+ }
1455
+ const K = Math.min(d.length - 1, Math.max(ONLINE_WARMUP, p * 10));
1456
+ if (K < 2 || !res.lines.length) return false;
1457
+ let tail;
1458
+ try {
1459
+ tail = normalizeIndicatorResult(
1460
+ entry.def.compute(d.slice(d.length - 1 - K), { ...entry.params, anchor: this._vwapAnchor })
1461
+ );
1462
+ } catch (_) {
1463
+ return false;
1464
+ }
1465
+ const want = d.length;
1466
+ const base = want - 1 - K; // data index of tail[0]
1467
+ // Only the last `p` values can have changed (window indicators depend
1468
+ // on the trailing window alone; recursive ones only move the new bar).
1469
+ // Writing deeper would replace good full-history values with the
1470
+ // tail's own warm-up error.
1471
+ const from = Math.max(base, want - 1 - p);
1472
+ const patch = (dst, src) => {
1473
+ if (!Array.isArray(src) || src.length !== K + 1) return false;
1474
+ if (dst.length === want - 1) dst.push(src[src.length - 1]); // a bar was appended
1475
+ else if (dst.length !== want) return false;
1476
+ for (let di = from; di < want; di++) {
1477
+ const v = src[di - base];
1478
+ if (v != null || dst[di] == null) dst[di] = v; // warm-up null never clobbers
1479
+ }
1480
+ return true;
1481
+ };
1482
+ for (let i = 0; i < res.lines.length; i++) {
1483
+ const t = tail.lines[i];
1484
+ if (!t || !patch(res.lines[i].values, t.values)) return false;
1485
+ }
1486
+ if (Array.isArray(res.histogram) && !patch(res.histogram, tail.histogram)) return false;
1487
+ return true;
1488
+ }
1489
+
1490
+ /** Remember a fresh series as the base for incremental tick updates
1491
+ * (online-capable builtins only). */
1492
+ _seedOnline(k, entry, res) {
1493
+ if (ONLINE_SKIP.has(entry.name) || BUILTIN_INDICATORS.get(entry.name) !== entry.def) return;
1494
+ const on = this._onlineSeries;
1495
+ if (on.epoch !== this._epoch) {
1496
+ on.epoch = this._epoch;
1497
+ on.map = {};
1498
+ }
1499
+ on.map[k] = { v: this._version, res };
1500
+ }
1501
+
1502
+ /** Bar count from which the worker path engages (below it, sync wins). */
1503
+ _workerCols() {
1504
+ const d = this._data;
1505
+ const n = d.length;
1506
+ const cols = {
1507
+ time: new Float64Array(n), open: new Float64Array(n), high: new Float64Array(n),
1508
+ low: new Float64Array(n), close: new Float64Array(n), volume: new Float64Array(n),
1509
+ };
1510
+ for (let i = 0; i < n; i++) {
1511
+ const b = d[i];
1512
+ cols.time[i] = b.time;
1513
+ cols.open[i] = b.open;
1514
+ cols.high[i] = b.high;
1515
+ cols.low[i] = b.low;
1516
+ cols.close[i] = b.close;
1517
+ cols.volume[i] = b.volume || 0;
1518
+ }
1519
+ return cols;
1520
+ }
1521
+
1522
+ /** Kick an off-thread compute for one indicator (idempotent per epoch). */
1523
+ _workerCompute(pool, entry, k) {
1524
+ const wc = this._workerCache;
1525
+ wc.epoch = this._epoch;
1526
+ if (wc.sent !== this._epoch) {
1527
+ wc.sent = this._epoch;
1528
+ wc.pending = {};
1529
+ pool
1530
+ .run({ type: 'epoch', sid: this._sid, epoch: this._epoch, cols: this._workerCols() })
1531
+ .catch(() => {
1532
+ if (wc.sent === this._epoch) wc.sent = -1; // resend on the next kick
1533
+ });
1534
+ }
1535
+ if (wc.pending[k] === this._epoch) return; // already in flight
1536
+ wc.pending[k] = this._epoch;
1537
+ const epoch = this._epoch; // captured at kick time — a bulk load that
1538
+ // lands while the compute is in flight must not adopt its result
1539
+ pool
1540
+ .run({
1541
+ type: 'indicator', sid: this._sid, epoch,
1542
+ name: entry.name, params: { ...entry.params, anchor: this._vwapAnchor },
1543
+ })
1544
+ .then((res) => this._workerArrived(k, epoch, res, entry))
1545
+ .catch((err) => {
1546
+ delete wc.pending[k];
1547
+ if (err && err.stale && wc.sent === this._epoch) {
1548
+ wc.sent = -1; // the worker no longer holds this epoch's data — resend
1549
+ } else if (wc.epoch === epoch) {
1550
+ wc.map[k] = { lines: [], histogram: null }; // negative cache: draw nothing this epoch
1551
+ }
1552
+ });
1553
+ }
1554
+
1555
+ _workerArrived(k, epoch, res, entry) {
1556
+ const wc = this._workerCache;
1557
+ delete wc.pending[k];
1558
+ if (wc.epoch !== epoch) return; // a newer bulk load won — drop the stale line
1559
+ const norm = normalizeIndicatorResult(res);
1560
+ wc.map[k] = norm;
1561
+ // the worker base doubles as the seed for incremental tick updates,
1562
+ // so streamed ticks stay fresh instead of waiting for the next load
1563
+ if (entry) this._seedOnline(k, entry, norm);
1564
+ this._fire('worker', { key: k, epoch });
1565
+ this._invalidate();
1566
+ }
1567
+
1322
1568
  /** Resolve a line color: #hex / rgb() / CSS name / palette key ('rsi', 'up', …) / cycle.
1323
1569
  * Untrusted values (URL/attribute-sourced) are validated — never interpolated raw. */
1324
1570
  _lineColor(entry, line, pal, cycleIdx) {
@@ -1492,6 +1738,11 @@ class WickChart extends HTMLElementBase {
1492
1738
  * the walk to those bars — used at deep zoom where bars are aggregated
1493
1739
  * into pixel columns (keeps this O(screen) instead of O(visible bars)).
1494
1740
  */
1741
+ /** An instant shifted into the chart's display zone, for the formatters. */
1742
+ _zt(t) {
1743
+ return t + zoneOffset(t, this._tz);
1744
+ }
1745
+
1495
1746
  _timeTicks(i0, i1, sampleIdx) {
1496
1747
  const d = this._data;
1497
1748
  const sp = this._view.spacing;
@@ -1519,7 +1770,6 @@ class WickChart extends HTMLElementBase {
1519
1770
  }
1520
1771
  }
1521
1772
 
1522
- const tz = (t) => -new Date(t).getTimezoneOffset() * 60000;
1523
1773
  const ticks = [];
1524
1774
  let prevKey = null;
1525
1775
  // Labels are built lazily — only for bars that actually start a new step.
@@ -1527,30 +1777,32 @@ class WickChart extends HTMLElementBase {
1527
1777
  const visit = (i) => {
1528
1778
  if (i < 0 || i >= d.length) return;
1529
1779
  const t = d[i].time;
1780
+ // shifted into the display zone once, then read with UTC getters
1781
+ const zt = this._zt(t);
1530
1782
  let key;
1531
1783
  let label = null;
1532
1784
  if (stepMs != null) {
1533
- key = Math.floor((t + tz(t)) / stepMs);
1785
+ key = Math.floor(zt / stepMs);
1534
1786
  if (prevKey !== null && key !== prevKey) {
1535
1787
  if (stepLabel === 'time') {
1536
1788
  const prevT = d[i - 1] ? d[i - 1].time : t;
1537
- const dayKey = Math.floor((t + tz(t)) / DAY);
1538
- const prevDay = Math.floor((prevT + tz(prevT)) / DAY);
1539
- label = dayKey !== prevDay ? fmtDay(t) : hhmm(t);
1789
+ const dayKey = Math.floor(zt / DAY);
1790
+ const prevDay = Math.floor(this._zt(prevT) / DAY);
1791
+ label = dayKey !== prevDay ? fmtDay(zt) : hhmm(zt);
1540
1792
  } else {
1541
- const dt_ = new Date(t);
1542
- label = dt_.getDate() === 1 ? fmtMonth(t, dt_.getMonth() === 0) : fmtDay(t);
1793
+ const dt_ = new Date(zt);
1794
+ label = dt_.getUTCDate() === 1 ? fmtMonth(zt, dt_.getUTCMonth() === 0) : fmtDay(zt);
1543
1795
  }
1544
1796
  }
1545
1797
  } else if (monthStep) {
1546
- const dt_ = new Date(t);
1547
- key = Math.floor((dt_.getFullYear() * 12 + dt_.getMonth()) / monthStep);
1798
+ const dt_ = new Date(zt);
1799
+ key = Math.floor((dt_.getUTCFullYear() * 12 + dt_.getUTCMonth()) / monthStep);
1548
1800
  if (prevKey !== null && key !== prevKey) {
1549
- label = fmtMonth(t, dt_.getMonth() === 0 || monthStep > 1);
1801
+ label = fmtMonth(zt, dt_.getUTCMonth() === 0 || monthStep > 1);
1550
1802
  }
1551
1803
  } else {
1552
- key = new Date(t).getFullYear();
1553
- if (prevKey !== null && key !== prevKey) label = fmtYear(t);
1804
+ key = new Date(zt).getUTCFullYear();
1805
+ if (prevKey !== null && key !== prevKey) label = fmtYear(zt);
1554
1806
  }
1555
1807
  if (label !== null) ticks.push({ x: this._xFor(i), label });
1556
1808
  prevKey = key;
@@ -1795,82 +2047,7 @@ class WickChart extends HTMLElementBase {
1795
2047
  }
1796
2048
  }
1797
2049
 
1798
- /* scenario projection: ghost path + σ-cone in future space (clipped to
1799
- * the main plot so out-of-range bands never bleed into the axis) */
1800
- if (this._scenario && d.length) {
1801
- const sc = this._scenario;
1802
- const col = resolveOverlayColor(sc.color, pal);
1803
- const baseIdx = d.length - 1;
1804
- const xAt = (h) => this._xFor(baseIdx + h);
1805
- ctx.save();
1806
- ctx.beginPath();
1807
- ctx.rect(0, main.y0, plotRight, main.h);
1808
- ctx.clip();
1809
- if (sc.cone) {
1810
- const cone = this._scenarioConeCache();
1811
- if (cone) {
1812
- for (let li = cone.levels.length - 1; li >= 0; li--) {
1813
- const b = cone.bands[cone.levels[li]];
1814
- ctx.globalAlpha = li === 0 ? 0.1 : 0.05;
1815
- ctx.fillStyle = col;
1816
- ctx.beginPath();
1817
- ctx.moveTo(xAt(0), yOf(b.up[0]));
1818
- for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(b.up[h]));
1819
- for (let h = cone.horizon; h >= 0; h--) ctx.lineTo(xAt(h), yOf(b.down[h]));
1820
- ctx.closePath();
1821
- ctx.fill();
1822
- }
1823
- const inner = cone.bands[cone.levels[0]];
1824
- ctx.globalAlpha = 0.4;
1825
- ctx.strokeStyle = col;
1826
- ctx.lineWidth = 1;
1827
- ctx.setLineDash([4, 4]);
1828
- for (const arr of [inner.up, inner.down]) {
1829
- ctx.beginPath();
1830
- ctx.moveTo(xAt(0), yOf(arr[0]));
1831
- for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(arr[h]));
1832
- ctx.stroke();
1833
- }
1834
- ctx.setLineDash([]);
1835
- }
1836
- }
1837
- if (sc.path.length) {
1838
- ctx.globalAlpha = 0.9;
1839
- ctx.strokeStyle = col;
1840
- ctx.lineWidth = 1.5;
1841
- ctx.setLineDash([6, 4]);
1842
- ctx.beginPath();
1843
- ctx.moveTo(xAt(0), yOf(d[baseIdx].close));
1844
- for (const p of sc.path) ctx.lineTo(xAt(p.h), yOf(p.price));
1845
- ctx.stroke();
1846
- ctx.setLineDash([]);
1847
- ctx.fillStyle = col;
1848
- for (const p of sc.path) {
1849
- const y = yOf(p.price);
1850
- if (y >= main.y0 && y <= main.y1) {
1851
- ctx.beginPath();
1852
- ctx.arc(xAt(p.h), y, 2.5, 0, Math.PI * 2);
1853
- ctx.fill();
1854
- }
1855
- }
1856
- if (sc.label) {
1857
- const p = sc.path[sc.path.length - 1];
1858
- ctx.font = pillFont();
1859
- ctx.globalAlpha = 0.95;
1860
- ctx.fillStyle = col;
1861
- ctx.textAlign = 'left';
1862
- ctx.textBaseline = 'middle';
1863
- ctx.fillText(
1864
- sc.label,
1865
- Math.min(xAt(p.h) + 8, plotRight - 4),
1866
- clamp(yOf(p.price), main.y0 + 8, main.y1 - 8)
1867
- );
1868
- }
1869
- }
1870
- ctx.restore();
1871
- }
1872
-
1873
- /* position zones (under series) */
2050
+ /* position zones (under series) */
1874
2051
  for (const pos of this._positions) {
1875
2052
  const yE = clamp(yOf(pos.entry), main.y0, main.y1);
1876
2053
  if (isNum(pos.target)) {
@@ -1885,68 +2062,7 @@ class WickChart extends HTMLElementBase {
1885
2062
  }
1886
2063
  }
1887
2064
 
1888
- /* risk plan: R-multiple grid risk/reward shading + kR lines */
1889
- if (this._riskPlan && d.length) {
1890
- const rp = this._riskPlan;
1891
- const fP = numberFmt(this._prec(scale.rawHi || 1));
1892
- const yE = yOf(rp.entry);
1893
- const yS = yOf(rp.stop);
1894
- ctx.fillStyle = hexToRgba(pal.down, 0.06);
1895
- ctx.fillRect(0, Math.min(yE, yS), plotRight, Math.abs(yS - yE));
1896
- const yTop = yOf(rp.levels[rp.levels.length - 1].price);
1897
- ctx.fillStyle = hexToRgba(pal.up, 0.05);
1898
- ctx.fillRect(0, Math.min(yE, yTop), plotRight, Math.abs(yTop - yE));
1899
- const line = (p, col, dash) => {
1900
- const y = yOf(p);
1901
- if (y < main.y0 || y > main.y1) return null;
1902
- ctx.strokeStyle = col;
1903
- ctx.lineWidth = 1.5;
1904
- if (dash) ctx.setLineDash([5, 4]);
1905
- ctx.beginPath();
1906
- ctx.moveTo(0, Math.round(y) + 0.5);
1907
- ctx.lineTo(plotRight, Math.round(y) + 0.5);
1908
- ctx.stroke();
1909
- ctx.setLineDash([]);
1910
- ctx.lineWidth = 1;
1911
- return y;
1912
- };
1913
- const pills = [];
1914
- for (let i = rp.levels.length - 1; i >= 0; i--) {
1915
- const lv = rp.levels[i];
1916
- const y = line(lv.price, pal.up, true);
1917
- if (y != null) {
1918
- const kk = lv.k % 1 === 0 ? lv.k : +lv.k.toFixed(2);
1919
- pills.push({ y, text: `${kk}R ${fP.format(lv.price)}`, bg: pal.up });
1920
- }
1921
- }
1922
- const yStop = line(rp.stop, pal.down, false);
1923
- if (yStop != null) pills.push({ y: yStop, text: `STOP ${fP.format(rp.stop)}`, bg: pal.down });
1924
- const yEnt = line(rp.entry, pal.accent, false);
1925
- if (yEnt != null) {
1926
- pills.push({ y: yEnt, text: `${rp.direction === 'long' ? 'LONG' : 'SHORT'} ${fP.format(rp.entry)}`, bg: pal.accent });
1927
- }
1928
- // stack right-edge pills instead of letting close lines overlap
1929
- pills.sort((a, b) => a.y - b.y);
1930
- let lastY = -Infinity;
1931
- for (const p of pills) {
1932
- const y = Math.max(p.y, lastY + 20);
1933
- lastY = y;
1934
- ctx.font = pillFont();
1935
- const tw = ctx.measureText(p.text).width + 12;
1936
- this._pill(plotRight - tw - 8, y, p.text, p.bg, pal.pillText, 'left', tw);
1937
- }
1938
- if (rp.label) {
1939
- ctx.font = pillFont();
1940
- ctx.fillStyle = pal.accent;
1941
- ctx.globalAlpha = 0.9;
1942
- ctx.textAlign = 'left';
1943
- ctx.textBaseline = 'bottom';
1944
- ctx.fillText(rp.label, 8, clamp(yE, main.y0 + 14, main.y1) - 3);
1945
- ctx.globalAlpha = 1;
1946
- }
1947
- }
1948
-
1949
- /* volume profile (behind the series) */
2065
+ /* volume profile (behind the series) */
1950
2066
  if (this._profile) {
1951
2067
  const pkey = `${i0}:${i1}:${this._version}`;
1952
2068
  if (this._profileKey !== pkey) {
@@ -2604,7 +2720,7 @@ class WickChart extends HTMLElementBase {
2604
2720
  }
2605
2721
 
2606
2722
  // time pill
2607
- const tLabel = fmtFull(d[h.index].time);
2723
+ const tLabel = fmtFull(this._zt(d[h.index].time));
2608
2724
  ctx.font = pillFont();
2609
2725
  const tw = ctx.measureText(tLabel).width + 12;
2610
2726
  this._pill(
@@ -2618,78 +2734,6 @@ class WickChart extends HTMLElementBase {
2618
2734
  );
2619
2735
  }
2620
2736
 
2621
- /* co-view presence: peer viewport bands along the top of the plot */
2622
- if (this._presence && this._presence.peers.size && d.length) {
2623
- const peers = this._presence.list().slice(0, 4);
2624
- ctx.save();
2625
- ctx.font = pillFont();
2626
- for (let row = 0; row < peers.length; row++) {
2627
- const p = peers[row];
2628
- if (!p.range) continue;
2629
- const cols = pal.overlay || [];
2630
- const col = cols[(row + 1) % Math.max(cols.length, 1)] || pal.accent;
2631
- const i0 = WickChart._indexForTime(this._data, p.range.from);
2632
- const i1 = WickChart._indexForTime(this._data, p.range.to);
2633
- const x0 = clamp(this._xFor(i0), 0, plotRight);
2634
- const x1 = clamp(this._xFor(i1), 0, plotRight);
2635
- const y = main.y0 + 2 + row * 5;
2636
- ctx.globalAlpha = 0.8;
2637
- ctx.fillStyle = col;
2638
- ctx.fillRect(x0, y, Math.max(x1 - x0, 3), 3);
2639
- if (x1 - x0 > 44) {
2640
- ctx.globalAlpha = 0.95;
2641
- ctx.textAlign = 'left';
2642
- ctx.textBaseline = 'top';
2643
- ctx.fillText(p.name || p.id, x0 + 3, y + 4);
2644
- }
2645
- }
2646
- ctx.restore();
2647
- }
2648
-
2649
- /* co-view ghost crosshair (peer pointer from another tab/chart) */
2650
- if (this._ghost) {
2651
- const g = this._ghost;
2652
- const gx = this._xFor(g.index);
2653
- const gxVisible = gx >= 0 && gx <= plotRight;
2654
- ctx.save();
2655
- ctx.strokeStyle = pal.accent;
2656
- ctx.globalAlpha = 0.7;
2657
- ctx.setLineDash([2, 3]);
2658
- ctx.beginPath();
2659
- if (gxVisible) {
2660
- const cx = Math.round(gx) + 0.5;
2661
- ctx.moveTo(cx, 0);
2662
- ctx.lineTo(cx, plotBottom);
2663
- }
2664
- if (g.yFrac != null) {
2665
- const gy = Math.round(main.y0 + g.yFrac * main.h) + 0.5;
2666
- ctx.moveTo(0, gy);
2667
- ctx.lineTo(plotRight, gy);
2668
- if (gxVisible) {
2669
- ctx.fillStyle = pal.accent;
2670
- ctx.beginPath();
2671
- ctx.arc(gx, main.y0 + g.yFrac * main.h, 3, 0, Math.PI * 2);
2672
- ctx.fill();
2673
- }
2674
- }
2675
- ctx.stroke();
2676
- ctx.restore();
2677
- if (gxVisible && this._data[g.index]) {
2678
- const tLabel = fmtFull(this._data[g.index].time);
2679
- ctx.font = pillFont();
2680
- const tw = ctx.measureText(tLabel).width + 12;
2681
- this._pill(
2682
- clamp(gx - tw / 2, 2, plotRight - tw - 2),
2683
- plotBottom + 2,
2684
- tLabel,
2685
- pal.accent,
2686
- pal.pillText,
2687
- 'left',
2688
- tw
2689
- );
2690
- }
2691
- }
2692
-
2693
2737
  /* measure tool overlay */
2694
2738
  if (this._measure && this._measure.pA != null && this._measure.pB != null) {
2695
2739
  const m = this._measure;
@@ -2921,7 +2965,7 @@ class WickChart extends HTMLElementBase {
2921
2965
  let html = '';
2922
2966
  for (const p of this._positions) {
2923
2967
  const pnl = positionPnl(p, price);
2924
- const pct = p.entry ? (pnl / p.entry) * 100 : 0;
2968
+ const pct = positionPnlPct(p, price);
2925
2969
  const cls = pnl >= 0 ? 'up' : 'dn';
2926
2970
  const qtyStr = p.qty != null ? ' ' + p.qty : '';
2927
2971
  html +=
@@ -3093,7 +3137,7 @@ class WickChart extends HTMLElementBase {
3093
3137
  const ly = this._ly;
3094
3138
  const d = this._data;
3095
3139
  if (!ly || !d.length || !isNum(time)) return null;
3096
- const t = time < 1e12 ? time * 1000 : time;
3140
+ const t = toMs(time);
3097
3141
  const last = d.length - 1;
3098
3142
  if (t >= d[last].time) {
3099
3143
  return this._xFor(last + (t - d[last].time) / (this._dt || HOUR));
@@ -3165,9 +3209,60 @@ class WickChart extends HTMLElementBase {
3165
3209
  return { x: e.clientX - r.left, y: e.clientY - r.top };
3166
3210
  }
3167
3211
 
3212
+ /**
3213
+ * Put the crosshair on the bar under a point and announce it. Shared by
3214
+ * mouse hover, keyboard walking and the touch scrub gesture.
3215
+ */
3216
+ _hoverAt(pt) {
3217
+ if (!this._ly || !this._data.length) return;
3218
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
3219
+ this._hover = { index: idx, x: this._xFor(idx), y: pt.y };
3220
+ this._emitCrosshair(this._hover);
3221
+ this._invalidate();
3222
+ }
3223
+
3224
+ /**
3225
+ * Start the long-press timer for a touch. A touchscreen has no hover, so
3226
+ * without this there is no way to read a bar's values on a phone: a tap
3227
+ * selects, a drag pans, and the legend never leaves the last candle.
3228
+ * Holding still opens the crosshair; moving first cancels and pans.
3229
+ */
3230
+ _armPress(pointerId, pt) {
3231
+ this._disarmPress();
3232
+ this._pressOrigin = { pointerId, x: pt.x, y: pt.y };
3233
+ this._pressTimer = setTimeout(() => {
3234
+ this._pressTimer = 0;
3235
+ const origin = this._pressOrigin;
3236
+ // Still one finger, still down, nothing else has claimed the gesture.
3237
+ if (!origin || this._pointers.size !== 1 || this._layerClaim) return;
3238
+ if (!this._pointers.has(origin.pointerId)) return;
3239
+ this._scrub = true;
3240
+ this._pan = null;
3241
+ this._measuring = false;
3242
+ this._canvas.classList.remove('grabbing');
3243
+ this._hoverAt(origin);
3244
+ }, WickChart._PRESS_MS);
3245
+ }
3246
+
3247
+ _disarmPress() {
3248
+ if (this._pressTimer) clearTimeout(this._pressTimer);
3249
+ this._pressTimer = 0;
3250
+ this._pressOrigin = null;
3251
+ }
3252
+
3253
+ /** Leave scrub mode and put the crosshair away. */
3254
+ _endScrub() {
3255
+ if (!this._scrub) return;
3256
+ this._scrub = false;
3257
+ if (this._hover) {
3258
+ this._hover = null;
3259
+ this._emitCrosshair(null);
3260
+ this._invalidate();
3261
+ }
3262
+ }
3263
+
3168
3264
  _pointerDown(e) {
3169
3265
  if (e.button !== 0) return;
3170
- this._stopPlayback(); // any touch interrupts the story
3171
3266
  this._canvas.setPointerCapture(e.pointerId);
3172
3267
  const pt = this._localPoint(e);
3173
3268
  this._pointers.set(e.pointerId, pt);
@@ -3185,6 +3280,9 @@ class WickChart extends HTMLElementBase {
3185
3280
  }
3186
3281
  }
3187
3282
  if (this._pointers.size === 2) {
3283
+ // a second finger is a pinch, never a press or a scrub
3284
+ this._disarmPress();
3285
+ this._endScrub();
3188
3286
  const [a, b] = [...this._pointers.values()];
3189
3287
  const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
3190
3288
  this._pinch = {
@@ -3220,6 +3318,7 @@ class WickChart extends HTMLElementBase {
3220
3318
  } else {
3221
3319
  this._pan = { x: pt.x, rightIndex: this._view.rightIndex, moved: false };
3222
3320
  this._canvas.classList.add('grabbing');
3321
+ if (e.pointerType === 'touch') this._armPress(e.pointerId, pt);
3223
3322
  }
3224
3323
  }
3225
3324
 
@@ -3234,6 +3333,20 @@ class WickChart extends HTMLElementBase {
3234
3333
  if (this._pointers.has(e.pointerId)) this._pointers.set(e.pointerId, pt);
3235
3334
  const ly = this._ly;
3236
3335
 
3336
+ // A finger that wanders before the press lands wanted to pan.
3337
+ const origin = this._pressOrigin;
3338
+ if (this._pressTimer && origin && origin.pointerId === e.pointerId) {
3339
+ if (Math.hypot(pt.x - origin.x, pt.y - origin.y) > WickChart._PRESS_SLOP) {
3340
+ this._disarmPress();
3341
+ }
3342
+ }
3343
+
3344
+ // Scrub: the finger walks the crosshair, the viewport stays put.
3345
+ if (this._scrub && this._pointers.has(e.pointerId)) {
3346
+ this._hoverAt(pt);
3347
+ return;
3348
+ }
3349
+
3237
3350
  if (this._pinch && this._pointers.size >= 2 && ly) {
3238
3351
  const [a, b] = [...this._pointers.values()];
3239
3352
  const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
@@ -3281,11 +3394,7 @@ class WickChart extends HTMLElementBase {
3281
3394
 
3282
3395
  if (!ly) return;
3283
3396
  // hover / crosshair
3284
- const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
3285
- this._hover = { index: idx, x: this._xFor(idx), y: pt.y };
3286
- this._maybeSonify(idx);
3287
- this._emitCrosshair(this._hover);
3288
- this._invalidate();
3397
+ this._hoverAt(pt);
3289
3398
  }
3290
3399
 
3291
3400
  _pointerUp(e) {
@@ -3299,8 +3408,12 @@ class WickChart extends HTMLElementBase {
3299
3408
  }
3300
3409
  const had = this._pointers.delete(e.pointerId);
3301
3410
  if (this._pointers.size < 2) this._pinch = null;
3411
+ if (this._pressOrigin && this._pressOrigin.pointerId === e.pointerId) this._disarmPress();
3302
3412
  if (this._pointers.size === 0) {
3303
3413
  this._canvas.classList.remove('grabbing');
3414
+ // Lifting ends the scrub — the crosshair is not left stranded on a
3415
+ // touchscreen, where nothing else would ever clear it.
3416
+ this._endScrub();
3304
3417
  if (this._brushDrag && had) {
3305
3418
  const b = this._brushDrag;
3306
3419
  this._brushDrag = null;
@@ -3346,10 +3459,14 @@ class WickChart extends HTMLElementBase {
3346
3459
  return useLog ? Math.pow(10, t) : t;
3347
3460
  }
3348
3461
 
3462
+ /** Dispatch a `wick:name` event on the element. */
3463
+ _fire(name, detail) {
3464
+ this.dispatchEvent(new CustomEvent('wick:' + name, { detail }));
3465
+ }
3466
+
3349
3467
  _wheel(e) {
3350
3468
  const ly = this._ly;
3351
3469
  if (!ly || !this._data.length) return;
3352
- this._stopPlayback();
3353
3470
  e.preventDefault();
3354
3471
  const pt = this._localPoint(e);
3355
3472
  const dx = e.deltaX;
@@ -3383,7 +3500,6 @@ class WickChart extends HTMLElementBase {
3383
3500
  _keydown(e) {
3384
3501
  const ly = this._ly;
3385
3502
  if (!ly || !this._data.length) return;
3386
- this._stopPlayback();
3387
3503
  if (e.key === 'Escape' && this._layerClaim) {
3388
3504
  const claim = this._layerClaim;
3389
3505
  this._layerClaim = null;
@@ -3404,7 +3520,6 @@ class WickChart extends HTMLElementBase {
3404
3520
  const cur = this._hover ? this._hover.index : d.length - 1;
3405
3521
  const idx = clamp(cur + (key === 'ArrowRight' ? step : -step), 0, d.length - 1);
3406
3522
  this._hover = { index: idx, x: this._xFor(idx), y: this._hover ? this._hover.y : ly.main.y1 * 0.5 };
3407
- this._maybeSonify(idx);
3408
3523
  this._emitCrosshair(this._hover);
3409
3524
  this._invalidate();
3410
3525
  } else if (key === 'Home') {
@@ -3442,103 +3557,9 @@ class WickChart extends HTMLElementBase {
3442
3557
  }
3443
3558
 
3444
3559
  /* ------------------------------------------------------------ *
3445
- * Sonification — the chart by ear (a11y)
3560
+ * Crosshair events
3446
3561
  * ------------------------------------------------------------ */
3447
3562
 
3448
- /** Lazily-created shared AudioContext (enable within a user gesture). */
3449
- _audio() {
3450
- if (this._actx) return this._actx;
3451
- const AC = window.AudioContext || window.webkitAudioContext;
3452
- if (!AC) return null;
3453
- try {
3454
- this._actx = new AC();
3455
- } catch (_) {
3456
- this._actx = null;
3457
- }
3458
- return this._actx;
3459
- }
3460
-
3461
- /** Short sine blip; `when` schedules against AudioContext time. */
3462
- _tone(freq, dur = 0.14, when = 0) {
3463
- const ctx = this._audio();
3464
- if (!ctx) return;
3465
- if (ctx.state === 'suspended') ctx.resume().catch(() => {});
3466
- const t0 = when || ctx.currentTime;
3467
- const osc = ctx.createOscillator();
3468
- const gain = ctx.createGain();
3469
- osc.type = 'sine';
3470
- osc.frequency.value = freq;
3471
- gain.gain.setValueAtTime(0.0001, t0);
3472
- gain.gain.exponentialRampToValueAtTime(0.18, t0 + 0.012);
3473
- gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
3474
- osc.connect(gain).connect(ctx.destination);
3475
- osc.start(t0);
3476
- osc.stop(t0 + dur + 0.03);
3477
- }
3478
-
3479
- /** One tone for a bar's close, pitched by its position on the y-scale. */
3480
- _sonifyBar(i) {
3481
- if (!this._sonify || !this._data.length || !this._lastScale) return;
3482
- const d = this._renderBars();
3483
- const b = d[clamp(i, 0, d.length - 1)];
3484
- if (!b) return;
3485
- this._tone(priceToFreq(b.close, this._lastScale));
3486
- }
3487
-
3488
- /** One tone per crosshair bar change (dedupes y-only moves). */
3489
- _maybeSonify(idx) {
3490
- if (!this._sonify) return;
3491
- if (this._lastToneIdx === idx) return;
3492
- this._lastToneIdx = idx;
3493
- this._sonifyBar(idx);
3494
- }
3495
-
3496
- /**
3497
- * Play the visible range as a pitch sweep (~4s), riding the crosshair —
3498
- * the audible equivalent of running your eye along the price line.
3499
- */
3500
- playRange() {
3501
- if (!this._data.length || !this._ly) return;
3502
- const ctx = this._audio();
3503
- if (!ctx) return;
3504
- if (ctx.state === 'suspended') ctx.resume().catch(() => {});
3505
- const d = this._renderBars();
3506
- const count = Math.max(2, Math.round(this._ly.plotRight / this._view.spacing));
3507
- const i0 = clamp(Math.floor(this._view.rightIndex - count) - 1, 0, d.length - 1);
3508
- const i1 = clamp(Math.ceil(this._view.rightIndex), 0, d.length - 1);
3509
- if (i1 - i0 < 2) return;
3510
- const N = Math.min(120, i1 - i0 + 1);
3511
- const stepMs = Math.min(70, Math.max(24, 4000 / N));
3512
- const t0 = ctx.currentTime + 0.05;
3513
- for (let k = 0; k < N; k++) {
3514
- const i = Math.round(i0 + ((i1 - i0) * k) / (N - 1));
3515
- const b = d[i];
3516
- if (!b) continue;
3517
- this._tone(priceToFreq(b.close, this._lastScale), stepMs / 1000 * 0.9, t0 + (k * stepMs) / 1000);
3518
- }
3519
- // ride the crosshair along the sweep for sighted users
3520
- this._playToken++;
3521
- const token = this._playToken;
3522
- let k = 0;
3523
- const timer = setInterval(() => {
3524
- if (token !== this._playToken || !this._connected) {
3525
- clearInterval(timer);
3526
- return;
3527
- }
3528
- if (k >= N) {
3529
- clearInterval(timer);
3530
- this._hover = null;
3531
- this._emitCrosshair(null);
3532
- this._invalidate();
3533
- return;
3534
- }
3535
- const i = Math.round(i0 + ((i1 - i0) * k) / (N - 1));
3536
- this._hover = { index: i, x: this._xFor(i), y: this._ly ? this._ly.main.h * 0.5 : 0 };
3537
- this._invalidate();
3538
- k++;
3539
- }, stepMs);
3540
- }
3541
-
3542
3563
  _emitCrosshair(hover) {
3543
3564
  let detail = null;
3544
3565
  if (hover && this._data[hover.index]) {
@@ -3551,250 +3572,6 @@ class WickChart extends HTMLElementBase {
3551
3572
  };
3552
3573
  }
3553
3574
  this._fire('crosshair', detail);
3554
-
3555
- // co-view: share the pointer with peer charts (leave events bypass throttle)
3556
- if (this._coviewCh) {
3557
- if (!detail) {
3558
- this._coviewSend({ type: 'cross', time: null, yFrac: null });
3559
- } else {
3560
- const now = performance.now();
3561
- if (now - this._coviewLast > 40) {
3562
- this._coviewLast = now;
3563
- const ly = this._ly;
3564
- this._coviewSend({
3565
- type: 'cross',
3566
- time: detail.bar.time,
3567
- yFrac: ly && isNum(detail.y) ? clamp(detail.y / ly.main.h, 0, 1) : null,
3568
- });
3569
- }
3570
- }
3571
- }
3572
- }
3573
-
3574
- /* ------------------------------------------------------------ *
3575
- * Cross-tab co-view (BroadcastChannel)
3576
- * ------------------------------------------------------------ */
3577
-
3578
- /** Join/leave the co-view channel named by the `co-view` attribute. */
3579
- _setupCoView() {
3580
- if (this._coviewCh) {
3581
- try {
3582
- this._coviewCh.close();
3583
- } catch (_) {}
3584
- this._coviewCh = null;
3585
- }
3586
- clearInterval(this._coviewBeat);
3587
- this._coviewBeat = 0;
3588
- clearTimeout(this._ghostTimer);
3589
- if (this._ghost) {
3590
- this._ghost = null;
3591
- this._invalidate();
3592
- }
3593
- if (this._presence && this._presence.peers.size) {
3594
- const left = this._presence.list();
3595
- this._presence = new PresenceTracker();
3596
- this._fire('peers', { peers: [], joined: [], left });
3597
- }
3598
- const name = this._coviewName;
3599
- if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
3600
- if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
3601
- try {
3602
- const ch = new BroadcastChannel('wick-co-view:' + name);
3603
- ch.onmessage = (ev) => this._onCoMessage(ev.data);
3604
- this._coviewCh = ch;
3605
- } catch (_) {}
3606
- // presence: announce immediately, then heartbeat so idle peers stay
3607
- // warm (and stale ones sweep) without waiting for a pan/zoom
3608
- this._coviewSendView(true);
3609
- this._coviewBeat = setInterval(() => {
3610
- this._coviewSendView(true);
3611
- const left = this._presence.sweep();
3612
- if (left.length) {
3613
- this._fire('peers', { peers: this._presence.list(), joined: [], left });
3614
- this._invalidate();
3615
- }
3616
- }, 4000);
3617
- }
3618
-
3619
- /** Broadcast our visible range for presence; throttled unless forced. */
3620
- _coviewSendView(force) {
3621
- if (!this._coviewCh) return;
3622
- const r = this.getVisibleRange();
3623
- if (!r) return;
3624
- const now = performance.now();
3625
- if (!force && now - this._coviewViewLast < 120) return;
3626
- this._coviewViewLast = now;
3627
- this._coviewSend({
3628
- type: 'view',
3629
- from: r.from,
3630
- to: r.to,
3631
- name: this._coviewLabel || null,
3632
- });
3633
- }
3634
-
3635
- _coviewSend(msg) {
3636
- if (!this._coviewCh) return;
3637
- try {
3638
- this._coviewCh.postMessage({ v: 1, peer: this._coviewPeer, ...msg });
3639
- } catch (_) {}
3640
- }
3641
-
3642
- _onCoMessage(m) {
3643
- if (!m || m.v !== 1 || m.peer === this._coviewPeer) return;
3644
- if (m.type === 'view') {
3645
- const joined = this._presence.track(m.peer, {
3646
- range: { from: m.from, to: m.to },
3647
- name: m.name,
3648
- });
3649
- this._invalidate();
3650
- if (joined) {
3651
- const p = this._presence.peers.get(m.peer);
3652
- this._fire('peers', {
3653
- peers: this._presence.list(),
3654
- joined: [p ? { ...p, range: p.range && { ...p.range } } : { id: m.peer }],
3655
- left: [],
3656
- });
3657
- }
3658
- return;
3659
- }
3660
- if (m.type === 'bye') {
3661
- const left = this._presence.drop(m.peer);
3662
- if (left) this._fire('peers', { peers: this._presence.list(), joined: [], left: [left] });
3663
- this._invalidate();
3664
- return;
3665
- }
3666
- if (m.type !== 'cross') return;
3667
- if (m.time == null) {
3668
- if (this._ghost) {
3669
- this._ghost = null;
3670
- clearTimeout(this._ghostTimer);
3671
- this._invalidate();
3672
- }
3673
- return;
3674
- }
3675
- if (!isNum(m.time) || !this._data.length) return;
3676
- this._ghost = {
3677
- index: WickChart._indexForTime(this._data, m.time),
3678
- yFrac: isNum(m.yFrac) ? clamp(m.yFrac, 0, 1) : null,
3679
- at: Date.now(),
3680
- };
3681
- clearTimeout(this._ghostTimer);
3682
- this._ghostTimer = setTimeout(() => {
3683
- this._ghost = null;
3684
- this._invalidate();
3685
- }, 2500);
3686
- this._invalidate();
3687
- }
3688
-
3689
- /** Dispatch `wick:name` (canonical) plus the deprecated `hab:name` alias,
3690
- * so 0.x listeners keep working until 2.0. */
3691
- _fire(name, detail) {
3692
- this.dispatchEvent(new CustomEvent('wick:' + name, { detail }));
3693
- this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
3694
- }
3695
-
3696
- /**
3697
- * Live co-view peers: who else is in the room and the time window each
3698
- * one is looking at — [{ id, name, range: {from, to}, at }], oldest
3699
- * sighting first. Peers fade out ~12 s after their last sighting.
3700
- * @returns {object[]}
3701
- */
3702
- getPeers() {
3703
- return this._presence ? this._presence.list() : [];
3704
- }
3705
-
3706
- /**
3707
- * Narrated timeline for a window (default: the visible range) — pivot
3708
- * highs/lows, volume spikes, gaps, RSI divergences plus derived legs
3709
- * ("+12.4% over 38 bars"), sorted by index. Pure data, perfect for
3710
- * caption UIs or the walk player.
3711
- * chart.narrate(); // visible range
3712
- * chart.narrate({ from, to }); // times in ms (s accepted)
3713
- * @param {{from?: number, to?: number}} [range]
3714
- * @returns {{i: number, time: number, type: string, side: string, note: string,
3715
- * legPct?: number, legBars?: number}[]}
3716
- */
3717
- narrate(range) {
3718
- const d = this._data;
3719
- if (!d.length) return [];
3720
- let i0 = 0;
3721
- let i1 = d.length - 1;
3722
- if (range && isNum(range.from) && isNum(range.to)) {
3723
- i0 = WickChart._indexForTime(d, WickChart._timeToMs(range.from));
3724
- i1 = WickChart._indexForTime(d, WickChart._timeToMs(range.to));
3725
- if (i0 > i1) [i0, i1] = [i1, i0];
3726
- }
3727
- return narrateWindow(d, i0, i1);
3728
- }
3729
-
3730
- /**
3731
- * Walk the chart through history like a story: the viewport slides
3732
- * from `from` to `to` while `wick:walk` events announce every step and
3733
- * the narrator's events (spikes, gaps, pivots, legs) as they're crossed.
3734
- * Any user interaction — pointer, wheel, keys, double-click — stops it.
3735
- * chart.walk({ from: 0, to: 500, speed: 120, step: 10 });
3736
- * chart.addEventListener('wick:walk', (e) => showCaption(e.detail));
3737
- * // detail: { phase: 'step'|'end'|'stop', index, events: [...], from, to }
3738
- * @param {{from?: number, to?: number, speed?: number, step?: number}} [opts]
3739
- * from/to are bar indices (default: last ~500 bars → the end)
3740
- * @returns {boolean} true when the walk started
3741
- */
3742
- walk(opts = {}) {
3743
- this.stopWalk(true);
3744
- const d = this._data;
3745
- if (!d.length || !this._connected) return false;
3746
- const to = clamp(Math.round(+opts.to || d.length - 1), 0, d.length - 1);
3747
- const from = clamp(Math.round(opts.from != null ? +opts.from : Math.max(0, to - 500)), 0, to);
3748
- const span = to - from + 1;
3749
- // window width: the current viewport, but never more than ~⅓ of the
3750
- // span (a fully zoomed-out chart would otherwise start at `to`)
3751
- const widthBars = clamp(
3752
- Math.min(
3753
- this._ly ? Math.round(this._ly.plotRight / this._view.spacing) : 120,
3754
- Math.max(10, Math.ceil(span / 3))
3755
- ),
3756
- 10,
3757
- span
3758
- );
3759
- const events = narrateWindow(d, from, to, { pivot: 8 });
3760
- const speed = clamp(Math.round(+opts.speed || 120), 16, 2000);
3761
- const step = clamp(Math.round(+opts.step || Math.max(1, Math.round(widthBars / 12))), 1, 500);
3762
- let cursor = Math.min(from + widthBars - 1, to);
3763
- let ev = 0;
3764
- let ended = false;
3765
- const tick = () => {
3766
- if (ended) return;
3767
- this._auto = false;
3768
- this._view.rightIndex = cursor;
3769
- this._clampView();
3770
- this._invalidate();
3771
- this._emitRange();
3772
- const hits = [];
3773
- while (ev < events.length && events[ev].i <= cursor) hits.push(events[ev++]);
3774
- this._fire('walk', { phase: 'step', index: cursor, events: hits, from, to });
3775
- if (cursor >= to) {
3776
- ended = true;
3777
- clearInterval(this._walkTimer);
3778
- this._walkTimer = 0;
3779
- this._fire('walk', { phase: 'end', index: cursor, events: [], from, to });
3780
- } else {
3781
- cursor = Math.min(cursor + step, to);
3782
- }
3783
- };
3784
- this._walkTimer = setInterval(tick, speed);
3785
- tick(); // first step lands immediately
3786
- return true;
3787
- }
3788
-
3789
- /**
3790
- * Stop the running walk (if any). Fires a final `wick:walk`
3791
- * { phase: 'stop' } unless called internally.
3792
- */
3793
- stopWalk(silent) {
3794
- if (!this._walkTimer) return;
3795
- clearInterval(this._walkTimer);
3796
- this._walkTimer = 0;
3797
- if (!silent) this._fire('walk', { phase: 'stop' });
3798
3575
  }
3799
3576
 
3800
3577
  /**
@@ -3832,171 +3609,47 @@ class WickChart extends HTMLElementBase {
3832
3609
  return { i0, i1, stats: { ...stats, from: { ...stats.from }, to: { ...stats.to } } };
3833
3610
  }
3834
3611
 
3835
- /**
3836
- * Capture the current chart state as a story scene: view, series type,
3837
- * indicators, overlays, scenario and risk plan, plus a title/note.
3838
- * Build guided tours by capturing several and playing them back.
3839
- * const story = [
3840
- * chart.captureScene('Overview', 'The full picture'),
3841
- * { title: 'The breakout', range: { from, to }, indicators: 'sma:20' },
3842
- * ];
3843
- * chart.playStory(story);
3844
- * @param {string} [title]
3845
- * @param {string} [note]
3846
- * @returns {object} scene (plain data — snapshot of the moment)
3847
- */
3848
- captureScene(title, note) {
3849
- const scene = {
3850
- title: title != null ? String(title).slice(0, 60) : '',
3851
- note: note != null ? String(note).slice(0, 200) : '',
3852
- range: this.getVisibleRange() || undefined,
3853
- type: this.getAttribute('type') || 'candles',
3854
- indicators: this.getAttribute('indicators') || null,
3855
- };
3856
- const ovs = this.overlays;
3857
- if (ovs.length) scene.overlays = ovs;
3858
- const sc = this.scenario;
3859
- if (sc) scene.scenario = sc;
3860
- const rp = this.riskPlan;
3861
- if (rp) scene.riskPlan = rp;
3862
- return scene;
3863
- }
3864
-
3865
- /** @returns {object[]|null} a copy of the last played story */
3866
- getStory() {
3867
- return this._story ? this._story.map((s) => ({ ...s })) : null;
3868
- }
3869
-
3870
- /**
3871
- * Play a story: each scene applies its state (type / indicators /
3872
- * overlays / scenario / risk plan — set or clear), the camera eases
3873
- * to its range, then holds for its dwell. `wick:story` events narrate:
3874
- * { phase: 'scene' | 'end' | 'stop', index, total, scene, title, note }
3875
- * Any user interaction — pointer, wheel, keys, double-click — stops it.
3876
- * @param {object[]} story scenes (invalid entries dropped, max 20)
3877
- * @param {{dwell?: number, panMs?: number, loop?: boolean}} [opts]
3878
- * panMs clamps 100–5000 (default 900); loop replays forever
3879
- * @returns {boolean} true when playback started
3880
- */
3881
- playStory(story, opts = {}) {
3882
- this.stopStory(true);
3883
- const scenes = sceneList(story);
3884
- if (!scenes.length || !this._data.length || !this._connected) return false;
3885
- const token = ++this._storyToken;
3886
- this._story = scenes;
3887
- const panMs = clamp(Math.round(+opts.panMs || 900), 100, 5000);
3888
- const loop = opts.loop === true;
3889
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
3890
- const run = async () => {
3891
- let idx = 0;
3892
- while (token === this._storyToken) {
3893
- const sc = scenes[idx];
3894
- this._fire('story', {
3895
- phase: 'scene', index: idx, total: scenes.length,
3896
- scene: sc, title: sc.title, note: sc.note,
3897
- });
3898
- this._applyScene(sc);
3899
- const target = this._sceneTarget(sc);
3900
- if (target) await this._storyTween(target, panMs, token);
3901
- if (token !== this._storyToken) return;
3902
- await wait(sc.dwell);
3903
- if (token !== this._storyToken) return;
3904
- idx++;
3905
- if (idx >= scenes.length) {
3906
- if (loop) idx = 0;
3907
- else {
3908
- this._fire('story', { phase: 'end', index: idx - 1, total: scenes.length });
3909
- return;
3910
- }
3911
- }
3912
- }
3913
- };
3914
- run();
3915
- return true;
3916
- }
3917
-
3918
- /**
3919
- * Stop story playback (if running). Fires a final `wick:story`
3920
- * { phase: 'stop' } unless called internally.
3921
- */
3922
- stopStory(silent) {
3923
- if (!this._storyToken) return;
3924
- this._storyToken = 0;
3925
- if (!silent) this._fire('story', { phase: 'stop' });
3926
- }
3927
-
3928
- /** Apply a scene's state (only the fields it carries). */
3929
- _applyScene(sc) {
3930
- if (sc.type) this.setAttribute('type', sc.type);
3931
- if (sc.indicators != null) this.setAttribute('indicators', sc.indicators);
3932
- if (sc.overlays) this.setOverlays(sc.overlays);
3933
- if (sc.scenario === 'clear') this.clearScenario();
3934
- else if (sc.scenario) this.setScenario(sc.scenario);
3935
- if (sc.riskPlan === 'clear') this.clearRiskPlan();
3936
- else if (sc.riskPlan) this.setRiskPlan(sc.riskPlan);
3937
- }
3938
-
3939
- /** Map a scene's time range to bar indices (null when not applicable). */
3940
- _sceneTarget(sc) {
3941
- if (!sc.range || !this._data.length) return null;
3942
- let i0 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.from));
3943
- let i1 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.to));
3944
- if (i0 > i1) [i0, i1] = [i1, i0];
3945
- return i1 - i0 >= 2 ? { i0, i1 } : null;
3946
- }
3947
-
3948
- /** Ease the viewport to { i0, i1 } over `ms`; resolves early if the
3949
- * token changes (superseded or stopped). rAF when available. */
3950
- _storyTween(target, ms, token) {
3951
- const ly = this._ly;
3952
- const d = this._data;
3953
- if (!ly || !d.length) return Promise.resolve();
3954
- const sp1 = clamp(ly.plotRight / (target.i1 - target.i0), this._minSpacing(), WickChart._MAX_SP);
3955
- const from = { right: this._view.rightIndex, sp: this._view.spacing };
3956
- const to = { right: target.i1, sp: sp1 };
3957
- const t0 = performance.now();
3958
- this._auto = false;
3959
- return new Promise((resolve) => {
3960
- const step = () => {
3961
- if (token !== this._storyToken) return resolve();
3962
- const e = easeInOutCubic(Math.min(1, (performance.now() - t0) / ms));
3963
- this._view.rightIndex = from.right + (to.right - from.right) * e;
3964
- this._view.spacing = from.sp + (to.sp - from.sp) * e;
3965
- this._clampView();
3966
- this._invalidate();
3967
- this._emitRange();
3968
- if (e >= 1) resolve();
3969
- else if (typeof requestAnimationFrame === 'function') requestAnimationFrame(step);
3970
- else setTimeout(step, 16);
3971
- };
3972
- step();
3973
- });
3974
- }
3975
-
3976
- /** Interrupt narrated playback (walk / story) on user input. */
3977
- _stopPlayback() {
3978
- if (this._walkTimer) this.stopWalk();
3979
- if (this._storyToken) this.stopStory();
3980
- }
3981
-
3982
3612
  _emitRange() {
3983
3613
  const r = this.getVisibleRange();
3984
3614
  if (!r) return;
3985
3615
  this._fire('range', r);
3986
- if (this._coviewCh) this._coviewSendView();
3987
3616
  }
3988
3617
  }
3989
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
+
3990
3649
  if (typeof customElements !== 'undefined') {
3991
3650
  if (!customElements.get('wick-chart')) {
3992
3651
  customElements.define('wick-chart', WickChart);
3993
3652
  }
3994
- // 0.x alias: same element under its old tag name (deprecated, removed in 2.0)
3995
- if (!customElements.get('hab-chart')) {
3996
- /** @deprecated use <wick-chart> */
3997
- class HabChart extends WickChart {}
3998
- customElements.define('hab-chart', HabChart);
3999
- }
4000
3653
  }
4001
3654
 
4002
3655
  export default WickChart;