wickchart 1.2.0 → 1.3.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 +98 -2
- package/package.json +1 -1
- package/src/core.js +320 -0
- package/src/wick-chart.js +587 -5
- package/types/core.d.ts +156 -0
- package/types/wick-chart.d.ts +182 -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,6 +220,15 @@ 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();
|
|
@@ -233,6 +249,8 @@ class WickChart extends HTMLElementBase {
|
|
|
233
249
|
|
|
234
250
|
// scenario projection (ghost path + vol cone)
|
|
235
251
|
this._scenario = null;
|
|
252
|
+
// risk plan (R-multiple grid)
|
|
253
|
+
this._riskPlan = null;
|
|
236
254
|
|
|
237
255
|
this._onResize = () => this._invalidate();
|
|
238
256
|
this._onPointerDown = (e) => this._pointerDown(e);
|
|
@@ -246,7 +264,10 @@ class WickChart extends HTMLElementBase {
|
|
|
246
264
|
}
|
|
247
265
|
};
|
|
248
266
|
this._onWheel = (e) => this._wheel(e);
|
|
249
|
-
this._onDbl = () =>
|
|
267
|
+
this._onDbl = () => {
|
|
268
|
+
this._stopPlayback();
|
|
269
|
+
this.fit();
|
|
270
|
+
};
|
|
250
271
|
this._onKey = (e) => this._keydown(e);
|
|
251
272
|
}
|
|
252
273
|
|
|
@@ -280,12 +301,22 @@ class WickChart extends HTMLElementBase {
|
|
|
280
301
|
|
|
281
302
|
disconnectedCallback() {
|
|
282
303
|
this._connected = false;
|
|
304
|
+
this.stopWalk(true);
|
|
305
|
+
this.stopStory(true);
|
|
283
306
|
if (this._coviewCh) {
|
|
307
|
+
this._coviewSend({ type: 'bye' });
|
|
284
308
|
try {
|
|
285
309
|
this._coviewCh.close();
|
|
286
310
|
} catch (_) {}
|
|
287
311
|
this._coviewCh = null;
|
|
288
312
|
}
|
|
313
|
+
clearInterval(this._coviewBeat);
|
|
314
|
+
this._coviewBeat = 0;
|
|
315
|
+
if (this._presence && this._presence.peers.size) {
|
|
316
|
+
const left = this._presence.list();
|
|
317
|
+
this._presence = new PresenceTracker();
|
|
318
|
+
this._fire('peers', { peers: [], joined: [], left });
|
|
319
|
+
}
|
|
289
320
|
clearTimeout(this._ghostTimer);
|
|
290
321
|
if (this._ro) this._ro.disconnect();
|
|
291
322
|
const cv = this._canvas;
|
|
@@ -355,6 +386,16 @@ class WickChart extends HTMLElementBase {
|
|
|
355
386
|
this._coviewName = val || null;
|
|
356
387
|
this._setupCoView();
|
|
357
388
|
break;
|
|
389
|
+
case 'co-view-name':
|
|
390
|
+
// display name travels with every presence message; no re-render
|
|
391
|
+
this._coviewLabel = val || null;
|
|
392
|
+
break;
|
|
393
|
+
case 'brush':
|
|
394
|
+
this._brush = val != null && val !== 'false';
|
|
395
|
+
this._brushSel = null;
|
|
396
|
+
this._brushDrag = null;
|
|
397
|
+
this._invalidate();
|
|
398
|
+
break;
|
|
358
399
|
case 'sonify':
|
|
359
400
|
this._sonify = val != null && val !== 'false';
|
|
360
401
|
this._lastToneIdx = -1;
|
|
@@ -424,6 +465,8 @@ class WickChart extends HTMLElementBase {
|
|
|
424
465
|
this.clearData();
|
|
425
466
|
return;
|
|
426
467
|
}
|
|
468
|
+
// selection indices are data-bound; a replacement invalidates them
|
|
469
|
+
if (this._brushSel || this._brushDrag) this.clearBrush();
|
|
427
470
|
const norm = [];
|
|
428
471
|
for (const b of bars) {
|
|
429
472
|
const nb = WickChart._normBar(b);
|
|
@@ -910,6 +953,41 @@ class WickChart extends HTMLElementBase {
|
|
|
910
953
|
return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
|
|
911
954
|
}
|
|
912
955
|
|
|
956
|
+
/**
|
|
957
|
+
* Risk plan: an R-multiple grid anchored at entry/stop. 1R = |entry −
|
|
958
|
+
* stop| (the risk unit); reward lines are drawn at kR beyond the entry
|
|
959
|
+
* with the risk/reward zones shaded, so sizing and take-profit choices
|
|
960
|
+
* read directly off the chart.
|
|
961
|
+
*
|
|
962
|
+
* chart.setRiskPlan({ entry: 64500, stop: 63800, multiples: [1, 2, 3] });
|
|
963
|
+
* chart.setRiskPlan({ entry: 64500, stop: 63800, targets: [65900, 67300] });
|
|
964
|
+
*
|
|
965
|
+
* Direction is derived (stop below entry ⇒ long). Targets convert to
|
|
966
|
+
* their R multiple; `multiples` win when both are given. Invalid specs
|
|
967
|
+
* clear the plan (replace semantics, like setScenario); excluded from
|
|
968
|
+
* getState/setState — it is app state, not chart state.
|
|
969
|
+
* @param {object} spec
|
|
970
|
+
* @returns {object|null} the normalized plan, or null when invalid
|
|
971
|
+
*/
|
|
972
|
+
setRiskPlan(spec) {
|
|
973
|
+
this._riskPlan = normalizeRiskPlan(spec);
|
|
974
|
+
this._invalidate();
|
|
975
|
+
return this._riskPlan;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
clearRiskPlan() {
|
|
979
|
+
if (this._riskPlan) {
|
|
980
|
+
this._riskPlan = null;
|
|
981
|
+
this._invalidate();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** @returns {object|null} a copy of the active risk plan */
|
|
986
|
+
get riskPlan() {
|
|
987
|
+
if (!this._riskPlan) return null;
|
|
988
|
+
return { ...this._riskPlan, levels: this._riskPlan.levels.map((l) => ({ ...l })) };
|
|
989
|
+
}
|
|
990
|
+
|
|
913
991
|
/** σ-cone for the active scenario, cached per data version. */
|
|
914
992
|
_scenarioConeCache() {
|
|
915
993
|
if (!this._scenario || !this._data.length) return null;
|
|
@@ -1799,6 +1877,67 @@ class WickChart extends HTMLElementBase {
|
|
|
1799
1877
|
}
|
|
1800
1878
|
}
|
|
1801
1879
|
|
|
1880
|
+
/* risk plan: R-multiple grid — risk/reward shading + kR lines */
|
|
1881
|
+
if (this._riskPlan && d.length) {
|
|
1882
|
+
const rp = this._riskPlan;
|
|
1883
|
+
const fP = numberFmt(this._prec(scale.rawHi || 1));
|
|
1884
|
+
const yE = yOf(rp.entry);
|
|
1885
|
+
const yS = yOf(rp.stop);
|
|
1886
|
+
ctx.fillStyle = hexToRgba(pal.down, 0.06);
|
|
1887
|
+
ctx.fillRect(0, Math.min(yE, yS), plotRight, Math.abs(yS - yE));
|
|
1888
|
+
const yTop = yOf(rp.levels[rp.levels.length - 1].price);
|
|
1889
|
+
ctx.fillStyle = hexToRgba(pal.up, 0.05);
|
|
1890
|
+
ctx.fillRect(0, Math.min(yE, yTop), plotRight, Math.abs(yTop - yE));
|
|
1891
|
+
const line = (p, col, dash) => {
|
|
1892
|
+
const y = yOf(p);
|
|
1893
|
+
if (y < main.y0 || y > main.y1) return null;
|
|
1894
|
+
ctx.strokeStyle = col;
|
|
1895
|
+
ctx.lineWidth = 1.5;
|
|
1896
|
+
if (dash) ctx.setLineDash([5, 4]);
|
|
1897
|
+
ctx.beginPath();
|
|
1898
|
+
ctx.moveTo(0, Math.round(y) + 0.5);
|
|
1899
|
+
ctx.lineTo(plotRight, Math.round(y) + 0.5);
|
|
1900
|
+
ctx.stroke();
|
|
1901
|
+
ctx.setLineDash([]);
|
|
1902
|
+
ctx.lineWidth = 1;
|
|
1903
|
+
return y;
|
|
1904
|
+
};
|
|
1905
|
+
const pills = [];
|
|
1906
|
+
for (let i = rp.levels.length - 1; i >= 0; i--) {
|
|
1907
|
+
const lv = rp.levels[i];
|
|
1908
|
+
const y = line(lv.price, pal.up, true);
|
|
1909
|
+
if (y != null) {
|
|
1910
|
+
const kk = lv.k % 1 === 0 ? lv.k : +lv.k.toFixed(2);
|
|
1911
|
+
pills.push({ y, text: `${kk}R ${fP.format(lv.price)}`, bg: pal.up });
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
const yStop = line(rp.stop, pal.down, false);
|
|
1915
|
+
if (yStop != null) pills.push({ y: yStop, text: `STOP ${fP.format(rp.stop)}`, bg: pal.down });
|
|
1916
|
+
const yEnt = line(rp.entry, pal.accent, false);
|
|
1917
|
+
if (yEnt != null) {
|
|
1918
|
+
pills.push({ y: yEnt, text: `${rp.direction === 'long' ? 'LONG' : 'SHORT'} ${fP.format(rp.entry)}`, bg: pal.accent });
|
|
1919
|
+
}
|
|
1920
|
+
// stack right-edge pills instead of letting close lines overlap
|
|
1921
|
+
pills.sort((a, b) => a.y - b.y);
|
|
1922
|
+
let lastY = -Infinity;
|
|
1923
|
+
for (const p of pills) {
|
|
1924
|
+
const y = Math.max(p.y, lastY + 20);
|
|
1925
|
+
lastY = y;
|
|
1926
|
+
ctx.font = pillFont();
|
|
1927
|
+
const tw = ctx.measureText(p.text).width + 12;
|
|
1928
|
+
this._pill(plotRight - tw - 8, y, p.text, p.bg, pal.pillText, 'left', tw);
|
|
1929
|
+
}
|
|
1930
|
+
if (rp.label) {
|
|
1931
|
+
ctx.font = pillFont();
|
|
1932
|
+
ctx.fillStyle = pal.accent;
|
|
1933
|
+
ctx.globalAlpha = 0.9;
|
|
1934
|
+
ctx.textAlign = 'left';
|
|
1935
|
+
ctx.textBaseline = 'bottom';
|
|
1936
|
+
ctx.fillText(rp.label, 8, clamp(yE, main.y0 + 14, main.y1) - 3);
|
|
1937
|
+
ctx.globalAlpha = 1;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1802
1941
|
/* volume profile (behind the series) */
|
|
1803
1942
|
if (this._profile) {
|
|
1804
1943
|
const pkey = `${i0}:${i1}:${this._version}`;
|
|
@@ -2458,6 +2597,34 @@ class WickChart extends HTMLElementBase {
|
|
|
2458
2597
|
);
|
|
2459
2598
|
}
|
|
2460
2599
|
|
|
2600
|
+
/* co-view presence: peer viewport bands along the top of the plot */
|
|
2601
|
+
if (this._presence && this._presence.peers.size && d.length) {
|
|
2602
|
+
const peers = this._presence.list().slice(0, 4);
|
|
2603
|
+
ctx.save();
|
|
2604
|
+
ctx.font = pillFont();
|
|
2605
|
+
for (let row = 0; row < peers.length; row++) {
|
|
2606
|
+
const p = peers[row];
|
|
2607
|
+
if (!p.range) continue;
|
|
2608
|
+
const cols = pal.overlay || [];
|
|
2609
|
+
const col = cols[(row + 1) % Math.max(cols.length, 1)] || pal.accent;
|
|
2610
|
+
const i0 = WickChart._indexForTime(this._data, p.range.from);
|
|
2611
|
+
const i1 = WickChart._indexForTime(this._data, p.range.to);
|
|
2612
|
+
const x0 = clamp(this._xFor(i0), 0, plotRight);
|
|
2613
|
+
const x1 = clamp(this._xFor(i1), 0, plotRight);
|
|
2614
|
+
const y = main.y0 + 2 + row * 5;
|
|
2615
|
+
ctx.globalAlpha = 0.8;
|
|
2616
|
+
ctx.fillStyle = col;
|
|
2617
|
+
ctx.fillRect(x0, y, Math.max(x1 - x0, 3), 3);
|
|
2618
|
+
if (x1 - x0 > 44) {
|
|
2619
|
+
ctx.globalAlpha = 0.95;
|
|
2620
|
+
ctx.textAlign = 'left';
|
|
2621
|
+
ctx.textBaseline = 'top';
|
|
2622
|
+
ctx.fillText(p.name || p.id, x0 + 3, y + 4);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
ctx.restore();
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2461
2628
|
/* co-view ghost crosshair (peer pointer from another tab/chart) */
|
|
2462
2629
|
if (this._ghost) {
|
|
2463
2630
|
const g = this._ghost;
|
|
@@ -2544,6 +2711,50 @@ class WickChart extends HTMLElementBase {
|
|
|
2544
2711
|
);
|
|
2545
2712
|
}
|
|
2546
2713
|
|
|
2714
|
+
/* delta brush selection: band + live delta chip */
|
|
2715
|
+
{
|
|
2716
|
+
const sel = this._brushDrag || this._brushSel;
|
|
2717
|
+
if (sel && d.length) {
|
|
2718
|
+
const bi0 = Math.min(sel.i0, sel.i1);
|
|
2719
|
+
const bi1 = Math.max(sel.i0, sel.i1);
|
|
2720
|
+
const xa = this._xFor(bi0) - this._view.spacing / 2;
|
|
2721
|
+
const xb = this._xFor(bi1) + this._view.spacing / 2;
|
|
2722
|
+
const bx0 = clamp(Math.min(xa, xb), 0, plotRight);
|
|
2723
|
+
const bx1 = clamp(Math.max(xa, xb), 0, plotRight);
|
|
2724
|
+
if (bx1 - bx0 > 1) {
|
|
2725
|
+
const live = this._brushDrag ? brushStats(this._data, bi0, bi1) : sel.stats;
|
|
2726
|
+
ctx.save();
|
|
2727
|
+
ctx.fillStyle = hexToRgba(pal.accent, this._brushDrag ? 0.13 : 0.08);
|
|
2728
|
+
ctx.fillRect(bx0, main.y0, bx1 - bx0, main.h);
|
|
2729
|
+
ctx.globalAlpha = 0.55;
|
|
2730
|
+
ctx.strokeStyle = pal.accent;
|
|
2731
|
+
ctx.lineWidth = 1;
|
|
2732
|
+
if (!this._brushDrag) ctx.setLineDash([4, 3]);
|
|
2733
|
+
ctx.strokeRect(bx0 + 0.5, main.y0 + 0.5, bx1 - bx0 - 1, main.h - 1);
|
|
2734
|
+
ctx.setLineDash([]);
|
|
2735
|
+
ctx.restore();
|
|
2736
|
+
if (live) {
|
|
2737
|
+
const fP = numberFmt(this._prec(Math.abs(live.lastClose) || 1));
|
|
2738
|
+
const sign = live.delta >= 0 ? '+' : '';
|
|
2739
|
+
const txt =
|
|
2740
|
+
`${sign}${live.deltaPct.toFixed(2)}% · ${live.bars} bars · ` +
|
|
2741
|
+
`H ${fP.format(live.high)} · L ${fP.format(live.low)} · Σvol ${fmtCompact(live.volume)}`;
|
|
2742
|
+
ctx.font = pillFont();
|
|
2743
|
+
const tw = ctx.measureText(txt).width + 12;
|
|
2744
|
+
this._pill(
|
|
2745
|
+
clamp((bx0 + bx1) / 2 - tw / 2, 2, plotRight - tw - 2),
|
|
2746
|
+
main.y0 + 11,
|
|
2747
|
+
txt,
|
|
2748
|
+
live.delta >= 0 ? pal.up : pal.down,
|
|
2749
|
+
pal.pillText,
|
|
2750
|
+
'left',
|
|
2751
|
+
tw
|
|
2752
|
+
);
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2547
2758
|
/* visible-range stats chip */
|
|
2548
2759
|
if (this._stats) {
|
|
2549
2760
|
const st = computeStats(d, i0, i1, this._dt);
|
|
@@ -2712,6 +2923,7 @@ class WickChart extends HTMLElementBase {
|
|
|
2712
2923
|
|
|
2713
2924
|
_pointerDown(e) {
|
|
2714
2925
|
if (e.button !== 0) return;
|
|
2926
|
+
this._stopPlayback(); // any touch interrupts the story
|
|
2715
2927
|
this._canvas.setPointerCapture(e.pointerId);
|
|
2716
2928
|
const pt = this._localPoint(e);
|
|
2717
2929
|
this._pointers.set(e.pointerId, pt);
|
|
@@ -2726,6 +2938,15 @@ class WickChart extends HTMLElementBase {
|
|
|
2726
2938
|
};
|
|
2727
2939
|
this._pan = null;
|
|
2728
2940
|
this._measuring = false;
|
|
2941
|
+
this._brushDrag = null;
|
|
2942
|
+
} else if (this._brush && !e.shiftKey && this._data.length) {
|
|
2943
|
+
// brush mode: plain drag selects a bar range (shift still measures)
|
|
2944
|
+
const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
|
|
2945
|
+
this._brushDrag = { i0: idx, i1: idx };
|
|
2946
|
+
this._brushSel = null;
|
|
2947
|
+
this._measuring = false;
|
|
2948
|
+
this._pan = null;
|
|
2949
|
+
this._invalidate();
|
|
2729
2950
|
} else if (e.shiftKey && this._data.length) {
|
|
2730
2951
|
// shift+drag → measure tool
|
|
2731
2952
|
this._measuring = true;
|
|
@@ -2777,6 +2998,13 @@ class WickChart extends HTMLElementBase {
|
|
|
2777
2998
|
return;
|
|
2778
2999
|
}
|
|
2779
3000
|
|
|
3001
|
+
if (this._brushDrag && this._pointers.has(e.pointerId) && this._data.length) {
|
|
3002
|
+
const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
|
|
3003
|
+
this._brushDrag.i1 = idx;
|
|
3004
|
+
this._invalidate();
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
|
|
2780
3008
|
if (this._pan && this._pointers.has(e.pointerId) && ly) {
|
|
2781
3009
|
const dx = pt.x - this._pan.x;
|
|
2782
3010
|
if (Math.abs(dx) > 3) this._pan.moved = true;
|
|
@@ -2802,7 +3030,11 @@ class WickChart extends HTMLElementBase {
|
|
|
2802
3030
|
if (this._pointers.size < 2) this._pinch = null;
|
|
2803
3031
|
if (this._pointers.size === 0) {
|
|
2804
3032
|
this._canvas.classList.remove('grabbing');
|
|
2805
|
-
if (this.
|
|
3033
|
+
if (this._brushDrag && had) {
|
|
3034
|
+
const b = this._brushDrag;
|
|
3035
|
+
this._brushDrag = null;
|
|
3036
|
+
this._brushFinish(Math.min(b.i0, b.i1), Math.max(b.i0, b.i1));
|
|
3037
|
+
} else if (this._measuring) {
|
|
2806
3038
|
this._measuring = false;
|
|
2807
3039
|
if (this._measure) {
|
|
2808
3040
|
this._measure.done = true;
|
|
@@ -2846,6 +3078,7 @@ class WickChart extends HTMLElementBase {
|
|
|
2846
3078
|
_wheel(e) {
|
|
2847
3079
|
const ly = this._ly;
|
|
2848
3080
|
if (!ly || !this._data.length) return;
|
|
3081
|
+
this._stopPlayback();
|
|
2849
3082
|
e.preventDefault();
|
|
2850
3083
|
const pt = this._localPoint(e);
|
|
2851
3084
|
const dx = e.deltaX;
|
|
@@ -2879,6 +3112,11 @@ class WickChart extends HTMLElementBase {
|
|
|
2879
3112
|
_keydown(e) {
|
|
2880
3113
|
const ly = this._ly;
|
|
2881
3114
|
if (!ly || !this._data.length) return;
|
|
3115
|
+
this._stopPlayback();
|
|
3116
|
+
if (e.key === 'Escape' && (this._brushSel || this._brushDrag)) {
|
|
3117
|
+
this.clearBrush();
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
2882
3120
|
const d = this._data;
|
|
2883
3121
|
const key = e.key;
|
|
2884
3122
|
const step = e.shiftKey ? 10 : 1;
|
|
@@ -3067,11 +3305,18 @@ class WickChart extends HTMLElementBase {
|
|
|
3067
3305
|
} catch (_) {}
|
|
3068
3306
|
this._coviewCh = null;
|
|
3069
3307
|
}
|
|
3308
|
+
clearInterval(this._coviewBeat);
|
|
3309
|
+
this._coviewBeat = 0;
|
|
3070
3310
|
clearTimeout(this._ghostTimer);
|
|
3071
3311
|
if (this._ghost) {
|
|
3072
3312
|
this._ghost = null;
|
|
3073
3313
|
this._invalidate();
|
|
3074
3314
|
}
|
|
3315
|
+
if (this._presence && this._presence.peers.size) {
|
|
3316
|
+
const left = this._presence.list();
|
|
3317
|
+
this._presence = new PresenceTracker();
|
|
3318
|
+
this._fire('peers', { peers: [], joined: [], left });
|
|
3319
|
+
}
|
|
3075
3320
|
const name = this._coviewName;
|
|
3076
3321
|
if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
|
|
3077
3322
|
if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
|
|
@@ -3080,6 +3325,33 @@ class WickChart extends HTMLElementBase {
|
|
|
3080
3325
|
ch.onmessage = (ev) => this._onCoMessage(ev.data);
|
|
3081
3326
|
this._coviewCh = ch;
|
|
3082
3327
|
} catch (_) {}
|
|
3328
|
+
// presence: announce immediately, then heartbeat so idle peers stay
|
|
3329
|
+
// warm (and stale ones sweep) without waiting for a pan/zoom
|
|
3330
|
+
this._coviewSendView(true);
|
|
3331
|
+
this._coviewBeat = setInterval(() => {
|
|
3332
|
+
this._coviewSendView(true);
|
|
3333
|
+
const left = this._presence.sweep();
|
|
3334
|
+
if (left.length) {
|
|
3335
|
+
this._fire('peers', { peers: this._presence.list(), joined: [], left });
|
|
3336
|
+
this._invalidate();
|
|
3337
|
+
}
|
|
3338
|
+
}, 4000);
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
/** Broadcast our visible range for presence; throttled unless forced. */
|
|
3342
|
+
_coviewSendView(force) {
|
|
3343
|
+
if (!this._coviewCh) return;
|
|
3344
|
+
const r = this.getVisibleRange();
|
|
3345
|
+
if (!r) return;
|
|
3346
|
+
const now = performance.now();
|
|
3347
|
+
if (!force && now - this._coviewViewLast < 120) return;
|
|
3348
|
+
this._coviewViewLast = now;
|
|
3349
|
+
this._coviewSend({
|
|
3350
|
+
type: 'view',
|
|
3351
|
+
from: r.from,
|
|
3352
|
+
to: r.to,
|
|
3353
|
+
name: this._coviewLabel || null,
|
|
3354
|
+
});
|
|
3083
3355
|
}
|
|
3084
3356
|
|
|
3085
3357
|
_coviewSend(msg) {
|
|
@@ -3090,7 +3362,30 @@ class WickChart extends HTMLElementBase {
|
|
|
3090
3362
|
}
|
|
3091
3363
|
|
|
3092
3364
|
_onCoMessage(m) {
|
|
3093
|
-
if (!m || m.v !== 1 || m.peer === this._coviewPeer
|
|
3365
|
+
if (!m || m.v !== 1 || m.peer === this._coviewPeer) return;
|
|
3366
|
+
if (m.type === 'view') {
|
|
3367
|
+
const joined = this._presence.track(m.peer, {
|
|
3368
|
+
range: { from: m.from, to: m.to },
|
|
3369
|
+
name: m.name,
|
|
3370
|
+
});
|
|
3371
|
+
this._invalidate();
|
|
3372
|
+
if (joined) {
|
|
3373
|
+
const p = this._presence.peers.get(m.peer);
|
|
3374
|
+
this._fire('peers', {
|
|
3375
|
+
peers: this._presence.list(),
|
|
3376
|
+
joined: [p ? { ...p, range: p.range && { ...p.range } } : { id: m.peer }],
|
|
3377
|
+
left: [],
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
return;
|
|
3381
|
+
}
|
|
3382
|
+
if (m.type === 'bye') {
|
|
3383
|
+
const left = this._presence.drop(m.peer);
|
|
3384
|
+
if (left) this._fire('peers', { peers: this._presence.list(), joined: [], left: [left] });
|
|
3385
|
+
this._invalidate();
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
if (m.type !== 'cross') return;
|
|
3094
3389
|
if (m.time == null) {
|
|
3095
3390
|
if (this._ghost) {
|
|
3096
3391
|
this._ghost = null;
|
|
@@ -3120,10 +3415,297 @@ class WickChart extends HTMLElementBase {
|
|
|
3120
3415
|
this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
|
|
3121
3416
|
}
|
|
3122
3417
|
|
|
3418
|
+
/**
|
|
3419
|
+
* Live co-view peers: who else is in the room and the time window each
|
|
3420
|
+
* one is looking at — [{ id, name, range: {from, to}, at }], oldest
|
|
3421
|
+
* sighting first. Peers fade out ~12 s after their last sighting.
|
|
3422
|
+
* @returns {object[]}
|
|
3423
|
+
*/
|
|
3424
|
+
getPeers() {
|
|
3425
|
+
return this._presence ? this._presence.list() : [];
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
/**
|
|
3429
|
+
* Narrated timeline for a window (default: the visible range) — pivot
|
|
3430
|
+
* highs/lows, volume spikes, gaps, RSI divergences plus derived legs
|
|
3431
|
+
* ("+12.4% over 38 bars"), sorted by index. Pure data, perfect for
|
|
3432
|
+
* caption UIs or the walk player.
|
|
3433
|
+
* chart.narrate(); // visible range
|
|
3434
|
+
* chart.narrate({ from, to }); // times in ms (s accepted)
|
|
3435
|
+
* @param {{from?: number, to?: number}} [range]
|
|
3436
|
+
* @returns {{i: number, time: number, type: string, side: string, note: string,
|
|
3437
|
+
* legPct?: number, legBars?: number}[]}
|
|
3438
|
+
*/
|
|
3439
|
+
narrate(range) {
|
|
3440
|
+
const d = this._data;
|
|
3441
|
+
if (!d.length) return [];
|
|
3442
|
+
let i0 = 0;
|
|
3443
|
+
let i1 = d.length - 1;
|
|
3444
|
+
if (range && isNum(range.from) && isNum(range.to)) {
|
|
3445
|
+
i0 = WickChart._indexForTime(d, WickChart._timeToMs(range.from));
|
|
3446
|
+
i1 = WickChart._indexForTime(d, WickChart._timeToMs(range.to));
|
|
3447
|
+
if (i0 > i1) [i0, i1] = [i1, i0];
|
|
3448
|
+
}
|
|
3449
|
+
return narrateWindow(d, i0, i1);
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
/**
|
|
3453
|
+
* Walk the chart through history like a story: the viewport slides
|
|
3454
|
+
* from `from` to `to` while `wick:walk` events announce every step and
|
|
3455
|
+
* the narrator's events (spikes, gaps, pivots, legs) as they're crossed.
|
|
3456
|
+
* Any user interaction — pointer, wheel, keys, double-click — stops it.
|
|
3457
|
+
* chart.walk({ from: 0, to: 500, speed: 120, step: 10 });
|
|
3458
|
+
* chart.addEventListener('wick:walk', (e) => showCaption(e.detail));
|
|
3459
|
+
* // detail: { phase: 'step'|'end'|'stop', index, events: [...], from, to }
|
|
3460
|
+
* @param {{from?: number, to?: number, speed?: number, step?: number}} [opts]
|
|
3461
|
+
* from/to are bar indices (default: last ~500 bars → the end)
|
|
3462
|
+
* @returns {boolean} true when the walk started
|
|
3463
|
+
*/
|
|
3464
|
+
walk(opts = {}) {
|
|
3465
|
+
this.stopWalk(true);
|
|
3466
|
+
const d = this._data;
|
|
3467
|
+
if (!d.length || !this._connected) return false;
|
|
3468
|
+
const to = clamp(Math.round(+opts.to || d.length - 1), 0, d.length - 1);
|
|
3469
|
+
const from = clamp(Math.round(opts.from != null ? +opts.from : Math.max(0, to - 500)), 0, to);
|
|
3470
|
+
const span = to - from + 1;
|
|
3471
|
+
// window width: the current viewport, but never more than ~⅓ of the
|
|
3472
|
+
// span (a fully zoomed-out chart would otherwise start at `to`)
|
|
3473
|
+
const widthBars = clamp(
|
|
3474
|
+
Math.min(
|
|
3475
|
+
this._ly ? Math.round(this._ly.plotRight / this._view.spacing) : 120,
|
|
3476
|
+
Math.max(10, Math.ceil(span / 3))
|
|
3477
|
+
),
|
|
3478
|
+
10,
|
|
3479
|
+
span
|
|
3480
|
+
);
|
|
3481
|
+
const events = narrateWindow(d, from, to, { pivot: 8 });
|
|
3482
|
+
const speed = clamp(Math.round(+opts.speed || 120), 16, 2000);
|
|
3483
|
+
const step = clamp(Math.round(+opts.step || Math.max(1, Math.round(widthBars / 12))), 1, 500);
|
|
3484
|
+
let cursor = Math.min(from + widthBars - 1, to);
|
|
3485
|
+
let ev = 0;
|
|
3486
|
+
let ended = false;
|
|
3487
|
+
const tick = () => {
|
|
3488
|
+
if (ended) return;
|
|
3489
|
+
this._auto = false;
|
|
3490
|
+
this._view.rightIndex = cursor;
|
|
3491
|
+
this._clampView();
|
|
3492
|
+
this._invalidate();
|
|
3493
|
+
this._emitRange();
|
|
3494
|
+
const hits = [];
|
|
3495
|
+
while (ev < events.length && events[ev].i <= cursor) hits.push(events[ev++]);
|
|
3496
|
+
this._fire('walk', { phase: 'step', index: cursor, events: hits, from, to });
|
|
3497
|
+
if (cursor >= to) {
|
|
3498
|
+
ended = true;
|
|
3499
|
+
clearInterval(this._walkTimer);
|
|
3500
|
+
this._walkTimer = 0;
|
|
3501
|
+
this._fire('walk', { phase: 'end', index: cursor, events: [], from, to });
|
|
3502
|
+
} else {
|
|
3503
|
+
cursor = Math.min(cursor + step, to);
|
|
3504
|
+
}
|
|
3505
|
+
};
|
|
3506
|
+
this._walkTimer = setInterval(tick, speed);
|
|
3507
|
+
tick(); // first step lands immediately
|
|
3508
|
+
return true;
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
/**
|
|
3512
|
+
* Stop the running walk (if any). Fires a final `wick:walk`
|
|
3513
|
+
* { phase: 'stop' } unless called internally.
|
|
3514
|
+
*/
|
|
3515
|
+
stopWalk(silent) {
|
|
3516
|
+
if (!this._walkTimer) return;
|
|
3517
|
+
clearInterval(this._walkTimer);
|
|
3518
|
+
this._walkTimer = 0;
|
|
3519
|
+
if (!silent) this._fire('walk', { phase: 'stop' });
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
/**
|
|
3523
|
+
* Commit a brush selection over [i0, i1]: stores it (draws the band
|
|
3524
|
+
* and delta chip) and fires `wick:brush` with the range statistics.
|
|
3525
|
+
* @param {number} i0 first index
|
|
3526
|
+
* @param {number} i1 last index
|
|
3527
|
+
*/
|
|
3528
|
+
_brushFinish(i0, i1) {
|
|
3529
|
+
if (!this._data.length) return;
|
|
3530
|
+
const stats = brushStats(this._data, i0, i1);
|
|
3531
|
+
if (!stats) {
|
|
3532
|
+
this._brushSel = null;
|
|
3533
|
+
this._invalidate();
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
this._brushSel = { i0, i1, stats };
|
|
3537
|
+
this._invalidate();
|
|
3538
|
+
this._fire('brush', stats);
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
/** Clear the committed brush selection (if any). Escape does the same. */
|
|
3542
|
+
clearBrush() {
|
|
3543
|
+
if (this._brushSel || this._brushDrag) {
|
|
3544
|
+
this._brushSel = null;
|
|
3545
|
+
this._brushDrag = null;
|
|
3546
|
+
this._invalidate();
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
/** @returns {object|null} the committed selection { i0, i1, stats } */
|
|
3551
|
+
get brushSelection() {
|
|
3552
|
+
if (!this._brushSel) return null;
|
|
3553
|
+
const { i0, i1, stats } = this._brushSel;
|
|
3554
|
+
return { i0, i1, stats: { ...stats, from: { ...stats.from }, to: { ...stats.to } } };
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
/**
|
|
3558
|
+
* Capture the current chart state as a story scene: view, series type,
|
|
3559
|
+
* indicators, overlays, scenario and risk plan, plus a title/note.
|
|
3560
|
+
* Build guided tours by capturing several and playing them back.
|
|
3561
|
+
* const story = [
|
|
3562
|
+
* chart.captureScene('Overview', 'The full picture'),
|
|
3563
|
+
* { title: 'The breakout', range: { from, to }, indicators: 'sma:20' },
|
|
3564
|
+
* ];
|
|
3565
|
+
* chart.playStory(story);
|
|
3566
|
+
* @param {string} [title]
|
|
3567
|
+
* @param {string} [note]
|
|
3568
|
+
* @returns {object} scene (plain data — snapshot of the moment)
|
|
3569
|
+
*/
|
|
3570
|
+
captureScene(title, note) {
|
|
3571
|
+
const scene = {
|
|
3572
|
+
title: title != null ? String(title).slice(0, 60) : '',
|
|
3573
|
+
note: note != null ? String(note).slice(0, 200) : '',
|
|
3574
|
+
range: this.getVisibleRange() || undefined,
|
|
3575
|
+
type: this.getAttribute('type') || 'candles',
|
|
3576
|
+
indicators: this.getAttribute('indicators') || null,
|
|
3577
|
+
};
|
|
3578
|
+
const ovs = this.overlays;
|
|
3579
|
+
if (ovs.length) scene.overlays = ovs;
|
|
3580
|
+
const sc = this.scenario;
|
|
3581
|
+
if (sc) scene.scenario = sc;
|
|
3582
|
+
const rp = this.riskPlan;
|
|
3583
|
+
if (rp) scene.riskPlan = rp;
|
|
3584
|
+
return scene;
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
/** @returns {object[]|null} a copy of the last played story */
|
|
3588
|
+
getStory() {
|
|
3589
|
+
return this._story ? this._story.map((s) => ({ ...s })) : null;
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
/**
|
|
3593
|
+
* Play a story: each scene applies its state (type / indicators /
|
|
3594
|
+
* overlays / scenario / risk plan — set or clear), the camera eases
|
|
3595
|
+
* to its range, then holds for its dwell. `wick:story` events narrate:
|
|
3596
|
+
* { phase: 'scene' | 'end' | 'stop', index, total, scene, title, note }
|
|
3597
|
+
* Any user interaction — pointer, wheel, keys, double-click — stops it.
|
|
3598
|
+
* @param {object[]} story scenes (invalid entries dropped, max 20)
|
|
3599
|
+
* @param {{dwell?: number, panMs?: number, loop?: boolean}} [opts]
|
|
3600
|
+
* panMs clamps 100–5000 (default 900); loop replays forever
|
|
3601
|
+
* @returns {boolean} true when playback started
|
|
3602
|
+
*/
|
|
3603
|
+
playStory(story, opts = {}) {
|
|
3604
|
+
this.stopStory(true);
|
|
3605
|
+
const scenes = sceneList(story);
|
|
3606
|
+
if (!scenes.length || !this._data.length || !this._connected) return false;
|
|
3607
|
+
const token = ++this._storyToken;
|
|
3608
|
+
this._story = scenes;
|
|
3609
|
+
const panMs = clamp(Math.round(+opts.panMs || 900), 100, 5000);
|
|
3610
|
+
const loop = opts.loop === true;
|
|
3611
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3612
|
+
const run = async () => {
|
|
3613
|
+
let idx = 0;
|
|
3614
|
+
while (token === this._storyToken) {
|
|
3615
|
+
const sc = scenes[idx];
|
|
3616
|
+
this._fire('story', {
|
|
3617
|
+
phase: 'scene', index: idx, total: scenes.length,
|
|
3618
|
+
scene: sc, title: sc.title, note: sc.note,
|
|
3619
|
+
});
|
|
3620
|
+
this._applyScene(sc);
|
|
3621
|
+
const target = this._sceneTarget(sc);
|
|
3622
|
+
if (target) await this._storyTween(target, panMs, token);
|
|
3623
|
+
if (token !== this._storyToken) return;
|
|
3624
|
+
await wait(sc.dwell);
|
|
3625
|
+
if (token !== this._storyToken) return;
|
|
3626
|
+
idx++;
|
|
3627
|
+
if (idx >= scenes.length) {
|
|
3628
|
+
if (loop) idx = 0;
|
|
3629
|
+
else {
|
|
3630
|
+
this._fire('story', { phase: 'end', index: idx - 1, total: scenes.length });
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
};
|
|
3636
|
+
run();
|
|
3637
|
+
return true;
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
/**
|
|
3641
|
+
* Stop story playback (if running). Fires a final `wick:story`
|
|
3642
|
+
* { phase: 'stop' } unless called internally.
|
|
3643
|
+
*/
|
|
3644
|
+
stopStory(silent) {
|
|
3645
|
+
if (!this._storyToken) return;
|
|
3646
|
+
this._storyToken = 0;
|
|
3647
|
+
if (!silent) this._fire('story', { phase: 'stop' });
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
/** Apply a scene's state (only the fields it carries). */
|
|
3651
|
+
_applyScene(sc) {
|
|
3652
|
+
if (sc.type) this.setAttribute('type', sc.type);
|
|
3653
|
+
if (sc.indicators != null) this.setAttribute('indicators', sc.indicators);
|
|
3654
|
+
if (sc.overlays) this.setOverlays(sc.overlays);
|
|
3655
|
+
if (sc.scenario === 'clear') this.clearScenario();
|
|
3656
|
+
else if (sc.scenario) this.setScenario(sc.scenario);
|
|
3657
|
+
if (sc.riskPlan === 'clear') this.clearRiskPlan();
|
|
3658
|
+
else if (sc.riskPlan) this.setRiskPlan(sc.riskPlan);
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
/** Map a scene's time range to bar indices (null when not applicable). */
|
|
3662
|
+
_sceneTarget(sc) {
|
|
3663
|
+
if (!sc.range || !this._data.length) return null;
|
|
3664
|
+
let i0 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.from));
|
|
3665
|
+
let i1 = WickChart._indexForTime(this._data, WickChart._timeToMs(sc.range.to));
|
|
3666
|
+
if (i0 > i1) [i0, i1] = [i1, i0];
|
|
3667
|
+
return i1 - i0 >= 2 ? { i0, i1 } : null;
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
/** Ease the viewport to { i0, i1 } over `ms`; resolves early if the
|
|
3671
|
+
* token changes (superseded or stopped). rAF when available. */
|
|
3672
|
+
_storyTween(target, ms, token) {
|
|
3673
|
+
const ly = this._ly;
|
|
3674
|
+
const d = this._data;
|
|
3675
|
+
if (!ly || !d.length) return Promise.resolve();
|
|
3676
|
+
const sp1 = clamp(ly.plotRight / (target.i1 - target.i0), this._minSpacing(), WickChart._MAX_SP);
|
|
3677
|
+
const from = { right: this._view.rightIndex, sp: this._view.spacing };
|
|
3678
|
+
const to = { right: target.i1, sp: sp1 };
|
|
3679
|
+
const t0 = performance.now();
|
|
3680
|
+
this._auto = false;
|
|
3681
|
+
return new Promise((resolve) => {
|
|
3682
|
+
const step = () => {
|
|
3683
|
+
if (token !== this._storyToken) return resolve();
|
|
3684
|
+
const e = easeInOutCubic(Math.min(1, (performance.now() - t0) / ms));
|
|
3685
|
+
this._view.rightIndex = from.right + (to.right - from.right) * e;
|
|
3686
|
+
this._view.spacing = from.sp + (to.sp - from.sp) * e;
|
|
3687
|
+
this._clampView();
|
|
3688
|
+
this._invalidate();
|
|
3689
|
+
this._emitRange();
|
|
3690
|
+
if (e >= 1) resolve();
|
|
3691
|
+
else if (typeof requestAnimationFrame === 'function') requestAnimationFrame(step);
|
|
3692
|
+
else setTimeout(step, 16);
|
|
3693
|
+
};
|
|
3694
|
+
step();
|
|
3695
|
+
});
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
/** Interrupt narrated playback (walk / story) on user input. */
|
|
3699
|
+
_stopPlayback() {
|
|
3700
|
+
if (this._walkTimer) this.stopWalk();
|
|
3701
|
+
if (this._storyToken) this.stopStory();
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3123
3704
|
_emitRange() {
|
|
3124
3705
|
const r = this.getVisibleRange();
|
|
3125
3706
|
if (!r) return;
|
|
3126
3707
|
this._fire('range', r);
|
|
3708
|
+
if (this._coviewCh) this._coviewSendView();
|
|
3127
3709
|
}
|
|
3128
3710
|
}
|
|
3129
3711
|
|