wickchart 1.6.0 → 1.7.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
@@ -21,10 +21,10 @@
21
21
  import {
22
22
  clamp, isNum, numberFmt, fmtCompact, autoPrecision, niceStep, hexToRgba,
23
23
  FONT_STACK, axisFont, pillFont, roundRectPath,
24
- TIME_STEPS, HOUR, DAY, hhmm, fmtDay, fmtMonth, fmtYear, fmtFull,
24
+ TIME_STEPS, HOUR, DAY, toMs, zoneOffset, hhmm, fmtDay, fmtMonth, fmtYear, fmtFull,
25
25
  THEMES, mergeOlderData, detectGaps,
26
26
  parseIndicators, normalizeIndicatorResult, BUILTIN_INDICATORS,
27
- positionPnl, checkAlertCross, computeStats, safeColor,
27
+ positionPnl, positionPnlPct, checkAlertCross, computeStats, safeColor,
28
28
  SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
29
29
  calcRSI, detectAnnotations, priceToFreq,
30
30
  calcRealizedVol, volRegimeBands, percentileOfSorted, parseVolShading,
@@ -44,15 +44,42 @@ import {
44
44
  * <wick-chart> and the deprecated <hab-chart> alias element. */
45
45
  const REGISTRY = new Map(BUILTIN_INDICATORS);
46
46
 
47
+ /** Bars from which the worker compute path engages — below it, sync wins
48
+ * (posting the epoch snapshot costs more than the compute it saves). */
49
+ const WORKER_MIN_BARS = 50000;
50
+ /** Returned while an off-thread compute is in flight: no lines yet, and —
51
+ * like a failed compute — every renderer draws nothing. */
52
+ const PENDING_SERIES = { lines: [], histogram: null };
53
+ /**
54
+ * Built-ins a streamed tick can update by recomputing a bounded tail with
55
+ * the SAME batch definition (window indicators exactly, recursive ones
56
+ * converge geometrically). Excluded: obv/vwap are cumulative over all
57
+ * history, supertrend is a path-dependent state machine — no tail can
58
+ * patch those; they keep the full-recompute behavior.
59
+ */
60
+ const ONLINE_SKIP = new Set(['obv', 'vwap', 'supertrend']);
61
+ /** Tail warm-up bars per tick: past the recursion decay of any realistic
62
+ * period (~10×), and still ~0.1 ms of work per indicator. */
63
+ const ONLINE_WARMUP = 400;
64
+ /** Per-chart worker session ids (see _workerCompute / src/worker-core.js). */
65
+ let CHART_SID = 0;
66
+
47
67
  /* SSR safety: importing this module under Node (Next.js/Nuxt server render)
48
68
  * must not throw — the element simply registers only in browsers. */
49
69
  const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
50
70
 
51
71
  class WickChart extends HTMLElementBase {
52
72
  static get observedAttributes() {
53
- return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'co-view-name', 'brush', 'sonify'];
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'];
54
74
  }
55
75
 
76
+ /**
77
+ * Shared ChartWorkerPool for the worker compute path (set by importing
78
+ * 'wickchart/worker'; null — everything sync — until then).
79
+ * @type {object|null}
80
+ */
81
+ static _workerPool = null;
82
+
56
83
  constructor() {
57
84
  super();
58
85
  const root = this.attachShadow({ mode: 'open' });
@@ -65,6 +92,9 @@ class WickChart extends HTMLElementBase {
65
92
  height: 100%;
66
93
  min-height: 220px;
67
94
  contain: content;
95
+ /* the overlays lay themselves out against the chart's own width
96
+ (see the @container rule at the end of this sheet) */
97
+ container-type: inline-size;
68
98
  }
69
99
  :host(:focus-visible) {
70
100
  outline: 2px solid var(--wick-accent, var(--hab-accent, #4c8dff));
@@ -75,10 +105,17 @@ class WickChart extends HTMLElementBase {
75
105
  position: absolute; inset: 0;
76
106
  width: 100%; height: 100%;
77
107
  display: block;
78
- touch-action: none;
108
+ /* pan-y, not none: a vertical swipe belongs to the page, or a
109
+ chart embedded in a phone article becomes a dead zone the
110
+ reader cannot scroll past. Horizontal drags and pinches still
111
+ arrive as pointer events; _onTouchMove takes the gesture back
112
+ (preventDefault) once the chart owns it. */
113
+ touch-action: pan-y;
79
114
  cursor: crosshair;
80
115
  user-select: none;
81
116
  -webkit-user-select: none;
117
+ /* long-press is the scrub gesture — suppress the iOS callout */
118
+ -webkit-touch-callout: none;
82
119
  }
83
120
  canvas.grabbing { cursor: grabbing; }
84
121
  .legend {
@@ -150,6 +187,32 @@ class WickChart extends HTMLElementBase {
150
187
  .hud .v { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-variant-numeric: tabular-nums; }
151
188
  .hud .up { color: var(--wick-up, var(--hab-up, #16c784)); }
152
189
  .hud .dn { color: var(--wick-down, var(--hab-down, #ea3943)); }
190
+
191
+ /* Narrow charts: the legend (top-left) and the HUD (top-right) are
192
+ both pinned to the top, so on a phone they land on top of each
193
+ other — a label plus a few indicators wraps the legend to three
194
+ rows and the stats row draws straight through it. Below 560px
195
+ they stack instead.
196
+
197
+ A container query, not a media query: what matters is how wide
198
+ the chart is, not the screen. A narrow chart in a sidebar on a
199
+ desktop has exactly the same problem.
200
+
201
+ They become position:relative rather than static so they stay in
202
+ flow *and* keep their stacking context — an unpositioned box would
203
+ paint underneath the absolutely positioned canvas. The canvas is
204
+ out of flow either way, so the flex column never moves it. */
205
+ @container (max-width: 560px) {
206
+ .wrap { display: flex; flex-direction: column; align-items: flex-start; }
207
+ .legend, .hud {
208
+ position: relative;
209
+ left: auto; right: auto; top: auto;
210
+ max-width: calc(100% - 20px);
211
+ }
212
+ .legend { margin: 8px 10px 0; }
213
+ .hud { margin: 4px 10px 0; align-items: flex-start; }
214
+ .hud .pos, .hud .statsrow { white-space: normal; }
215
+ }
153
216
  </style>
154
217
  <div class="wrap" part="wrap">
155
218
  <canvas part="canvas" role="img"></canvas>
@@ -231,9 +294,27 @@ class WickChart extends HTMLElementBase {
231
294
  this._brushDrag = null; // { i0, i1 } — while the pointer is down
232
295
  this._ind = { overlays: [], panes: [], volume: true };
233
296
 
297
+ // worker compute path (see _indicatorSeries): built-in indicators over
298
+ // big histories compute off the main thread. Results are cached per
299
+ // data *epoch* (bulk loads: setData/clearData/backfill) — streamed
300
+ // ticks bump _version, not _epoch, so they stop recomputing the full
301
+ // series per bar; the forming bar's value catches up on the next load.
302
+ this._workerOn = false;
303
+ this._epoch = 0;
304
+ this._workerCache = { epoch: -1, map: {}, pending: {}, sent: -1 };
305
+ this._sid = ++CHART_SID;
306
+ // incremental tick updates: { epoch, map: key → { v, res } } — the
307
+ // freshest series per key, patched in place by _onlineTick
308
+ this._onlineSeries = { epoch: -1, map: {} };
309
+
234
310
  this._pointers = new Map();
235
311
  this._pan = null;
236
312
  this._pinch = null;
313
+ // long-press scrub: touch has no hover, so reading a bar needs a
314
+ // gesture of its own (see _armPress)
315
+ this._scrub = false;
316
+ this._pressTimer = 0;
317
+ this._pressOrigin = null;
237
318
 
238
319
  // plugin layers: external draw hooks + pointer claims (see addLayer)
239
320
  this._layers = [];
@@ -248,6 +329,12 @@ class WickChart extends HTMLElementBase {
248
329
  this._positions = [];
249
330
  this._alerts = [];
250
331
  this._seq = 0;
332
+ // Default evaluation mode for alerts that don't pick one, and the last
333
+ // bar index known to be final — see _lastClosedIndex().
334
+ this._alertEval = 'live';
335
+ this._lastClosedIdx = -1;
336
+ this._tz = 'local';
337
+ this._vwapAnchor = 'utc';
251
338
 
252
339
  // server-side overlays (zones & levels)
253
340
  this._overlays = [];
@@ -274,6 +361,32 @@ class WickChart extends HTMLElementBase {
274
361
  this.fit();
275
362
  };
276
363
  this._onKey = (e) => this._keydown(e);
364
+ // The canvas leaves vertical scrolling to the page (touch-action:
365
+ // pan-y). Once a chart gesture owns the touch — a pinch, a scrub, a
366
+ // layer drag, or a pan that has committed to a direction — the
367
+ // gesture is taken back, or the browser would hand it to the scroller
368
+ // halfway through. Must be non-passive to be allowed to.
369
+ this._onTouchMove = (e) => {
370
+ if (
371
+ this._scrub ||
372
+ this._pointers.size >= 2 ||
373
+ this._layerClaim ||
374
+ (this._pan && this._pan.moved)
375
+ ) {
376
+ e.preventDefault();
377
+ }
378
+ };
379
+ // devicePixelRatio changes without the CSS box changing size — dragging
380
+ // the window to a monitor with a different ratio, or a browser zoom
381
+ // that lands on the same layout width. ResizeObserver stays silent for
382
+ // those, so the canvas would keep its old backing store and render
383
+ // soft until something else forced a resize. A `resolution` media
384
+ // query is the only event for it; it only ever matches the ratio it
385
+ // was created with, so each change re-arms a fresh one.
386
+ this._onDprChange = () => {
387
+ this._watchDpr();
388
+ this._invalidate();
389
+ };
277
390
  }
278
391
 
279
392
  connectedCallback() {
@@ -294,6 +407,7 @@ class WickChart extends HTMLElementBase {
294
407
  cv.addEventListener('pointercancel', this._onPointerUp);
295
408
  cv.addEventListener('pointerleave', this._onPointerLeave);
296
409
  cv.addEventListener('wheel', this._onWheel, { passive: false });
410
+ cv.addEventListener('touchmove', this._onTouchMove, { passive: false });
297
411
  cv.addEventListener('dblclick', this._onDbl);
298
412
  this.addEventListener('keydown', this._onKey);
299
413
 
@@ -301,6 +415,7 @@ class WickChart extends HTMLElementBase {
301
415
  document.fonts.ready.then(() => this._invalidate()).catch(() => {});
302
416
  }
303
417
  if (this._coviewName) this._setupCoView();
418
+ this._watchDpr();
304
419
  this._invalidate();
305
420
  }
306
421
 
@@ -325,6 +440,7 @@ class WickChart extends HTMLElementBase {
325
440
  }
326
441
  clearTimeout(this._ghostTimer);
327
442
  if (this._ro) this._ro.disconnect();
443
+ this._unwatchDpr();
328
444
  const cv = this._canvas;
329
445
  cv.removeEventListener('pointerdown', this._onPointerDown);
330
446
  cv.removeEventListener('pointermove', this._onPointerMove);
@@ -332,11 +448,29 @@ class WickChart extends HTMLElementBase {
332
448
  cv.removeEventListener('pointercancel', this._onPointerUp);
333
449
  cv.removeEventListener('pointerleave', this._onPointerLeave);
334
450
  cv.removeEventListener('wheel', this._onWheel);
451
+ cv.removeEventListener('touchmove', this._onTouchMove);
335
452
  cv.removeEventListener('dblclick', this._onDbl);
336
453
  this.removeEventListener('keydown', this._onKey);
454
+ this._disarmPress();
455
+ this._scrub = false;
337
456
  if (this._raf) cancelAnimationFrame(this._raf), (this._raf = 0);
338
457
  }
339
458
 
459
+ /** (Re)arm the devicePixelRatio watcher for the current ratio. */
460
+ _watchDpr() {
461
+ this._unwatchDpr();
462
+ if (typeof matchMedia !== 'function') return;
463
+ const dpr = window.devicePixelRatio || 1;
464
+ this._dprMq = matchMedia(`(resolution: ${dpr}dppx)`);
465
+ this._dprMq.addEventListener('change', this._onDprChange);
466
+ }
467
+
468
+ _unwatchDpr() {
469
+ if (!this._dprMq) return;
470
+ this._dprMq.removeEventListener('change', this._onDprChange);
471
+ this._dprMq = null;
472
+ }
473
+
340
474
  attributeChangedCallback(name, _old, val) {
341
475
  switch (name) {
342
476
  case 'theme':
@@ -360,6 +494,10 @@ class WickChart extends HTMLElementBase {
360
494
  case 'indicators':
361
495
  this._ind = parseIndicators(val, WickChart._registry());
362
496
  break;
497
+ case 'worker':
498
+ this._workerOn = val != null && val !== 'false';
499
+ this._invalidate();
500
+ break;
363
501
  case 'stats':
364
502
  this._stats = val != null && val !== 'false';
365
503
  this._statsKey = '';
@@ -406,6 +544,26 @@ class WickChart extends HTMLElementBase {
406
544
  this._sonify = val != null && val !== 'false';
407
545
  this._lastToneIdx = -1;
408
546
  break;
547
+ // Default evaluation mode for alerts added without one. Anything
548
+ // other than "close" means live, so a typo cannot silently mute
549
+ // signals — it degrades to today's behaviour.
550
+ case 'alert-evaluate':
551
+ this._alertEval = val === 'close' ? 'close' : 'live';
552
+ break;
553
+ // Display zone for axis labels and the crosshair readout: 'local'
554
+ // (default), 'utc', or an IANA name. Deliberately does NOT re-anchor
555
+ // VWAP — the trading session is a separate question from how times
556
+ // are shown; see calcVWAP's `anchor`.
557
+ case 'timezone':
558
+ this._tz = val || 'local';
559
+ break;
560
+ // Session anchor for VWAP: 'utc' (default), 'local', an IANA zone, or
561
+ // a fixed offset in ms. Bumping the version drops the per-version
562
+ // indicator cache so the series recomputes on the next frame.
563
+ case 'vwap-anchor':
564
+ this._vwapAnchor = val || 'utc';
565
+ this._version++;
566
+ break;
409
567
  }
410
568
  this._invalidate();
411
569
  }
@@ -449,9 +607,10 @@ class WickChart extends HTMLElementBase {
449
607
  });
450
608
  }
451
609
 
452
- /** The custom element class (also exported implicitly for users). */
610
+ /** The tag this class registers as. (<hab-chart> is a deprecated alias
611
+ * registered from the HabChart subclass, not this name.) */
453
612
  static get elementName() {
454
- return 'hab-chart';
613
+ return 'wick-chart';
455
614
  }
456
615
 
457
616
  /* ------------------------------------------------------------ *
@@ -487,9 +646,23 @@ class WickChart extends HTMLElementBase {
487
646
  }
488
647
  }
489
648
  if (!sorted) norm.sort((a, b) => a.time - b.time);
649
+ // One bar per timestamp. A REST history fetch and the websocket that
650
+ // takes over from it overlap, so the same final candle routinely
651
+ // arrives twice; the later copy is the corrected one and wins. In
652
+ // place and allocation-free when the data is already unique.
653
+ let dst = 0;
654
+ for (let i = 0; i < norm.length; i++) {
655
+ if (dst > 0 && norm[i].time === norm[dst - 1].time) norm[dst - 1] = norm[i];
656
+ else norm[dst++] = norm[i];
657
+ }
658
+ norm.length = dst;
490
659
  this._data = norm;
491
660
  this._version++;
661
+ this._epoch++;
492
662
  this._computeDt();
663
+ // history is not a live signal: re-baseline so close-mode alerts only
664
+ // fire on candles that close from here on
665
+ this._syncClosedIdx();
493
666
  this._needsFit = true;
494
667
  this._auto = this._autoAttr();
495
668
  this._hover = null;
@@ -508,21 +681,37 @@ class WickChart extends HTMLElementBase {
508
681
  if (!b) return;
509
682
  const d = this._data;
510
683
  const last = d[d.length - 1];
511
- this._checkAlerts(last ? last.close : NaN, b);
684
+ const prevClose = last ? last.close : NaN;
685
+ // Only the front of the series is a live signal. A historical
686
+ // correction or a backfilled candle must never be compared against the
687
+ // latest price — that would fire an alert on a stale bar.
688
+ let live = true;
512
689
  if (!last || b.time > last.time) {
513
690
  d.push(b);
514
691
  if (d.length > 1) this._computeDt();
515
692
  } else if (b.time === last.time) {
516
693
  d[d.length - 1] = b;
517
694
  } 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);
695
+ // out-of-order / backfill: replace matching or insert. Binary search
696
+ // for the slot a backward scan is O(n) per bar, which turns a
697
+ // backfill of old candles into O(n·m) over a long history.
698
+ live = false;
699
+ const i = WickChart._indexForTime(d, b.time);
700
+ if (d[i] && d[i].time === b.time) d[i] = b;
701
+ else d.splice(i, 0, b);
523
702
  this._computeDt();
524
703
  }
525
704
  this._version++;
705
+ // a live tick (append / forming-bar replace) patches every
706
+ // online-capable series in O(warm-up) before the next render
707
+ if (live) this._onlineTick();
708
+ // Alerts run after the dataset AND the version are updated, so scripted
709
+ // predicates evaluate over the bar that just arrived rather than
710
+ // re-reading the previous version's memoized series.
711
+ // A historical insert shifts indices without closing anything, so the
712
+ // cursor moves with the data rather than reading as a fresh close.
713
+ if (live) this._checkAlerts(prevClose, b);
714
+ else this._syncClosedIdx();
526
715
  if (this._hover && this._hover.index >= d.length) this._hover = null;
527
716
  this._updateAria();
528
717
  this._invalidate();
@@ -531,6 +720,8 @@ class WickChart extends HTMLElementBase {
531
720
  clearData() {
532
721
  this._data = [];
533
722
  this._version++;
723
+ this._epoch++;
724
+ this._syncClosedIdx();
534
725
  this._hover = null;
535
726
  this._needsFit = true;
536
727
  this._noMore = false;
@@ -574,9 +765,11 @@ class WickChart extends HTMLElementBase {
574
765
  }
575
766
  this._data = merged;
576
767
  this._version++;
768
+ this._epoch++;
577
769
  this._computeDt();
578
770
  // keep the exact same bars on screen: every index shifts by `added`
579
771
  this._view.rightIndex += added;
772
+ this._syncClosedIdx(); // backfill shifts indices, it closes nothing
580
773
  if (this._hover) this._hover.index = Math.min(this._hover.index + added, this._data.length - 1);
581
774
  this._clampView();
582
775
  this._invalidate();
@@ -701,8 +894,8 @@ class WickChart extends HTMLElementBase {
701
894
  .filter((a) => !a.fired)
702
895
  .map((a) =>
703
896
  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 }
897
+ ? { id: a.id, when: a.when, once: a.once, evaluate: a.evaluate }
898
+ : { id: a.id, price: a.price, direction: a.direction, once: a.once, evaluate: a.evaluate }
706
899
  ),
707
900
  };
708
901
  }
@@ -750,6 +943,7 @@ class WickChart extends HTMLElementBase {
750
943
  when: a.when.trim(),
751
944
  compiled: compileScript(a.when),
752
945
  once: a.once !== false,
946
+ evaluate: this._evalMode(a.evaluate),
753
947
  fired: false,
754
948
  armed: true,
755
949
  };
@@ -762,6 +956,7 @@ class WickChart extends HTMLElementBase {
762
956
  price: a.price,
763
957
  direction: a.direction || 'cross',
764
958
  once: a.once !== false,
959
+ evaluate: this._evalMode(a.evaluate),
765
960
  fired: false,
766
961
  };
767
962
  })
@@ -845,6 +1040,7 @@ class WickChart extends HTMLElementBase {
845
1040
  when: alert.when.trim(),
846
1041
  compiled,
847
1042
  once: alert.once !== false,
1043
+ evaluate: this._evalMode(alert.evaluate),
848
1044
  fired: false,
849
1045
  armed: true,
850
1046
  };
@@ -855,6 +1051,7 @@ class WickChart extends HTMLElementBase {
855
1051
  price: alert.price,
856
1052
  direction: alert.direction || 'cross',
857
1053
  once: alert.once !== false,
1054
+ evaluate: this._evalMode(alert.evaluate),
858
1055
  fired: false,
859
1056
  };
860
1057
  }
@@ -1081,27 +1278,78 @@ class WickChart extends HTMLElementBase {
1081
1278
  /** Check alerts against an incoming bar (prev close → new close).
1082
1279
  * Scripted (`when`) alerts evaluate their predicate series, cached per
1083
1280
  * data version, and fire on the false→true edge. */
1281
+ /**
1282
+ * Index of the newest bar known to be final: any bar with a newer bar
1283
+ * behind it, plus the front bar when the feed flagged it `closed: true`
1284
+ * (Binance's `k.x`). -1 when nothing has closed yet.
1285
+ */
1286
+ _lastClosedIndex() {
1287
+ const d = this._data;
1288
+ if (!d.length) return -1;
1289
+ const last = d.length - 1;
1290
+ return d[last] && d[last].closed === true ? last : last - 1;
1291
+ }
1292
+
1293
+ /** Re-baseline the closed-bar cursor without firing anything. */
1294
+ _syncClosedIdx() {
1295
+ this._lastClosedIdx = this._lastClosedIndex();
1296
+ }
1297
+
1298
+ /**
1299
+ * Resolve an alert's evaluation mode: an explicit 'close' / 'live' on the
1300
+ * alert wins, otherwise the chart-level `alert-evaluate` default (itself
1301
+ * 'live', so 1.x behaviour is unchanged unless asked for).
1302
+ * @param {string|undefined} v
1303
+ * @returns {'live'|'close'}
1304
+ */
1305
+ _evalMode(v) {
1306
+ if (v === 'close' || v === 'live') return v;
1307
+ return this._alertEval === 'close' ? 'close' : 'live';
1308
+ }
1309
+
1310
+ /** Dispatch one alert, retiring it when it was a `once` alert. */
1311
+ _fireAlert(alert, detail) {
1312
+ if (alert.once) alert.fired = true;
1313
+ this._fire('alert', { id: alert.id, ...detail });
1314
+ if (alert.once) this._alerts = this._alerts.filter((x) => x !== alert);
1315
+ }
1316
+
1084
1317
  _checkAlerts(prevClose, bar) {
1318
+ const closedIdx = this._lastClosedIndex();
1319
+ // A bar closing is an edge, not a level: close-mode alerts evaluate
1320
+ // only on the update that finalizes a candle, so a candle that ticks
1321
+ // through a threshold and back never produces a signal.
1322
+ const justClosed = closedIdx > this._lastClosedIdx;
1323
+ if (justClosed) this._lastClosedIdx = closedIdx;
1085
1324
  if (!this._alerts.length) return;
1325
+ const d = this._data;
1086
1326
  for (const a of [...this._alerts]) {
1327
+ // `fired` means "spent forever" — it is only ever set on `once`
1328
+ // alerts. Repeating (once:false) alerts re-fire on every edge:
1329
+ // price alerts are edge-triggered by checkAlertCross(), scripted
1330
+ // ones by the armed/scriptAlertStep() latch.
1087
1331
  if (a.fired) continue;
1332
+
1333
+ // Close mode reads the newest final candle; live mode reads the
1334
+ // front of the series, forming or not.
1335
+ const onClose = a.evaluate === 'close';
1336
+ if (onClose && (!justClosed || closedIdx < 0)) continue;
1337
+ const idx = onClose ? closedIdx : d.length - 1;
1338
+ const cur = onClose ? d[idx] : bar;
1339
+ if (!cur) continue;
1340
+
1088
1341
  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);
1342
+ // predicates are causal, so the value at `idx` is the same whether
1343
+ // it was computed over the whole series or just the prefix
1344
+ const step = scriptAlertStep(a.armed, this._predicateCache(a)[idx] === true);
1092
1345
  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
- }
1346
+ if (step.fire) this._fireAlert(a, { price: cur.close, when: a.when, bar: cur });
1098
1347
  continue;
1099
1348
  }
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);
1349
+ const prev = onClose ? (d[idx - 1] ? d[idx - 1].close : NaN) : prevClose;
1350
+ if (!isNum(prev)) continue;
1351
+ if (checkAlertCross(a, prev, cur.close)) {
1352
+ this._fireAlert(a, { price: a.price, bar: cur });
1105
1353
  }
1106
1354
  }
1107
1355
  }
@@ -1136,6 +1384,11 @@ class WickChart extends HTMLElementBase {
1136
1384
 
1137
1385
  static _MAX_SP = 90;
1138
1386
 
1387
+ /** Hold this long on a touchscreen to open the crosshair (ms). */
1388
+ static _PRESS_MS = 350;
1389
+ /** Finger travel that cancels the press and makes it a pan (px). */
1390
+ static _PRESS_SLOP = 10;
1391
+
1139
1392
  /**
1140
1393
  * Lowest allowed px/bar: either 0.35, or whatever fits the entire
1141
1394
  * dataset on screen — so any history can be zoomed out fully.
@@ -1147,18 +1400,19 @@ class WickChart extends HTMLElementBase {
1147
1400
  }
1148
1401
 
1149
1402
  static _timeToMs(t) {
1150
- return isNum(t) ? (t < 1e12 ? t * 1000 : t) : Date.now();
1403
+ if (t instanceof Date) return t.getTime();
1404
+ return isNum(t) ? toMs(t) : Date.now();
1151
1405
  }
1152
1406
 
1153
1407
  static _normBar(b) {
1154
1408
  if (!b) return null;
1155
1409
  const t = b.time != null ? b.time : b.t;
1156
- if (!isNum(t)) return null;
1410
+ if (!isNum(t) && !(t instanceof Date)) return null;
1157
1411
  const time = WickChart._timeToMs(t);
1158
1412
  const close = isNum(b.close) ? b.close : isNum(b.value) ? b.value : NaN;
1159
1413
  if (!isNum(close)) return null;
1160
1414
  const open = isNum(b.open) ? b.open : close;
1161
- return {
1415
+ const nb = {
1162
1416
  time,
1163
1417
  open,
1164
1418
  high: isNum(b.high) ? b.high : Math.max(open, close),
@@ -1166,6 +1420,10 @@ class WickChart extends HTMLElementBase {
1166
1420
  close,
1167
1421
  volume: isNum(b.volume) ? b.volume : isNum(b.v) ? b.v : 0,
1168
1422
  };
1423
+ // Only carried when the feed actually says the candle is final
1424
+ // (Binance `k.x`), so the bar shape is unchanged for everyone else.
1425
+ if (b.closed === true) nb.closed = true;
1426
+ return nb;
1169
1427
  }
1170
1428
 
1171
1429
  static _indexForTime(d, time) {
@@ -1303,22 +1561,188 @@ class WickChart extends HTMLElementBase {
1303
1561
 
1304
1562
  /** Compute (and cache per data version) an indicator entry's series. */
1305
1563
  _indicatorSeries(entry) {
1564
+ {
1565
+ // incremental path first: a series patched for this exact version
1566
+ // by _onlineTick is the freshest thing there is
1567
+ const on = this._onlineSeries;
1568
+ const cur = on.epoch === this._epoch ? on.map['ind:' + entry.key] : null;
1569
+ if (cur && cur.v === this._version) return cur.res;
1570
+ }
1306
1571
  if (this._cache.v !== this._version) {
1307
1572
  this._cache = { v: this._version, map: {} };
1308
1573
  }
1309
1574
  const k = 'ind:' + entry.key;
1310
1575
  if (!this._cache.map[k]) {
1576
+ // Worker compute path: built-in indicators over big histories run
1577
+ // off the main thread. The dataset crosses once per data epoch as
1578
+ // six transferable Float64Arrays (~25 ms/M bars — cloning objects
1579
+ // would cost ~1 s). Closures (custom/scripted defs) can't cross;
1580
+ // neither can anything below WORKER_MIN_BARS — both stay sync.
1581
+ const pool = WickChart._workerPool;
1582
+ if (pool && pool.available && this._workerOn && this._data.length >= WORKER_MIN_BARS &&
1583
+ BUILTIN_INDICATORS.get(entry.name) === entry.def) {
1584
+ const wc = this._workerCache;
1585
+ if (wc.epoch === this._epoch && wc.map[k]) return wc.map[k];
1586
+ this._workerCompute(pool, entry, k);
1587
+ return PENDING_SERIES; // the line lands when the result arrives
1588
+ }
1311
1589
  let res;
1312
1590
  try {
1313
- res = entry.def.compute(this._data, entry.params);
1591
+ // the session anchor rides along for indicators that observe one
1592
+ // (vwap); the rest ignore the extra key
1593
+ res = entry.def.compute(this._data, { ...entry.params, anchor: this._vwapAnchor });
1314
1594
  } catch (err) {
1315
1595
  res = null;
1316
1596
  }
1317
1597
  this._cache.map[k] = normalizeIndicatorResult(res);
1598
+ this._seedOnline(k, entry, this._cache.map[k]);
1318
1599
  }
1319
1600
  return this._cache.map[k];
1320
1601
  }
1321
1602
 
1603
+ /**
1604
+ * After a live stream tick (append or forming-bar replace), patch every
1605
+ * online-capable series by recomputing a bounded tail with the same
1606
+ * batch definition — O(warm-up) instead of a full-history recompute per
1607
+ * indicator per tick. Bases are seeded by the sync or worker path; bulk
1608
+ * loads (epoch changes) reseed automatically.
1609
+ */
1610
+ _onlineTick() {
1611
+ const d = this._data;
1612
+ const on = this._onlineSeries;
1613
+ if (!d.length || on.epoch !== this._epoch) return;
1614
+ for (const entry of this._ind.overlays.concat(this._ind.panes)) {
1615
+ const k = 'ind:' + entry.key;
1616
+ const cur = on.map[k];
1617
+ if (!cur || ONLINE_SKIP.has(entry.name) || BUILTIN_INDICATORS.get(entry.name) !== entry.def) {
1618
+ continue;
1619
+ }
1620
+ if (this._patchSeriesTail(cur.res, entry, d)) cur.v = this._version;
1621
+ else delete on.map[k]; // length mismatch etc. — reseed on the next compute
1622
+ }
1623
+ }
1624
+
1625
+ /** Recompute the last K bars of one series in place (K = max(400,
1626
+ * 10×period), clamped to the data). False when the series and data
1627
+ * lengths can't line up — the caller drops the base and reseeds. */
1628
+ _patchSeriesTail(res, entry, d) {
1629
+ let p = 0;
1630
+ for (const v of Object.values(entry.params || {})) {
1631
+ if (Number.isFinite(+v) && +v > p) p = +v;
1632
+ }
1633
+ const K = Math.min(d.length - 1, Math.max(ONLINE_WARMUP, p * 10));
1634
+ if (K < 2 || !res.lines.length) return false;
1635
+ let tail;
1636
+ try {
1637
+ tail = normalizeIndicatorResult(
1638
+ entry.def.compute(d.slice(d.length - 1 - K), { ...entry.params, anchor: this._vwapAnchor })
1639
+ );
1640
+ } catch (_) {
1641
+ return false;
1642
+ }
1643
+ const want = d.length;
1644
+ const base = want - 1 - K; // data index of tail[0]
1645
+ // Only the last `p` values can have changed (window indicators depend
1646
+ // on the trailing window alone; recursive ones only move the new bar).
1647
+ // Writing deeper would replace good full-history values with the
1648
+ // tail's own warm-up error.
1649
+ const from = Math.max(base, want - 1 - p);
1650
+ const patch = (dst, src) => {
1651
+ if (!Array.isArray(src) || src.length !== K + 1) return false;
1652
+ if (dst.length === want - 1) dst.push(src[src.length - 1]); // a bar was appended
1653
+ else if (dst.length !== want) return false;
1654
+ for (let di = from; di < want; di++) {
1655
+ const v = src[di - base];
1656
+ if (v != null || dst[di] == null) dst[di] = v; // warm-up null never clobbers
1657
+ }
1658
+ return true;
1659
+ };
1660
+ for (let i = 0; i < res.lines.length; i++) {
1661
+ const t = tail.lines[i];
1662
+ if (!t || !patch(res.lines[i].values, t.values)) return false;
1663
+ }
1664
+ if (Array.isArray(res.histogram) && !patch(res.histogram, tail.histogram)) return false;
1665
+ return true;
1666
+ }
1667
+
1668
+ /** Remember a fresh series as the base for incremental tick updates
1669
+ * (online-capable builtins only). */
1670
+ _seedOnline(k, entry, res) {
1671
+ if (ONLINE_SKIP.has(entry.name) || BUILTIN_INDICATORS.get(entry.name) !== entry.def) return;
1672
+ const on = this._onlineSeries;
1673
+ if (on.epoch !== this._epoch) {
1674
+ on.epoch = this._epoch;
1675
+ on.map = {};
1676
+ }
1677
+ on.map[k] = { v: this._version, res };
1678
+ }
1679
+
1680
+ /** Bar count from which the worker path engages (below it, sync wins). */
1681
+ _workerCols() {
1682
+ const d = this._data;
1683
+ const n = d.length;
1684
+ const cols = {
1685
+ time: new Float64Array(n), open: new Float64Array(n), high: new Float64Array(n),
1686
+ low: new Float64Array(n), close: new Float64Array(n), volume: new Float64Array(n),
1687
+ };
1688
+ for (let i = 0; i < n; i++) {
1689
+ const b = d[i];
1690
+ cols.time[i] = b.time;
1691
+ cols.open[i] = b.open;
1692
+ cols.high[i] = b.high;
1693
+ cols.low[i] = b.low;
1694
+ cols.close[i] = b.close;
1695
+ cols.volume[i] = b.volume || 0;
1696
+ }
1697
+ return cols;
1698
+ }
1699
+
1700
+ /** Kick an off-thread compute for one indicator (idempotent per epoch). */
1701
+ _workerCompute(pool, entry, k) {
1702
+ const wc = this._workerCache;
1703
+ wc.epoch = this._epoch;
1704
+ if (wc.sent !== this._epoch) {
1705
+ wc.sent = this._epoch;
1706
+ wc.pending = {};
1707
+ pool
1708
+ .run({ type: 'epoch', sid: this._sid, epoch: this._epoch, cols: this._workerCols() })
1709
+ .catch(() => {
1710
+ if (wc.sent === this._epoch) wc.sent = -1; // resend on the next kick
1711
+ });
1712
+ }
1713
+ if (wc.pending[k] === this._epoch) return; // already in flight
1714
+ wc.pending[k] = this._epoch;
1715
+ const epoch = this._epoch; // captured at kick time — a bulk load that
1716
+ // lands while the compute is in flight must not adopt its result
1717
+ pool
1718
+ .run({
1719
+ type: 'indicator', sid: this._sid, epoch,
1720
+ name: entry.name, params: { ...entry.params, anchor: this._vwapAnchor },
1721
+ })
1722
+ .then((res) => this._workerArrived(k, epoch, res, entry))
1723
+ .catch((err) => {
1724
+ delete wc.pending[k];
1725
+ if (err && err.stale && wc.sent === this._epoch) {
1726
+ wc.sent = -1; // the worker no longer holds this epoch's data — resend
1727
+ } else if (wc.epoch === epoch) {
1728
+ wc.map[k] = { lines: [], histogram: null }; // negative cache: draw nothing this epoch
1729
+ }
1730
+ });
1731
+ }
1732
+
1733
+ _workerArrived(k, epoch, res, entry) {
1734
+ const wc = this._workerCache;
1735
+ delete wc.pending[k];
1736
+ if (wc.epoch !== epoch) return; // a newer bulk load won — drop the stale line
1737
+ const norm = normalizeIndicatorResult(res);
1738
+ wc.map[k] = norm;
1739
+ // the worker base doubles as the seed for incremental tick updates,
1740
+ // so streamed ticks stay fresh instead of waiting for the next load
1741
+ if (entry) this._seedOnline(k, entry, norm);
1742
+ this._fire('worker', { key: k, epoch });
1743
+ this._invalidate();
1744
+ }
1745
+
1322
1746
  /** Resolve a line color: #hex / rgb() / CSS name / palette key ('rsi', 'up', …) / cycle.
1323
1747
  * Untrusted values (URL/attribute-sourced) are validated — never interpolated raw. */
1324
1748
  _lineColor(entry, line, pal, cycleIdx) {
@@ -1492,6 +1916,11 @@ class WickChart extends HTMLElementBase {
1492
1916
  * the walk to those bars — used at deep zoom where bars are aggregated
1493
1917
  * into pixel columns (keeps this O(screen) instead of O(visible bars)).
1494
1918
  */
1919
+ /** An instant shifted into the chart's display zone, for the formatters. */
1920
+ _zt(t) {
1921
+ return t + zoneOffset(t, this._tz);
1922
+ }
1923
+
1495
1924
  _timeTicks(i0, i1, sampleIdx) {
1496
1925
  const d = this._data;
1497
1926
  const sp = this._view.spacing;
@@ -1519,7 +1948,6 @@ class WickChart extends HTMLElementBase {
1519
1948
  }
1520
1949
  }
1521
1950
 
1522
- const tz = (t) => -new Date(t).getTimezoneOffset() * 60000;
1523
1951
  const ticks = [];
1524
1952
  let prevKey = null;
1525
1953
  // Labels are built lazily — only for bars that actually start a new step.
@@ -1527,30 +1955,32 @@ class WickChart extends HTMLElementBase {
1527
1955
  const visit = (i) => {
1528
1956
  if (i < 0 || i >= d.length) return;
1529
1957
  const t = d[i].time;
1958
+ // shifted into the display zone once, then read with UTC getters
1959
+ const zt = this._zt(t);
1530
1960
  let key;
1531
1961
  let label = null;
1532
1962
  if (stepMs != null) {
1533
- key = Math.floor((t + tz(t)) / stepMs);
1963
+ key = Math.floor(zt / stepMs);
1534
1964
  if (prevKey !== null && key !== prevKey) {
1535
1965
  if (stepLabel === 'time') {
1536
1966
  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);
1967
+ const dayKey = Math.floor(zt / DAY);
1968
+ const prevDay = Math.floor(this._zt(prevT) / DAY);
1969
+ label = dayKey !== prevDay ? fmtDay(zt) : hhmm(zt);
1540
1970
  } else {
1541
- const dt_ = new Date(t);
1542
- label = dt_.getDate() === 1 ? fmtMonth(t, dt_.getMonth() === 0) : fmtDay(t);
1971
+ const dt_ = new Date(zt);
1972
+ label = dt_.getUTCDate() === 1 ? fmtMonth(zt, dt_.getUTCMonth() === 0) : fmtDay(zt);
1543
1973
  }
1544
1974
  }
1545
1975
  } else if (monthStep) {
1546
- const dt_ = new Date(t);
1547
- key = Math.floor((dt_.getFullYear() * 12 + dt_.getMonth()) / monthStep);
1976
+ const dt_ = new Date(zt);
1977
+ key = Math.floor((dt_.getUTCFullYear() * 12 + dt_.getUTCMonth()) / monthStep);
1548
1978
  if (prevKey !== null && key !== prevKey) {
1549
- label = fmtMonth(t, dt_.getMonth() === 0 || monthStep > 1);
1979
+ label = fmtMonth(zt, dt_.getUTCMonth() === 0 || monthStep > 1);
1550
1980
  }
1551
1981
  } else {
1552
- key = new Date(t).getFullYear();
1553
- if (prevKey !== null && key !== prevKey) label = fmtYear(t);
1982
+ key = new Date(zt).getUTCFullYear();
1983
+ if (prevKey !== null && key !== prevKey) label = fmtYear(zt);
1554
1984
  }
1555
1985
  if (label !== null) ticks.push({ x: this._xFor(i), label });
1556
1986
  prevKey = key;
@@ -2604,7 +3034,7 @@ class WickChart extends HTMLElementBase {
2604
3034
  }
2605
3035
 
2606
3036
  // time pill
2607
- const tLabel = fmtFull(d[h.index].time);
3037
+ const tLabel = fmtFull(this._zt(d[h.index].time));
2608
3038
  ctx.font = pillFont();
2609
3039
  const tw = ctx.measureText(tLabel).width + 12;
2610
3040
  this._pill(
@@ -2675,7 +3105,7 @@ class WickChart extends HTMLElementBase {
2675
3105
  ctx.stroke();
2676
3106
  ctx.restore();
2677
3107
  if (gxVisible && this._data[g.index]) {
2678
- const tLabel = fmtFull(this._data[g.index].time);
3108
+ const tLabel = fmtFull(this._zt(this._data[g.index].time));
2679
3109
  ctx.font = pillFont();
2680
3110
  const tw = ctx.measureText(tLabel).width + 12;
2681
3111
  this._pill(
@@ -2921,7 +3351,7 @@ class WickChart extends HTMLElementBase {
2921
3351
  let html = '';
2922
3352
  for (const p of this._positions) {
2923
3353
  const pnl = positionPnl(p, price);
2924
- const pct = p.entry ? (pnl / p.entry) * 100 : 0;
3354
+ const pct = positionPnlPct(p, price);
2925
3355
  const cls = pnl >= 0 ? 'up' : 'dn';
2926
3356
  const qtyStr = p.qty != null ? ' ' + p.qty : '';
2927
3357
  html +=
@@ -3093,7 +3523,7 @@ class WickChart extends HTMLElementBase {
3093
3523
  const ly = this._ly;
3094
3524
  const d = this._data;
3095
3525
  if (!ly || !d.length || !isNum(time)) return null;
3096
- const t = time < 1e12 ? time * 1000 : time;
3526
+ const t = toMs(time);
3097
3527
  const last = d.length - 1;
3098
3528
  if (t >= d[last].time) {
3099
3529
  return this._xFor(last + (t - d[last].time) / (this._dt || HOUR));
@@ -3165,6 +3595,59 @@ class WickChart extends HTMLElementBase {
3165
3595
  return { x: e.clientX - r.left, y: e.clientY - r.top };
3166
3596
  }
3167
3597
 
3598
+ /**
3599
+ * Put the crosshair on the bar under a point and announce it. Shared by
3600
+ * mouse hover, keyboard walking and the touch scrub gesture.
3601
+ */
3602
+ _hoverAt(pt) {
3603
+ if (!this._ly || !this._data.length) return;
3604
+ const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
3605
+ this._hover = { index: idx, x: this._xFor(idx), y: pt.y };
3606
+ this._maybeSonify(idx);
3607
+ this._emitCrosshair(this._hover);
3608
+ this._invalidate();
3609
+ }
3610
+
3611
+ /**
3612
+ * Start the long-press timer for a touch. A touchscreen has no hover, so
3613
+ * without this there is no way to read a bar's values on a phone: a tap
3614
+ * selects, a drag pans, and the legend never leaves the last candle.
3615
+ * Holding still opens the crosshair; moving first cancels and pans.
3616
+ */
3617
+ _armPress(pointerId, pt) {
3618
+ this._disarmPress();
3619
+ this._pressOrigin = { pointerId, x: pt.x, y: pt.y };
3620
+ this._pressTimer = setTimeout(() => {
3621
+ this._pressTimer = 0;
3622
+ const origin = this._pressOrigin;
3623
+ // Still one finger, still down, nothing else has claimed the gesture.
3624
+ if (!origin || this._pointers.size !== 1 || this._layerClaim) return;
3625
+ if (!this._pointers.has(origin.pointerId)) return;
3626
+ this._scrub = true;
3627
+ this._pan = null;
3628
+ this._measuring = false;
3629
+ this._canvas.classList.remove('grabbing');
3630
+ this._hoverAt(origin);
3631
+ }, WickChart._PRESS_MS);
3632
+ }
3633
+
3634
+ _disarmPress() {
3635
+ if (this._pressTimer) clearTimeout(this._pressTimer);
3636
+ this._pressTimer = 0;
3637
+ this._pressOrigin = null;
3638
+ }
3639
+
3640
+ /** Leave scrub mode and put the crosshair away. */
3641
+ _endScrub() {
3642
+ if (!this._scrub) return;
3643
+ this._scrub = false;
3644
+ if (this._hover) {
3645
+ this._hover = null;
3646
+ this._emitCrosshair(null);
3647
+ this._invalidate();
3648
+ }
3649
+ }
3650
+
3168
3651
  _pointerDown(e) {
3169
3652
  if (e.button !== 0) return;
3170
3653
  this._stopPlayback(); // any touch interrupts the story
@@ -3185,6 +3668,9 @@ class WickChart extends HTMLElementBase {
3185
3668
  }
3186
3669
  }
3187
3670
  if (this._pointers.size === 2) {
3671
+ // a second finger is a pinch, never a press or a scrub
3672
+ this._disarmPress();
3673
+ this._endScrub();
3188
3674
  const [a, b] = [...this._pointers.values()];
3189
3675
  const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
3190
3676
  this._pinch = {
@@ -3220,6 +3706,7 @@ class WickChart extends HTMLElementBase {
3220
3706
  } else {
3221
3707
  this._pan = { x: pt.x, rightIndex: this._view.rightIndex, moved: false };
3222
3708
  this._canvas.classList.add('grabbing');
3709
+ if (e.pointerType === 'touch') this._armPress(e.pointerId, pt);
3223
3710
  }
3224
3711
  }
3225
3712
 
@@ -3234,6 +3721,20 @@ class WickChart extends HTMLElementBase {
3234
3721
  if (this._pointers.has(e.pointerId)) this._pointers.set(e.pointerId, pt);
3235
3722
  const ly = this._ly;
3236
3723
 
3724
+ // A finger that wanders before the press lands wanted to pan.
3725
+ const origin = this._pressOrigin;
3726
+ if (this._pressTimer && origin && origin.pointerId === e.pointerId) {
3727
+ if (Math.hypot(pt.x - origin.x, pt.y - origin.y) > WickChart._PRESS_SLOP) {
3728
+ this._disarmPress();
3729
+ }
3730
+ }
3731
+
3732
+ // Scrub: the finger walks the crosshair, the viewport stays put.
3733
+ if (this._scrub && this._pointers.has(e.pointerId)) {
3734
+ this._hoverAt(pt);
3735
+ return;
3736
+ }
3737
+
3237
3738
  if (this._pinch && this._pointers.size >= 2 && ly) {
3238
3739
  const [a, b] = [...this._pointers.values()];
3239
3740
  const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
@@ -3281,11 +3782,7 @@ class WickChart extends HTMLElementBase {
3281
3782
 
3282
3783
  if (!ly) return;
3283
3784
  // 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();
3785
+ this._hoverAt(pt);
3289
3786
  }
3290
3787
 
3291
3788
  _pointerUp(e) {
@@ -3299,8 +3796,12 @@ class WickChart extends HTMLElementBase {
3299
3796
  }
3300
3797
  const had = this._pointers.delete(e.pointerId);
3301
3798
  if (this._pointers.size < 2) this._pinch = null;
3799
+ if (this._pressOrigin && this._pressOrigin.pointerId === e.pointerId) this._disarmPress();
3302
3800
  if (this._pointers.size === 0) {
3303
3801
  this._canvas.classList.remove('grabbing');
3802
+ // Lifting ends the scrub — the crosshair is not left stranded on a
3803
+ // touchscreen, where nothing else would ever clear it.
3804
+ this._endScrub();
3304
3805
  if (this._brushDrag && had) {
3305
3806
  const b = this._brushDrag;
3306
3807
  this._brushDrag = null;