wickchart 1.7.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -16
- package/package.json +5 -4
- package/src/core.js +2590 -3012
- package/src/wick-chart.js +133 -943
- package/src/wick-feed.js +4 -12
- package/types/core.d.ts +4 -234
- package/types/wick-chart.d.ts +30 -263
- package/types/wick-feed.d.ts +2 -2
package/src/wick-chart.js
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* Zero dependencies. Canvas-rendered. Framework-agnostic (works in React,
|
|
13
13
|
* Vue, plain HTML). Themeable with --wick-* CSS custom properties.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* 2.0: the guided-playback (narrate/walk/sonify/story), co-view, scenario/
|
|
16
|
+
* risk-plan and AI-agent families live in their plugin packages; the core
|
|
17
|
+
* methods are warn-once stubs until a package attaches.
|
|
17
18
|
*
|
|
18
19
|
* MIT License.
|
|
19
20
|
* ========================================================================== */
|
|
@@ -26,22 +27,19 @@ import {
|
|
|
26
27
|
parseIndicators, normalizeIndicatorResult, BUILTIN_INDICATORS,
|
|
27
28
|
positionPnl, positionPnlPct, checkAlertCross, computeStats, safeColor,
|
|
28
29
|
SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
|
|
29
|
-
calcRSI, detectAnnotations,
|
|
30
|
+
calcRSI, detectAnnotations,
|
|
30
31
|
calcRealizedVol, volRegimeBands, percentileOfSorted, parseVolShading,
|
|
31
32
|
windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
|
|
32
33
|
compileScript, predicateTrueSeries, scriptAlertStep,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
narrateWindow, brushStats,
|
|
36
|
-
easeInOutCubic, sceneList,
|
|
34
|
+
brushStats,
|
|
35
|
+
warnDeprecatedAlias,
|
|
37
36
|
} from './core.js';
|
|
38
37
|
|
|
39
38
|
/* ------------------------------------------------------------------ *
|
|
40
39
|
* <wick-chart>
|
|
41
40
|
* ------------------------------------------------------------------ */
|
|
42
41
|
|
|
43
|
-
/** Indicator registry — module scope
|
|
44
|
-
* <wick-chart> and the deprecated <hab-chart> alias element. */
|
|
42
|
+
/** Indicator registry — module scope, shared by every chart instance. */
|
|
45
43
|
const REGISTRY = new Map(BUILTIN_INDICATORS);
|
|
46
44
|
|
|
47
45
|
/** Bars from which the worker compute path engages — below it, sync wins
|
|
@@ -70,7 +68,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
|
|
|
70
68
|
|
|
71
69
|
class WickChart extends HTMLElementBase {
|
|
72
70
|
static get observedAttributes() {
|
|
73
|
-
return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', '
|
|
71
|
+
return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'brush', 'alert-evaluate', 'timezone', 'vwap-anchor', 'worker'];
|
|
74
72
|
}
|
|
75
73
|
|
|
76
74
|
/**
|
|
@@ -97,7 +95,7 @@ class WickChart extends HTMLElementBase {
|
|
|
97
95
|
container-type: inline-size;
|
|
98
96
|
}
|
|
99
97
|
:host(:focus-visible) {
|
|
100
|
-
outline: 2px solid var(--wick-accent,
|
|
98
|
+
outline: 2px solid var(--wick-accent, #4c8dff);
|
|
101
99
|
outline-offset: -2px;
|
|
102
100
|
}
|
|
103
101
|
.wrap { position: absolute; inset: 0; overflow: hidden; }
|
|
@@ -127,26 +125,26 @@ class WickChart extends HTMLElementBase {
|
|
|
127
125
|
}
|
|
128
126
|
.legend .row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
129
127
|
.legend .sym {
|
|
130
|
-
color: var(--wick-text-strong,
|
|
128
|
+
color: var(--wick-text-strong, #e6edf3);
|
|
131
129
|
font-weight: 700;
|
|
132
130
|
font-size: 13px;
|
|
133
131
|
letter-spacing: 0.02em;
|
|
134
132
|
}
|
|
135
133
|
.legend .kv { display: inline-flex; gap: 5px; align-items: baseline; white-space: nowrap; }
|
|
136
|
-
.legend .k { color: var(--wick-text,
|
|
137
|
-
.legend .v { color: var(--wick-text-strong,
|
|
134
|
+
.legend .k { color: var(--wick-text, #8b949e); font-size: 11px; }
|
|
135
|
+
.legend .v { color: var(--wick-text-strong, #e6edf3); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
138
136
|
.legend .pct { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
139
|
-
.legend .up { color: var(--wick-up,
|
|
140
|
-
.legend .dn { color: var(--wick-down,
|
|
137
|
+
.legend .up { color: var(--wick-up, #16c784); }
|
|
138
|
+
.legend .dn { color: var(--wick-down, #ea3943); }
|
|
141
139
|
.legend .ind {
|
|
142
140
|
display: inline-flex; align-items: center; gap: 6px;
|
|
143
|
-
color: var(--wick-text,
|
|
141
|
+
color: var(--wick-text, #8b949e); font-size: 11.5px; white-space: nowrap;
|
|
144
142
|
}
|
|
145
143
|
.legend .ind i { width: 8px; height: 2.5px; border-radius: 2px; display: inline-block; }
|
|
146
144
|
.legend .ind .v { font-size: 12px; }
|
|
147
145
|
.legend .insight {
|
|
148
|
-
color: var(--wick-accent,
|
|
149
|
-
background: var(--wick-chip,
|
|
146
|
+
color: var(--wick-accent, #4c8dff);
|
|
147
|
+
background: var(--wick-chip, rgba(127, 137, 153, 0.12));
|
|
150
148
|
border-radius: 6px;
|
|
151
149
|
padding: 1px 8px;
|
|
152
150
|
font-size: 11.5px;
|
|
@@ -155,7 +153,7 @@ class WickChart extends HTMLElementBase {
|
|
|
155
153
|
.nodata {
|
|
156
154
|
position: absolute; inset: 0;
|
|
157
155
|
display: flex; align-items: center; justify-content: center;
|
|
158
|
-
color: var(--wick-text,
|
|
156
|
+
color: var(--wick-text, #8b949e);
|
|
159
157
|
font: 500 13px ${FONT_STACK};
|
|
160
158
|
pointer-events: none;
|
|
161
159
|
}
|
|
@@ -168,25 +166,25 @@ class WickChart extends HTMLElementBase {
|
|
|
168
166
|
}
|
|
169
167
|
.hud .pos {
|
|
170
168
|
display: inline-flex; gap: 9px; align-items: baseline; white-space: nowrap;
|
|
171
|
-
background: var(--wick-chip,
|
|
172
|
-
border: 1px solid var(--wick-border,
|
|
169
|
+
background: var(--wick-chip, rgba(127, 137, 153, 0.12));
|
|
170
|
+
border: 1px solid var(--wick-border, rgba(148, 163, 184, 0.2));
|
|
173
171
|
border-radius: 7px;
|
|
174
172
|
padding: 3px 9px;
|
|
175
173
|
}
|
|
176
174
|
.hud .statsrow {
|
|
177
175
|
display: inline-flex; gap: 12px; white-space: nowrap;
|
|
178
|
-
background: var(--wick-chip,
|
|
179
|
-
border: 1px solid var(--wick-border,
|
|
176
|
+
background: var(--wick-chip, rgba(127, 137, 153, 0.12));
|
|
177
|
+
border: 1px solid var(--wick-border, rgba(148, 163, 184, 0.2));
|
|
180
178
|
border-radius: 7px;
|
|
181
179
|
padding: 3px 10px;
|
|
182
|
-
color: var(--wick-text,
|
|
180
|
+
color: var(--wick-text, #8b949e);
|
|
183
181
|
font-variant-numeric: tabular-nums;
|
|
184
182
|
}
|
|
185
|
-
.hud .statsrow b { color: var(--wick-text-strong,
|
|
186
|
-
.hud .k { color: var(--wick-text,
|
|
187
|
-
.hud .v { color: var(--wick-text-strong,
|
|
188
|
-
.hud .up { color: var(--wick-up,
|
|
189
|
-
.hud .dn { color: var(--wick-down,
|
|
183
|
+
.hud .statsrow b { color: var(--wick-text-strong, #e6edf3); font-weight: 600; }
|
|
184
|
+
.hud .k { color: var(--wick-text, #8b949e); font-weight: 500; }
|
|
185
|
+
.hud .v { color: var(--wick-text-strong, #e6edf3); font-variant-numeric: tabular-nums; }
|
|
186
|
+
.hud .up { color: var(--wick-up, #16c784); }
|
|
187
|
+
.hud .dn { color: var(--wick-down, #ea3943); }
|
|
190
188
|
|
|
191
189
|
/* Narrow charts: the legend (top-left) and the HUD (top-right) are
|
|
192
190
|
both pinned to the top, so on a phone they land on top of each
|
|
@@ -240,6 +238,8 @@ class WickChart extends HTMLElementBase {
|
|
|
240
238
|
this._hover = null; // { index, x, y }
|
|
241
239
|
this._dt = HOUR; // median bar interval (ms)
|
|
242
240
|
this._ly = null; // last layout
|
|
241
|
+
this._priceW = null; // settled price-axis width (see _axisWidth)
|
|
242
|
+
this._pwNarrow = null; // pending axis shrink { want, t }
|
|
243
243
|
this._cache = { v: -1, map: {} };
|
|
244
244
|
this._pal = null; // palette cache
|
|
245
245
|
this._palKey = '';
|
|
@@ -263,31 +263,21 @@ class WickChart extends HTMLElementBase {
|
|
|
263
263
|
this._annoList = null;
|
|
264
264
|
this._volshade = null;
|
|
265
265
|
|
|
266
|
-
//
|
|
267
|
-
|
|
266
|
+
// co-view seams (2.0: driven by the wickchart-coview plugin — the
|
|
267
|
+
// layer/state contract its presence bands and ghost crosshair use,
|
|
268
|
+
// and disconnectedCallback cleans up)
|
|
268
269
|
this._coviewCh = null;
|
|
269
|
-
this._coviewPeer = '';
|
|
270
|
-
this._coviewLast = 0;
|
|
271
270
|
this._ghost = null;
|
|
272
271
|
this._ghostTimer = 0;
|
|
273
|
-
|
|
274
|
-
this._coviewLabel = null; // display name from the co-view-name attribute
|
|
275
|
-
this._presence = new PresenceTracker();
|
|
272
|
+
this._presence = null;
|
|
276
273
|
this._coviewBeat = 0;
|
|
277
|
-
|
|
274
|
+
// scenario/risk seams (2.0: driven by the wickchart-scenario plugin;
|
|
275
|
+
// _rightMargin reserves future space from the active scenario)
|
|
276
|
+
this._scenario = null;
|
|
277
|
+
this._riskPlan = null;
|
|
278
278
|
|
|
279
|
-
// sonification state
|
|
280
|
-
this._sonify = false;
|
|
281
|
-
this._actx = null;
|
|
282
|
-
this._lastToneIdx = -1;
|
|
283
|
-
this._playToken = 0;
|
|
284
279
|
this._measure = null; // { iA, pA, iB, pB, done }
|
|
285
280
|
this._measuring = false;
|
|
286
|
-
// bar-walk narrator state
|
|
287
|
-
this._walkTimer = 0;
|
|
288
|
-
// story mode state: token cancels stale async runs
|
|
289
|
-
this._storyToken = 0;
|
|
290
|
-
this._story = null;
|
|
291
281
|
// delta brush state: mode flag + current/finished selection
|
|
292
282
|
this._brush = false;
|
|
293
283
|
this._brushSel = null; // { i0, i1, stats } — the committed selection
|
|
@@ -339,11 +329,6 @@ class WickChart extends HTMLElementBase {
|
|
|
339
329
|
// server-side overlays (zones & levels)
|
|
340
330
|
this._overlays = [];
|
|
341
331
|
|
|
342
|
-
// scenario projection (ghost path + vol cone)
|
|
343
|
-
this._scenario = null;
|
|
344
|
-
// risk plan (R-multiple grid)
|
|
345
|
-
this._riskPlan = null;
|
|
346
|
-
|
|
347
332
|
this._onResize = () => this._invalidate();
|
|
348
333
|
this._onPointerDown = (e) => this._pointerDown(e);
|
|
349
334
|
this._onPointerMove = (e) => this._pointerMove(e);
|
|
@@ -357,7 +342,6 @@ class WickChart extends HTMLElementBase {
|
|
|
357
342
|
};
|
|
358
343
|
this._onWheel = (e) => this._wheel(e);
|
|
359
344
|
this._onDbl = () => {
|
|
360
|
-
this._stopPlayback();
|
|
361
345
|
this.fit();
|
|
362
346
|
};
|
|
363
347
|
this._onKey = (e) => this._keydown(e);
|
|
@@ -414,18 +398,16 @@ class WickChart extends HTMLElementBase {
|
|
|
414
398
|
if (document.fonts && document.fonts.ready) {
|
|
415
399
|
document.fonts.ready.then(() => this._invalidate()).catch(() => {});
|
|
416
400
|
}
|
|
417
|
-
if (this._coviewName) this._setupCoView();
|
|
418
401
|
this._watchDpr();
|
|
419
402
|
this._invalidate();
|
|
420
403
|
}
|
|
421
404
|
|
|
422
405
|
disconnectedCallback() {
|
|
423
406
|
this._connected = false;
|
|
424
|
-
this.stopWalk(true);
|
|
425
|
-
this.stopStory(true);
|
|
426
407
|
this._layerClaim = null;
|
|
408
|
+
// co-view seams (driven by the wickchart-coview plugin): close the
|
|
409
|
+
// room cleanly and stop its timers so a removed chart goes quiet
|
|
427
410
|
if (this._coviewCh) {
|
|
428
|
-
this._coviewSend({ type: 'bye' });
|
|
429
411
|
try {
|
|
430
412
|
this._coviewCh.close();
|
|
431
413
|
} catch (_) {}
|
|
@@ -433,12 +415,9 @@ class WickChart extends HTMLElementBase {
|
|
|
433
415
|
}
|
|
434
416
|
clearInterval(this._coviewBeat);
|
|
435
417
|
this._coviewBeat = 0;
|
|
436
|
-
if (this._presence && this._presence.peers.size) {
|
|
437
|
-
const left = this._presence.list();
|
|
438
|
-
this._presence = new PresenceTracker();
|
|
439
|
-
this._fire('peers', { peers: [], joined: [], left });
|
|
440
|
-
}
|
|
441
418
|
clearTimeout(this._ghostTimer);
|
|
419
|
+
this._ghost = null;
|
|
420
|
+
this._presence = null;
|
|
442
421
|
if (this._ro) this._ro.disconnect();
|
|
443
422
|
this._unwatchDpr();
|
|
444
423
|
const cv = this._canvas;
|
|
@@ -526,24 +505,12 @@ class WickChart extends HTMLElementBase {
|
|
|
526
505
|
this._overlays = ovs;
|
|
527
506
|
break;
|
|
528
507
|
}
|
|
529
|
-
case 'co-view':
|
|
530
|
-
this._coviewName = val || null;
|
|
531
|
-
this._setupCoView();
|
|
532
|
-
break;
|
|
533
|
-
case 'co-view-name':
|
|
534
|
-
// display name travels with every presence message; no re-render
|
|
535
|
-
this._coviewLabel = val || null;
|
|
536
|
-
break;
|
|
537
508
|
case 'brush':
|
|
538
509
|
this._brush = val != null && val !== 'false';
|
|
539
510
|
this._brushSel = null;
|
|
540
511
|
this._brushDrag = null;
|
|
541
512
|
this._invalidate();
|
|
542
513
|
break;
|
|
543
|
-
case 'sonify':
|
|
544
|
-
this._sonify = val != null && val !== 'false';
|
|
545
|
-
this._lastToneIdx = -1;
|
|
546
|
-
break;
|
|
547
514
|
// Default evaluation mode for alerts added without one. Anything
|
|
548
515
|
// other than "close" means live, so a typo cannot silently mute
|
|
549
516
|
// signals — it degrades to today's behaviour.
|
|
@@ -607,8 +574,7 @@ class WickChart extends HTMLElementBase {
|
|
|
607
574
|
});
|
|
608
575
|
}
|
|
609
576
|
|
|
610
|
-
/** The tag this class registers as.
|
|
611
|
-
* registered from the HabChart subclass, not this name.) */
|
|
577
|
+
/** The tag this class registers as. */
|
|
612
578
|
static get elementName() {
|
|
613
579
|
return 'wick-chart';
|
|
614
580
|
}
|
|
@@ -660,6 +626,9 @@ class WickChart extends HTMLElementBase {
|
|
|
660
626
|
this._version++;
|
|
661
627
|
this._epoch++;
|
|
662
628
|
this._computeDt();
|
|
629
|
+
// a replacement dataset is a new instrument regime — re-measure the
|
|
630
|
+
// axis from its own prices instead of debouncing down from the old one
|
|
631
|
+
this._priceW = this._pwNarrow = null;
|
|
663
632
|
// history is not a live signal: re-baseline so close-mode alerts only
|
|
664
633
|
// fire on candles that close from here on
|
|
665
634
|
this._syncClosedIdx();
|
|
@@ -721,6 +690,7 @@ class WickChart extends HTMLElementBase {
|
|
|
721
690
|
this._data = [];
|
|
722
691
|
this._version++;
|
|
723
692
|
this._epoch++;
|
|
693
|
+
this._priceW = this._pwNarrow = null;
|
|
724
694
|
this._syncClosedIdx();
|
|
725
695
|
this._hover = null;
|
|
726
696
|
this._needsFit = true;
|
|
@@ -1018,8 +988,7 @@ class WickChart extends HTMLElementBase {
|
|
|
1018
988
|
* WickScript predicate (`when`) on every streamed bar and fire on its
|
|
1019
989
|
* false→true edge — e.g. `when: 'crossup(rsi(close,14), 30)'` or
|
|
1020
990
|
* `when: 'volume > sma(volume,20) * 3'`. Scripted events carry the
|
|
1021
|
-
* triggering close as `price` plus the `when` source
|
|
1022
|
-
* `hab:alert` alias still dispatched).
|
|
991
|
+
* triggering close as `price` plus the `when` source.
|
|
1023
992
|
* @param {{id?: string, price?: number, direction?: 'above'|'below'|'cross',
|
|
1024
993
|
* when?: string, once?: boolean}} alert
|
|
1025
994
|
* @returns {string|null} the alert id (null when no valid price/when,
|
|
@@ -1123,157 +1092,21 @@ class WickChart extends HTMLElementBase {
|
|
|
1123
1092
|
this._invalidate();
|
|
1124
1093
|
}
|
|
1125
1094
|
|
|
1126
|
-
/**
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
* chart.setScenario({ path: [64000, 65500, 68000], label: 'bull case' });
|
|
1131
|
-
* chart.setScenario({ horizon: 48, cone: true }); // cone-only
|
|
1132
|
-
*
|
|
1133
|
-
* The path is an array of prices (or {price} objects) for future bars
|
|
1134
|
-
* 1..N; horizon defaults to the path length (1–500). `cone` (default
|
|
1135
|
-
* true) draws ±levels·σ bands widening with √h from the current realized
|
|
1136
|
-
* vol; `color` accepts up|down|accent or safe CSS colors. Setting a
|
|
1137
|
-
* scenario reserves future space on the right; analysis data — excluded
|
|
1138
|
-
* from getState/setState.
|
|
1139
|
-
* @param {object} spec
|
|
1140
|
-
* @returns {object|null} the normalized scenario, or null when invalid
|
|
1141
|
-
*/
|
|
1142
|
-
setScenario(spec) {
|
|
1143
|
-
this._scenario = normalizeScenario(spec);
|
|
1144
|
-
this._invalidate();
|
|
1145
|
-
return this._scenario;
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
clearScenario() {
|
|
1149
|
-
this._scenario = null;
|
|
1150
|
-
this._invalidate();
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
/** @returns {object|null} a copy of the active scenario */
|
|
1095
|
+
/** @returns {object|null} a copy of the active scenario (set through the
|
|
1096
|
+
* `_scenario` seam by the wickchart-scenario plugin; reserves future
|
|
1097
|
+
* space via _rightMargin) */
|
|
1154
1098
|
get scenario() {
|
|
1155
1099
|
if (!this._scenario) return null;
|
|
1156
1100
|
return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
|
|
1157
1101
|
}
|
|
1158
1102
|
|
|
1159
|
-
/**
|
|
1160
|
-
*
|
|
1161
|
-
* stop| (the risk unit); reward lines are drawn at kR beyond the entry
|
|
1162
|
-
* with the risk/reward zones shaded, so sizing and take-profit choices
|
|
1163
|
-
* read directly off the chart.
|
|
1164
|
-
*
|
|
1165
|
-
* chart.setRiskPlan({ entry: 64500, stop: 63800, multiples: [1, 2, 3] });
|
|
1166
|
-
* chart.setRiskPlan({ entry: 64500, stop: 63800, targets: [65900, 67300] });
|
|
1167
|
-
*
|
|
1168
|
-
* Direction is derived (stop below entry ⇒ long). Targets convert to
|
|
1169
|
-
* their R multiple; `multiples` win when both are given. Invalid specs
|
|
1170
|
-
* clear the plan (replace semantics, like setScenario); excluded from
|
|
1171
|
-
* getState/setState — it is app state, not chart state.
|
|
1172
|
-
* @param {object} spec
|
|
1173
|
-
* @returns {object|null} the normalized plan, or null when invalid
|
|
1174
|
-
*/
|
|
1175
|
-
setRiskPlan(spec) {
|
|
1176
|
-
this._riskPlan = normalizeRiskPlan(spec);
|
|
1177
|
-
this._invalidate();
|
|
1178
|
-
return this._riskPlan;
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
clearRiskPlan() {
|
|
1182
|
-
if (this._riskPlan) {
|
|
1183
|
-
this._riskPlan = null;
|
|
1184
|
-
this._invalidate();
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
/** @returns {object|null} a copy of the active risk plan */
|
|
1103
|
+
/** @returns {object|null} a copy of the active risk plan (set through
|
|
1104
|
+
* the `_riskPlan` seam by the wickchart-scenario plugin) */
|
|
1189
1105
|
get riskPlan() {
|
|
1190
1106
|
if (!this._riskPlan) return null;
|
|
1191
1107
|
return { ...this._riskPlan, levels: this._riskPlan.levels.map((l) => ({ ...l })) };
|
|
1192
1108
|
}
|
|
1193
1109
|
|
|
1194
|
-
/** σ-cone for the active scenario, cached per data version. */
|
|
1195
|
-
_scenarioConeCache() {
|
|
1196
|
-
if (!this._scenario || !this._data.length) return null;
|
|
1197
|
-
if (this._cache.v !== this._version) {
|
|
1198
|
-
this._cache = { v: this._version, map: {} };
|
|
1199
|
-
}
|
|
1200
|
-
if (!this._cache.map.__scenario) {
|
|
1201
|
-
const d = this._data;
|
|
1202
|
-
const vol = calcRealizedVol(d.map((b) => b.close), 20);
|
|
1203
|
-
let v = NaN;
|
|
1204
|
-
for (let i = vol.length - 1; i >= 0; i--) {
|
|
1205
|
-
if (Number.isFinite(vol[i])) { v = vol[i]; break; }
|
|
1206
|
-
}
|
|
1207
|
-
this._cache.map.__scenario = calcVolCone(
|
|
1208
|
-
d[d.length - 1].close,
|
|
1209
|
-
v,
|
|
1210
|
-
this._scenario.horizon,
|
|
1211
|
-
this._scenario.levels
|
|
1212
|
-
);
|
|
1213
|
-
}
|
|
1214
|
-
return this._cache.map.__scenario;
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
/* ------------------------------------------------------------ *
|
|
1218
|
-
* AI agent interface — the chart as a tool surface
|
|
1219
|
-
* ------------------------------------------------------------ */
|
|
1220
|
-
|
|
1221
|
-
/** Tool manifest for LLM control — JSON-safe copy of AI_TOOLS. */
|
|
1222
|
-
aiTools() {
|
|
1223
|
-
return JSON.parse(JSON.stringify(AI_TOOLS));
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
/** System prompt for agent control — paste into any LLM alongside aiTools(). */
|
|
1227
|
-
aiPrompt() {
|
|
1228
|
-
return aiPromptText();
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
/** Grounding context for a model: current state + visible-window summary. */
|
|
1232
|
-
aiContext() {
|
|
1233
|
-
return { state: this.getState(), window: this.getDataWindow() };
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
/**
|
|
1237
|
-
* Apply a list of {tool, args} ops (typically LLM output) through the
|
|
1238
|
-
* validated dispatcher in core. Never throws — each op resolves
|
|
1239
|
-
* {ok, tool, result} or {ok: false, tool, error} so an agent can
|
|
1240
|
-
* self-correct.
|
|
1241
|
-
* @param {any} ops
|
|
1242
|
-
* @returns {Array<object>}
|
|
1243
|
-
*/
|
|
1244
|
-
applyAI(ops) {
|
|
1245
|
-
return applyChartOps(this, ops);
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
/**
|
|
1249
|
-
* Ask an AI to operate the chart. Builds the payload {system,
|
|
1250
|
-
* instruction, chart, tools}; with a `run` async function (your model
|
|
1251
|
-
* call — the chart itself never touches the network), applies the
|
|
1252
|
-
* returned ops and resolves {payload, ops, results}. Without `run`,
|
|
1253
|
-
* returns the payload for manual wiring — send it anywhere, then call
|
|
1254
|
-
* chart.applyAI(ops) with the model's answer.
|
|
1255
|
-
*
|
|
1256
|
-
* const { results } = await chart.ask('add RSI and mark the demand zone', {
|
|
1257
|
-
* run: async (payload) => (await callMyLLM(payload)).ops,
|
|
1258
|
-
* });
|
|
1259
|
-
*
|
|
1260
|
-
* @param {string} instruction natural-language request
|
|
1261
|
-
* @param {{run?: (payload: object) => Promise<any>}} [opts]
|
|
1262
|
-
*/
|
|
1263
|
-
async ask(instruction, opts = {}) {
|
|
1264
|
-
const payload = {
|
|
1265
|
-
system: aiPromptText(),
|
|
1266
|
-
instruction: String(instruction == null ? '' : instruction),
|
|
1267
|
-
chart: this.aiContext(),
|
|
1268
|
-
tools: this.aiTools(),
|
|
1269
|
-
};
|
|
1270
|
-
if (typeof opts.run !== 'function') {
|
|
1271
|
-
return { payload, ops: null, results: null };
|
|
1272
|
-
}
|
|
1273
|
-
const ops = await opts.run(payload);
|
|
1274
|
-
const results = this.applyAI(ops);
|
|
1275
|
-
return { payload, ops, results };
|
|
1276
|
-
}
|
|
1277
1110
|
|
|
1278
1111
|
/** Check alerts against an incoming bar (prev close → new close).
|
|
1279
1112
|
* Scripted (`when`) alerts evaluate their predicate series, cached per
|
|
@@ -1483,6 +1316,32 @@ class WickChart extends HTMLElementBase {
|
|
|
1483
1316
|
this._raf = requestAnimationFrame(() => this._render());
|
|
1484
1317
|
}
|
|
1485
1318
|
|
|
1319
|
+
/**
|
|
1320
|
+
* Price-axis width with tick-noise immunity. The width is measured from
|
|
1321
|
+
* the live last close, and digits in a proportional font measure
|
|
1322
|
+
* differently ("1" is narrower than "8") — so on every tick the ceil()
|
|
1323
|
+
* can flip a pixel, dragging the axis separator and with it every candle
|
|
1324
|
+
* sideways (visibly shaking on high-frequency pairs). Grow at once so a
|
|
1325
|
+
* wider label never clips; adopt a narrower width only after it has held
|
|
1326
|
+
* for 750ms, and only when it's a real change (≥2px) rather than
|
|
1327
|
+
* digit-width noise, so a flapping price can never wobble the layout.
|
|
1328
|
+
*/
|
|
1329
|
+
_axisWidth(want) {
|
|
1330
|
+
const prev = this._priceW;
|
|
1331
|
+
if (prev == null || want >= prev) {
|
|
1332
|
+
this._priceW = want;
|
|
1333
|
+
this._pwNarrow = null;
|
|
1334
|
+
} else if (want <= prev - 2) {
|
|
1335
|
+
if (this._pwNarrow == null || this._pwNarrow.want !== want) {
|
|
1336
|
+
this._pwNarrow = { want, t: performance.now() };
|
|
1337
|
+
} else if (performance.now() - this._pwNarrow.t >= 750) {
|
|
1338
|
+
this._priceW = want;
|
|
1339
|
+
this._pwNarrow = null;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return this._priceW;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1486
1345
|
_prec(v) {
|
|
1487
1346
|
return this._precision != null ? this._precision : autoPrecision(v);
|
|
1488
1347
|
}
|
|
@@ -1491,11 +1350,7 @@ class WickChart extends HTMLElementBase {
|
|
|
1491
1350
|
if (this._pal && this._palKey === this._theme) return this._pal;
|
|
1492
1351
|
const base = THEMES[this._theme] || THEMES.dark;
|
|
1493
1352
|
const cs = getComputedStyle(this);
|
|
1494
|
-
|
|
1495
|
-
const get = (name, fallback) => {
|
|
1496
|
-
const v = cs.getPropertyValue('--wick-' + name).trim() || cs.getPropertyValue('--hab-' + name).trim();
|
|
1497
|
-
return v || fallback;
|
|
1498
|
-
};
|
|
1353
|
+
const get = (name, fallback) => cs.getPropertyValue('--wick-' + name).trim() || fallback;
|
|
1499
1354
|
const pal = {};
|
|
1500
1355
|
for (const k of Object.keys(base)) {
|
|
1501
1356
|
if (k === 'overlay') {
|
|
@@ -1504,8 +1359,7 @@ class WickChart extends HTMLElementBase {
|
|
|
1504
1359
|
o.push(get('overlay-' + i, base.overlay[i]));
|
|
1505
1360
|
}
|
|
1506
1361
|
// allow single overlay color
|
|
1507
|
-
const single =
|
|
1508
|
-
cs.getPropertyValue('--wick-overlay').trim() || cs.getPropertyValue('--hab-overlay').trim();
|
|
1362
|
+
const single = get('overlay', '');
|
|
1509
1363
|
pal.overlay = single ? base.overlay.map(() => single) : o;
|
|
1510
1364
|
} else {
|
|
1511
1365
|
pal[k] = get(k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), base[k]);
|
|
@@ -2017,14 +1871,16 @@ class WickChart extends HTMLElementBase {
|
|
|
2017
1871
|
const sample = d.length ? d[d.length - 1].close : 0;
|
|
2018
1872
|
const p = this._prec(sample || 1);
|
|
2019
1873
|
const f = numberFmt(p);
|
|
2020
|
-
priceW =
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
Math.
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
1874
|
+
priceW = this._axisWidth(
|
|
1875
|
+
Math.max(
|
|
1876
|
+
52,
|
|
1877
|
+
Math.ceil(
|
|
1878
|
+
Math.max(
|
|
1879
|
+
measure(f.format(sample || 1000)),
|
|
1880
|
+
measure(fmtCompact(1234567))
|
|
1881
|
+
)
|
|
1882
|
+
) + 16
|
|
1883
|
+
)
|
|
2028
1884
|
);
|
|
2029
1885
|
}
|
|
2030
1886
|
const timeH = 26;
|
|
@@ -2057,6 +1913,10 @@ class WickChart extends HTMLElementBase {
|
|
|
2057
1913
|
});
|
|
2058
1914
|
|
|
2059
1915
|
/* background */
|
|
1916
|
+
// Clear explicitly: the fill below is also the only clear, and with
|
|
1917
|
+
// --wick-bg: transparent it paints nothing — without this every redraw
|
|
1918
|
+
// stacks on the previous frame's pixels.
|
|
1919
|
+
ctx.clearRect(0, 0, W, H);
|
|
2060
1920
|
ctx.fillStyle = pal.bg;
|
|
2061
1921
|
ctx.fillRect(0, 0, W, H);
|
|
2062
1922
|
this._nodata.hidden = d.length > 0;
|
|
@@ -2225,82 +2085,7 @@ class WickChart extends HTMLElementBase {
|
|
|
2225
2085
|
}
|
|
2226
2086
|
}
|
|
2227
2087
|
|
|
2228
|
-
|
|
2229
|
-
* the main plot so out-of-range bands never bleed into the axis) */
|
|
2230
|
-
if (this._scenario && d.length) {
|
|
2231
|
-
const sc = this._scenario;
|
|
2232
|
-
const col = resolveOverlayColor(sc.color, pal);
|
|
2233
|
-
const baseIdx = d.length - 1;
|
|
2234
|
-
const xAt = (h) => this._xFor(baseIdx + h);
|
|
2235
|
-
ctx.save();
|
|
2236
|
-
ctx.beginPath();
|
|
2237
|
-
ctx.rect(0, main.y0, plotRight, main.h);
|
|
2238
|
-
ctx.clip();
|
|
2239
|
-
if (sc.cone) {
|
|
2240
|
-
const cone = this._scenarioConeCache();
|
|
2241
|
-
if (cone) {
|
|
2242
|
-
for (let li = cone.levels.length - 1; li >= 0; li--) {
|
|
2243
|
-
const b = cone.bands[cone.levels[li]];
|
|
2244
|
-
ctx.globalAlpha = li === 0 ? 0.1 : 0.05;
|
|
2245
|
-
ctx.fillStyle = col;
|
|
2246
|
-
ctx.beginPath();
|
|
2247
|
-
ctx.moveTo(xAt(0), yOf(b.up[0]));
|
|
2248
|
-
for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(b.up[h]));
|
|
2249
|
-
for (let h = cone.horizon; h >= 0; h--) ctx.lineTo(xAt(h), yOf(b.down[h]));
|
|
2250
|
-
ctx.closePath();
|
|
2251
|
-
ctx.fill();
|
|
2252
|
-
}
|
|
2253
|
-
const inner = cone.bands[cone.levels[0]];
|
|
2254
|
-
ctx.globalAlpha = 0.4;
|
|
2255
|
-
ctx.strokeStyle = col;
|
|
2256
|
-
ctx.lineWidth = 1;
|
|
2257
|
-
ctx.setLineDash([4, 4]);
|
|
2258
|
-
for (const arr of [inner.up, inner.down]) {
|
|
2259
|
-
ctx.beginPath();
|
|
2260
|
-
ctx.moveTo(xAt(0), yOf(arr[0]));
|
|
2261
|
-
for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(arr[h]));
|
|
2262
|
-
ctx.stroke();
|
|
2263
|
-
}
|
|
2264
|
-
ctx.setLineDash([]);
|
|
2265
|
-
}
|
|
2266
|
-
}
|
|
2267
|
-
if (sc.path.length) {
|
|
2268
|
-
ctx.globalAlpha = 0.9;
|
|
2269
|
-
ctx.strokeStyle = col;
|
|
2270
|
-
ctx.lineWidth = 1.5;
|
|
2271
|
-
ctx.setLineDash([6, 4]);
|
|
2272
|
-
ctx.beginPath();
|
|
2273
|
-
ctx.moveTo(xAt(0), yOf(d[baseIdx].close));
|
|
2274
|
-
for (const p of sc.path) ctx.lineTo(xAt(p.h), yOf(p.price));
|
|
2275
|
-
ctx.stroke();
|
|
2276
|
-
ctx.setLineDash([]);
|
|
2277
|
-
ctx.fillStyle = col;
|
|
2278
|
-
for (const p of sc.path) {
|
|
2279
|
-
const y = yOf(p.price);
|
|
2280
|
-
if (y >= main.y0 && y <= main.y1) {
|
|
2281
|
-
ctx.beginPath();
|
|
2282
|
-
ctx.arc(xAt(p.h), y, 2.5, 0, Math.PI * 2);
|
|
2283
|
-
ctx.fill();
|
|
2284
|
-
}
|
|
2285
|
-
}
|
|
2286
|
-
if (sc.label) {
|
|
2287
|
-
const p = sc.path[sc.path.length - 1];
|
|
2288
|
-
ctx.font = pillFont();
|
|
2289
|
-
ctx.globalAlpha = 0.95;
|
|
2290
|
-
ctx.fillStyle = col;
|
|
2291
|
-
ctx.textAlign = 'left';
|
|
2292
|
-
ctx.textBaseline = 'middle';
|
|
2293
|
-
ctx.fillText(
|
|
2294
|
-
sc.label,
|
|
2295
|
-
Math.min(xAt(p.h) + 8, plotRight - 4),
|
|
2296
|
-
clamp(yOf(p.price), main.y0 + 8, main.y1 - 8)
|
|
2297
|
-
);
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
ctx.restore();
|
|
2301
|
-
}
|
|
2302
|
-
|
|
2303
|
-
/* position zones (under series) */
|
|
2088
|
+
/* position zones (under series) */
|
|
2304
2089
|
for (const pos of this._positions) {
|
|
2305
2090
|
const yE = clamp(yOf(pos.entry), main.y0, main.y1);
|
|
2306
2091
|
if (isNum(pos.target)) {
|
|
@@ -2315,68 +2100,7 @@ class WickChart extends HTMLElementBase {
|
|
|
2315
2100
|
}
|
|
2316
2101
|
}
|
|
2317
2102
|
|
|
2318
|
-
|
|
2319
|
-
if (this._riskPlan && d.length) {
|
|
2320
|
-
const rp = this._riskPlan;
|
|
2321
|
-
const fP = numberFmt(this._prec(scale.rawHi || 1));
|
|
2322
|
-
const yE = yOf(rp.entry);
|
|
2323
|
-
const yS = yOf(rp.stop);
|
|
2324
|
-
ctx.fillStyle = hexToRgba(pal.down, 0.06);
|
|
2325
|
-
ctx.fillRect(0, Math.min(yE, yS), plotRight, Math.abs(yS - yE));
|
|
2326
|
-
const yTop = yOf(rp.levels[rp.levels.length - 1].price);
|
|
2327
|
-
ctx.fillStyle = hexToRgba(pal.up, 0.05);
|
|
2328
|
-
ctx.fillRect(0, Math.min(yE, yTop), plotRight, Math.abs(yTop - yE));
|
|
2329
|
-
const line = (p, col, dash) => {
|
|
2330
|
-
const y = yOf(p);
|
|
2331
|
-
if (y < main.y0 || y > main.y1) return null;
|
|
2332
|
-
ctx.strokeStyle = col;
|
|
2333
|
-
ctx.lineWidth = 1.5;
|
|
2334
|
-
if (dash) ctx.setLineDash([5, 4]);
|
|
2335
|
-
ctx.beginPath();
|
|
2336
|
-
ctx.moveTo(0, Math.round(y) + 0.5);
|
|
2337
|
-
ctx.lineTo(plotRight, Math.round(y) + 0.5);
|
|
2338
|
-
ctx.stroke();
|
|
2339
|
-
ctx.setLineDash([]);
|
|
2340
|
-
ctx.lineWidth = 1;
|
|
2341
|
-
return y;
|
|
2342
|
-
};
|
|
2343
|
-
const pills = [];
|
|
2344
|
-
for (let i = rp.levels.length - 1; i >= 0; i--) {
|
|
2345
|
-
const lv = rp.levels[i];
|
|
2346
|
-
const y = line(lv.price, pal.up, true);
|
|
2347
|
-
if (y != null) {
|
|
2348
|
-
const kk = lv.k % 1 === 0 ? lv.k : +lv.k.toFixed(2);
|
|
2349
|
-
pills.push({ y, text: `${kk}R ${fP.format(lv.price)}`, bg: pal.up });
|
|
2350
|
-
}
|
|
2351
|
-
}
|
|
2352
|
-
const yStop = line(rp.stop, pal.down, false);
|
|
2353
|
-
if (yStop != null) pills.push({ y: yStop, text: `STOP ${fP.format(rp.stop)}`, bg: pal.down });
|
|
2354
|
-
const yEnt = line(rp.entry, pal.accent, false);
|
|
2355
|
-
if (yEnt != null) {
|
|
2356
|
-
pills.push({ y: yEnt, text: `${rp.direction === 'long' ? 'LONG' : 'SHORT'} ${fP.format(rp.entry)}`, bg: pal.accent });
|
|
2357
|
-
}
|
|
2358
|
-
// stack right-edge pills instead of letting close lines overlap
|
|
2359
|
-
pills.sort((a, b) => a.y - b.y);
|
|
2360
|
-
let lastY = -Infinity;
|
|
2361
|
-
for (const p of pills) {
|
|
2362
|
-
const y = Math.max(p.y, lastY + 20);
|
|
2363
|
-
lastY = y;
|
|
2364
|
-
ctx.font = pillFont();
|
|
2365
|
-
const tw = ctx.measureText(p.text).width + 12;
|
|
2366
|
-
this._pill(plotRight - tw - 8, y, p.text, p.bg, pal.pillText, 'left', tw);
|
|
2367
|
-
}
|
|
2368
|
-
if (rp.label) {
|
|
2369
|
-
ctx.font = pillFont();
|
|
2370
|
-
ctx.fillStyle = pal.accent;
|
|
2371
|
-
ctx.globalAlpha = 0.9;
|
|
2372
|
-
ctx.textAlign = 'left';
|
|
2373
|
-
ctx.textBaseline = 'bottom';
|
|
2374
|
-
ctx.fillText(rp.label, 8, clamp(yE, main.y0 + 14, main.y1) - 3);
|
|
2375
|
-
ctx.globalAlpha = 1;
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
|
-
/* volume profile (behind the series) */
|
|
2103
|
+
/* volume profile (behind the series) */
|
|
2380
2104
|
if (this._profile) {
|
|
2381
2105
|
const pkey = `${i0}:${i1}:${this._version}`;
|
|
2382
2106
|
if (this._profileKey !== pkey) {
|
|
@@ -3048,78 +2772,6 @@ class WickChart extends HTMLElementBase {
|
|
|
3048
2772
|
);
|
|
3049
2773
|
}
|
|
3050
2774
|
|
|
3051
|
-
/* co-view presence: peer viewport bands along the top of the plot */
|
|
3052
|
-
if (this._presence && this._presence.peers.size && d.length) {
|
|
3053
|
-
const peers = this._presence.list().slice(0, 4);
|
|
3054
|
-
ctx.save();
|
|
3055
|
-
ctx.font = pillFont();
|
|
3056
|
-
for (let row = 0; row < peers.length; row++) {
|
|
3057
|
-
const p = peers[row];
|
|
3058
|
-
if (!p.range) continue;
|
|
3059
|
-
const cols = pal.overlay || [];
|
|
3060
|
-
const col = cols[(row + 1) % Math.max(cols.length, 1)] || pal.accent;
|
|
3061
|
-
const i0 = WickChart._indexForTime(this._data, p.range.from);
|
|
3062
|
-
const i1 = WickChart._indexForTime(this._data, p.range.to);
|
|
3063
|
-
const x0 = clamp(this._xFor(i0), 0, plotRight);
|
|
3064
|
-
const x1 = clamp(this._xFor(i1), 0, plotRight);
|
|
3065
|
-
const y = main.y0 + 2 + row * 5;
|
|
3066
|
-
ctx.globalAlpha = 0.8;
|
|
3067
|
-
ctx.fillStyle = col;
|
|
3068
|
-
ctx.fillRect(x0, y, Math.max(x1 - x0, 3), 3);
|
|
3069
|
-
if (x1 - x0 > 44) {
|
|
3070
|
-
ctx.globalAlpha = 0.95;
|
|
3071
|
-
ctx.textAlign = 'left';
|
|
3072
|
-
ctx.textBaseline = 'top';
|
|
3073
|
-
ctx.fillText(p.name || p.id, x0 + 3, y + 4);
|
|
3074
|
-
}
|
|
3075
|
-
}
|
|
3076
|
-
ctx.restore();
|
|
3077
|
-
}
|
|
3078
|
-
|
|
3079
|
-
/* co-view ghost crosshair (peer pointer from another tab/chart) */
|
|
3080
|
-
if (this._ghost) {
|
|
3081
|
-
const g = this._ghost;
|
|
3082
|
-
const gx = this._xFor(g.index);
|
|
3083
|
-
const gxVisible = gx >= 0 && gx <= plotRight;
|
|
3084
|
-
ctx.save();
|
|
3085
|
-
ctx.strokeStyle = pal.accent;
|
|
3086
|
-
ctx.globalAlpha = 0.7;
|
|
3087
|
-
ctx.setLineDash([2, 3]);
|
|
3088
|
-
ctx.beginPath();
|
|
3089
|
-
if (gxVisible) {
|
|
3090
|
-
const cx = Math.round(gx) + 0.5;
|
|
3091
|
-
ctx.moveTo(cx, 0);
|
|
3092
|
-
ctx.lineTo(cx, plotBottom);
|
|
3093
|
-
}
|
|
3094
|
-
if (g.yFrac != null) {
|
|
3095
|
-
const gy = Math.round(main.y0 + g.yFrac * main.h) + 0.5;
|
|
3096
|
-
ctx.moveTo(0, gy);
|
|
3097
|
-
ctx.lineTo(plotRight, gy);
|
|
3098
|
-
if (gxVisible) {
|
|
3099
|
-
ctx.fillStyle = pal.accent;
|
|
3100
|
-
ctx.beginPath();
|
|
3101
|
-
ctx.arc(gx, main.y0 + g.yFrac * main.h, 3, 0, Math.PI * 2);
|
|
3102
|
-
ctx.fill();
|
|
3103
|
-
}
|
|
3104
|
-
}
|
|
3105
|
-
ctx.stroke();
|
|
3106
|
-
ctx.restore();
|
|
3107
|
-
if (gxVisible && this._data[g.index]) {
|
|
3108
|
-
const tLabel = fmtFull(this._zt(this._data[g.index].time));
|
|
3109
|
-
ctx.font = pillFont();
|
|
3110
|
-
const tw = ctx.measureText(tLabel).width + 12;
|
|
3111
|
-
this._pill(
|
|
3112
|
-
clamp(gx - tw / 2, 2, plotRight - tw - 2),
|
|
3113
|
-
plotBottom + 2,
|
|
3114
|
-
tLabel,
|
|
3115
|
-
pal.accent,
|
|
3116
|
-
pal.pillText,
|
|
3117
|
-
'left',
|
|
3118
|
-
tw
|
|
3119
|
-
);
|
|
3120
|
-
}
|
|
3121
|
-
}
|
|
3122
|
-
|
|
3123
2775
|
/* measure tool overlay */
|
|
3124
2776
|
if (this._measure && this._measure.pA != null && this._measure.pB != null) {
|
|
3125
2777
|
const m = this._measure;
|
|
@@ -3603,7 +3255,6 @@ class WickChart extends HTMLElementBase {
|
|
|
3603
3255
|
if (!this._ly || !this._data.length) return;
|
|
3604
3256
|
const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
|
|
3605
3257
|
this._hover = { index: idx, x: this._xFor(idx), y: pt.y };
|
|
3606
|
-
this._maybeSonify(idx);
|
|
3607
3258
|
this._emitCrosshair(this._hover);
|
|
3608
3259
|
this._invalidate();
|
|
3609
3260
|
}
|
|
@@ -3650,7 +3301,6 @@ class WickChart extends HTMLElementBase {
|
|
|
3650
3301
|
|
|
3651
3302
|
_pointerDown(e) {
|
|
3652
3303
|
if (e.button !== 0) return;
|
|
3653
|
-
this._stopPlayback(); // any touch interrupts the story
|
|
3654
3304
|
this._canvas.setPointerCapture(e.pointerId);
|
|
3655
3305
|
const pt = this._localPoint(e);
|
|
3656
3306
|
this._pointers.set(e.pointerId, pt);
|
|
@@ -3847,10 +3497,14 @@ class WickChart extends HTMLElementBase {
|
|
|
3847
3497
|
return useLog ? Math.pow(10, t) : t;
|
|
3848
3498
|
}
|
|
3849
3499
|
|
|
3500
|
+
/** Dispatch a `wick:name` event on the element. */
|
|
3501
|
+
_fire(name, detail) {
|
|
3502
|
+
this.dispatchEvent(new CustomEvent('wick:' + name, { detail }));
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3850
3505
|
_wheel(e) {
|
|
3851
3506
|
const ly = this._ly;
|
|
3852
3507
|
if (!ly || !this._data.length) return;
|
|
3853
|
-
this._stopPlayback();
|
|
3854
3508
|
e.preventDefault();
|
|
3855
3509
|
const pt = this._localPoint(e);
|
|
3856
3510
|
const dx = e.deltaX;
|
|
@@ -3884,7 +3538,6 @@ class WickChart extends HTMLElementBase {
|
|
|
3884
3538
|
_keydown(e) {
|
|
3885
3539
|
const ly = this._ly;
|
|
3886
3540
|
if (!ly || !this._data.length) return;
|
|
3887
|
-
this._stopPlayback();
|
|
3888
3541
|
if (e.key === 'Escape' && this._layerClaim) {
|
|
3889
3542
|
const claim = this._layerClaim;
|
|
3890
3543
|
this._layerClaim = null;
|
|
@@ -3905,7 +3558,6 @@ class WickChart extends HTMLElementBase {
|
|
|
3905
3558
|
const cur = this._hover ? this._hover.index : d.length - 1;
|
|
3906
3559
|
const idx = clamp(cur + (key === 'ArrowRight' ? step : -step), 0, d.length - 1);
|
|
3907
3560
|
this._hover = { index: idx, x: this._xFor(idx), y: this._hover ? this._hover.y : ly.main.y1 * 0.5 };
|
|
3908
|
-
this._maybeSonify(idx);
|
|
3909
3561
|
this._emitCrosshair(this._hover);
|
|
3910
3562
|
this._invalidate();
|
|
3911
3563
|
} else if (key === 'Home') {
|
|
@@ -3943,103 +3595,9 @@ class WickChart extends HTMLElementBase {
|
|
|
3943
3595
|
}
|
|
3944
3596
|
|
|
3945
3597
|
/* ------------------------------------------------------------ *
|
|
3946
|
-
*
|
|
3598
|
+
* Crosshair events
|
|
3947
3599
|
* ------------------------------------------------------------ */
|
|
3948
3600
|
|
|
3949
|
-
/** Lazily-created shared AudioContext (enable within a user gesture). */
|
|
3950
|
-
_audio() {
|
|
3951
|
-
if (this._actx) return this._actx;
|
|
3952
|
-
const AC = window.AudioContext || window.webkitAudioContext;
|
|
3953
|
-
if (!AC) return null;
|
|
3954
|
-
try {
|
|
3955
|
-
this._actx = new AC();
|
|
3956
|
-
} catch (_) {
|
|
3957
|
-
this._actx = null;
|
|
3958
|
-
}
|
|
3959
|
-
return this._actx;
|
|
3960
|
-
}
|
|
3961
|
-
|
|
3962
|
-
/** Short sine blip; `when` schedules against AudioContext time. */
|
|
3963
|
-
_tone(freq, dur = 0.14, when = 0) {
|
|
3964
|
-
const ctx = this._audio();
|
|
3965
|
-
if (!ctx) return;
|
|
3966
|
-
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
|
|
3967
|
-
const t0 = when || ctx.currentTime;
|
|
3968
|
-
const osc = ctx.createOscillator();
|
|
3969
|
-
const gain = ctx.createGain();
|
|
3970
|
-
osc.type = 'sine';
|
|
3971
|
-
osc.frequency.value = freq;
|
|
3972
|
-
gain.gain.setValueAtTime(0.0001, t0);
|
|
3973
|
-
gain.gain.exponentialRampToValueAtTime(0.18, t0 + 0.012);
|
|
3974
|
-
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
3975
|
-
osc.connect(gain).connect(ctx.destination);
|
|
3976
|
-
osc.start(t0);
|
|
3977
|
-
osc.stop(t0 + dur + 0.03);
|
|
3978
|
-
}
|
|
3979
|
-
|
|
3980
|
-
/** One tone for a bar's close, pitched by its position on the y-scale. */
|
|
3981
|
-
_sonifyBar(i) {
|
|
3982
|
-
if (!this._sonify || !this._data.length || !this._lastScale) return;
|
|
3983
|
-
const d = this._renderBars();
|
|
3984
|
-
const b = d[clamp(i, 0, d.length - 1)];
|
|
3985
|
-
if (!b) return;
|
|
3986
|
-
this._tone(priceToFreq(b.close, this._lastScale));
|
|
3987
|
-
}
|
|
3988
|
-
|
|
3989
|
-
/** One tone per crosshair bar change (dedupes y-only moves). */
|
|
3990
|
-
_maybeSonify(idx) {
|
|
3991
|
-
if (!this._sonify) return;
|
|
3992
|
-
if (this._lastToneIdx === idx) return;
|
|
3993
|
-
this._lastToneIdx = idx;
|
|
3994
|
-
this._sonifyBar(idx);
|
|
3995
|
-
}
|
|
3996
|
-
|
|
3997
|
-
/**
|
|
3998
|
-
* Play the visible range as a pitch sweep (~4s), riding the crosshair —
|
|
3999
|
-
* the audible equivalent of running your eye along the price line.
|
|
4000
|
-
*/
|
|
4001
|
-
playRange() {
|
|
4002
|
-
if (!this._data.length || !this._ly) return;
|
|
4003
|
-
const ctx = this._audio();
|
|
4004
|
-
if (!ctx) return;
|
|
4005
|
-
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
|
|
4006
|
-
const d = this._renderBars();
|
|
4007
|
-
const count = Math.max(2, Math.round(this._ly.plotRight / this._view.spacing));
|
|
4008
|
-
const i0 = clamp(Math.floor(this._view.rightIndex - count) - 1, 0, d.length - 1);
|
|
4009
|
-
const i1 = clamp(Math.ceil(this._view.rightIndex), 0, d.length - 1);
|
|
4010
|
-
if (i1 - i0 < 2) return;
|
|
4011
|
-
const N = Math.min(120, i1 - i0 + 1);
|
|
4012
|
-
const stepMs = Math.min(70, Math.max(24, 4000 / N));
|
|
4013
|
-
const t0 = ctx.currentTime + 0.05;
|
|
4014
|
-
for (let k = 0; k < N; k++) {
|
|
4015
|
-
const i = Math.round(i0 + ((i1 - i0) * k) / (N - 1));
|
|
4016
|
-
const b = d[i];
|
|
4017
|
-
if (!b) continue;
|
|
4018
|
-
this._tone(priceToFreq(b.close, this._lastScale), stepMs / 1000 * 0.9, t0 + (k * stepMs) / 1000);
|
|
4019
|
-
}
|
|
4020
|
-
// ride the crosshair along the sweep for sighted users
|
|
4021
|
-
this._playToken++;
|
|
4022
|
-
const token = this._playToken;
|
|
4023
|
-
let k = 0;
|
|
4024
|
-
const timer = setInterval(() => {
|
|
4025
|
-
if (token !== this._playToken || !this._connected) {
|
|
4026
|
-
clearInterval(timer);
|
|
4027
|
-
return;
|
|
4028
|
-
}
|
|
4029
|
-
if (k >= N) {
|
|
4030
|
-
clearInterval(timer);
|
|
4031
|
-
this._hover = null;
|
|
4032
|
-
this._emitCrosshair(null);
|
|
4033
|
-
this._invalidate();
|
|
4034
|
-
return;
|
|
4035
|
-
}
|
|
4036
|
-
const i = Math.round(i0 + ((i1 - i0) * k) / (N - 1));
|
|
4037
|
-
this._hover = { index: i, x: this._xFor(i), y: this._ly ? this._ly.main.h * 0.5 : 0 };
|
|
4038
|
-
this._invalidate();
|
|
4039
|
-
k++;
|
|
4040
|
-
}, stepMs);
|
|
4041
|
-
}
|
|
4042
|
-
|
|
4043
3601
|
_emitCrosshair(hover) {
|
|
4044
3602
|
let detail = null;
|
|
4045
3603
|
if (hover && this._data[hover.index]) {
|
|
@@ -4052,250 +3610,6 @@ class WickChart extends HTMLElementBase {
|
|
|
4052
3610
|
};
|
|
4053
3611
|
}
|
|
4054
3612
|
this._fire('crosshair', detail);
|
|
4055
|
-
|
|
4056
|
-
// co-view: share the pointer with peer charts (leave events bypass throttle)
|
|
4057
|
-
if (this._coviewCh) {
|
|
4058
|
-
if (!detail) {
|
|
4059
|
-
this._coviewSend({ type: 'cross', time: null, yFrac: null });
|
|
4060
|
-
} else {
|
|
4061
|
-
const now = performance.now();
|
|
4062
|
-
if (now - this._coviewLast > 40) {
|
|
4063
|
-
this._coviewLast = now;
|
|
4064
|
-
const ly = this._ly;
|
|
4065
|
-
this._coviewSend({
|
|
4066
|
-
type: 'cross',
|
|
4067
|
-
time: detail.bar.time,
|
|
4068
|
-
yFrac: ly && isNum(detail.y) ? clamp(detail.y / ly.main.h, 0, 1) : null,
|
|
4069
|
-
});
|
|
4070
|
-
}
|
|
4071
|
-
}
|
|
4072
|
-
}
|
|
4073
|
-
}
|
|
4074
|
-
|
|
4075
|
-
/* ------------------------------------------------------------ *
|
|
4076
|
-
* Cross-tab co-view (BroadcastChannel)
|
|
4077
|
-
* ------------------------------------------------------------ */
|
|
4078
|
-
|
|
4079
|
-
/** Join/leave the co-view channel named by the `co-view` attribute. */
|
|
4080
|
-
_setupCoView() {
|
|
4081
|
-
if (this._coviewCh) {
|
|
4082
|
-
try {
|
|
4083
|
-
this._coviewCh.close();
|
|
4084
|
-
} catch (_) {}
|
|
4085
|
-
this._coviewCh = null;
|
|
4086
|
-
}
|
|
4087
|
-
clearInterval(this._coviewBeat);
|
|
4088
|
-
this._coviewBeat = 0;
|
|
4089
|
-
clearTimeout(this._ghostTimer);
|
|
4090
|
-
if (this._ghost) {
|
|
4091
|
-
this._ghost = null;
|
|
4092
|
-
this._invalidate();
|
|
4093
|
-
}
|
|
4094
|
-
if (this._presence && this._presence.peers.size) {
|
|
4095
|
-
const left = this._presence.list();
|
|
4096
|
-
this._presence = new PresenceTracker();
|
|
4097
|
-
this._fire('peers', { peers: [], joined: [], left });
|
|
4098
|
-
}
|
|
4099
|
-
const name = this._coviewName;
|
|
4100
|
-
if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
|
|
4101
|
-
if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
|
|
4102
|
-
try {
|
|
4103
|
-
const ch = new BroadcastChannel('wick-co-view:' + name);
|
|
4104
|
-
ch.onmessage = (ev) => this._onCoMessage(ev.data);
|
|
4105
|
-
this._coviewCh = ch;
|
|
4106
|
-
} catch (_) {}
|
|
4107
|
-
// presence: announce immediately, then heartbeat so idle peers stay
|
|
4108
|
-
// warm (and stale ones sweep) without waiting for a pan/zoom
|
|
4109
|
-
this._coviewSendView(true);
|
|
4110
|
-
this._coviewBeat = setInterval(() => {
|
|
4111
|
-
this._coviewSendView(true);
|
|
4112
|
-
const left = this._presence.sweep();
|
|
4113
|
-
if (left.length) {
|
|
4114
|
-
this._fire('peers', { peers: this._presence.list(), joined: [], left });
|
|
4115
|
-
this._invalidate();
|
|
4116
|
-
}
|
|
4117
|
-
}, 4000);
|
|
4118
|
-
}
|
|
4119
|
-
|
|
4120
|
-
/** Broadcast our visible range for presence; throttled unless forced. */
|
|
4121
|
-
_coviewSendView(force) {
|
|
4122
|
-
if (!this._coviewCh) return;
|
|
4123
|
-
const r = this.getVisibleRange();
|
|
4124
|
-
if (!r) return;
|
|
4125
|
-
const now = performance.now();
|
|
4126
|
-
if (!force && now - this._coviewViewLast < 120) return;
|
|
4127
|
-
this._coviewViewLast = now;
|
|
4128
|
-
this._coviewSend({
|
|
4129
|
-
type: 'view',
|
|
4130
|
-
from: r.from,
|
|
4131
|
-
to: r.to,
|
|
4132
|
-
name: this._coviewLabel || null,
|
|
4133
|
-
});
|
|
4134
|
-
}
|
|
4135
|
-
|
|
4136
|
-
_coviewSend(msg) {
|
|
4137
|
-
if (!this._coviewCh) return;
|
|
4138
|
-
try {
|
|
4139
|
-
this._coviewCh.postMessage({ v: 1, peer: this._coviewPeer, ...msg });
|
|
4140
|
-
} catch (_) {}
|
|
4141
|
-
}
|
|
4142
|
-
|
|
4143
|
-
_onCoMessage(m) {
|
|
4144
|
-
if (!m || m.v !== 1 || m.peer === this._coviewPeer) return;
|
|
4145
|
-
if (m.type === 'view') {
|
|
4146
|
-
const joined = this._presence.track(m.peer, {
|
|
4147
|
-
range: { from: m.from, to: m.to },
|
|
4148
|
-
name: m.name,
|
|
4149
|
-
});
|
|
4150
|
-
this._invalidate();
|
|
4151
|
-
if (joined) {
|
|
4152
|
-
const p = this._presence.peers.get(m.peer);
|
|
4153
|
-
this._fire('peers', {
|
|
4154
|
-
peers: this._presence.list(),
|
|
4155
|
-
joined: [p ? { ...p, range: p.range && { ...p.range } } : { id: m.peer }],
|
|
4156
|
-
left: [],
|
|
4157
|
-
});
|
|
4158
|
-
}
|
|
4159
|
-
return;
|
|
4160
|
-
}
|
|
4161
|
-
if (m.type === 'bye') {
|
|
4162
|
-
const left = this._presence.drop(m.peer);
|
|
4163
|
-
if (left) this._fire('peers', { peers: this._presence.list(), joined: [], left: [left] });
|
|
4164
|
-
this._invalidate();
|
|
4165
|
-
return;
|
|
4166
|
-
}
|
|
4167
|
-
if (m.type !== 'cross') return;
|
|
4168
|
-
if (m.time == null) {
|
|
4169
|
-
if (this._ghost) {
|
|
4170
|
-
this._ghost = null;
|
|
4171
|
-
clearTimeout(this._ghostTimer);
|
|
4172
|
-
this._invalidate();
|
|
4173
|
-
}
|
|
4174
|
-
return;
|
|
4175
|
-
}
|
|
4176
|
-
if (!isNum(m.time) || !this._data.length) return;
|
|
4177
|
-
this._ghost = {
|
|
4178
|
-
index: WickChart._indexForTime(this._data, m.time),
|
|
4179
|
-
yFrac: isNum(m.yFrac) ? clamp(m.yFrac, 0, 1) : null,
|
|
4180
|
-
at: Date.now(),
|
|
4181
|
-
};
|
|
4182
|
-
clearTimeout(this._ghostTimer);
|
|
4183
|
-
this._ghostTimer = setTimeout(() => {
|
|
4184
|
-
this._ghost = null;
|
|
4185
|
-
this._invalidate();
|
|
4186
|
-
}, 2500);
|
|
4187
|
-
this._invalidate();
|
|
4188
|
-
}
|
|
4189
|
-
|
|
4190
|
-
/** Dispatch `wick:name` (canonical) plus the deprecated `hab:name` alias,
|
|
4191
|
-
* so 0.x listeners keep working until 2.0. */
|
|
4192
|
-
_fire(name, detail) {
|
|
4193
|
-
this.dispatchEvent(new CustomEvent('wick:' + name, { detail }));
|
|
4194
|
-
this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
|
|
4195
|
-
}
|
|
4196
|
-
|
|
4197
|
-
/**
|
|
4198
|
-
* Live co-view peers: who else is in the room and the time window each
|
|
4199
|
-
* one is looking at — [{ id, name, range: {from, to}, at }], oldest
|
|
4200
|
-
* sighting first. Peers fade out ~12 s after their last sighting.
|
|
4201
|
-
* @returns {object[]}
|
|
4202
|
-
*/
|
|
4203
|
-
getPeers() {
|
|
4204
|
-
return this._presence ? this._presence.list() : [];
|
|
4205
|
-
}
|
|
4206
|
-
|
|
4207
|
-
/**
|
|
4208
|
-
* Narrated timeline for a window (default: the visible range) — pivot
|
|
4209
|
-
* highs/lows, volume spikes, gaps, RSI divergences plus derived legs
|
|
4210
|
-
* ("+12.4% over 38 bars"), sorted by index. Pure data, perfect for
|
|
4211
|
-
* caption UIs or the walk player.
|
|
4212
|
-
* chart.narrate(); // visible range
|
|
4213
|
-
* chart.narrate({ from, to }); // times in ms (s accepted)
|
|
4214
|
-
* @param {{from?: number, to?: number}} [range]
|
|
4215
|
-
* @returns {{i: number, time: number, type: string, side: string, note: string,
|
|
4216
|
-
* legPct?: number, legBars?: number}[]}
|
|
4217
|
-
*/
|
|
4218
|
-
narrate(range) {
|
|
4219
|
-
const d = this._data;
|
|
4220
|
-
if (!d.length) return [];
|
|
4221
|
-
let i0 = 0;
|
|
4222
|
-
let i1 = d.length - 1;
|
|
4223
|
-
if (range && isNum(range.from) && isNum(range.to)) {
|
|
4224
|
-
i0 = WickChart._indexForTime(d, WickChart._timeToMs(range.from));
|
|
4225
|
-
i1 = WickChart._indexForTime(d, WickChart._timeToMs(range.to));
|
|
4226
|
-
if (i0 > i1) [i0, i1] = [i1, i0];
|
|
4227
|
-
}
|
|
4228
|
-
return narrateWindow(d, i0, i1);
|
|
4229
|
-
}
|
|
4230
|
-
|
|
4231
|
-
/**
|
|
4232
|
-
* Walk the chart through history like a story: the viewport slides
|
|
4233
|
-
* from `from` to `to` while `wick:walk` events announce every step and
|
|
4234
|
-
* the narrator's events (spikes, gaps, pivots, legs) as they're crossed.
|
|
4235
|
-
* Any user interaction — pointer, wheel, keys, double-click — stops it.
|
|
4236
|
-
* chart.walk({ from: 0, to: 500, speed: 120, step: 10 });
|
|
4237
|
-
* chart.addEventListener('wick:walk', (e) => showCaption(e.detail));
|
|
4238
|
-
* // detail: { phase: 'step'|'end'|'stop', index, events: [...], from, to }
|
|
4239
|
-
* @param {{from?: number, to?: number, speed?: number, step?: number}} [opts]
|
|
4240
|
-
* from/to are bar indices (default: last ~500 bars → the end)
|
|
4241
|
-
* @returns {boolean} true when the walk started
|
|
4242
|
-
*/
|
|
4243
|
-
walk(opts = {}) {
|
|
4244
|
-
this.stopWalk(true);
|
|
4245
|
-
const d = this._data;
|
|
4246
|
-
if (!d.length || !this._connected) return false;
|
|
4247
|
-
const to = clamp(Math.round(+opts.to || d.length - 1), 0, d.length - 1);
|
|
4248
|
-
const from = clamp(Math.round(opts.from != null ? +opts.from : Math.max(0, to - 500)), 0, to);
|
|
4249
|
-
const span = to - from + 1;
|
|
4250
|
-
// window width: the current viewport, but never more than ~⅓ of the
|
|
4251
|
-
// span (a fully zoomed-out chart would otherwise start at `to`)
|
|
4252
|
-
const widthBars = clamp(
|
|
4253
|
-
Math.min(
|
|
4254
|
-
this._ly ? Math.round(this._ly.plotRight / this._view.spacing) : 120,
|
|
4255
|
-
Math.max(10, Math.ceil(span / 3))
|
|
4256
|
-
),
|
|
4257
|
-
10,
|
|
4258
|
-
span
|
|
4259
|
-
);
|
|
4260
|
-
const events = narrateWindow(d, from, to, { pivot: 8 });
|
|
4261
|
-
const speed = clamp(Math.round(+opts.speed || 120), 16, 2000);
|
|
4262
|
-
const step = clamp(Math.round(+opts.step || Math.max(1, Math.round(widthBars / 12))), 1, 500);
|
|
4263
|
-
let cursor = Math.min(from + widthBars - 1, to);
|
|
4264
|
-
let ev = 0;
|
|
4265
|
-
let ended = false;
|
|
4266
|
-
const tick = () => {
|
|
4267
|
-
if (ended) return;
|
|
4268
|
-
this._auto = false;
|
|
4269
|
-
this._view.rightIndex = cursor;
|
|
4270
|
-
this._clampView();
|
|
4271
|
-
this._invalidate();
|
|
4272
|
-
this._emitRange();
|
|
4273
|
-
const hits = [];
|
|
4274
|
-
while (ev < events.length && events[ev].i <= cursor) hits.push(events[ev++]);
|
|
4275
|
-
this._fire('walk', { phase: 'step', index: cursor, events: hits, from, to });
|
|
4276
|
-
if (cursor >= to) {
|
|
4277
|
-
ended = true;
|
|
4278
|
-
clearInterval(this._walkTimer);
|
|
4279
|
-
this._walkTimer = 0;
|
|
4280
|
-
this._fire('walk', { phase: 'end', index: cursor, events: [], from, to });
|
|
4281
|
-
} else {
|
|
4282
|
-
cursor = Math.min(cursor + step, to);
|
|
4283
|
-
}
|
|
4284
|
-
};
|
|
4285
|
-
this._walkTimer = setInterval(tick, speed);
|
|
4286
|
-
tick(); // first step lands immediately
|
|
4287
|
-
return true;
|
|
4288
|
-
}
|
|
4289
|
-
|
|
4290
|
-
/**
|
|
4291
|
-
* Stop the running walk (if any). Fires a final `wick:walk`
|
|
4292
|
-
* { phase: 'stop' } unless called internally.
|
|
4293
|
-
*/
|
|
4294
|
-
stopWalk(silent) {
|
|
4295
|
-
if (!this._walkTimer) return;
|
|
4296
|
-
clearInterval(this._walkTimer);
|
|
4297
|
-
this._walkTimer = 0;
|
|
4298
|
-
if (!silent) this._fire('walk', { phase: 'stop' });
|
|
4299
3613
|
}
|
|
4300
3614
|
|
|
4301
3615
|
/**
|
|
@@ -4333,171 +3647,47 @@ class WickChart extends HTMLElementBase {
|
|
|
4333
3647
|
return { i0, i1, stats: { ...stats, from: { ...stats.from }, to: { ...stats.to } } };
|
|
4334
3648
|
}
|
|
4335
3649
|
|
|
4336
|
-
/**
|
|
4337
|
-
* Capture the current chart state as a story scene: view, series type,
|
|
4338
|
-
* indicators, overlays, scenario and risk plan, plus a title/note.
|
|
4339
|
-
* Build guided tours by capturing several and playing them back.
|
|
4340
|
-
* const story = [
|
|
4341
|
-
* chart.captureScene('Overview', 'The full picture'),
|
|
4342
|
-
* { title: 'The breakout', range: { from, to }, indicators: 'sma:20' },
|
|
4343
|
-
* ];
|
|
4344
|
-
* chart.playStory(story);
|
|
4345
|
-
* @param {string} [title]
|
|
4346
|
-
* @param {string} [note]
|
|
4347
|
-
* @returns {object} scene (plain data — snapshot of the moment)
|
|
4348
|
-
*/
|
|
4349
|
-
captureScene(title, note) {
|
|
4350
|
-
const scene = {
|
|
4351
|
-
title: title != null ? String(title).slice(0, 60) : '',
|
|
4352
|
-
note: note != null ? String(note).slice(0, 200) : '',
|
|
4353
|
-
range: this.getVisibleRange() || undefined,
|
|
4354
|
-
type: this.getAttribute('type') || 'candles',
|
|
4355
|
-
indicators: this.getAttribute('indicators') || null,
|
|
4356
|
-
};
|
|
4357
|
-
const ovs = this.overlays;
|
|
4358
|
-
if (ovs.length) scene.overlays = ovs;
|
|
4359
|
-
const sc = this.scenario;
|
|
4360
|
-
if (sc) scene.scenario = sc;
|
|
4361
|
-
const rp = this.riskPlan;
|
|
4362
|
-
if (rp) scene.riskPlan = rp;
|
|
4363
|
-
return scene;
|
|
4364
|
-
}
|
|
4365
|
-
|
|
4366
|
-
/** @returns {object[]|null} a copy of the last played story */
|
|
4367
|
-
getStory() {
|
|
4368
|
-
return this._story ? this._story.map((s) => ({ ...s })) : null;
|
|
4369
|
-
}
|
|
4370
|
-
|
|
4371
|
-
/**
|
|
4372
|
-
* Play a story: each scene applies its state (type / indicators /
|
|
4373
|
-
* overlays / scenario / risk plan — set or clear), the camera eases
|
|
4374
|
-
* to its range, then holds for its dwell. `wick:story` events narrate:
|
|
4375
|
-
* { phase: 'scene' | 'end' | 'stop', index, total, scene, title, note }
|
|
4376
|
-
* Any user interaction — pointer, wheel, keys, double-click — stops it.
|
|
4377
|
-
* @param {object[]} story scenes (invalid entries dropped, max 20)
|
|
4378
|
-
* @param {{dwell?: number, panMs?: number, loop?: boolean}} [opts]
|
|
4379
|
-
* panMs clamps 100–5000 (default 900); loop replays forever
|
|
4380
|
-
* @returns {boolean} true when playback started
|
|
4381
|
-
*/
|
|
4382
|
-
playStory(story, opts = {}) {
|
|
4383
|
-
this.stopStory(true);
|
|
4384
|
-
const scenes = sceneList(story);
|
|
4385
|
-
if (!scenes.length || !this._data.length || !this._connected) return false;
|
|
4386
|
-
const token = ++this._storyToken;
|
|
4387
|
-
this._story = scenes;
|
|
4388
|
-
const panMs = clamp(Math.round(+opts.panMs || 900), 100, 5000);
|
|
4389
|
-
const loop = opts.loop === true;
|
|
4390
|
-
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4391
|
-
const run = async () => {
|
|
4392
|
-
let idx = 0;
|
|
4393
|
-
while (token === this._storyToken) {
|
|
4394
|
-
const sc = scenes[idx];
|
|
4395
|
-
this._fire('story', {
|
|
4396
|
-
phase: 'scene', index: idx, total: scenes.length,
|
|
4397
|
-
scene: sc, title: sc.title, note: sc.note,
|
|
4398
|
-
});
|
|
4399
|
-
this._applyScene(sc);
|
|
4400
|
-
const target = this._sceneTarget(sc);
|
|
4401
|
-
if (target) await this._storyTween(target, panMs, token);
|
|
4402
|
-
if (token !== this._storyToken) return;
|
|
4403
|
-
await wait(sc.dwell);
|
|
4404
|
-
if (token !== this._storyToken) return;
|
|
4405
|
-
idx++;
|
|
4406
|
-
if (idx >= scenes.length) {
|
|
4407
|
-
if (loop) idx = 0;
|
|
4408
|
-
else {
|
|
4409
|
-
this._fire('story', { phase: 'end', index: idx - 1, total: scenes.length });
|
|
4410
|
-
return;
|
|
4411
|
-
}
|
|
4412
|
-
}
|
|
4413
|
-
}
|
|
4414
|
-
};
|
|
4415
|
-
run();
|
|
4416
|
-
return true;
|
|
4417
|
-
}
|
|
4418
|
-
|
|
4419
|
-
/**
|
|
4420
|
-
* Stop story playback (if running). Fires a final `wick:story`
|
|
4421
|
-
* { phase: 'stop' } unless called internally.
|
|
4422
|
-
*/
|
|
4423
|
-
stopStory(silent) {
|
|
4424
|
-
if (!this._storyToken) return;
|
|
4425
|
-
this._storyToken = 0;
|
|
4426
|
-
if (!silent) this._fire('story', { phase: 'stop' });
|
|
4427
|
-
}
|
|
4428
|
-
|
|
4429
|
-
/** Apply a scene's state (only the fields it carries). */
|
|
4430
|
-
_applyScene(sc) {
|
|
4431
|
-
if (sc.type) this.setAttribute('type', sc.type);
|
|
4432
|
-
if (sc.indicators != null) this.setAttribute('indicators', sc.indicators);
|
|
4433
|
-
if (sc.overlays) this.setOverlays(sc.overlays);
|
|
4434
|
-
if (sc.scenario === 'clear') this.clearScenario();
|
|
4435
|
-
else if (sc.scenario) this.setScenario(sc.scenario);
|
|
4436
|
-
if (sc.riskPlan === 'clear') this.clearRiskPlan();
|
|
4437
|
-
else if (sc.riskPlan) this.setRiskPlan(sc.riskPlan);
|
|
4438
|
-
}
|
|
4439
|
-
|
|
4440
|
-
/** Map a scene's time range to bar indices (null when not applicable). */
|
|
4441
|
-
_sceneTarget(sc) {
|
|
4442
|
-
if (!sc.range || !this._data.length) return null;
|
|
4443
|
-
let i0 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.from));
|
|
4444
|
-
let i1 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.to));
|
|
4445
|
-
if (i0 > i1) [i0, i1] = [i1, i0];
|
|
4446
|
-
return i1 - i0 >= 2 ? { i0, i1 } : null;
|
|
4447
|
-
}
|
|
4448
|
-
|
|
4449
|
-
/** Ease the viewport to { i0, i1 } over `ms`; resolves early if the
|
|
4450
|
-
* token changes (superseded or stopped). rAF when available. */
|
|
4451
|
-
_storyTween(target, ms, token) {
|
|
4452
|
-
const ly = this._ly;
|
|
4453
|
-
const d = this._data;
|
|
4454
|
-
if (!ly || !d.length) return Promise.resolve();
|
|
4455
|
-
const sp1 = clamp(ly.plotRight / (target.i1 - target.i0), this._minSpacing(), WickChart._MAX_SP);
|
|
4456
|
-
const from = { right: this._view.rightIndex, sp: this._view.spacing };
|
|
4457
|
-
const to = { right: target.i1, sp: sp1 };
|
|
4458
|
-
const t0 = performance.now();
|
|
4459
|
-
this._auto = false;
|
|
4460
|
-
return new Promise((resolve) => {
|
|
4461
|
-
const step = () => {
|
|
4462
|
-
if (token !== this._storyToken) return resolve();
|
|
4463
|
-
const e = easeInOutCubic(Math.min(1, (performance.now() - t0) / ms));
|
|
4464
|
-
this._view.rightIndex = from.right + (to.right - from.right) * e;
|
|
4465
|
-
this._view.spacing = from.sp + (to.sp - from.sp) * e;
|
|
4466
|
-
this._clampView();
|
|
4467
|
-
this._invalidate();
|
|
4468
|
-
this._emitRange();
|
|
4469
|
-
if (e >= 1) resolve();
|
|
4470
|
-
else if (typeof requestAnimationFrame === 'function') requestAnimationFrame(step);
|
|
4471
|
-
else setTimeout(step, 16);
|
|
4472
|
-
};
|
|
4473
|
-
step();
|
|
4474
|
-
});
|
|
4475
|
-
}
|
|
4476
|
-
|
|
4477
|
-
/** Interrupt narrated playback (walk / story) on user input. */
|
|
4478
|
-
_stopPlayback() {
|
|
4479
|
-
if (this._walkTimer) this.stopWalk();
|
|
4480
|
-
if (this._storyToken) this.stopStory();
|
|
4481
|
-
}
|
|
4482
|
-
|
|
4483
3650
|
_emitRange() {
|
|
4484
3651
|
const r = this.getVisibleRange();
|
|
4485
3652
|
if (!r) return;
|
|
4486
3653
|
this._fire('range', r);
|
|
4487
|
-
if (this._coviewCh) this._coviewSendView();
|
|
4488
3654
|
}
|
|
4489
3655
|
}
|
|
4490
3656
|
|
|
3657
|
+
/* 2.0: the guided-playback, planning, co-view and agent families moved to
|
|
3658
|
+
* their packages. Until one attaches, the familiar methods warn once and
|
|
3659
|
+
* no-op (deleted in 3.0); attaching installs the real thing as an own
|
|
3660
|
+
* property that shadows these. */
|
|
3661
|
+
for (const [name, pkg] of [
|
|
3662
|
+
['narrate', 'wickchart-narrator'],
|
|
3663
|
+
['walk', 'wickchart-narrator'],
|
|
3664
|
+
['stopWalk', 'wickchart-narrator'],
|
|
3665
|
+
['playRange', 'wickchart-narrator'],
|
|
3666
|
+
['captureScene', 'wickchart-narrator'],
|
|
3667
|
+
['getStory', 'wickchart-narrator'],
|
|
3668
|
+
['playStory', 'wickchart-narrator'],
|
|
3669
|
+
['stopStory', 'wickchart-narrator'],
|
|
3670
|
+
['getPeers', 'wickchart-coview'],
|
|
3671
|
+
['setScenario', 'wickchart-scenario'],
|
|
3672
|
+
['clearScenario', 'wickchart-scenario'],
|
|
3673
|
+
['setRiskPlan', 'wickchart-scenario'],
|
|
3674
|
+
['clearRiskPlan', 'wickchart-scenario'],
|
|
3675
|
+
['aiTools', 'wickchart-ai'],
|
|
3676
|
+
['aiPrompt', 'wickchart-ai'],
|
|
3677
|
+
['aiContext', 'wickchart-ai'],
|
|
3678
|
+
['applyAI', 'wickchart-ai'],
|
|
3679
|
+
['ask', 'wickchart-ai'],
|
|
3680
|
+
]) {
|
|
3681
|
+
WickChart.prototype[name] = function () {
|
|
3682
|
+
warnDeprecatedAlias(`${name}() moved to the ${pkg} package in 2.0`);
|
|
3683
|
+
return undefined;
|
|
3684
|
+
};
|
|
3685
|
+
}
|
|
3686
|
+
|
|
4491
3687
|
if (typeof customElements !== 'undefined') {
|
|
4492
3688
|
if (!customElements.get('wick-chart')) {
|
|
4493
3689
|
customElements.define('wick-chart', WickChart);
|
|
4494
3690
|
}
|
|
4495
|
-
// 0.x alias: same element under its old tag name (deprecated, removed in 2.0)
|
|
4496
|
-
if (!customElements.get('hab-chart')) {
|
|
4497
|
-
/** @deprecated use <wick-chart> */
|
|
4498
|
-
class HabChart extends WickChart {}
|
|
4499
|
-
customElements.define('hab-chart', HabChart);
|
|
4500
|
-
}
|
|
4501
3691
|
}
|
|
4502
3692
|
|
|
4503
3693
|
export default WickChart;
|