wickchart 1.2.0 → 1.4.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 +156 -2
- package/package.json +3 -3
- package/src/core.js +320 -0
- package/src/wick-chart.js +831 -5
- package/types/core.d.ts +156 -0
- package/types/wick-chart.d.ts +257 -0
package/src/wick-chart.js
CHANGED
|
@@ -31,7 +31,9 @@ import {
|
|
|
31
31
|
windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
|
|
32
32
|
compileScript, predicateTrueSeries, scriptAlertStep,
|
|
33
33
|
AI_TOOLS, aiPromptText, applyChartOps,
|
|
34
|
-
calcVolCone, normalizeScenario,
|
|
34
|
+
calcVolCone, normalizeScenario, normalizeRiskPlan, PresenceTracker,
|
|
35
|
+
narrateWindow, brushStats,
|
|
36
|
+
easeInOutCubic, sceneList,
|
|
35
37
|
} from './core.js';
|
|
36
38
|
|
|
37
39
|
/* ------------------------------------------------------------------ *
|
|
@@ -48,7 +50,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
|
|
|
48
50
|
|
|
49
51
|
class WickChart extends HTMLElementBase {
|
|
50
52
|
static get observedAttributes() {
|
|
51
|
-
return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'sonify'];
|
|
53
|
+
return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'co-view-name', 'brush', 'sonify'];
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
constructor() {
|
|
@@ -205,6 +207,11 @@ class WickChart extends HTMLElementBase {
|
|
|
205
207
|
this._coviewLast = 0;
|
|
206
208
|
this._ghost = null;
|
|
207
209
|
this._ghostTimer = 0;
|
|
210
|
+
// presence: peer viewports (who is looking where)
|
|
211
|
+
this._coviewLabel = null; // display name from the co-view-name attribute
|
|
212
|
+
this._presence = new PresenceTracker();
|
|
213
|
+
this._coviewBeat = 0;
|
|
214
|
+
this._coviewViewLast = 0;
|
|
208
215
|
|
|
209
216
|
// sonification state
|
|
210
217
|
this._sonify = false;
|
|
@@ -213,12 +220,26 @@ class WickChart extends HTMLElementBase {
|
|
|
213
220
|
this._playToken = 0;
|
|
214
221
|
this._measure = null; // { iA, pA, iB, pB, done }
|
|
215
222
|
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
|
+
// delta brush state: mode flag + current/finished selection
|
|
229
|
+
this._brush = false;
|
|
230
|
+
this._brushSel = null; // { i0, i1, stats } — the committed selection
|
|
231
|
+
this._brushDrag = null; // { i0, i1 } — while the pointer is down
|
|
216
232
|
this._ind = { overlays: [], panes: [], volume: true };
|
|
217
233
|
|
|
218
234
|
this._pointers = new Map();
|
|
219
235
|
this._pan = null;
|
|
220
236
|
this._pinch = null;
|
|
221
237
|
|
|
238
|
+
// plugin layers: external draw hooks + pointer claims (see addLayer)
|
|
239
|
+
this._layers = [];
|
|
240
|
+
this._layerClaim = null; // { layer, pointerId } while a layer owns a drag
|
|
241
|
+
this._layerSeq = 0;
|
|
242
|
+
|
|
222
243
|
// history backfill state (onloadmore declared as a class field above)
|
|
223
244
|
this._loadingMore = false;
|
|
224
245
|
this._noMore = false;
|
|
@@ -233,6 +254,8 @@ class WickChart extends HTMLElementBase {
|
|
|
233
254
|
|
|
234
255
|
// scenario projection (ghost path + vol cone)
|
|
235
256
|
this._scenario = null;
|
|
257
|
+
// risk plan (R-multiple grid)
|
|
258
|
+
this._riskPlan = null;
|
|
236
259
|
|
|
237
260
|
this._onResize = () => this._invalidate();
|
|
238
261
|
this._onPointerDown = (e) => this._pointerDown(e);
|
|
@@ -246,7 +269,10 @@ class WickChart extends HTMLElementBase {
|
|
|
246
269
|
}
|
|
247
270
|
};
|
|
248
271
|
this._onWheel = (e) => this._wheel(e);
|
|
249
|
-
this._onDbl = () =>
|
|
272
|
+
this._onDbl = () => {
|
|
273
|
+
this._stopPlayback();
|
|
274
|
+
this.fit();
|
|
275
|
+
};
|
|
250
276
|
this._onKey = (e) => this._keydown(e);
|
|
251
277
|
}
|
|
252
278
|
|
|
@@ -280,12 +306,23 @@ class WickChart extends HTMLElementBase {
|
|
|
280
306
|
|
|
281
307
|
disconnectedCallback() {
|
|
282
308
|
this._connected = false;
|
|
309
|
+
this.stopWalk(true);
|
|
310
|
+
this.stopStory(true);
|
|
311
|
+
this._layerClaim = null;
|
|
283
312
|
if (this._coviewCh) {
|
|
313
|
+
this._coviewSend({ type: 'bye' });
|
|
284
314
|
try {
|
|
285
315
|
this._coviewCh.close();
|
|
286
316
|
} catch (_) {}
|
|
287
317
|
this._coviewCh = null;
|
|
288
318
|
}
|
|
319
|
+
clearInterval(this._coviewBeat);
|
|
320
|
+
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
|
+
}
|
|
289
326
|
clearTimeout(this._ghostTimer);
|
|
290
327
|
if (this._ro) this._ro.disconnect();
|
|
291
328
|
const cv = this._canvas;
|
|
@@ -355,6 +392,16 @@ class WickChart extends HTMLElementBase {
|
|
|
355
392
|
this._coviewName = val || null;
|
|
356
393
|
this._setupCoView();
|
|
357
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
|
+
case 'brush':
|
|
400
|
+
this._brush = val != null && val !== 'false';
|
|
401
|
+
this._brushSel = null;
|
|
402
|
+
this._brushDrag = null;
|
|
403
|
+
this._invalidate();
|
|
404
|
+
break;
|
|
358
405
|
case 'sonify':
|
|
359
406
|
this._sonify = val != null && val !== 'false';
|
|
360
407
|
this._lastToneIdx = -1;
|
|
@@ -424,6 +471,8 @@ class WickChart extends HTMLElementBase {
|
|
|
424
471
|
this.clearData();
|
|
425
472
|
return;
|
|
426
473
|
}
|
|
474
|
+
// selection indices are data-bound; a replacement invalidates them
|
|
475
|
+
if (this._brushSel || this._brushDrag) this.clearBrush();
|
|
427
476
|
const norm = [];
|
|
428
477
|
for (const b of bars) {
|
|
429
478
|
const nb = WickChart._normBar(b);
|
|
@@ -910,6 +959,41 @@ class WickChart extends HTMLElementBase {
|
|
|
910
959
|
return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
|
|
911
960
|
}
|
|
912
961
|
|
|
962
|
+
/**
|
|
963
|
+
* Risk plan: an R-multiple grid anchored at entry/stop. 1R = |entry −
|
|
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 */
|
|
992
|
+
get riskPlan() {
|
|
993
|
+
if (!this._riskPlan) return null;
|
|
994
|
+
return { ...this._riskPlan, levels: this._riskPlan.levels.map((l) => ({ ...l })) };
|
|
995
|
+
}
|
|
996
|
+
|
|
913
997
|
/** σ-cone for the active scenario, cached per data version. */
|
|
914
998
|
_scenarioConeCache() {
|
|
915
999
|
if (!this._scenario || !this._data.length) return null;
|
|
@@ -1799,6 +1883,67 @@ class WickChart extends HTMLElementBase {
|
|
|
1799
1883
|
}
|
|
1800
1884
|
}
|
|
1801
1885
|
|
|
1886
|
+
/* risk plan: R-multiple grid — risk/reward shading + kR lines */
|
|
1887
|
+
if (this._riskPlan && d.length) {
|
|
1888
|
+
const rp = this._riskPlan;
|
|
1889
|
+
const fP = numberFmt(this._prec(scale.rawHi || 1));
|
|
1890
|
+
const yE = yOf(rp.entry);
|
|
1891
|
+
const yS = yOf(rp.stop);
|
|
1892
|
+
ctx.fillStyle = hexToRgba(pal.down, 0.06);
|
|
1893
|
+
ctx.fillRect(0, Math.min(yE, yS), plotRight, Math.abs(yS - yE));
|
|
1894
|
+
const yTop = yOf(rp.levels[rp.levels.length - 1].price);
|
|
1895
|
+
ctx.fillStyle = hexToRgba(pal.up, 0.05);
|
|
1896
|
+
ctx.fillRect(0, Math.min(yE, yTop), plotRight, Math.abs(yTop - yE));
|
|
1897
|
+
const line = (p, col, dash) => {
|
|
1898
|
+
const y = yOf(p);
|
|
1899
|
+
if (y < main.y0 || y > main.y1) return null;
|
|
1900
|
+
ctx.strokeStyle = col;
|
|
1901
|
+
ctx.lineWidth = 1.5;
|
|
1902
|
+
if (dash) ctx.setLineDash([5, 4]);
|
|
1903
|
+
ctx.beginPath();
|
|
1904
|
+
ctx.moveTo(0, Math.round(y) + 0.5);
|
|
1905
|
+
ctx.lineTo(plotRight, Math.round(y) + 0.5);
|
|
1906
|
+
ctx.stroke();
|
|
1907
|
+
ctx.setLineDash([]);
|
|
1908
|
+
ctx.lineWidth = 1;
|
|
1909
|
+
return y;
|
|
1910
|
+
};
|
|
1911
|
+
const pills = [];
|
|
1912
|
+
for (let i = rp.levels.length - 1; i >= 0; i--) {
|
|
1913
|
+
const lv = rp.levels[i];
|
|
1914
|
+
const y = line(lv.price, pal.up, true);
|
|
1915
|
+
if (y != null) {
|
|
1916
|
+
const kk = lv.k % 1 === 0 ? lv.k : +lv.k.toFixed(2);
|
|
1917
|
+
pills.push({ y, text: `${kk}R ${fP.format(lv.price)}`, bg: pal.up });
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
const yStop = line(rp.stop, pal.down, false);
|
|
1921
|
+
if (yStop != null) pills.push({ y: yStop, text: `STOP ${fP.format(rp.stop)}`, bg: pal.down });
|
|
1922
|
+
const yEnt = line(rp.entry, pal.accent, false);
|
|
1923
|
+
if (yEnt != null) {
|
|
1924
|
+
pills.push({ y: yEnt, text: `${rp.direction === 'long' ? 'LONG' : 'SHORT'} ${fP.format(rp.entry)}`, bg: pal.accent });
|
|
1925
|
+
}
|
|
1926
|
+
// stack right-edge pills instead of letting close lines overlap
|
|
1927
|
+
pills.sort((a, b) => a.y - b.y);
|
|
1928
|
+
let lastY = -Infinity;
|
|
1929
|
+
for (const p of pills) {
|
|
1930
|
+
const y = Math.max(p.y, lastY + 20);
|
|
1931
|
+
lastY = y;
|
|
1932
|
+
ctx.font = pillFont();
|
|
1933
|
+
const tw = ctx.measureText(p.text).width + 12;
|
|
1934
|
+
this._pill(plotRight - tw - 8, y, p.text, p.bg, pal.pillText, 'left', tw);
|
|
1935
|
+
}
|
|
1936
|
+
if (rp.label) {
|
|
1937
|
+
ctx.font = pillFont();
|
|
1938
|
+
ctx.fillStyle = pal.accent;
|
|
1939
|
+
ctx.globalAlpha = 0.9;
|
|
1940
|
+
ctx.textAlign = 'left';
|
|
1941
|
+
ctx.textBaseline = 'bottom';
|
|
1942
|
+
ctx.fillText(rp.label, 8, clamp(yE, main.y0 + 14, main.y1) - 3);
|
|
1943
|
+
ctx.globalAlpha = 1;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1802
1947
|
/* volume profile (behind the series) */
|
|
1803
1948
|
if (this._profile) {
|
|
1804
1949
|
const pkey = `${i0}:${i1}:${this._version}`;
|
|
@@ -2393,6 +2538,9 @@ class WickChart extends HTMLElementBase {
|
|
|
2393
2538
|
);
|
|
2394
2539
|
}
|
|
2395
2540
|
|
|
2541
|
+
/* plugin layers — above chart content, under the pointer-following UI */
|
|
2542
|
+
if (this._layers.length) this._drawLayers(ctx, pal, ly, d);
|
|
2543
|
+
|
|
2396
2544
|
/* crosshair */
|
|
2397
2545
|
if (this._hover && this._hover.index < d.length) {
|
|
2398
2546
|
const h = this._hover;
|
|
@@ -2458,6 +2606,34 @@ class WickChart extends HTMLElementBase {
|
|
|
2458
2606
|
);
|
|
2459
2607
|
}
|
|
2460
2608
|
|
|
2609
|
+
/* co-view presence: peer viewport bands along the top of the plot */
|
|
2610
|
+
if (this._presence && this._presence.peers.size && d.length) {
|
|
2611
|
+
const peers = this._presence.list().slice(0, 4);
|
|
2612
|
+
ctx.save();
|
|
2613
|
+
ctx.font = pillFont();
|
|
2614
|
+
for (let row = 0; row < peers.length; row++) {
|
|
2615
|
+
const p = peers[row];
|
|
2616
|
+
if (!p.range) continue;
|
|
2617
|
+
const cols = pal.overlay || [];
|
|
2618
|
+
const col = cols[(row + 1) % Math.max(cols.length, 1)] || pal.accent;
|
|
2619
|
+
const i0 = WickChart._indexForTime(this._data, p.range.from);
|
|
2620
|
+
const i1 = WickChart._indexForTime(this._data, p.range.to);
|
|
2621
|
+
const x0 = clamp(this._xFor(i0), 0, plotRight);
|
|
2622
|
+
const x1 = clamp(this._xFor(i1), 0, plotRight);
|
|
2623
|
+
const y = main.y0 + 2 + row * 5;
|
|
2624
|
+
ctx.globalAlpha = 0.8;
|
|
2625
|
+
ctx.fillStyle = col;
|
|
2626
|
+
ctx.fillRect(x0, y, Math.max(x1 - x0, 3), 3);
|
|
2627
|
+
if (x1 - x0 > 44) {
|
|
2628
|
+
ctx.globalAlpha = 0.95;
|
|
2629
|
+
ctx.textAlign = 'left';
|
|
2630
|
+
ctx.textBaseline = 'top';
|
|
2631
|
+
ctx.fillText(p.name || p.id, x0 + 3, y + 4);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
ctx.restore();
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2461
2637
|
/* co-view ghost crosshair (peer pointer from another tab/chart) */
|
|
2462
2638
|
if (this._ghost) {
|
|
2463
2639
|
const g = this._ghost;
|
|
@@ -2544,6 +2720,50 @@ class WickChart extends HTMLElementBase {
|
|
|
2544
2720
|
);
|
|
2545
2721
|
}
|
|
2546
2722
|
|
|
2723
|
+
/* delta brush selection: band + live delta chip */
|
|
2724
|
+
{
|
|
2725
|
+
const sel = this._brushDrag || this._brushSel;
|
|
2726
|
+
if (sel && d.length) {
|
|
2727
|
+
const bi0 = Math.min(sel.i0, sel.i1);
|
|
2728
|
+
const bi1 = Math.max(sel.i0, sel.i1);
|
|
2729
|
+
const xa = this._xFor(bi0) - this._view.spacing / 2;
|
|
2730
|
+
const xb = this._xFor(bi1) + this._view.spacing / 2;
|
|
2731
|
+
const bx0 = clamp(Math.min(xa, xb), 0, plotRight);
|
|
2732
|
+
const bx1 = clamp(Math.max(xa, xb), 0, plotRight);
|
|
2733
|
+
if (bx1 - bx0 > 1) {
|
|
2734
|
+
const live = this._brushDrag ? brushStats(this._data, bi0, bi1) : sel.stats;
|
|
2735
|
+
ctx.save();
|
|
2736
|
+
ctx.fillStyle = hexToRgba(pal.accent, this._brushDrag ? 0.13 : 0.08);
|
|
2737
|
+
ctx.fillRect(bx0, main.y0, bx1 - bx0, main.h);
|
|
2738
|
+
ctx.globalAlpha = 0.55;
|
|
2739
|
+
ctx.strokeStyle = pal.accent;
|
|
2740
|
+
ctx.lineWidth = 1;
|
|
2741
|
+
if (!this._brushDrag) ctx.setLineDash([4, 3]);
|
|
2742
|
+
ctx.strokeRect(bx0 + 0.5, main.y0 + 0.5, bx1 - bx0 - 1, main.h - 1);
|
|
2743
|
+
ctx.setLineDash([]);
|
|
2744
|
+
ctx.restore();
|
|
2745
|
+
if (live) {
|
|
2746
|
+
const fP = numberFmt(this._prec(Math.abs(live.lastClose) || 1));
|
|
2747
|
+
const sign = live.delta >= 0 ? '+' : '';
|
|
2748
|
+
const txt =
|
|
2749
|
+
`${sign}${live.deltaPct.toFixed(2)}% · ${live.bars} bars · ` +
|
|
2750
|
+
`H ${fP.format(live.high)} · L ${fP.format(live.low)} · Σvol ${fmtCompact(live.volume)}`;
|
|
2751
|
+
ctx.font = pillFont();
|
|
2752
|
+
const tw = ctx.measureText(txt).width + 12;
|
|
2753
|
+
this._pill(
|
|
2754
|
+
clamp((bx0 + bx1) / 2 - tw / 2, 2, plotRight - tw - 2),
|
|
2755
|
+
main.y0 + 11,
|
|
2756
|
+
txt,
|
|
2757
|
+
live.delta >= 0 ? pal.up : pal.down,
|
|
2758
|
+
pal.pillText,
|
|
2759
|
+
'left',
|
|
2760
|
+
tw
|
|
2761
|
+
);
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2547
2767
|
/* visible-range stats chip */
|
|
2548
2768
|
if (this._stats) {
|
|
2549
2769
|
const st = computeStats(d, i0, i1, this._dt);
|
|
@@ -2701,6 +2921,207 @@ class WickChart extends HTMLElementBase {
|
|
|
2701
2921
|
poss.innerHTML = html;
|
|
2702
2922
|
}
|
|
2703
2923
|
|
|
2924
|
+
/* ------------------------------------------------------------ *
|
|
2925
|
+
* Plugin layers — external draw hooks + pointer claims
|
|
2926
|
+
* ------------------------------------------------------------ */
|
|
2927
|
+
|
|
2928
|
+
/**
|
|
2929
|
+
* Register a plugin layer. `layer.draw(api)` runs on every render, above
|
|
2930
|
+
* chart content and under the crosshair; `layer.onPointer(pev)` is asked
|
|
2931
|
+
* first on pointerdown and claims the gesture by returning true — the
|
|
2932
|
+
* layer then receives that pointer's move/up/cancel events (plus a
|
|
2933
|
+
* 'cancel' on Escape) and the chart suppresses its own pan/measure/brush
|
|
2934
|
+
* for the duration.
|
|
2935
|
+
* @param {{id?: string, draw: Function, onPointer?: Function}} layer
|
|
2936
|
+
* @returns {object|null} the normalized layer handle (with `id`), or null
|
|
2937
|
+
* if the layer was rejected (no draw fn, or 16 layers already added)
|
|
2938
|
+
*/
|
|
2939
|
+
addLayer(layer) {
|
|
2940
|
+
if (!layer || typeof layer !== 'object' || typeof layer.draw !== 'function') return null;
|
|
2941
|
+
if (this._layers.length >= 16) return null;
|
|
2942
|
+
const id =
|
|
2943
|
+
layer.id != null && String(layer.id).trim()
|
|
2944
|
+
? String(layer.id).slice(0, 64)
|
|
2945
|
+
: 'layer-' + ++this._layerSeq;
|
|
2946
|
+
const entry = {
|
|
2947
|
+
id,
|
|
2948
|
+
draw: layer.draw,
|
|
2949
|
+
onPointer: typeof layer.onPointer === 'function' ? layer.onPointer : null,
|
|
2950
|
+
};
|
|
2951
|
+
const at = this._layers.findIndex((l) => l.id === id);
|
|
2952
|
+
if (at >= 0) this._layers[at] = entry; // same id replaces
|
|
2953
|
+
else this._layers.push(entry);
|
|
2954
|
+
this._invalidate();
|
|
2955
|
+
return entry;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
/**
|
|
2959
|
+
* Remove a layer added via addLayer (pass the returned handle or its id).
|
|
2960
|
+
* @param {object|string} idOrLayer
|
|
2961
|
+
* @returns {boolean} true if a layer was removed
|
|
2962
|
+
*/
|
|
2963
|
+
removeLayer(idOrLayer) {
|
|
2964
|
+
const id = idOrLayer != null && typeof idOrLayer === 'object' ? idOrLayer.id : idOrLayer;
|
|
2965
|
+
if (id == null) return false;
|
|
2966
|
+
const at = this._layers.findIndex((l) => l.id === String(id));
|
|
2967
|
+
if (at < 0) return false;
|
|
2968
|
+
const [gone] = this._layers.splice(at, 1);
|
|
2969
|
+
if (this._layerClaim && this._layerClaim.layer === gone) this._layerClaim = null;
|
|
2970
|
+
this._invalidate();
|
|
2971
|
+
return true;
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
/** Ask for a repaint on the next frame (interactive layers call this). */
|
|
2975
|
+
requestDraw() {
|
|
2976
|
+
this._invalidate();
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
/** Paint every registered layer. Called from _render with live state. */
|
|
2980
|
+
_drawLayers(ctx, pal, ly, d) {
|
|
2981
|
+
for (const layer of this._layers) {
|
|
2982
|
+
try {
|
|
2983
|
+
layer.draw({
|
|
2984
|
+
ctx, // 2D context, already DPR-scaled — draw in CSS pixels
|
|
2985
|
+
layout: ly,
|
|
2986
|
+
palette: pal,
|
|
2987
|
+
data: d,
|
|
2988
|
+
view: this._view,
|
|
2989
|
+
timeToX: (t) => this.timeToX(t),
|
|
2990
|
+
xToTime: (x) => this.xToTime(x),
|
|
2991
|
+
priceToY: (p) => this.priceToY(p),
|
|
2992
|
+
yToPrice: (y) => this.yToPrice(y),
|
|
2993
|
+
});
|
|
2994
|
+
} catch (err) {
|
|
2995
|
+
console.warn('wick-chart: layer "' + layer.id + '" threw in draw', err);
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
/** Ask layers, in order, whether one claims this pointerdown. */
|
|
3001
|
+
_layerHit(e, pt) {
|
|
3002
|
+
for (const layer of this._layers) {
|
|
3003
|
+
if (!layer.onPointer) continue;
|
|
3004
|
+
let claimed = false;
|
|
3005
|
+
try {
|
|
3006
|
+
claimed = layer.onPointer(this._layerPointerEvent(e, pt, 'down')) === true;
|
|
3007
|
+
} catch (err) {
|
|
3008
|
+
console.warn('wick-chart: layer "' + layer.id + '" threw in onPointer', err);
|
|
3009
|
+
}
|
|
3010
|
+
if (claimed) return layer;
|
|
3011
|
+
}
|
|
3012
|
+
return null;
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
/** Deliver a pointer event to a claiming layer; never throws outward. */
|
|
3016
|
+
_routeLayer(layer, e, pt, type) {
|
|
3017
|
+
const ev = pt
|
|
3018
|
+
? this._layerPointerEvent(e, pt, type)
|
|
3019
|
+
: {
|
|
3020
|
+
type,
|
|
3021
|
+
x: null,
|
|
3022
|
+
y: null,
|
|
3023
|
+
pointerId: e.pointerId == null ? 0 : e.pointerId,
|
|
3024
|
+
button: 0,
|
|
3025
|
+
shiftKey: !!e.shiftKey,
|
|
3026
|
+
ctrlKey: !!e.ctrlKey,
|
|
3027
|
+
altKey: !!e.altKey,
|
|
3028
|
+
metaKey: !!e.metaKey,
|
|
3029
|
+
};
|
|
3030
|
+
try {
|
|
3031
|
+
if (layer.onPointer) layer.onPointer(ev);
|
|
3032
|
+
} catch (err) {
|
|
3033
|
+
console.warn('wick-chart: layer "' + layer.id + '" threw in onPointer', err);
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
_layerPointerEvent(e, pt, type) {
|
|
3038
|
+
return {
|
|
3039
|
+
type,
|
|
3040
|
+
x: pt.x,
|
|
3041
|
+
y: pt.y,
|
|
3042
|
+
pointerId: e.pointerId,
|
|
3043
|
+
button: e.button == null ? 0 : e.button,
|
|
3044
|
+
shiftKey: !!e.shiftKey,
|
|
3045
|
+
ctrlKey: !!e.ctrlKey,
|
|
3046
|
+
altKey: !!e.altKey,
|
|
3047
|
+
metaKey: !!e.metaKey,
|
|
3048
|
+
};
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
/**
|
|
3052
|
+
* x-pixel for a bar time (ms or s, auto-detected). Extrapolates past the
|
|
3053
|
+
* last bar into future space using the median bar interval, so layers can
|
|
3054
|
+
* anchor trendlines to tomorrow, not just to history.
|
|
3055
|
+
* @param {number} time
|
|
3056
|
+
* @returns {number|null}
|
|
3057
|
+
*/
|
|
3058
|
+
timeToX(time) {
|
|
3059
|
+
const ly = this._ly;
|
|
3060
|
+
const d = this._data;
|
|
3061
|
+
if (!ly || !d.length || !isNum(time)) return null;
|
|
3062
|
+
const t = time < 1e12 ? time * 1000 : time;
|
|
3063
|
+
const last = d.length - 1;
|
|
3064
|
+
if (t >= d[last].time) {
|
|
3065
|
+
return this._xFor(last + (t - d[last].time) / (this._dt || HOUR));
|
|
3066
|
+
}
|
|
3067
|
+
const i = WickChart._indexForTime(d, t);
|
|
3068
|
+
if (i === 0 && t < d[0].time) {
|
|
3069
|
+
return this._xFor((t - d[0].time) / (this._dt || HOUR));
|
|
3070
|
+
}
|
|
3071
|
+
if (i > 0 && t < d[i].time) {
|
|
3072
|
+
// between two bars: fractional index
|
|
3073
|
+
const a = d[i - 1];
|
|
3074
|
+
const b = d[i];
|
|
3075
|
+
return this._xFor(i - 1 + (t - a.time) / (b.time - a.time || 1));
|
|
3076
|
+
}
|
|
3077
|
+
return this._xFor(i);
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
/**
|
|
3081
|
+
* Bar time (ms) at an x-pixel — the inverse of timeToX, interpolating
|
|
3082
|
+
* between bars and extrapolating beyond both data edges.
|
|
3083
|
+
* @param {number} x
|
|
3084
|
+
* @returns {number|null}
|
|
3085
|
+
*/
|
|
3086
|
+
xToTime(x) {
|
|
3087
|
+
const ly = this._ly;
|
|
3088
|
+
const d = this._data;
|
|
3089
|
+
if (!ly || !d.length || !isNum(x)) return null;
|
|
3090
|
+
const idx = this._indexForX(x);
|
|
3091
|
+
const last = d.length - 1;
|
|
3092
|
+
if (idx >= last) return d[last].time + (idx - last) * (this._dt || HOUR);
|
|
3093
|
+
if (idx <= 0) return d[0].time + idx * (this._dt || HOUR);
|
|
3094
|
+
const i0 = Math.floor(idx);
|
|
3095
|
+
const a = d[i0];
|
|
3096
|
+
const b = d[Math.min(i0 + 1, last)];
|
|
3097
|
+
return a.time + (b.time - a.time) * (idx - i0);
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
/**
|
|
3101
|
+
* y-pixel for a price in the main pane (current scale; log-aware).
|
|
3102
|
+
* Unclamped — values off-screen still map, layers decide how to clip.
|
|
3103
|
+
* @param {number} price
|
|
3104
|
+
* @returns {number|null}
|
|
3105
|
+
*/
|
|
3106
|
+
priceToY(price) {
|
|
3107
|
+
const ly = this._ly;
|
|
3108
|
+
const scale = this._lastScale;
|
|
3109
|
+
if (!ly || !scale || !isNum(price)) return null;
|
|
3110
|
+
const { min, max, useLog } = scale;
|
|
3111
|
+
const v = useLog ? Math.log10(Math.max(price, 1e-12)) : price;
|
|
3112
|
+
return ly.main.y0 + ((max - v) / (max - min)) * ly.main.h;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
/**
|
|
3116
|
+
* Price at a y-pixel in the main pane — the inverse of priceToY.
|
|
3117
|
+
* @param {number} y
|
|
3118
|
+
* @returns {number|null}
|
|
3119
|
+
*/
|
|
3120
|
+
yToPrice(y) {
|
|
3121
|
+
if (!isNum(y)) return null;
|
|
3122
|
+
return this._yToPrice(y);
|
|
3123
|
+
}
|
|
3124
|
+
|
|
2704
3125
|
/* ------------------------------------------------------------ *
|
|
2705
3126
|
* Interaction
|
|
2706
3127
|
* ------------------------------------------------------------ */
|
|
@@ -2712,9 +3133,23 @@ class WickChart extends HTMLElementBase {
|
|
|
2712
3133
|
|
|
2713
3134
|
_pointerDown(e) {
|
|
2714
3135
|
if (e.button !== 0) return;
|
|
3136
|
+
this._stopPlayback(); // any touch interrupts the story
|
|
2715
3137
|
this._canvas.setPointerCapture(e.pointerId);
|
|
2716
3138
|
const pt = this._localPoint(e);
|
|
2717
3139
|
this._pointers.set(e.pointerId, pt);
|
|
3140
|
+
if (this._layerClaim) return; // a layer owns a gesture: extra pointers are inert
|
|
3141
|
+
if (this._layers.length) {
|
|
3142
|
+
// layers get first claim on the pointer (hit-test their content);
|
|
3143
|
+
// a claim suppresses pinch/measure/brush/pan for this gesture
|
|
3144
|
+
const layer = this._layerHit(e, pt);
|
|
3145
|
+
if (layer) {
|
|
3146
|
+
this._pan = null;
|
|
3147
|
+
this._measuring = false;
|
|
3148
|
+
this._brushDrag = null;
|
|
3149
|
+
this._layerClaim = { layer, pointerId: e.pointerId };
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
2718
3153
|
if (this._pointers.size === 2) {
|
|
2719
3154
|
const [a, b] = [...this._pointers.values()];
|
|
2720
3155
|
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
@@ -2726,6 +3161,15 @@ class WickChart extends HTMLElementBase {
|
|
|
2726
3161
|
};
|
|
2727
3162
|
this._pan = null;
|
|
2728
3163
|
this._measuring = false;
|
|
3164
|
+
this._brushDrag = null;
|
|
3165
|
+
} else if (this._brush && !e.shiftKey && this._data.length) {
|
|
3166
|
+
// brush mode: plain drag selects a bar range (shift still measures)
|
|
3167
|
+
const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
|
|
3168
|
+
this._brushDrag = { i0: idx, i1: idx };
|
|
3169
|
+
this._brushSel = null;
|
|
3170
|
+
this._measuring = false;
|
|
3171
|
+
this._pan = null;
|
|
3172
|
+
this._invalidate();
|
|
2729
3173
|
} else if (e.shiftKey && this._data.length) {
|
|
2730
3174
|
// shift+drag → measure tool
|
|
2731
3175
|
this._measuring = true;
|
|
@@ -2746,6 +3190,12 @@ class WickChart extends HTMLElementBase {
|
|
|
2746
3190
|
}
|
|
2747
3191
|
|
|
2748
3192
|
_pointerMove(e) {
|
|
3193
|
+
if (this._layerClaim) {
|
|
3194
|
+
if (this._layerClaim.pointerId === e.pointerId) {
|
|
3195
|
+
this._routeLayer(this._layerClaim.layer, e, this._localPoint(e), 'move');
|
|
3196
|
+
}
|
|
3197
|
+
return; // inert while a layer owns the gesture
|
|
3198
|
+
}
|
|
2749
3199
|
const pt = this._localPoint(e);
|
|
2750
3200
|
if (this._pointers.has(e.pointerId)) this._pointers.set(e.pointerId, pt);
|
|
2751
3201
|
const ly = this._ly;
|
|
@@ -2777,6 +3227,13 @@ class WickChart extends HTMLElementBase {
|
|
|
2777
3227
|
return;
|
|
2778
3228
|
}
|
|
2779
3229
|
|
|
3230
|
+
if (this._brushDrag && this._pointers.has(e.pointerId) && this._data.length) {
|
|
3231
|
+
const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
|
|
3232
|
+
this._brushDrag.i1 = idx;
|
|
3233
|
+
this._invalidate();
|
|
3234
|
+
return;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
2780
3237
|
if (this._pan && this._pointers.has(e.pointerId) && ly) {
|
|
2781
3238
|
const dx = pt.x - this._pan.x;
|
|
2782
3239
|
if (Math.abs(dx) > 3) this._pan.moved = true;
|
|
@@ -2798,11 +3255,23 @@ class WickChart extends HTMLElementBase {
|
|
|
2798
3255
|
}
|
|
2799
3256
|
|
|
2800
3257
|
_pointerUp(e) {
|
|
3258
|
+
const claim = this._layerClaim;
|
|
3259
|
+
if (claim && claim.pointerId === e.pointerId) {
|
|
3260
|
+
this._layerClaim = null;
|
|
3261
|
+
this._pointers.delete(e.pointerId);
|
|
3262
|
+
this._canvas.classList.remove('grabbing');
|
|
3263
|
+
this._routeLayer(claim.layer, e, this._localPoint(e), e.type === 'pointercancel' ? 'cancel' : 'up');
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
2801
3266
|
const had = this._pointers.delete(e.pointerId);
|
|
2802
3267
|
if (this._pointers.size < 2) this._pinch = null;
|
|
2803
3268
|
if (this._pointers.size === 0) {
|
|
2804
3269
|
this._canvas.classList.remove('grabbing');
|
|
2805
|
-
if (this.
|
|
3270
|
+
if (this._brushDrag && had) {
|
|
3271
|
+
const b = this._brushDrag;
|
|
3272
|
+
this._brushDrag = null;
|
|
3273
|
+
this._brushFinish(Math.min(b.i0, b.i1), Math.max(b.i0, b.i1));
|
|
3274
|
+
} else if (this._measuring) {
|
|
2806
3275
|
this._measuring = false;
|
|
2807
3276
|
if (this._measure) {
|
|
2808
3277
|
this._measure.done = true;
|
|
@@ -2846,6 +3315,7 @@ class WickChart extends HTMLElementBase {
|
|
|
2846
3315
|
_wheel(e) {
|
|
2847
3316
|
const ly = this._ly;
|
|
2848
3317
|
if (!ly || !this._data.length) return;
|
|
3318
|
+
this._stopPlayback();
|
|
2849
3319
|
e.preventDefault();
|
|
2850
3320
|
const pt = this._localPoint(e);
|
|
2851
3321
|
const dx = e.deltaX;
|
|
@@ -2879,6 +3349,18 @@ class WickChart extends HTMLElementBase {
|
|
|
2879
3349
|
_keydown(e) {
|
|
2880
3350
|
const ly = this._ly;
|
|
2881
3351
|
if (!ly || !this._data.length) return;
|
|
3352
|
+
this._stopPlayback();
|
|
3353
|
+
if (e.key === 'Escape' && this._layerClaim) {
|
|
3354
|
+
const claim = this._layerClaim;
|
|
3355
|
+
this._layerClaim = null;
|
|
3356
|
+
this._routeLayer(claim.layer, { pointerId: claim.pointerId }, null, 'cancel');
|
|
3357
|
+
this._invalidate();
|
|
3358
|
+
return;
|
|
3359
|
+
}
|
|
3360
|
+
if (e.key === 'Escape' && (this._brushSel || this._brushDrag)) {
|
|
3361
|
+
this.clearBrush();
|
|
3362
|
+
return;
|
|
3363
|
+
}
|
|
2882
3364
|
const d = this._data;
|
|
2883
3365
|
const key = e.key;
|
|
2884
3366
|
const step = e.shiftKey ? 10 : 1;
|
|
@@ -3067,11 +3549,18 @@ class WickChart extends HTMLElementBase {
|
|
|
3067
3549
|
} catch (_) {}
|
|
3068
3550
|
this._coviewCh = null;
|
|
3069
3551
|
}
|
|
3552
|
+
clearInterval(this._coviewBeat);
|
|
3553
|
+
this._coviewBeat = 0;
|
|
3070
3554
|
clearTimeout(this._ghostTimer);
|
|
3071
3555
|
if (this._ghost) {
|
|
3072
3556
|
this._ghost = null;
|
|
3073
3557
|
this._invalidate();
|
|
3074
3558
|
}
|
|
3559
|
+
if (this._presence && this._presence.peers.size) {
|
|
3560
|
+
const left = this._presence.list();
|
|
3561
|
+
this._presence = new PresenceTracker();
|
|
3562
|
+
this._fire('peers', { peers: [], joined: [], left });
|
|
3563
|
+
}
|
|
3075
3564
|
const name = this._coviewName;
|
|
3076
3565
|
if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
|
|
3077
3566
|
if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
|
|
@@ -3080,6 +3569,33 @@ class WickChart extends HTMLElementBase {
|
|
|
3080
3569
|
ch.onmessage = (ev) => this._onCoMessage(ev.data);
|
|
3081
3570
|
this._coviewCh = ch;
|
|
3082
3571
|
} catch (_) {}
|
|
3572
|
+
// presence: announce immediately, then heartbeat so idle peers stay
|
|
3573
|
+
// warm (and stale ones sweep) without waiting for a pan/zoom
|
|
3574
|
+
this._coviewSendView(true);
|
|
3575
|
+
this._coviewBeat = setInterval(() => {
|
|
3576
|
+
this._coviewSendView(true);
|
|
3577
|
+
const left = this._presence.sweep();
|
|
3578
|
+
if (left.length) {
|
|
3579
|
+
this._fire('peers', { peers: this._presence.list(), joined: [], left });
|
|
3580
|
+
this._invalidate();
|
|
3581
|
+
}
|
|
3582
|
+
}, 4000);
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
/** Broadcast our visible range for presence; throttled unless forced. */
|
|
3586
|
+
_coviewSendView(force) {
|
|
3587
|
+
if (!this._coviewCh) return;
|
|
3588
|
+
const r = this.getVisibleRange();
|
|
3589
|
+
if (!r) return;
|
|
3590
|
+
const now = performance.now();
|
|
3591
|
+
if (!force && now - this._coviewViewLast < 120) return;
|
|
3592
|
+
this._coviewViewLast = now;
|
|
3593
|
+
this._coviewSend({
|
|
3594
|
+
type: 'view',
|
|
3595
|
+
from: r.from,
|
|
3596
|
+
to: r.to,
|
|
3597
|
+
name: this._coviewLabel || null,
|
|
3598
|
+
});
|
|
3083
3599
|
}
|
|
3084
3600
|
|
|
3085
3601
|
_coviewSend(msg) {
|
|
@@ -3090,7 +3606,30 @@ class WickChart extends HTMLElementBase {
|
|
|
3090
3606
|
}
|
|
3091
3607
|
|
|
3092
3608
|
_onCoMessage(m) {
|
|
3093
|
-
if (!m || m.v !== 1 || m.peer === this._coviewPeer
|
|
3609
|
+
if (!m || m.v !== 1 || m.peer === this._coviewPeer) return;
|
|
3610
|
+
if (m.type === 'view') {
|
|
3611
|
+
const joined = this._presence.track(m.peer, {
|
|
3612
|
+
range: { from: m.from, to: m.to },
|
|
3613
|
+
name: m.name,
|
|
3614
|
+
});
|
|
3615
|
+
this._invalidate();
|
|
3616
|
+
if (joined) {
|
|
3617
|
+
const p = this._presence.peers.get(m.peer);
|
|
3618
|
+
this._fire('peers', {
|
|
3619
|
+
peers: this._presence.list(),
|
|
3620
|
+
joined: [p ? { ...p, range: p.range && { ...p.range } } : { id: m.peer }],
|
|
3621
|
+
left: [],
|
|
3622
|
+
});
|
|
3623
|
+
}
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
if (m.type === 'bye') {
|
|
3627
|
+
const left = this._presence.drop(m.peer);
|
|
3628
|
+
if (left) this._fire('peers', { peers: this._presence.list(), joined: [], left: [left] });
|
|
3629
|
+
this._invalidate();
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
if (m.type !== 'cross') return;
|
|
3094
3633
|
if (m.time == null) {
|
|
3095
3634
|
if (this._ghost) {
|
|
3096
3635
|
this._ghost = null;
|
|
@@ -3120,10 +3659,297 @@ class WickChart extends HTMLElementBase {
|
|
|
3120
3659
|
this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
|
|
3121
3660
|
}
|
|
3122
3661
|
|
|
3662
|
+
/**
|
|
3663
|
+
* Live co-view peers: who else is in the room and the time window each
|
|
3664
|
+
* one is looking at — [{ id, name, range: {from, to}, at }], oldest
|
|
3665
|
+
* sighting first. Peers fade out ~12 s after their last sighting.
|
|
3666
|
+
* @returns {object[]}
|
|
3667
|
+
*/
|
|
3668
|
+
getPeers() {
|
|
3669
|
+
return this._presence ? this._presence.list() : [];
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
/**
|
|
3673
|
+
* Narrated timeline for a window (default: the visible range) — pivot
|
|
3674
|
+
* highs/lows, volume spikes, gaps, RSI divergences plus derived legs
|
|
3675
|
+
* ("+12.4% over 38 bars"), sorted by index. Pure data, perfect for
|
|
3676
|
+
* caption UIs or the walk player.
|
|
3677
|
+
* chart.narrate(); // visible range
|
|
3678
|
+
* chart.narrate({ from, to }); // times in ms (s accepted)
|
|
3679
|
+
* @param {{from?: number, to?: number}} [range]
|
|
3680
|
+
* @returns {{i: number, time: number, type: string, side: string, note: string,
|
|
3681
|
+
* legPct?: number, legBars?: number}[]}
|
|
3682
|
+
*/
|
|
3683
|
+
narrate(range) {
|
|
3684
|
+
const d = this._data;
|
|
3685
|
+
if (!d.length) return [];
|
|
3686
|
+
let i0 = 0;
|
|
3687
|
+
let i1 = d.length - 1;
|
|
3688
|
+
if (range && isNum(range.from) && isNum(range.to)) {
|
|
3689
|
+
i0 = WickChart._indexForTime(d, WickChart._timeToMs(range.from));
|
|
3690
|
+
i1 = WickChart._indexForTime(d, WickChart._timeToMs(range.to));
|
|
3691
|
+
if (i0 > i1) [i0, i1] = [i1, i0];
|
|
3692
|
+
}
|
|
3693
|
+
return narrateWindow(d, i0, i1);
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
/**
|
|
3697
|
+
* Walk the chart through history like a story: the viewport slides
|
|
3698
|
+
* from `from` to `to` while `wick:walk` events announce every step and
|
|
3699
|
+
* the narrator's events (spikes, gaps, pivots, legs) as they're crossed.
|
|
3700
|
+
* Any user interaction — pointer, wheel, keys, double-click — stops it.
|
|
3701
|
+
* chart.walk({ from: 0, to: 500, speed: 120, step: 10 });
|
|
3702
|
+
* chart.addEventListener('wick:walk', (e) => showCaption(e.detail));
|
|
3703
|
+
* // detail: { phase: 'step'|'end'|'stop', index, events: [...], from, to }
|
|
3704
|
+
* @param {{from?: number, to?: number, speed?: number, step?: number}} [opts]
|
|
3705
|
+
* from/to are bar indices (default: last ~500 bars → the end)
|
|
3706
|
+
* @returns {boolean} true when the walk started
|
|
3707
|
+
*/
|
|
3708
|
+
walk(opts = {}) {
|
|
3709
|
+
this.stopWalk(true);
|
|
3710
|
+
const d = this._data;
|
|
3711
|
+
if (!d.length || !this._connected) return false;
|
|
3712
|
+
const to = clamp(Math.round(+opts.to || d.length - 1), 0, d.length - 1);
|
|
3713
|
+
const from = clamp(Math.round(opts.from != null ? +opts.from : Math.max(0, to - 500)), 0, to);
|
|
3714
|
+
const span = to - from + 1;
|
|
3715
|
+
// window width: the current viewport, but never more than ~⅓ of the
|
|
3716
|
+
// span (a fully zoomed-out chart would otherwise start at `to`)
|
|
3717
|
+
const widthBars = clamp(
|
|
3718
|
+
Math.min(
|
|
3719
|
+
this._ly ? Math.round(this._ly.plotRight / this._view.spacing) : 120,
|
|
3720
|
+
Math.max(10, Math.ceil(span / 3))
|
|
3721
|
+
),
|
|
3722
|
+
10,
|
|
3723
|
+
span
|
|
3724
|
+
);
|
|
3725
|
+
const events = narrateWindow(d, from, to, { pivot: 8 });
|
|
3726
|
+
const speed = clamp(Math.round(+opts.speed || 120), 16, 2000);
|
|
3727
|
+
const step = clamp(Math.round(+opts.step || Math.max(1, Math.round(widthBars / 12))), 1, 500);
|
|
3728
|
+
let cursor = Math.min(from + widthBars - 1, to);
|
|
3729
|
+
let ev = 0;
|
|
3730
|
+
let ended = false;
|
|
3731
|
+
const tick = () => {
|
|
3732
|
+
if (ended) return;
|
|
3733
|
+
this._auto = false;
|
|
3734
|
+
this._view.rightIndex = cursor;
|
|
3735
|
+
this._clampView();
|
|
3736
|
+
this._invalidate();
|
|
3737
|
+
this._emitRange();
|
|
3738
|
+
const hits = [];
|
|
3739
|
+
while (ev < events.length && events[ev].i <= cursor) hits.push(events[ev++]);
|
|
3740
|
+
this._fire('walk', { phase: 'step', index: cursor, events: hits, from, to });
|
|
3741
|
+
if (cursor >= to) {
|
|
3742
|
+
ended = true;
|
|
3743
|
+
clearInterval(this._walkTimer);
|
|
3744
|
+
this._walkTimer = 0;
|
|
3745
|
+
this._fire('walk', { phase: 'end', index: cursor, events: [], from, to });
|
|
3746
|
+
} else {
|
|
3747
|
+
cursor = Math.min(cursor + step, to);
|
|
3748
|
+
}
|
|
3749
|
+
};
|
|
3750
|
+
this._walkTimer = setInterval(tick, speed);
|
|
3751
|
+
tick(); // first step lands immediately
|
|
3752
|
+
return true;
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
/**
|
|
3756
|
+
* Stop the running walk (if any). Fires a final `wick:walk`
|
|
3757
|
+
* { phase: 'stop' } unless called internally.
|
|
3758
|
+
*/
|
|
3759
|
+
stopWalk(silent) {
|
|
3760
|
+
if (!this._walkTimer) return;
|
|
3761
|
+
clearInterval(this._walkTimer);
|
|
3762
|
+
this._walkTimer = 0;
|
|
3763
|
+
if (!silent) this._fire('walk', { phase: 'stop' });
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
/**
|
|
3767
|
+
* Commit a brush selection over [i0, i1]: stores it (draws the band
|
|
3768
|
+
* and delta chip) and fires `wick:brush` with the range statistics.
|
|
3769
|
+
* @param {number} i0 first index
|
|
3770
|
+
* @param {number} i1 last index
|
|
3771
|
+
*/
|
|
3772
|
+
_brushFinish(i0, i1) {
|
|
3773
|
+
if (!this._data.length) return;
|
|
3774
|
+
const stats = brushStats(this._data, i0, i1);
|
|
3775
|
+
if (!stats) {
|
|
3776
|
+
this._brushSel = null;
|
|
3777
|
+
this._invalidate();
|
|
3778
|
+
return;
|
|
3779
|
+
}
|
|
3780
|
+
this._brushSel = { i0, i1, stats };
|
|
3781
|
+
this._invalidate();
|
|
3782
|
+
this._fire('brush', stats);
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
/** Clear the committed brush selection (if any). Escape does the same. */
|
|
3786
|
+
clearBrush() {
|
|
3787
|
+
if (this._brushSel || this._brushDrag) {
|
|
3788
|
+
this._brushSel = null;
|
|
3789
|
+
this._brushDrag = null;
|
|
3790
|
+
this._invalidate();
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
/** @returns {object|null} the committed selection { i0, i1, stats } */
|
|
3795
|
+
get brushSelection() {
|
|
3796
|
+
if (!this._brushSel) return null;
|
|
3797
|
+
const { i0, i1, stats } = this._brushSel;
|
|
3798
|
+
return { i0, i1, stats: { ...stats, from: { ...stats.from }, to: { ...stats.to } } };
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
/**
|
|
3802
|
+
* Capture the current chart state as a story scene: view, series type,
|
|
3803
|
+
* indicators, overlays, scenario and risk plan, plus a title/note.
|
|
3804
|
+
* Build guided tours by capturing several and playing them back.
|
|
3805
|
+
* const story = [
|
|
3806
|
+
* chart.captureScene('Overview', 'The full picture'),
|
|
3807
|
+
* { title: 'The breakout', range: { from, to }, indicators: 'sma:20' },
|
|
3808
|
+
* ];
|
|
3809
|
+
* chart.playStory(story);
|
|
3810
|
+
* @param {string} [title]
|
|
3811
|
+
* @param {string} [note]
|
|
3812
|
+
* @returns {object} scene (plain data — snapshot of the moment)
|
|
3813
|
+
*/
|
|
3814
|
+
captureScene(title, note) {
|
|
3815
|
+
const scene = {
|
|
3816
|
+
title: title != null ? String(title).slice(0, 60) : '',
|
|
3817
|
+
note: note != null ? String(note).slice(0, 200) : '',
|
|
3818
|
+
range: this.getVisibleRange() || undefined,
|
|
3819
|
+
type: this.getAttribute('type') || 'candles',
|
|
3820
|
+
indicators: this.getAttribute('indicators') || null,
|
|
3821
|
+
};
|
|
3822
|
+
const ovs = this.overlays;
|
|
3823
|
+
if (ovs.length) scene.overlays = ovs;
|
|
3824
|
+
const sc = this.scenario;
|
|
3825
|
+
if (sc) scene.scenario = sc;
|
|
3826
|
+
const rp = this.riskPlan;
|
|
3827
|
+
if (rp) scene.riskPlan = rp;
|
|
3828
|
+
return scene;
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
/** @returns {object[]|null} a copy of the last played story */
|
|
3832
|
+
getStory() {
|
|
3833
|
+
return this._story ? this._story.map((s) => ({ ...s })) : null;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
/**
|
|
3837
|
+
* Play a story: each scene applies its state (type / indicators /
|
|
3838
|
+
* overlays / scenario / risk plan — set or clear), the camera eases
|
|
3839
|
+
* to its range, then holds for its dwell. `wick:story` events narrate:
|
|
3840
|
+
* { phase: 'scene' | 'end' | 'stop', index, total, scene, title, note }
|
|
3841
|
+
* Any user interaction — pointer, wheel, keys, double-click — stops it.
|
|
3842
|
+
* @param {object[]} story scenes (invalid entries dropped, max 20)
|
|
3843
|
+
* @param {{dwell?: number, panMs?: number, loop?: boolean}} [opts]
|
|
3844
|
+
* panMs clamps 100–5000 (default 900); loop replays forever
|
|
3845
|
+
* @returns {boolean} true when playback started
|
|
3846
|
+
*/
|
|
3847
|
+
playStory(story, opts = {}) {
|
|
3848
|
+
this.stopStory(true);
|
|
3849
|
+
const scenes = sceneList(story);
|
|
3850
|
+
if (!scenes.length || !this._data.length || !this._connected) return false;
|
|
3851
|
+
const token = ++this._storyToken;
|
|
3852
|
+
this._story = scenes;
|
|
3853
|
+
const panMs = clamp(Math.round(+opts.panMs || 900), 100, 5000);
|
|
3854
|
+
const loop = opts.loop === true;
|
|
3855
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3856
|
+
const run = async () => {
|
|
3857
|
+
let idx = 0;
|
|
3858
|
+
while (token === this._storyToken) {
|
|
3859
|
+
const sc = scenes[idx];
|
|
3860
|
+
this._fire('story', {
|
|
3861
|
+
phase: 'scene', index: idx, total: scenes.length,
|
|
3862
|
+
scene: sc, title: sc.title, note: sc.note,
|
|
3863
|
+
});
|
|
3864
|
+
this._applyScene(sc);
|
|
3865
|
+
const target = this._sceneTarget(sc);
|
|
3866
|
+
if (target) await this._storyTween(target, panMs, token);
|
|
3867
|
+
if (token !== this._storyToken) return;
|
|
3868
|
+
await wait(sc.dwell);
|
|
3869
|
+
if (token !== this._storyToken) return;
|
|
3870
|
+
idx++;
|
|
3871
|
+
if (idx >= scenes.length) {
|
|
3872
|
+
if (loop) idx = 0;
|
|
3873
|
+
else {
|
|
3874
|
+
this._fire('story', { phase: 'end', index: idx - 1, total: scenes.length });
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
};
|
|
3880
|
+
run();
|
|
3881
|
+
return true;
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
/**
|
|
3885
|
+
* Stop story playback (if running). Fires a final `wick:story`
|
|
3886
|
+
* { phase: 'stop' } unless called internally.
|
|
3887
|
+
*/
|
|
3888
|
+
stopStory(silent) {
|
|
3889
|
+
if (!this._storyToken) return;
|
|
3890
|
+
this._storyToken = 0;
|
|
3891
|
+
if (!silent) this._fire('story', { phase: 'stop' });
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
/** Apply a scene's state (only the fields it carries). */
|
|
3895
|
+
_applyScene(sc) {
|
|
3896
|
+
if (sc.type) this.setAttribute('type', sc.type);
|
|
3897
|
+
if (sc.indicators != null) this.setAttribute('indicators', sc.indicators);
|
|
3898
|
+
if (sc.overlays) this.setOverlays(sc.overlays);
|
|
3899
|
+
if (sc.scenario === 'clear') this.clearScenario();
|
|
3900
|
+
else if (sc.scenario) this.setScenario(sc.scenario);
|
|
3901
|
+
if (sc.riskPlan === 'clear') this.clearRiskPlan();
|
|
3902
|
+
else if (sc.riskPlan) this.setRiskPlan(sc.riskPlan);
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
/** Map a scene's time range to bar indices (null when not applicable). */
|
|
3906
|
+
_sceneTarget(sc) {
|
|
3907
|
+
if (!sc.range || !this._data.length) return null;
|
|
3908
|
+
let i0 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.from));
|
|
3909
|
+
let i1 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.to));
|
|
3910
|
+
if (i0 > i1) [i0, i1] = [i1, i0];
|
|
3911
|
+
return i1 - i0 >= 2 ? { i0, i1 } : null;
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
/** Ease the viewport to { i0, i1 } over `ms`; resolves early if the
|
|
3915
|
+
* token changes (superseded or stopped). rAF when available. */
|
|
3916
|
+
_storyTween(target, ms, token) {
|
|
3917
|
+
const ly = this._ly;
|
|
3918
|
+
const d = this._data;
|
|
3919
|
+
if (!ly || !d.length) return Promise.resolve();
|
|
3920
|
+
const sp1 = clamp(ly.plotRight / (target.i1 - target.i0), this._minSpacing(), WickChart._MAX_SP);
|
|
3921
|
+
const from = { right: this._view.rightIndex, sp: this._view.spacing };
|
|
3922
|
+
const to = { right: target.i1, sp: sp1 };
|
|
3923
|
+
const t0 = performance.now();
|
|
3924
|
+
this._auto = false;
|
|
3925
|
+
return new Promise((resolve) => {
|
|
3926
|
+
const step = () => {
|
|
3927
|
+
if (token !== this._storyToken) return resolve();
|
|
3928
|
+
const e = easeInOutCubic(Math.min(1, (performance.now() - t0) / ms));
|
|
3929
|
+
this._view.rightIndex = from.right + (to.right - from.right) * e;
|
|
3930
|
+
this._view.spacing = from.sp + (to.sp - from.sp) * e;
|
|
3931
|
+
this._clampView();
|
|
3932
|
+
this._invalidate();
|
|
3933
|
+
this._emitRange();
|
|
3934
|
+
if (e >= 1) resolve();
|
|
3935
|
+
else if (typeof requestAnimationFrame === 'function') requestAnimationFrame(step);
|
|
3936
|
+
else setTimeout(step, 16);
|
|
3937
|
+
};
|
|
3938
|
+
step();
|
|
3939
|
+
});
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
/** Interrupt narrated playback (walk / story) on user input. */
|
|
3943
|
+
_stopPlayback() {
|
|
3944
|
+
if (this._walkTimer) this.stopWalk();
|
|
3945
|
+
if (this._storyToken) this.stopStory();
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3123
3948
|
_emitRange() {
|
|
3124
3949
|
const r = this.getVisibleRange();
|
|
3125
3950
|
if (!r) return;
|
|
3126
3951
|
this._fire('range', r);
|
|
3952
|
+
if (this._coviewCh) this._coviewSendView();
|
|
3127
3953
|
}
|
|
3128
3954
|
}
|
|
3129
3955
|
|