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/README.md +198 -5
- package/package.json +17 -4
- package/src/core.js +3012 -2882
- package/src/feeds.js +333 -1
- package/src/react-core.js +7 -1
- package/src/report.js +309 -0
- package/src/wick-chart.js +553 -52
- package/src/wick-feed.js +213 -2
- package/src/worker-core.js +89 -0
- package/src/worker.js +128 -0
- package/types/core.d.ts +60 -17
- package/types/feeds.d.ts +165 -0
- package/types/report.d.ts +114 -0
- package/types/wick-chart.d.ts +108 -5
- package/types/wick-feed.d.ts +19 -0
- package/types/worker-core.d.ts +11 -0
- package/types/worker.d.ts +36 -0
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
|
-
|
|
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
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
const step = scriptAlertStep(a.armed,
|
|
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
|
-
|
|
1101
|
-
if (
|
|
1102
|
-
|
|
1103
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1538
|
-
const prevDay = Math.floor((prevT
|
|
1539
|
-
label = dayKey !== prevDay ? fmtDay(
|
|
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(
|
|
1542
|
-
label = dt_.
|
|
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(
|
|
1547
|
-
key = Math.floor((dt_.
|
|
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(
|
|
1979
|
+
label = fmtMonth(zt, dt_.getUTCMonth() === 0 || monthStep > 1);
|
|
1550
1980
|
}
|
|
1551
1981
|
} else {
|
|
1552
|
-
key = new Date(
|
|
1553
|
-
if (prevKey !== null && key !== prevKey) label = fmtYear(
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|