wickchart 1.0.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 +298 -3
- package/package.json +20 -3
- package/src/core.js +763 -5
- package/src/react-core.js +191 -0
- package/src/react.js +21 -0
- package/src/wick-chart.js +1021 -34
- package/types/core.d.ts +362 -0
- package/types/react-core.d.ts +64 -0
- package/types/react.d.ts +3 -0
- package/types/wick-chart.d.ts +307 -8
package/src/wick-chart.js
CHANGED
|
@@ -28,7 +28,12 @@ import {
|
|
|
28
28
|
SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
|
|
29
29
|
calcRSI, detectAnnotations, priceToFreq,
|
|
30
30
|
calcRealizedVol, volRegimeBands, percentileOfSorted, parseVolShading,
|
|
31
|
-
windowSummary,
|
|
31
|
+
windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
|
|
32
|
+
compileScript, predicateTrueSeries, scriptAlertStep,
|
|
33
|
+
AI_TOOLS, aiPromptText, applyChartOps,
|
|
34
|
+
calcVolCone, normalizeScenario, normalizeRiskPlan, PresenceTracker,
|
|
35
|
+
narrateWindow, brushStats,
|
|
36
|
+
easeInOutCubic, sceneList,
|
|
32
37
|
} from './core.js';
|
|
33
38
|
|
|
34
39
|
/* ------------------------------------------------------------------ *
|
|
@@ -45,7 +50,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
|
|
|
45
50
|
|
|
46
51
|
class WickChart extends HTMLElementBase {
|
|
47
52
|
static get observedAttributes() {
|
|
48
|
-
return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'co-view', 'sonify'];
|
|
53
|
+
return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'co-view-name', 'brush', 'sonify'];
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
constructor() {
|
|
@@ -202,6 +207,11 @@ class WickChart extends HTMLElementBase {
|
|
|
202
207
|
this._coviewLast = 0;
|
|
203
208
|
this._ghost = null;
|
|
204
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;
|
|
205
215
|
|
|
206
216
|
// sonification state
|
|
207
217
|
this._sonify = false;
|
|
@@ -210,6 +220,15 @@ class WickChart extends HTMLElementBase {
|
|
|
210
220
|
this._playToken = 0;
|
|
211
221
|
this._measure = null; // { iA, pA, iB, pB, done }
|
|
212
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
|
|
213
232
|
this._ind = { overlays: [], panes: [], volume: true };
|
|
214
233
|
|
|
215
234
|
this._pointers = new Map();
|
|
@@ -225,6 +244,14 @@ class WickChart extends HTMLElementBase {
|
|
|
225
244
|
this._alerts = [];
|
|
226
245
|
this._seq = 0;
|
|
227
246
|
|
|
247
|
+
// server-side overlays (zones & levels)
|
|
248
|
+
this._overlays = [];
|
|
249
|
+
|
|
250
|
+
// scenario projection (ghost path + vol cone)
|
|
251
|
+
this._scenario = null;
|
|
252
|
+
// risk plan (R-multiple grid)
|
|
253
|
+
this._riskPlan = null;
|
|
254
|
+
|
|
228
255
|
this._onResize = () => this._invalidate();
|
|
229
256
|
this._onPointerDown = (e) => this._pointerDown(e);
|
|
230
257
|
this._onPointerMove = (e) => this._pointerMove(e);
|
|
@@ -237,7 +264,10 @@ class WickChart extends HTMLElementBase {
|
|
|
237
264
|
}
|
|
238
265
|
};
|
|
239
266
|
this._onWheel = (e) => this._wheel(e);
|
|
240
|
-
this._onDbl = () =>
|
|
267
|
+
this._onDbl = () => {
|
|
268
|
+
this._stopPlayback();
|
|
269
|
+
this.fit();
|
|
270
|
+
};
|
|
241
271
|
this._onKey = (e) => this._keydown(e);
|
|
242
272
|
}
|
|
243
273
|
|
|
@@ -271,12 +301,22 @@ class WickChart extends HTMLElementBase {
|
|
|
271
301
|
|
|
272
302
|
disconnectedCallback() {
|
|
273
303
|
this._connected = false;
|
|
304
|
+
this.stopWalk(true);
|
|
305
|
+
this.stopStory(true);
|
|
274
306
|
if (this._coviewCh) {
|
|
307
|
+
this._coviewSend({ type: 'bye' });
|
|
275
308
|
try {
|
|
276
309
|
this._coviewCh.close();
|
|
277
310
|
} catch (_) {}
|
|
278
311
|
this._coviewCh = null;
|
|
279
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
|
+
}
|
|
280
320
|
clearTimeout(this._ghostTimer);
|
|
281
321
|
if (this._ro) this._ro.disconnect();
|
|
282
322
|
const cv = this._canvas;
|
|
@@ -330,10 +370,32 @@ class WickChart extends HTMLElementBase {
|
|
|
330
370
|
this._volshade = val != null && val !== 'false' ? parseVolShading(val) : null;
|
|
331
371
|
this._legendKey = '';
|
|
332
372
|
break;
|
|
373
|
+
case 'overlays': {
|
|
374
|
+
let ovs = [];
|
|
375
|
+
if (val != null && val !== '') {
|
|
376
|
+
try {
|
|
377
|
+
ovs = normalizeOverlays(JSON.parse(val));
|
|
378
|
+
} catch (err) {
|
|
379
|
+
ovs = [];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
this._overlays = ovs;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
333
385
|
case 'co-view':
|
|
334
386
|
this._coviewName = val || null;
|
|
335
387
|
this._setupCoView();
|
|
336
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;
|
|
337
399
|
case 'sonify':
|
|
338
400
|
this._sonify = val != null && val !== 'false';
|
|
339
401
|
this._lastToneIdx = -1;
|
|
@@ -403,6 +465,8 @@ class WickChart extends HTMLElementBase {
|
|
|
403
465
|
this.clearData();
|
|
404
466
|
return;
|
|
405
467
|
}
|
|
468
|
+
// selection indices are data-bound; a replacement invalidates them
|
|
469
|
+
if (this._brushSel || this._brushDrag) this.clearBrush();
|
|
406
470
|
const norm = [];
|
|
407
471
|
for (const b of bars) {
|
|
408
472
|
const nb = WickChart._normBar(b);
|
|
@@ -629,7 +693,11 @@ class WickChart extends HTMLElementBase {
|
|
|
629
693
|
})),
|
|
630
694
|
alerts: this._alerts
|
|
631
695
|
.filter((a) => !a.fired)
|
|
632
|
-
.map((a) =>
|
|
696
|
+
.map((a) =>
|
|
697
|
+
a.when != null
|
|
698
|
+
? { id: a.id, when: a.when, once: a.once }
|
|
699
|
+
: { id: a.id, price: a.price, direction: a.direction, once: a.once }
|
|
700
|
+
),
|
|
633
701
|
};
|
|
634
702
|
}
|
|
635
703
|
|
|
@@ -667,14 +735,31 @@ class WickChart extends HTMLElementBase {
|
|
|
667
735
|
}
|
|
668
736
|
if (Array.isArray(state.alerts)) {
|
|
669
737
|
this._alerts = state.alerts
|
|
670
|
-
.filter((a) => a && isNum(a.price))
|
|
671
|
-
.map((a) =>
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
738
|
+
.filter((a) => a && (isNum(a.price) || typeof a.when === 'string'))
|
|
739
|
+
.map((a) => {
|
|
740
|
+
if (typeof a.when === 'string' && a.when.trim()) {
|
|
741
|
+
try {
|
|
742
|
+
return {
|
|
743
|
+
id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
|
|
744
|
+
when: a.when.trim(),
|
|
745
|
+
compiled: compileScript(a.when),
|
|
746
|
+
once: a.once !== false,
|
|
747
|
+
fired: false,
|
|
748
|
+
armed: true,
|
|
749
|
+
};
|
|
750
|
+
} catch (err) {
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return {
|
|
755
|
+
id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
|
|
756
|
+
price: a.price,
|
|
757
|
+
direction: a.direction || 'cross',
|
|
758
|
+
once: a.once !== false,
|
|
759
|
+
fired: false,
|
|
760
|
+
};
|
|
761
|
+
})
|
|
762
|
+
.filter(Boolean);
|
|
678
763
|
}
|
|
679
764
|
if (state.view && state.view.from != null && state.view.to != null) {
|
|
680
765
|
if (this._ly && this._data.length > 1) {
|
|
@@ -727,22 +812,46 @@ class WickChart extends HTMLElementBase {
|
|
|
727
812
|
}
|
|
728
813
|
|
|
729
814
|
/**
|
|
730
|
-
* Price alert.
|
|
731
|
-
* (
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
815
|
+
* Price or scripted alert. Price alerts fire `wick:alert`
|
|
816
|
+
* ({id, price, bar}) on an edge crossing; scripted alerts evaluate a
|
|
817
|
+
* WickScript predicate (`when`) on every streamed bar and fire on its
|
|
818
|
+
* false→true edge — e.g. `when: 'crossup(rsi(close,14), 30)'` or
|
|
819
|
+
* `when: 'volume > sma(volume,20) * 3'`. Scripted events carry the
|
|
820
|
+
* triggering close as `price` plus the `when` source (deprecated
|
|
821
|
+
* `hab:alert` alias still dispatched).
|
|
822
|
+
* @param {{id?: string, price?: number, direction?: 'above'|'below'|'cross',
|
|
823
|
+
* when?: string, once?: boolean}} alert
|
|
824
|
+
* @returns {string|null} the alert id (null when no valid price/when,
|
|
825
|
+
* or the predicate fails to compile)
|
|
736
826
|
*/
|
|
737
827
|
addAlert(alert) {
|
|
738
|
-
if (!alert
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
828
|
+
if (!alert) return null;
|
|
829
|
+
let a;
|
|
830
|
+
if (typeof alert.when === 'string' && alert.when.trim()) {
|
|
831
|
+
let compiled;
|
|
832
|
+
try {
|
|
833
|
+
compiled = compileScript(alert.when);
|
|
834
|
+
} catch (err) {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
a = {
|
|
838
|
+
id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
|
|
839
|
+
when: alert.when.trim(),
|
|
840
|
+
compiled,
|
|
841
|
+
once: alert.once !== false,
|
|
842
|
+
fired: false,
|
|
843
|
+
armed: true,
|
|
844
|
+
};
|
|
845
|
+
} else {
|
|
846
|
+
if (!isNum(alert.price)) return null;
|
|
847
|
+
a = {
|
|
848
|
+
id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
|
|
849
|
+
price: alert.price,
|
|
850
|
+
direction: alert.direction || 'cross',
|
|
851
|
+
once: alert.once !== false,
|
|
852
|
+
fired: false,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
746
855
|
const i = this._alerts.findIndex((x) => x.id === a.id);
|
|
747
856
|
if (i >= 0) this._alerts[i] = a;
|
|
748
857
|
else this._alerts.push(a);
|
|
@@ -760,11 +869,229 @@ class WickChart extends HTMLElementBase {
|
|
|
760
869
|
this._invalidate();
|
|
761
870
|
}
|
|
762
871
|
|
|
763
|
-
/**
|
|
872
|
+
/**
|
|
873
|
+
* Server-side overlays: zones & levels anchored in time × price — e.g.
|
|
874
|
+
* supply/demand zones from an analysis API. Zones with no `to` extend
|
|
875
|
+
* into future space past the last bar, like TradingView drawings.
|
|
876
|
+
*
|
|
877
|
+
* zone: { type:'zone', from?:ms, to?:ms|null, priceFrom, priceTo,
|
|
878
|
+
* color?, alpha?, border?, label?, id? }
|
|
879
|
+
* level: { type:'level', price, from?, to?, color?, width?, dash?,
|
|
880
|
+
* label?, id? }
|
|
881
|
+
*
|
|
882
|
+
* Invalid entries are dropped, never thrown. Colors accept hex/rgb()/CSS
|
|
883
|
+
* names plus the palette keys 'up' | 'down' | 'accent'.
|
|
884
|
+
* @param {object[]} list
|
|
885
|
+
* @returns {string[]} applied overlay ids
|
|
886
|
+
*/
|
|
887
|
+
setOverlays(list) {
|
|
888
|
+
this._overlays = normalizeOverlays(list);
|
|
889
|
+
this._invalidate();
|
|
890
|
+
return this._overlays.map((o) => o.id);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** @returns {object[]} a copy of the current overlays */
|
|
894
|
+
get overlays() {
|
|
895
|
+
return this._overlays.map((o) => ({ ...o }));
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Add or replace (upsert, by id) a single overlay.
|
|
900
|
+
* @returns {string|null} the overlay id, or null if invalid
|
|
901
|
+
*/
|
|
902
|
+
addOverlay(ov) {
|
|
903
|
+
const norm = normalizeOverlays([ov]);
|
|
904
|
+
if (!norm.length) return null;
|
|
905
|
+
const one = norm[0];
|
|
906
|
+
const i = this._overlays.findIndex((x) => x.id === one.id);
|
|
907
|
+
if (i >= 0) this._overlays[i] = one;
|
|
908
|
+
else this._overlays.push(one);
|
|
909
|
+
this._invalidate();
|
|
910
|
+
return one.id;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
removeOverlay(id) {
|
|
914
|
+
this._overlays = this._overlays.filter((o) => o.id !== String(id));
|
|
915
|
+
this._invalidate();
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
clearOverlays() {
|
|
919
|
+
this._overlays = [];
|
|
920
|
+
this._invalidate();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Scenario projection into future space: a ghost path of future prices
|
|
925
|
+
* plus optional σ-bands (vol cone) from realized volatility.
|
|
926
|
+
*
|
|
927
|
+
* chart.setScenario({ path: [64000, 65500, 68000], label: 'bull case' });
|
|
928
|
+
* chart.setScenario({ horizon: 48, cone: true }); // cone-only
|
|
929
|
+
*
|
|
930
|
+
* The path is an array of prices (or {price} objects) for future bars
|
|
931
|
+
* 1..N; horizon defaults to the path length (1–500). `cone` (default
|
|
932
|
+
* true) draws ±levels·σ bands widening with √h from the current realized
|
|
933
|
+
* vol; `color` accepts up|down|accent or safe CSS colors. Setting a
|
|
934
|
+
* scenario reserves future space on the right; analysis data — excluded
|
|
935
|
+
* from getState/setState.
|
|
936
|
+
* @param {object} spec
|
|
937
|
+
* @returns {object|null} the normalized scenario, or null when invalid
|
|
938
|
+
*/
|
|
939
|
+
setScenario(spec) {
|
|
940
|
+
this._scenario = normalizeScenario(spec);
|
|
941
|
+
this._invalidate();
|
|
942
|
+
return this._scenario;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
clearScenario() {
|
|
946
|
+
this._scenario = null;
|
|
947
|
+
this._invalidate();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** @returns {object|null} a copy of the active scenario */
|
|
951
|
+
get scenario() {
|
|
952
|
+
if (!this._scenario) return null;
|
|
953
|
+
return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
|
|
954
|
+
}
|
|
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
|
+
|
|
991
|
+
/** σ-cone for the active scenario, cached per data version. */
|
|
992
|
+
_scenarioConeCache() {
|
|
993
|
+
if (!this._scenario || !this._data.length) return null;
|
|
994
|
+
if (this._cache.v !== this._version) {
|
|
995
|
+
this._cache = { v: this._version, map: {} };
|
|
996
|
+
}
|
|
997
|
+
if (!this._cache.map.__scenario) {
|
|
998
|
+
const d = this._data;
|
|
999
|
+
const vol = calcRealizedVol(d.map((b) => b.close), 20);
|
|
1000
|
+
let v = NaN;
|
|
1001
|
+
for (let i = vol.length - 1; i >= 0; i--) {
|
|
1002
|
+
if (Number.isFinite(vol[i])) { v = vol[i]; break; }
|
|
1003
|
+
}
|
|
1004
|
+
this._cache.map.__scenario = calcVolCone(
|
|
1005
|
+
d[d.length - 1].close,
|
|
1006
|
+
v,
|
|
1007
|
+
this._scenario.horizon,
|
|
1008
|
+
this._scenario.levels
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
return this._cache.map.__scenario;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/* ------------------------------------------------------------ *
|
|
1015
|
+
* AI agent interface — the chart as a tool surface
|
|
1016
|
+
* ------------------------------------------------------------ */
|
|
1017
|
+
|
|
1018
|
+
/** Tool manifest for LLM control — JSON-safe copy of AI_TOOLS. */
|
|
1019
|
+
aiTools() {
|
|
1020
|
+
return JSON.parse(JSON.stringify(AI_TOOLS));
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** System prompt for agent control — paste into any LLM alongside aiTools(). */
|
|
1024
|
+
aiPrompt() {
|
|
1025
|
+
return aiPromptText();
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** Grounding context for a model: current state + visible-window summary. */
|
|
1029
|
+
aiContext() {
|
|
1030
|
+
return { state: this.getState(), window: this.getDataWindow() };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Apply a list of {tool, args} ops (typically LLM output) through the
|
|
1035
|
+
* validated dispatcher in core. Never throws — each op resolves
|
|
1036
|
+
* {ok, tool, result} or {ok: false, tool, error} so an agent can
|
|
1037
|
+
* self-correct.
|
|
1038
|
+
* @param {any} ops
|
|
1039
|
+
* @returns {Array<object>}
|
|
1040
|
+
*/
|
|
1041
|
+
applyAI(ops) {
|
|
1042
|
+
return applyChartOps(this, ops);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Ask an AI to operate the chart. Builds the payload {system,
|
|
1047
|
+
* instruction, chart, tools}; with a `run` async function (your model
|
|
1048
|
+
* call — the chart itself never touches the network), applies the
|
|
1049
|
+
* returned ops and resolves {payload, ops, results}. Without `run`,
|
|
1050
|
+
* returns the payload for manual wiring — send it anywhere, then call
|
|
1051
|
+
* chart.applyAI(ops) with the model's answer.
|
|
1052
|
+
*
|
|
1053
|
+
* const { results } = await chart.ask('add RSI and mark the demand zone', {
|
|
1054
|
+
* run: async (payload) => (await callMyLLM(payload)).ops,
|
|
1055
|
+
* });
|
|
1056
|
+
*
|
|
1057
|
+
* @param {string} instruction natural-language request
|
|
1058
|
+
* @param {{run?: (payload: object) => Promise<any>}} [opts]
|
|
1059
|
+
*/
|
|
1060
|
+
async ask(instruction, opts = {}) {
|
|
1061
|
+
const payload = {
|
|
1062
|
+
system: aiPromptText(),
|
|
1063
|
+
instruction: String(instruction == null ? '' : instruction),
|
|
1064
|
+
chart: this.aiContext(),
|
|
1065
|
+
tools: this.aiTools(),
|
|
1066
|
+
};
|
|
1067
|
+
if (typeof opts.run !== 'function') {
|
|
1068
|
+
return { payload, ops: null, results: null };
|
|
1069
|
+
}
|
|
1070
|
+
const ops = await opts.run(payload);
|
|
1071
|
+
const results = this.applyAI(ops);
|
|
1072
|
+
return { payload, ops, results };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Check alerts against an incoming bar (prev close → new close).
|
|
1076
|
+
* Scripted (`when`) alerts evaluate their predicate series, cached per
|
|
1077
|
+
* data version, and fire on the false→true edge. */
|
|
764
1078
|
_checkAlerts(prevClose, bar) {
|
|
765
|
-
if (!this._alerts.length
|
|
1079
|
+
if (!this._alerts.length) return;
|
|
766
1080
|
for (const a of [...this._alerts]) {
|
|
767
1081
|
if (a.fired) continue;
|
|
1082
|
+
if (a.when != null) {
|
|
1083
|
+
const series = this._predicateCache(a);
|
|
1084
|
+
const curTrue = series.length ? series[series.length - 1] : false;
|
|
1085
|
+
const step = scriptAlertStep(a.armed, curTrue);
|
|
1086
|
+
a.armed = step.armed;
|
|
1087
|
+
if (step.fire) {
|
|
1088
|
+
a.fired = true;
|
|
1089
|
+
this._fire('alert', { id: a.id, price: bar.close, when: a.when, bar });
|
|
1090
|
+
if (a.once) this._alerts = this._alerts.filter((x) => x !== a);
|
|
1091
|
+
}
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
if (!isNum(prevClose)) continue;
|
|
768
1095
|
if (checkAlertCross(a, prevClose, bar.close)) {
|
|
769
1096
|
a.fired = true;
|
|
770
1097
|
this._fire('alert', { id: a.id, price: a.price, bar });
|
|
@@ -773,6 +1100,18 @@ class WickChart extends HTMLElementBase {
|
|
|
773
1100
|
}
|
|
774
1101
|
}
|
|
775
1102
|
|
|
1103
|
+
/** Cached boolean series for a scripted alert's predicate (per data version). */
|
|
1104
|
+
_predicateCache(alert) {
|
|
1105
|
+
if (this._cache.v !== this._version) {
|
|
1106
|
+
this._cache = { v: this._version, map: {} };
|
|
1107
|
+
}
|
|
1108
|
+
const k = 'pred:' + alert.when;
|
|
1109
|
+
if (!this._cache.map[k]) {
|
|
1110
|
+
this._cache.map[k] = predicateTrueSeries(alert.compiled, this._data);
|
|
1111
|
+
}
|
|
1112
|
+
return this._cache.map[k];
|
|
1113
|
+
}
|
|
1114
|
+
|
|
776
1115
|
/* reflected properties */
|
|
777
1116
|
get theme() { return this._theme; }
|
|
778
1117
|
set theme(v) { this.setAttribute('theme', v); }
|
|
@@ -1013,7 +1352,9 @@ class WickChart extends HTMLElementBase {
|
|
|
1013
1352
|
_rightMargin() {
|
|
1014
1353
|
const ly = this._ly;
|
|
1015
1354
|
const w = ly ? ly.plotRight : 600;
|
|
1016
|
-
|
|
1355
|
+
const base = Math.max(3, (w / this._view.spacing) * 0.06);
|
|
1356
|
+
// a scenario projection reserves future space so the cone stays visible
|
|
1357
|
+
return this._scenario ? Math.max(base, this._scenario.horizon + 3) : base;
|
|
1017
1358
|
}
|
|
1018
1359
|
|
|
1019
1360
|
_applyFit() {
|
|
@@ -1381,6 +1722,146 @@ class WickChart extends HTMLElementBase {
|
|
|
1381
1722
|
}
|
|
1382
1723
|
}
|
|
1383
1724
|
|
|
1725
|
+
/* server-side overlays: zones & levels (above regime shading, under series).
|
|
1726
|
+
* Zones with `to == null` extend into future space past the last bar. */
|
|
1727
|
+
if (this._overlays.length) {
|
|
1728
|
+
const d = this._data;
|
|
1729
|
+
const idxFor = (t, fallback) => (t == null ? fallback : barIndexForTime(d, t));
|
|
1730
|
+
for (const ov of this._overlays) {
|
|
1731
|
+
const col = resolveOverlayColor(ov.color, pal);
|
|
1732
|
+
if (ov.type === 'zone') {
|
|
1733
|
+
const iA = Math.max(0, idxFor(ov.from, 0));
|
|
1734
|
+
const iB = ov.to == null ? null : Math.max(0, idxFor(ov.to, d.length - 1));
|
|
1735
|
+
const zx0 = clamp(this._xFor(iA) - sp * 0.5, 0, plotRight);
|
|
1736
|
+
const zx1 = iB == null ? plotRight : clamp(this._xFor(iB) + sp * 0.5, 0, plotRight);
|
|
1737
|
+
const zyT = clamp(yOf(ov.priceTo), main.y0, main.y1);
|
|
1738
|
+
const zyB = clamp(yOf(ov.priceFrom), main.y0, main.y1);
|
|
1739
|
+
if (zx1 - zx0 < 1 || zyB - zyT < 1) continue;
|
|
1740
|
+
ctx.save();
|
|
1741
|
+
ctx.globalAlpha = ov.alpha;
|
|
1742
|
+
ctx.fillStyle = col;
|
|
1743
|
+
ctx.fillRect(zx0, zyT, zx1 - zx0, zyB - zyT);
|
|
1744
|
+
if (ov.border) {
|
|
1745
|
+
ctx.globalAlpha = Math.min(1, ov.alpha + 0.4);
|
|
1746
|
+
ctx.lineWidth = 1;
|
|
1747
|
+
ctx.strokeStyle = col;
|
|
1748
|
+
ctx.strokeRect(
|
|
1749
|
+
Math.round(zx0) + 0.5, Math.round(zyT) + 0.5,
|
|
1750
|
+
Math.max(2, Math.round(zx1 - zx0) - 1), Math.max(2, Math.round(zyB - zyT) - 1)
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
if (ov.label) {
|
|
1754
|
+
ctx.globalAlpha = 0.95;
|
|
1755
|
+
ctx.font = pillFont();
|
|
1756
|
+
ctx.fillStyle = col;
|
|
1757
|
+
ctx.textAlign = 'left';
|
|
1758
|
+
ctx.textBaseline = 'top';
|
|
1759
|
+
ctx.fillText(ov.label, zx0 + 6, Math.max(main.y0, zyT) + 4);
|
|
1760
|
+
}
|
|
1761
|
+
ctx.restore();
|
|
1762
|
+
} else {
|
|
1763
|
+
const lx0 = clamp(this._xFor(Math.max(0, idxFor(ov.from, 0))) - sp * 0.5, 0, plotRight);
|
|
1764
|
+
const lx1 =
|
|
1765
|
+
ov.to == null
|
|
1766
|
+
? plotRight
|
|
1767
|
+
: clamp(this._xFor(Math.max(0, idxFor(ov.to, d.length - 1))) + sp * 0.5, 0, plotRight);
|
|
1768
|
+
const ly = Math.round(yOf(ov.price)) + 0.5;
|
|
1769
|
+
if (lx1 - lx0 < 1 || ly < main.y0 || ly > main.y1) continue;
|
|
1770
|
+
ctx.save();
|
|
1771
|
+
ctx.strokeStyle = col;
|
|
1772
|
+
ctx.lineWidth = ov.width;
|
|
1773
|
+
if (ov.dash) ctx.setLineDash([5, 4]);
|
|
1774
|
+
ctx.beginPath();
|
|
1775
|
+
ctx.moveTo(lx0, ly);
|
|
1776
|
+
ctx.lineTo(lx1, ly);
|
|
1777
|
+
ctx.stroke();
|
|
1778
|
+
if (ov.label) {
|
|
1779
|
+
ctx.font = pillFont();
|
|
1780
|
+
ctx.fillStyle = col;
|
|
1781
|
+
ctx.textAlign = 'right';
|
|
1782
|
+
ctx.textBaseline = 'bottom';
|
|
1783
|
+
ctx.fillText(ov.label, Math.min(lx1, plotRight) - 6, ly - 2);
|
|
1784
|
+
}
|
|
1785
|
+
ctx.restore();
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/* scenario projection: ghost path + σ-cone in future space (clipped to
|
|
1791
|
+
* the main plot so out-of-range bands never bleed into the axis) */
|
|
1792
|
+
if (this._scenario && d.length) {
|
|
1793
|
+
const sc = this._scenario;
|
|
1794
|
+
const col = resolveOverlayColor(sc.color, pal);
|
|
1795
|
+
const baseIdx = d.length - 1;
|
|
1796
|
+
const xAt = (h) => this._xFor(baseIdx + h);
|
|
1797
|
+
ctx.save();
|
|
1798
|
+
ctx.beginPath();
|
|
1799
|
+
ctx.rect(0, main.y0, plotRight, main.h);
|
|
1800
|
+
ctx.clip();
|
|
1801
|
+
if (sc.cone) {
|
|
1802
|
+
const cone = this._scenarioConeCache();
|
|
1803
|
+
if (cone) {
|
|
1804
|
+
for (let li = cone.levels.length - 1; li >= 0; li--) {
|
|
1805
|
+
const b = cone.bands[cone.levels[li]];
|
|
1806
|
+
ctx.globalAlpha = li === 0 ? 0.1 : 0.05;
|
|
1807
|
+
ctx.fillStyle = col;
|
|
1808
|
+
ctx.beginPath();
|
|
1809
|
+
ctx.moveTo(xAt(0), yOf(b.up[0]));
|
|
1810
|
+
for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(b.up[h]));
|
|
1811
|
+
for (let h = cone.horizon; h >= 0; h--) ctx.lineTo(xAt(h), yOf(b.down[h]));
|
|
1812
|
+
ctx.closePath();
|
|
1813
|
+
ctx.fill();
|
|
1814
|
+
}
|
|
1815
|
+
const inner = cone.bands[cone.levels[0]];
|
|
1816
|
+
ctx.globalAlpha = 0.4;
|
|
1817
|
+
ctx.strokeStyle = col;
|
|
1818
|
+
ctx.lineWidth = 1;
|
|
1819
|
+
ctx.setLineDash([4, 4]);
|
|
1820
|
+
for (const arr of [inner.up, inner.down]) {
|
|
1821
|
+
ctx.beginPath();
|
|
1822
|
+
ctx.moveTo(xAt(0), yOf(arr[0]));
|
|
1823
|
+
for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(arr[h]));
|
|
1824
|
+
ctx.stroke();
|
|
1825
|
+
}
|
|
1826
|
+
ctx.setLineDash([]);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (sc.path.length) {
|
|
1830
|
+
ctx.globalAlpha = 0.9;
|
|
1831
|
+
ctx.strokeStyle = col;
|
|
1832
|
+
ctx.lineWidth = 1.5;
|
|
1833
|
+
ctx.setLineDash([6, 4]);
|
|
1834
|
+
ctx.beginPath();
|
|
1835
|
+
ctx.moveTo(xAt(0), yOf(d[baseIdx].close));
|
|
1836
|
+
for (const p of sc.path) ctx.lineTo(xAt(p.h), yOf(p.price));
|
|
1837
|
+
ctx.stroke();
|
|
1838
|
+
ctx.setLineDash([]);
|
|
1839
|
+
ctx.fillStyle = col;
|
|
1840
|
+
for (const p of sc.path) {
|
|
1841
|
+
const y = yOf(p.price);
|
|
1842
|
+
if (y >= main.y0 && y <= main.y1) {
|
|
1843
|
+
ctx.beginPath();
|
|
1844
|
+
ctx.arc(xAt(p.h), y, 2.5, 0, Math.PI * 2);
|
|
1845
|
+
ctx.fill();
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
if (sc.label) {
|
|
1849
|
+
const p = sc.path[sc.path.length - 1];
|
|
1850
|
+
ctx.font = pillFont();
|
|
1851
|
+
ctx.globalAlpha = 0.95;
|
|
1852
|
+
ctx.fillStyle = col;
|
|
1853
|
+
ctx.textAlign = 'left';
|
|
1854
|
+
ctx.textBaseline = 'middle';
|
|
1855
|
+
ctx.fillText(
|
|
1856
|
+
sc.label,
|
|
1857
|
+
Math.min(xAt(p.h) + 8, plotRight - 4),
|
|
1858
|
+
clamp(yOf(p.price), main.y0 + 8, main.y1 - 8)
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
ctx.restore();
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1384
1865
|
/* position zones (under series) */
|
|
1385
1866
|
for (const pos of this._positions) {
|
|
1386
1867
|
const yE = clamp(yOf(pos.entry), main.y0, main.y1);
|
|
@@ -1396,6 +1877,67 @@ class WickChart extends HTMLElementBase {
|
|
|
1396
1877
|
}
|
|
1397
1878
|
}
|
|
1398
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
|
+
|
|
1399
1941
|
/* volume profile (behind the series) */
|
|
1400
1942
|
if (this._profile) {
|
|
1401
1943
|
const pkey = `${i0}:${i1}:${this._version}`;
|
|
@@ -1724,7 +2266,7 @@ class WickChart extends HTMLElementBase {
|
|
|
1724
2266
|
ctx.setLineDash([5, 4]);
|
|
1725
2267
|
ctx.strokeStyle = pal.overlay[0];
|
|
1726
2268
|
for (const a of this._alerts) {
|
|
1727
|
-
if (a.fired) continue;
|
|
2269
|
+
if (a.fired || !isNum(a.price)) continue; // scripted alerts have no line
|
|
1728
2270
|
const y = yOf(a.price);
|
|
1729
2271
|
if (y < main.y0 || y > main.y1) continue;
|
|
1730
2272
|
ctx.globalAlpha = 0.8;
|
|
@@ -2055,6 +2597,34 @@ class WickChart extends HTMLElementBase {
|
|
|
2055
2597
|
);
|
|
2056
2598
|
}
|
|
2057
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
|
+
|
|
2058
2628
|
/* co-view ghost crosshair (peer pointer from another tab/chart) */
|
|
2059
2629
|
if (this._ghost) {
|
|
2060
2630
|
const g = this._ghost;
|
|
@@ -2141,6 +2711,50 @@ class WickChart extends HTMLElementBase {
|
|
|
2141
2711
|
);
|
|
2142
2712
|
}
|
|
2143
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
|
+
|
|
2144
2758
|
/* visible-range stats chip */
|
|
2145
2759
|
if (this._stats) {
|
|
2146
2760
|
const st = computeStats(d, i0, i1, this._dt);
|
|
@@ -2309,6 +2923,7 @@ class WickChart extends HTMLElementBase {
|
|
|
2309
2923
|
|
|
2310
2924
|
_pointerDown(e) {
|
|
2311
2925
|
if (e.button !== 0) return;
|
|
2926
|
+
this._stopPlayback(); // any touch interrupts the story
|
|
2312
2927
|
this._canvas.setPointerCapture(e.pointerId);
|
|
2313
2928
|
const pt = this._localPoint(e);
|
|
2314
2929
|
this._pointers.set(e.pointerId, pt);
|
|
@@ -2323,6 +2938,15 @@ class WickChart extends HTMLElementBase {
|
|
|
2323
2938
|
};
|
|
2324
2939
|
this._pan = null;
|
|
2325
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();
|
|
2326
2950
|
} else if (e.shiftKey && this._data.length) {
|
|
2327
2951
|
// shift+drag → measure tool
|
|
2328
2952
|
this._measuring = true;
|
|
@@ -2374,6 +2998,13 @@ class WickChart extends HTMLElementBase {
|
|
|
2374
2998
|
return;
|
|
2375
2999
|
}
|
|
2376
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
|
+
|
|
2377
3008
|
if (this._pan && this._pointers.has(e.pointerId) && ly) {
|
|
2378
3009
|
const dx = pt.x - this._pan.x;
|
|
2379
3010
|
if (Math.abs(dx) > 3) this._pan.moved = true;
|
|
@@ -2399,7 +3030,11 @@ class WickChart extends HTMLElementBase {
|
|
|
2399
3030
|
if (this._pointers.size < 2) this._pinch = null;
|
|
2400
3031
|
if (this._pointers.size === 0) {
|
|
2401
3032
|
this._canvas.classList.remove('grabbing');
|
|
2402
|
-
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) {
|
|
2403
3038
|
this._measuring = false;
|
|
2404
3039
|
if (this._measure) {
|
|
2405
3040
|
this._measure.done = true;
|
|
@@ -2443,14 +3078,17 @@ class WickChart extends HTMLElementBase {
|
|
|
2443
3078
|
_wheel(e) {
|
|
2444
3079
|
const ly = this._ly;
|
|
2445
3080
|
if (!ly || !this._data.length) return;
|
|
3081
|
+
this._stopPlayback();
|
|
2446
3082
|
e.preventDefault();
|
|
2447
3083
|
const pt = this._localPoint(e);
|
|
2448
3084
|
const dx = e.deltaX;
|
|
2449
3085
|
const dy = e.deltaY * (e.deltaMode === 1 ? 33 : 1);
|
|
2450
3086
|
|
|
2451
3087
|
if (Math.abs(dx) > Math.abs(dy) && !e.ctrlKey) {
|
|
2452
|
-
// trackpad horizontal scroll → pan
|
|
2453
|
-
|
|
3088
|
+
// trackpad horizontal scroll → pan. Wheel deltas are viewport-relative:
|
|
3089
|
+
// deltaX>0 means "scroll right", i.e. reveal newer bars. On natural-scroll
|
|
3090
|
+
// trackpads this makes the content follow the fingers, matching drag.
|
|
3091
|
+
this._view.rightIndex += dx / this._view.spacing;
|
|
2454
3092
|
this._auto = this._atRight();
|
|
2455
3093
|
this._clampView();
|
|
2456
3094
|
this._invalidate();
|
|
@@ -2474,6 +3112,11 @@ class WickChart extends HTMLElementBase {
|
|
|
2474
3112
|
_keydown(e) {
|
|
2475
3113
|
const ly = this._ly;
|
|
2476
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
|
+
}
|
|
2477
3120
|
const d = this._data;
|
|
2478
3121
|
const key = e.key;
|
|
2479
3122
|
const step = e.shiftKey ? 10 : 1;
|
|
@@ -2662,11 +3305,18 @@ class WickChart extends HTMLElementBase {
|
|
|
2662
3305
|
} catch (_) {}
|
|
2663
3306
|
this._coviewCh = null;
|
|
2664
3307
|
}
|
|
3308
|
+
clearInterval(this._coviewBeat);
|
|
3309
|
+
this._coviewBeat = 0;
|
|
2665
3310
|
clearTimeout(this._ghostTimer);
|
|
2666
3311
|
if (this._ghost) {
|
|
2667
3312
|
this._ghost = null;
|
|
2668
3313
|
this._invalidate();
|
|
2669
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
|
+
}
|
|
2670
3320
|
const name = this._coviewName;
|
|
2671
3321
|
if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
|
|
2672
3322
|
if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
|
|
@@ -2675,6 +3325,33 @@ class WickChart extends HTMLElementBase {
|
|
|
2675
3325
|
ch.onmessage = (ev) => this._onCoMessage(ev.data);
|
|
2676
3326
|
this._coviewCh = ch;
|
|
2677
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
|
+
});
|
|
2678
3355
|
}
|
|
2679
3356
|
|
|
2680
3357
|
_coviewSend(msg) {
|
|
@@ -2685,7 +3362,30 @@ class WickChart extends HTMLElementBase {
|
|
|
2685
3362
|
}
|
|
2686
3363
|
|
|
2687
3364
|
_onCoMessage(m) {
|
|
2688
|
-
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;
|
|
2689
3389
|
if (m.time == null) {
|
|
2690
3390
|
if (this._ghost) {
|
|
2691
3391
|
this._ghost = null;
|
|
@@ -2715,10 +3415,297 @@ class WickChart extends HTMLElementBase {
|
|
|
2715
3415
|
this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
|
|
2716
3416
|
}
|
|
2717
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
|
+
|
|
2718
3704
|
_emitRange() {
|
|
2719
3705
|
const r = this.getVisibleRange();
|
|
2720
3706
|
if (!r) return;
|
|
2721
3707
|
this._fire('range', r);
|
|
3708
|
+
if (this._coviewCh) this._coviewSendView();
|
|
2722
3709
|
}
|
|
2723
3710
|
}
|
|
2724
3711
|
|