wickchart 1.5.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 +385 -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 +579 -54
- 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 +125 -6
- 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;
|
|
@@ -1598,8 +2028,9 @@ class WickChart extends HTMLElementBase {
|
|
|
1598
2028
|
);
|
|
1599
2029
|
}
|
|
1600
2030
|
const timeH = 26;
|
|
2031
|
+
const dock = this._dockInset();
|
|
1601
2032
|
const plotRight = Math.max(30, W - priceW);
|
|
1602
|
-
const plotBottom = H - timeH;
|
|
2033
|
+
const plotBottom = H - timeH - dock;
|
|
1603
2034
|
const paneList = this._ind.panes;
|
|
1604
2035
|
const paneArea = paneList.length
|
|
1605
2036
|
? Math.min(
|
|
@@ -1622,6 +2053,7 @@ class WickChart extends HTMLElementBase {
|
|
|
1622
2053
|
plotBottom,
|
|
1623
2054
|
main: { y0: 0, y1: mainH, h: mainH },
|
|
1624
2055
|
panes,
|
|
2056
|
+
dock: dock > 0 ? { y0: H - dock, h: dock } : null,
|
|
1625
2057
|
});
|
|
1626
2058
|
|
|
1627
2059
|
/* background */
|
|
@@ -2602,7 +3034,7 @@ class WickChart extends HTMLElementBase {
|
|
|
2602
3034
|
}
|
|
2603
3035
|
|
|
2604
3036
|
// time pill
|
|
2605
|
-
const tLabel = fmtFull(d[h.index].time);
|
|
3037
|
+
const tLabel = fmtFull(this._zt(d[h.index].time));
|
|
2606
3038
|
ctx.font = pillFont();
|
|
2607
3039
|
const tw = ctx.measureText(tLabel).width + 12;
|
|
2608
3040
|
this._pill(
|
|
@@ -2673,7 +3105,7 @@ class WickChart extends HTMLElementBase {
|
|
|
2673
3105
|
ctx.stroke();
|
|
2674
3106
|
ctx.restore();
|
|
2675
3107
|
if (gxVisible && this._data[g.index]) {
|
|
2676
|
-
const tLabel = fmtFull(this._data[g.index].time);
|
|
3108
|
+
const tLabel = fmtFull(this._zt(this._data[g.index].time));
|
|
2677
3109
|
ctx.font = pillFont();
|
|
2678
3110
|
const tw = ctx.measureText(tLabel).width + 12;
|
|
2679
3111
|
this._pill(
|
|
@@ -2919,7 +3351,7 @@ class WickChart extends HTMLElementBase {
|
|
|
2919
3351
|
let html = '';
|
|
2920
3352
|
for (const p of this._positions) {
|
|
2921
3353
|
const pnl = positionPnl(p, price);
|
|
2922
|
-
const pct = p
|
|
3354
|
+
const pct = positionPnlPct(p, price);
|
|
2923
3355
|
const cls = pnl >= 0 ? 'up' : 'dn';
|
|
2924
3356
|
const qtyStr = p.qty != null ? ' ' + p.qty : '';
|
|
2925
3357
|
html +=
|
|
@@ -2942,7 +3374,13 @@ class WickChart extends HTMLElementBase {
|
|
|
2942
3374
|
* layer then receives that pointer's move/up/cancel events (plus a
|
|
2943
3375
|
* 'cancel' on Escape) and the chart suppresses its own pan/measure/brush
|
|
2944
3376
|
* for the duration.
|
|
2945
|
-
*
|
|
3377
|
+
*
|
|
3378
|
+
* A layer may also declare `insetBottom` (px, 0..160): the largest
|
|
3379
|
+
* declared inset reserves a docked strip at the very bottom of the
|
|
3380
|
+
* canvas — all chart content (panes + time axis) shrinks above it and
|
|
3381
|
+
* the strip is handed to layers as `api.layout.dock = { y0, h }`
|
|
3382
|
+
* (used by the wickchart-navigator plugin).
|
|
3383
|
+
* @param {{id?: string, draw: Function, onPointer?: Function, insetBottom?: number}} layer
|
|
2946
3384
|
* @returns {object|null} the normalized layer handle (with `id`), or null
|
|
2947
3385
|
* if the layer was rejected (no draw fn, or 16 layers already added)
|
|
2948
3386
|
*/
|
|
@@ -2956,6 +3394,10 @@ class WickChart extends HTMLElementBase {
|
|
|
2956
3394
|
const entry = {
|
|
2957
3395
|
id,
|
|
2958
3396
|
draw: layer.draw,
|
|
3397
|
+
insetBottom:
|
|
3398
|
+
typeof layer.insetBottom === 'number' && Number.isFinite(layer.insetBottom)
|
|
3399
|
+
? Math.max(0, Math.min(160, Math.round(layer.insetBottom)))
|
|
3400
|
+
: 0,
|
|
2959
3401
|
onPointer: typeof layer.onPointer === 'function' ? layer.onPointer : null,
|
|
2960
3402
|
};
|
|
2961
3403
|
const at = this._layers.findIndex((l) => l.id === id);
|
|
@@ -3007,6 +3449,18 @@ class WickChart extends HTMLElementBase {
|
|
|
3007
3449
|
}
|
|
3008
3450
|
}
|
|
3009
3451
|
|
|
3452
|
+
/**
|
|
3453
|
+
* Bottom space reserved by plugin layers: the largest declared
|
|
3454
|
+
* `insetBottom` (px, clamped 0..160 at addLayer time), or 0.
|
|
3455
|
+
*/
|
|
3456
|
+
_dockInset() {
|
|
3457
|
+
let dock = 0;
|
|
3458
|
+
for (const l of this._layers) {
|
|
3459
|
+
if (l.insetBottom > dock) dock = l.insetBottom;
|
|
3460
|
+
}
|
|
3461
|
+
return dock;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3010
3464
|
/** Ask layers, in order, whether one claims this pointerdown. */
|
|
3011
3465
|
_layerHit(e, pt) {
|
|
3012
3466
|
for (const layer of this._layers) {
|
|
@@ -3069,7 +3523,7 @@ class WickChart extends HTMLElementBase {
|
|
|
3069
3523
|
const ly = this._ly;
|
|
3070
3524
|
const d = this._data;
|
|
3071
3525
|
if (!ly || !d.length || !isNum(time)) return null;
|
|
3072
|
-
const t = time
|
|
3526
|
+
const t = toMs(time);
|
|
3073
3527
|
const last = d.length - 1;
|
|
3074
3528
|
if (t >= d[last].time) {
|
|
3075
3529
|
return this._xFor(last + (t - d[last].time) / (this._dt || HOUR));
|
|
@@ -3141,6 +3595,59 @@ class WickChart extends HTMLElementBase {
|
|
|
3141
3595
|
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
3142
3596
|
}
|
|
3143
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
|
+
|
|
3144
3651
|
_pointerDown(e) {
|
|
3145
3652
|
if (e.button !== 0) return;
|
|
3146
3653
|
this._stopPlayback(); // any touch interrupts the story
|
|
@@ -3161,6 +3668,9 @@ class WickChart extends HTMLElementBase {
|
|
|
3161
3668
|
}
|
|
3162
3669
|
}
|
|
3163
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();
|
|
3164
3674
|
const [a, b] = [...this._pointers.values()];
|
|
3165
3675
|
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
3166
3676
|
this._pinch = {
|
|
@@ -3196,6 +3706,7 @@ class WickChart extends HTMLElementBase {
|
|
|
3196
3706
|
} else {
|
|
3197
3707
|
this._pan = { x: pt.x, rightIndex: this._view.rightIndex, moved: false };
|
|
3198
3708
|
this._canvas.classList.add('grabbing');
|
|
3709
|
+
if (e.pointerType === 'touch') this._armPress(e.pointerId, pt);
|
|
3199
3710
|
}
|
|
3200
3711
|
}
|
|
3201
3712
|
|
|
@@ -3210,6 +3721,20 @@ class WickChart extends HTMLElementBase {
|
|
|
3210
3721
|
if (this._pointers.has(e.pointerId)) this._pointers.set(e.pointerId, pt);
|
|
3211
3722
|
const ly = this._ly;
|
|
3212
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
|
+
|
|
3213
3738
|
if (this._pinch && this._pointers.size >= 2 && ly) {
|
|
3214
3739
|
const [a, b] = [...this._pointers.values()];
|
|
3215
3740
|
const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
|
|
@@ -3257,11 +3782,7 @@ class WickChart extends HTMLElementBase {
|
|
|
3257
3782
|
|
|
3258
3783
|
if (!ly) return;
|
|
3259
3784
|
// hover / crosshair
|
|
3260
|
-
|
|
3261
|
-
this._hover = { index: idx, x: this._xFor(idx), y: pt.y };
|
|
3262
|
-
this._maybeSonify(idx);
|
|
3263
|
-
this._emitCrosshair(this._hover);
|
|
3264
|
-
this._invalidate();
|
|
3785
|
+
this._hoverAt(pt);
|
|
3265
3786
|
}
|
|
3266
3787
|
|
|
3267
3788
|
_pointerUp(e) {
|
|
@@ -3275,8 +3796,12 @@ class WickChart extends HTMLElementBase {
|
|
|
3275
3796
|
}
|
|
3276
3797
|
const had = this._pointers.delete(e.pointerId);
|
|
3277
3798
|
if (this._pointers.size < 2) this._pinch = null;
|
|
3799
|
+
if (this._pressOrigin && this._pressOrigin.pointerId === e.pointerId) this._disarmPress();
|
|
3278
3800
|
if (this._pointers.size === 0) {
|
|
3279
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();
|
|
3280
3805
|
if (this._brushDrag && had) {
|
|
3281
3806
|
const b = this._brushDrag;
|
|
3282
3807
|
this._brushDrag = null;
|