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