wickchart 0.4.0 → 1.2.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 +274 -48
- package/package.json +29 -12
- package/src/core.js +452 -12
- package/src/feeds.js +1 -1
- package/src/react-core.js +191 -0
- package/src/react.js +21 -0
- package/src/{hab-chart.js → wick-chart.js} +525 -111
- package/src/{hab-feed.js → wick-feed.js} +34 -19
- package/types/core.d.ts +209 -3
- package/types/react-core.d.ts +64 -0
- package/types/react.d.ts +3 -0
- package/types/{hab-chart.d.ts → wick-chart.d.ts} +215 -13
- package/types/{hab-feed.d.ts → wick-feed.d.ts} +8 -4
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/* ==========================================================================
|
|
2
|
-
* <
|
|
2
|
+
* <wick-chart> — a modern, dependency-free financial charting web component.
|
|
3
3
|
*
|
|
4
|
-
* <
|
|
5
|
-
* </
|
|
4
|
+
* <wick-chart label="BTC · 1h" type="candles" indicators="sma:20 volume">
|
|
5
|
+
* </wick-chart>
|
|
6
6
|
* <script type="module">
|
|
7
|
-
* const chart = document.querySelector('
|
|
7
|
+
* const chart = document.querySelector('wick-chart');
|
|
8
8
|
* chart.setData(bars); // [{ time, open, high, low, close, volume }]
|
|
9
9
|
* chart.update(bar); // streaming update / append
|
|
10
10
|
* </script>
|
|
11
11
|
*
|
|
12
12
|
* Zero dependencies. Canvas-rendered. Framework-agnostic (works in React,
|
|
13
|
-
* Vue,
|
|
13
|
+
* Vue, plain HTML). Themeable with --wick-* CSS custom properties.
|
|
14
|
+
*
|
|
15
|
+
* The 0.x names (<hab-chart>, --hab-*, hab:* events) still work as
|
|
16
|
+
* deprecated aliases — see the README migration notes.
|
|
14
17
|
*
|
|
15
18
|
* MIT License.
|
|
16
19
|
* ========================================================================== */
|
|
@@ -25,20 +28,27 @@ import {
|
|
|
25
28
|
SERIES_TYPES, calcHeikinAshi, buildColumns, computeVolumeProfile,
|
|
26
29
|
calcRSI, detectAnnotations, priceToFreq,
|
|
27
30
|
calcRealizedVol, volRegimeBands, percentileOfSorted, parseVolShading,
|
|
28
|
-
windowSummary,
|
|
31
|
+
windowSummary, normalizeOverlays, barIndexForTime, resolveOverlayColor,
|
|
32
|
+
compileScript, predicateTrueSeries, scriptAlertStep,
|
|
33
|
+
AI_TOOLS, aiPromptText, applyChartOps,
|
|
34
|
+
calcVolCone, normalizeScenario,
|
|
29
35
|
} from './core.js';
|
|
30
36
|
|
|
31
37
|
/* ------------------------------------------------------------------ *
|
|
32
|
-
* <
|
|
38
|
+
* <wick-chart>
|
|
33
39
|
* ------------------------------------------------------------------ */
|
|
34
40
|
|
|
41
|
+
/** Indicator registry — module scope so registrations are shared by
|
|
42
|
+
* <wick-chart> and the deprecated <hab-chart> alias element. */
|
|
43
|
+
const REGISTRY = new Map(BUILTIN_INDICATORS);
|
|
44
|
+
|
|
35
45
|
/* SSR safety: importing this module under Node (Next.js/Nuxt server render)
|
|
36
46
|
* must not throw — the element simply registers only in browsers. */
|
|
37
47
|
const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
|
|
38
48
|
|
|
39
|
-
class
|
|
49
|
+
class WickChart extends HTMLElementBase {
|
|
40
50
|
static get observedAttributes() {
|
|
41
|
-
return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'co-view', 'sonify'];
|
|
51
|
+
return ['theme', 'type', 'log', 'auto', 'indicators', 'precision', 'label', 'stats', 'profile', 'annotations', 'volshading', 'overlays', 'co-view', 'sonify'];
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
constructor() {
|
|
@@ -55,7 +65,7 @@ class HabChart extends HTMLElementBase {
|
|
|
55
65
|
contain: content;
|
|
56
66
|
}
|
|
57
67
|
:host(:focus-visible) {
|
|
58
|
-
outline: 2px solid var(--hab-accent, #4c8dff);
|
|
68
|
+
outline: 2px solid var(--wick-accent, var(--hab-accent, #4c8dff));
|
|
59
69
|
outline-offset: -2px;
|
|
60
70
|
}
|
|
61
71
|
.wrap { position: absolute; inset: 0; overflow: hidden; }
|
|
@@ -78,26 +88,26 @@ class HabChart extends HTMLElementBase {
|
|
|
78
88
|
}
|
|
79
89
|
.legend .row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
80
90
|
.legend .sym {
|
|
81
|
-
color: var(--hab-text-strong, #e6edf3);
|
|
91
|
+
color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3));
|
|
82
92
|
font-weight: 700;
|
|
83
93
|
font-size: 13px;
|
|
84
94
|
letter-spacing: 0.02em;
|
|
85
95
|
}
|
|
86
96
|
.legend .kv { display: inline-flex; gap: 5px; align-items: baseline; white-space: nowrap; }
|
|
87
|
-
.legend .k { color: var(--hab-text, #8b949e); font-size: 11px; }
|
|
88
|
-
.legend .v { color: var(--hab-text-strong, #e6edf3); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
97
|
+
.legend .k { color: var(--wick-text, var(--hab-text, #8b949e)); font-size: 11px; }
|
|
98
|
+
.legend .v { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
89
99
|
.legend .pct { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
90
|
-
.legend .up { color: var(--hab-up, #16c784); }
|
|
91
|
-
.legend .dn { color: var(--hab-down, #ea3943); }
|
|
100
|
+
.legend .up { color: var(--wick-up, var(--hab-up, #16c784)); }
|
|
101
|
+
.legend .dn { color: var(--wick-down, var(--hab-down, #ea3943)); }
|
|
92
102
|
.legend .ind {
|
|
93
103
|
display: inline-flex; align-items: center; gap: 6px;
|
|
94
|
-
color: var(--hab-text, #8b949e); font-size: 11.5px; white-space: nowrap;
|
|
104
|
+
color: var(--wick-text, var(--hab-text, #8b949e)); font-size: 11.5px; white-space: nowrap;
|
|
95
105
|
}
|
|
96
106
|
.legend .ind i { width: 8px; height: 2.5px; border-radius: 2px; display: inline-block; }
|
|
97
107
|
.legend .ind .v { font-size: 12px; }
|
|
98
108
|
.legend .insight {
|
|
99
|
-
color: var(--hab-accent, #4c8dff);
|
|
100
|
-
background: var(--hab-chip, rgba(127, 137, 153, 0.12));
|
|
109
|
+
color: var(--wick-accent, var(--hab-accent, #4c8dff));
|
|
110
|
+
background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
|
|
101
111
|
border-radius: 6px;
|
|
102
112
|
padding: 1px 8px;
|
|
103
113
|
font-size: 11.5px;
|
|
@@ -106,7 +116,7 @@ class HabChart extends HTMLElementBase {
|
|
|
106
116
|
.nodata {
|
|
107
117
|
position: absolute; inset: 0;
|
|
108
118
|
display: flex; align-items: center; justify-content: center;
|
|
109
|
-
color: var(--hab-text, #8b949e);
|
|
119
|
+
color: var(--wick-text, var(--hab-text, #8b949e));
|
|
110
120
|
font: 500 13px ${FONT_STACK};
|
|
111
121
|
pointer-events: none;
|
|
112
122
|
}
|
|
@@ -119,25 +129,25 @@ class HabChart extends HTMLElementBase {
|
|
|
119
129
|
}
|
|
120
130
|
.hud .pos {
|
|
121
131
|
display: inline-flex; gap: 9px; align-items: baseline; white-space: nowrap;
|
|
122
|
-
background: var(--hab-chip, rgba(127, 137, 153, 0.12));
|
|
123
|
-
border: 1px solid var(--hab-border, rgba(148, 163, 184, 0.2));
|
|
132
|
+
background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
|
|
133
|
+
border: 1px solid var(--wick-border, var(--hab-border, rgba(148, 163, 184, 0.2)));
|
|
124
134
|
border-radius: 7px;
|
|
125
135
|
padding: 3px 9px;
|
|
126
136
|
}
|
|
127
137
|
.hud .statsrow {
|
|
128
138
|
display: inline-flex; gap: 12px; white-space: nowrap;
|
|
129
|
-
background: var(--hab-chip, rgba(127, 137, 153, 0.12));
|
|
130
|
-
border: 1px solid var(--hab-border, rgba(148, 163, 184, 0.2));
|
|
139
|
+
background: var(--wick-chip, var(--hab-chip, rgba(127, 137, 153, 0.12)));
|
|
140
|
+
border: 1px solid var(--wick-border, var(--hab-border, rgba(148, 163, 184, 0.2)));
|
|
131
141
|
border-radius: 7px;
|
|
132
142
|
padding: 3px 10px;
|
|
133
|
-
color: var(--hab-text, #8b949e);
|
|
143
|
+
color: var(--wick-text, var(--hab-text, #8b949e));
|
|
134
144
|
font-variant-numeric: tabular-nums;
|
|
135
145
|
}
|
|
136
|
-
.hud .statsrow b { color: var(--hab-text-strong, #e6edf3); font-weight: 600; }
|
|
137
|
-
.hud .k { color: var(--hab-text, #8b949e); font-weight: 500; }
|
|
138
|
-
.hud .v { color: var(--hab-text-strong, #e6edf3); font-variant-numeric: tabular-nums; }
|
|
139
|
-
.hud .up { color: var(--hab-up, #16c784); }
|
|
140
|
-
.hud .dn { color: var(--hab-down, #ea3943); }
|
|
146
|
+
.hud .statsrow b { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-weight: 600; }
|
|
147
|
+
.hud .k { color: var(--wick-text, var(--hab-text, #8b949e)); font-weight: 500; }
|
|
148
|
+
.hud .v { color: var(--wick-text-strong, var(--hab-text-strong, #e6edf3)); font-variant-numeric: tabular-nums; }
|
|
149
|
+
.hud .up { color: var(--wick-up, var(--hab-up, #16c784)); }
|
|
150
|
+
.hud .dn { color: var(--wick-down, var(--hab-down, #ea3943)); }
|
|
141
151
|
</style>
|
|
142
152
|
<div class="wrap" part="wrap">
|
|
143
153
|
<canvas part="canvas" role="img"></canvas>
|
|
@@ -218,6 +228,12 @@ class HabChart extends HTMLElementBase {
|
|
|
218
228
|
this._alerts = [];
|
|
219
229
|
this._seq = 0;
|
|
220
230
|
|
|
231
|
+
// server-side overlays (zones & levels)
|
|
232
|
+
this._overlays = [];
|
|
233
|
+
|
|
234
|
+
// scenario projection (ghost path + vol cone)
|
|
235
|
+
this._scenario = null;
|
|
236
|
+
|
|
221
237
|
this._onResize = () => this._invalidate();
|
|
222
238
|
this._onPointerDown = (e) => this._pointerDown(e);
|
|
223
239
|
this._onPointerMove = (e) => this._pointerMove(e);
|
|
@@ -305,7 +321,7 @@ class HabChart extends HTMLElementBase {
|
|
|
305
321
|
this._label = val || '';
|
|
306
322
|
break;
|
|
307
323
|
case 'indicators':
|
|
308
|
-
this._ind = parseIndicators(val,
|
|
324
|
+
this._ind = parseIndicators(val, WickChart._registry());
|
|
309
325
|
break;
|
|
310
326
|
case 'stats':
|
|
311
327
|
this._stats = val != null && val !== 'false';
|
|
@@ -323,6 +339,18 @@ class HabChart extends HTMLElementBase {
|
|
|
323
339
|
this._volshade = val != null && val !== 'false' ? parseVolShading(val) : null;
|
|
324
340
|
this._legendKey = '';
|
|
325
341
|
break;
|
|
342
|
+
case 'overlays': {
|
|
343
|
+
let ovs = [];
|
|
344
|
+
if (val != null && val !== '') {
|
|
345
|
+
try {
|
|
346
|
+
ovs = normalizeOverlays(JSON.parse(val));
|
|
347
|
+
} catch (err) {
|
|
348
|
+
ovs = [];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
this._overlays = ovs;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
326
354
|
case 'co-view':
|
|
327
355
|
this._coviewName = val || null;
|
|
328
356
|
this._setupCoView();
|
|
@@ -339,20 +367,15 @@ class HabChart extends HTMLElementBase {
|
|
|
339
367
|
* Indicator registry
|
|
340
368
|
* ------------------------------------------------------------ */
|
|
341
369
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
/** Lazily-built registry, seeded with the built-in indicators. */
|
|
370
|
+
/** Indicator registry (module scope — shared with the legacy alias tag). */
|
|
345
371
|
static _registry() {
|
|
346
|
-
|
|
347
|
-
HabChart._registryMap = new Map(BUILTIN_INDICATORS);
|
|
348
|
-
}
|
|
349
|
-
return HabChart._registryMap;
|
|
372
|
+
return REGISTRY;
|
|
350
373
|
}
|
|
351
374
|
|
|
352
375
|
/**
|
|
353
376
|
* Register a custom indicator.
|
|
354
377
|
*
|
|
355
|
-
*
|
|
378
|
+
* WickChart.registerIndicator('vwap', {
|
|
356
379
|
* kind: 'overlay', // or 'pane'
|
|
357
380
|
* params: { period: 20 }, // defaults; settable via name:period
|
|
358
381
|
* compute(bars, params) { // bars: normalized {time,o,h,l,c,v}
|
|
@@ -373,7 +396,7 @@ class HabChart extends HTMLElementBase {
|
|
|
373
396
|
if (!def || typeof def.compute !== 'function') {
|
|
374
397
|
throw new Error('registerIndicator: def.compute must be a function');
|
|
375
398
|
}
|
|
376
|
-
|
|
399
|
+
REGISTRY.set(name.toLowerCase(), {
|
|
377
400
|
kind: def.kind === 'pane' ? 'pane' : 'overlay',
|
|
378
401
|
...def,
|
|
379
402
|
});
|
|
@@ -403,7 +426,7 @@ class HabChart extends HTMLElementBase {
|
|
|
403
426
|
}
|
|
404
427
|
const norm = [];
|
|
405
428
|
for (const b of bars) {
|
|
406
|
-
const nb =
|
|
429
|
+
const nb = WickChart._normBar(b);
|
|
407
430
|
if (nb) norm.push(nb);
|
|
408
431
|
}
|
|
409
432
|
// skip the O(n log n) sort when already ascending (typical for feeds)
|
|
@@ -432,7 +455,7 @@ class HabChart extends HTMLElementBase {
|
|
|
432
455
|
* @param {import('./core.js').Bar} bar
|
|
433
456
|
*/
|
|
434
457
|
update(bar) {
|
|
435
|
-
const b =
|
|
458
|
+
const b = WickChart._normBar(bar);
|
|
436
459
|
if (!b) return;
|
|
437
460
|
const d = this._data;
|
|
438
461
|
const last = d[d.length - 1];
|
|
@@ -492,7 +515,7 @@ class HabChart extends HTMLElementBase {
|
|
|
492
515
|
}
|
|
493
516
|
const older = [];
|
|
494
517
|
for (const b of bars) {
|
|
495
|
-
const nb =
|
|
518
|
+
const nb = WickChart._normBar(b);
|
|
496
519
|
if (nb) older.push(nb);
|
|
497
520
|
}
|
|
498
521
|
const { bars: merged, added } = mergeOlderData(this._data, older);
|
|
@@ -540,14 +563,14 @@ class HabChart extends HTMLElementBase {
|
|
|
540
563
|
setVisibleRange(range) {
|
|
541
564
|
const d = this._data;
|
|
542
565
|
if (!d.length || !range || !this._ly) return;
|
|
543
|
-
const from =
|
|
544
|
-
const to =
|
|
545
|
-
let i0 =
|
|
546
|
-
let i1 =
|
|
566
|
+
const from = WickChart._timeToMs(range.from);
|
|
567
|
+
const to = WickChart._timeToMs(range.to);
|
|
568
|
+
let i0 = WickChart._indexForTime(d, from);
|
|
569
|
+
let i1 = WickChart._indexForTime(d, to);
|
|
547
570
|
if (i0 > i1) [i0, i1] = [i1, i0];
|
|
548
571
|
if (i1 - i0 < 2) return;
|
|
549
572
|
const { plotRight } = this._ly;
|
|
550
|
-
this._view.spacing = clamp(plotRight / (i1 - i0), this._minSpacing(),
|
|
573
|
+
this._view.spacing = clamp(plotRight / (i1 - i0), this._minSpacing(), WickChart._MAX_SP);
|
|
551
574
|
this._view.rightIndex = i1;
|
|
552
575
|
this._auto = false;
|
|
553
576
|
this._clampView();
|
|
@@ -627,7 +650,11 @@ class HabChart extends HTMLElementBase {
|
|
|
627
650
|
})),
|
|
628
651
|
alerts: this._alerts
|
|
629
652
|
.filter((a) => !a.fired)
|
|
630
|
-
.map((a) =>
|
|
653
|
+
.map((a) =>
|
|
654
|
+
a.when != null
|
|
655
|
+
? { id: a.id, when: a.when, once: a.once }
|
|
656
|
+
: { id: a.id, price: a.price, direction: a.direction, once: a.once }
|
|
657
|
+
),
|
|
631
658
|
};
|
|
632
659
|
}
|
|
633
660
|
|
|
@@ -665,14 +692,31 @@ class HabChart extends HTMLElementBase {
|
|
|
665
692
|
}
|
|
666
693
|
if (Array.isArray(state.alerts)) {
|
|
667
694
|
this._alerts = state.alerts
|
|
668
|
-
.filter((a) => a && isNum(a.price))
|
|
669
|
-
.map((a) =>
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
695
|
+
.filter((a) => a && (isNum(a.price) || typeof a.when === 'string'))
|
|
696
|
+
.map((a) => {
|
|
697
|
+
if (typeof a.when === 'string' && a.when.trim()) {
|
|
698
|
+
try {
|
|
699
|
+
return {
|
|
700
|
+
id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
|
|
701
|
+
when: a.when.trim(),
|
|
702
|
+
compiled: compileScript(a.when),
|
|
703
|
+
once: a.once !== false,
|
|
704
|
+
fired: false,
|
|
705
|
+
armed: true,
|
|
706
|
+
};
|
|
707
|
+
} catch (err) {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
id: a.id != null ? String(a.id) : 'alert-' + ++this._seq,
|
|
713
|
+
price: a.price,
|
|
714
|
+
direction: a.direction || 'cross',
|
|
715
|
+
once: a.once !== false,
|
|
716
|
+
fired: false,
|
|
717
|
+
};
|
|
718
|
+
})
|
|
719
|
+
.filter(Boolean);
|
|
676
720
|
}
|
|
677
721
|
if (state.view && state.view.from != null && state.view.to != null) {
|
|
678
722
|
if (this._ly && this._data.length > 1) {
|
|
@@ -725,21 +769,46 @@ class HabChart extends HTMLElementBase {
|
|
|
725
769
|
}
|
|
726
770
|
|
|
727
771
|
/**
|
|
728
|
-
* Price alert.
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
772
|
+
* Price or scripted alert. Price alerts fire `wick:alert`
|
|
773
|
+
* ({id, price, bar}) on an edge crossing; scripted alerts evaluate a
|
|
774
|
+
* WickScript predicate (`when`) on every streamed bar and fire on its
|
|
775
|
+
* false→true edge — e.g. `when: 'crossup(rsi(close,14), 30)'` or
|
|
776
|
+
* `when: 'volume > sma(volume,20) * 3'`. Scripted events carry the
|
|
777
|
+
* triggering close as `price` plus the `when` source (deprecated
|
|
778
|
+
* `hab:alert` alias still dispatched).
|
|
779
|
+
* @param {{id?: string, price?: number, direction?: 'above'|'below'|'cross',
|
|
780
|
+
* when?: string, once?: boolean}} alert
|
|
781
|
+
* @returns {string|null} the alert id (null when no valid price/when,
|
|
782
|
+
* or the predicate fails to compile)
|
|
733
783
|
*/
|
|
734
784
|
addAlert(alert) {
|
|
735
|
-
if (!alert
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
785
|
+
if (!alert) return null;
|
|
786
|
+
let a;
|
|
787
|
+
if (typeof alert.when === 'string' && alert.when.trim()) {
|
|
788
|
+
let compiled;
|
|
789
|
+
try {
|
|
790
|
+
compiled = compileScript(alert.when);
|
|
791
|
+
} catch (err) {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
a = {
|
|
795
|
+
id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
|
|
796
|
+
when: alert.when.trim(),
|
|
797
|
+
compiled,
|
|
798
|
+
once: alert.once !== false,
|
|
799
|
+
fired: false,
|
|
800
|
+
armed: true,
|
|
801
|
+
};
|
|
802
|
+
} else {
|
|
803
|
+
if (!isNum(alert.price)) return null;
|
|
804
|
+
a = {
|
|
805
|
+
id: alert.id != null ? String(alert.id) : 'alert-' + ++this._seq,
|
|
806
|
+
price: alert.price,
|
|
807
|
+
direction: alert.direction || 'cross',
|
|
808
|
+
once: alert.once !== false,
|
|
809
|
+
fired: false,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
743
812
|
const i = this._alerts.findIndex((x) => x.id === a.id);
|
|
744
813
|
if (i >= 0) this._alerts[i] = a;
|
|
745
814
|
else this._alerts.push(a);
|
|
@@ -757,21 +826,214 @@ class HabChart extends HTMLElementBase {
|
|
|
757
826
|
this._invalidate();
|
|
758
827
|
}
|
|
759
828
|
|
|
760
|
-
/**
|
|
829
|
+
/**
|
|
830
|
+
* Server-side overlays: zones & levels anchored in time × price — e.g.
|
|
831
|
+
* supply/demand zones from an analysis API. Zones with no `to` extend
|
|
832
|
+
* into future space past the last bar, like TradingView drawings.
|
|
833
|
+
*
|
|
834
|
+
* zone: { type:'zone', from?:ms, to?:ms|null, priceFrom, priceTo,
|
|
835
|
+
* color?, alpha?, border?, label?, id? }
|
|
836
|
+
* level: { type:'level', price, from?, to?, color?, width?, dash?,
|
|
837
|
+
* label?, id? }
|
|
838
|
+
*
|
|
839
|
+
* Invalid entries are dropped, never thrown. Colors accept hex/rgb()/CSS
|
|
840
|
+
* names plus the palette keys 'up' | 'down' | 'accent'.
|
|
841
|
+
* @param {object[]} list
|
|
842
|
+
* @returns {string[]} applied overlay ids
|
|
843
|
+
*/
|
|
844
|
+
setOverlays(list) {
|
|
845
|
+
this._overlays = normalizeOverlays(list);
|
|
846
|
+
this._invalidate();
|
|
847
|
+
return this._overlays.map((o) => o.id);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** @returns {object[]} a copy of the current overlays */
|
|
851
|
+
get overlays() {
|
|
852
|
+
return this._overlays.map((o) => ({ ...o }));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Add or replace (upsert, by id) a single overlay.
|
|
857
|
+
* @returns {string|null} the overlay id, or null if invalid
|
|
858
|
+
*/
|
|
859
|
+
addOverlay(ov) {
|
|
860
|
+
const norm = normalizeOverlays([ov]);
|
|
861
|
+
if (!norm.length) return null;
|
|
862
|
+
const one = norm[0];
|
|
863
|
+
const i = this._overlays.findIndex((x) => x.id === one.id);
|
|
864
|
+
if (i >= 0) this._overlays[i] = one;
|
|
865
|
+
else this._overlays.push(one);
|
|
866
|
+
this._invalidate();
|
|
867
|
+
return one.id;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
removeOverlay(id) {
|
|
871
|
+
this._overlays = this._overlays.filter((o) => o.id !== String(id));
|
|
872
|
+
this._invalidate();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
clearOverlays() {
|
|
876
|
+
this._overlays = [];
|
|
877
|
+
this._invalidate();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Scenario projection into future space: a ghost path of future prices
|
|
882
|
+
* plus optional σ-bands (vol cone) from realized volatility.
|
|
883
|
+
*
|
|
884
|
+
* chart.setScenario({ path: [64000, 65500, 68000], label: 'bull case' });
|
|
885
|
+
* chart.setScenario({ horizon: 48, cone: true }); // cone-only
|
|
886
|
+
*
|
|
887
|
+
* The path is an array of prices (or {price} objects) for future bars
|
|
888
|
+
* 1..N; horizon defaults to the path length (1–500). `cone` (default
|
|
889
|
+
* true) draws ±levels·σ bands widening with √h from the current realized
|
|
890
|
+
* vol; `color` accepts up|down|accent or safe CSS colors. Setting a
|
|
891
|
+
* scenario reserves future space on the right; analysis data — excluded
|
|
892
|
+
* from getState/setState.
|
|
893
|
+
* @param {object} spec
|
|
894
|
+
* @returns {object|null} the normalized scenario, or null when invalid
|
|
895
|
+
*/
|
|
896
|
+
setScenario(spec) {
|
|
897
|
+
this._scenario = normalizeScenario(spec);
|
|
898
|
+
this._invalidate();
|
|
899
|
+
return this._scenario;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
clearScenario() {
|
|
903
|
+
this._scenario = null;
|
|
904
|
+
this._invalidate();
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** @returns {object|null} a copy of the active scenario */
|
|
908
|
+
get scenario() {
|
|
909
|
+
if (!this._scenario) return null;
|
|
910
|
+
return { ...this._scenario, path: this._scenario.path.map((p) => ({ ...p })) };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** σ-cone for the active scenario, cached per data version. */
|
|
914
|
+
_scenarioConeCache() {
|
|
915
|
+
if (!this._scenario || !this._data.length) return null;
|
|
916
|
+
if (this._cache.v !== this._version) {
|
|
917
|
+
this._cache = { v: this._version, map: {} };
|
|
918
|
+
}
|
|
919
|
+
if (!this._cache.map.__scenario) {
|
|
920
|
+
const d = this._data;
|
|
921
|
+
const vol = calcRealizedVol(d.map((b) => b.close), 20);
|
|
922
|
+
let v = NaN;
|
|
923
|
+
for (let i = vol.length - 1; i >= 0; i--) {
|
|
924
|
+
if (Number.isFinite(vol[i])) { v = vol[i]; break; }
|
|
925
|
+
}
|
|
926
|
+
this._cache.map.__scenario = calcVolCone(
|
|
927
|
+
d[d.length - 1].close,
|
|
928
|
+
v,
|
|
929
|
+
this._scenario.horizon,
|
|
930
|
+
this._scenario.levels
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
return this._cache.map.__scenario;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/* ------------------------------------------------------------ *
|
|
937
|
+
* AI agent interface — the chart as a tool surface
|
|
938
|
+
* ------------------------------------------------------------ */
|
|
939
|
+
|
|
940
|
+
/** Tool manifest for LLM control — JSON-safe copy of AI_TOOLS. */
|
|
941
|
+
aiTools() {
|
|
942
|
+
return JSON.parse(JSON.stringify(AI_TOOLS));
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** System prompt for agent control — paste into any LLM alongside aiTools(). */
|
|
946
|
+
aiPrompt() {
|
|
947
|
+
return aiPromptText();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Grounding context for a model: current state + visible-window summary. */
|
|
951
|
+
aiContext() {
|
|
952
|
+
return { state: this.getState(), window: this.getDataWindow() };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Apply a list of {tool, args} ops (typically LLM output) through the
|
|
957
|
+
* validated dispatcher in core. Never throws — each op resolves
|
|
958
|
+
* {ok, tool, result} or {ok: false, tool, error} so an agent can
|
|
959
|
+
* self-correct.
|
|
960
|
+
* @param {any} ops
|
|
961
|
+
* @returns {Array<object>}
|
|
962
|
+
*/
|
|
963
|
+
applyAI(ops) {
|
|
964
|
+
return applyChartOps(this, ops);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Ask an AI to operate the chart. Builds the payload {system,
|
|
969
|
+
* instruction, chart, tools}; with a `run` async function (your model
|
|
970
|
+
* call — the chart itself never touches the network), applies the
|
|
971
|
+
* returned ops and resolves {payload, ops, results}. Without `run`,
|
|
972
|
+
* returns the payload for manual wiring — send it anywhere, then call
|
|
973
|
+
* chart.applyAI(ops) with the model's answer.
|
|
974
|
+
*
|
|
975
|
+
* const { results } = await chart.ask('add RSI and mark the demand zone', {
|
|
976
|
+
* run: async (payload) => (await callMyLLM(payload)).ops,
|
|
977
|
+
* });
|
|
978
|
+
*
|
|
979
|
+
* @param {string} instruction natural-language request
|
|
980
|
+
* @param {{run?: (payload: object) => Promise<any>}} [opts]
|
|
981
|
+
*/
|
|
982
|
+
async ask(instruction, opts = {}) {
|
|
983
|
+
const payload = {
|
|
984
|
+
system: aiPromptText(),
|
|
985
|
+
instruction: String(instruction == null ? '' : instruction),
|
|
986
|
+
chart: this.aiContext(),
|
|
987
|
+
tools: this.aiTools(),
|
|
988
|
+
};
|
|
989
|
+
if (typeof opts.run !== 'function') {
|
|
990
|
+
return { payload, ops: null, results: null };
|
|
991
|
+
}
|
|
992
|
+
const ops = await opts.run(payload);
|
|
993
|
+
const results = this.applyAI(ops);
|
|
994
|
+
return { payload, ops, results };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Check alerts against an incoming bar (prev close → new close).
|
|
998
|
+
* Scripted (`when`) alerts evaluate their predicate series, cached per
|
|
999
|
+
* data version, and fire on the false→true edge. */
|
|
761
1000
|
_checkAlerts(prevClose, bar) {
|
|
762
|
-
if (!this._alerts.length
|
|
1001
|
+
if (!this._alerts.length) return;
|
|
763
1002
|
for (const a of [...this._alerts]) {
|
|
764
1003
|
if (a.fired) continue;
|
|
1004
|
+
if (a.when != null) {
|
|
1005
|
+
const series = this._predicateCache(a);
|
|
1006
|
+
const curTrue = series.length ? series[series.length - 1] : false;
|
|
1007
|
+
const step = scriptAlertStep(a.armed, curTrue);
|
|
1008
|
+
a.armed = step.armed;
|
|
1009
|
+
if (step.fire) {
|
|
1010
|
+
a.fired = true;
|
|
1011
|
+
this._fire('alert', { id: a.id, price: bar.close, when: a.when, bar });
|
|
1012
|
+
if (a.once) this._alerts = this._alerts.filter((x) => x !== a);
|
|
1013
|
+
}
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (!isNum(prevClose)) continue;
|
|
765
1017
|
if (checkAlertCross(a, prevClose, bar.close)) {
|
|
766
1018
|
a.fired = true;
|
|
767
|
-
this.
|
|
768
|
-
new CustomEvent('hab:alert', { detail: { id: a.id, price: a.price, bar } })
|
|
769
|
-
);
|
|
1019
|
+
this._fire('alert', { id: a.id, price: a.price, bar });
|
|
770
1020
|
if (a.once) this._alerts = this._alerts.filter((x) => x !== a);
|
|
771
1021
|
}
|
|
772
1022
|
}
|
|
773
1023
|
}
|
|
774
1024
|
|
|
1025
|
+
/** Cached boolean series for a scripted alert's predicate (per data version). */
|
|
1026
|
+
_predicateCache(alert) {
|
|
1027
|
+
if (this._cache.v !== this._version) {
|
|
1028
|
+
this._cache = { v: this._version, map: {} };
|
|
1029
|
+
}
|
|
1030
|
+
const k = 'pred:' + alert.when;
|
|
1031
|
+
if (!this._cache.map[k]) {
|
|
1032
|
+
this._cache.map[k] = predicateTrueSeries(alert.compiled, this._data);
|
|
1033
|
+
}
|
|
1034
|
+
return this._cache.map[k];
|
|
1035
|
+
}
|
|
1036
|
+
|
|
775
1037
|
/* reflected properties */
|
|
776
1038
|
get theme() { return this._theme; }
|
|
777
1039
|
set theme(v) { this.setAttribute('theme', v); }
|
|
@@ -808,7 +1070,7 @@ class HabChart extends HTMLElementBase {
|
|
|
808
1070
|
if (!b) return null;
|
|
809
1071
|
const t = b.time != null ? b.time : b.t;
|
|
810
1072
|
if (!isNum(t)) return null;
|
|
811
|
-
const time =
|
|
1073
|
+
const time = WickChart._timeToMs(t);
|
|
812
1074
|
const close = isNum(b.close) ? b.close : isNum(b.value) ? b.value : NaN;
|
|
813
1075
|
if (!isNum(close)) return null;
|
|
814
1076
|
const open = isNum(b.open) ? b.open : close;
|
|
@@ -887,8 +1149,9 @@ class HabChart extends HTMLElementBase {
|
|
|
887
1149
|
if (this._pal && this._palKey === this._theme) return this._pal;
|
|
888
1150
|
const base = THEMES[this._theme] || THEMES.dark;
|
|
889
1151
|
const cs = getComputedStyle(this);
|
|
1152
|
+
// --wick-* is canonical; --hab-* still honored as the 0.x fallback
|
|
890
1153
|
const get = (name, fallback) => {
|
|
891
|
-
const v = cs.getPropertyValue('--hab-' + name).trim();
|
|
1154
|
+
const v = cs.getPropertyValue('--wick-' + name).trim() || cs.getPropertyValue('--hab-' + name).trim();
|
|
892
1155
|
return v || fallback;
|
|
893
1156
|
};
|
|
894
1157
|
const pal = {};
|
|
@@ -899,7 +1162,8 @@ class HabChart extends HTMLElementBase {
|
|
|
899
1162
|
o.push(get('overlay-' + i, base.overlay[i]));
|
|
900
1163
|
}
|
|
901
1164
|
// allow single overlay color
|
|
902
|
-
const single =
|
|
1165
|
+
const single =
|
|
1166
|
+
cs.getPropertyValue('--wick-overlay').trim() || cs.getPropertyValue('--hab-overlay').trim();
|
|
903
1167
|
pal.overlay = single ? base.overlay.map(() => single) : o;
|
|
904
1168
|
} else {
|
|
905
1169
|
pal[k] = get(k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()), base[k]);
|
|
@@ -1010,7 +1274,9 @@ class HabChart extends HTMLElementBase {
|
|
|
1010
1274
|
_rightMargin() {
|
|
1011
1275
|
const ly = this._ly;
|
|
1012
1276
|
const w = ly ? ly.plotRight : 600;
|
|
1013
|
-
|
|
1277
|
+
const base = Math.max(3, (w / this._view.spacing) * 0.06);
|
|
1278
|
+
// a scenario projection reserves future space so the cone stays visible
|
|
1279
|
+
return this._scenario ? Math.max(base, this._scenario.horizon + 3) : base;
|
|
1014
1280
|
}
|
|
1015
1281
|
|
|
1016
1282
|
_applyFit() {
|
|
@@ -1018,7 +1284,7 @@ class HabChart extends HTMLElementBase {
|
|
|
1018
1284
|
if (!d.length || !this._ly) return;
|
|
1019
1285
|
const { plotRight } = this._ly;
|
|
1020
1286
|
const target = Math.min(d.length, 150);
|
|
1021
|
-
this._view.spacing = clamp(plotRight / target, this._minSpacing(),
|
|
1287
|
+
this._view.spacing = clamp(plotRight / target, this._minSpacing(), WickChart._MAX_SP);
|
|
1022
1288
|
this._view.rightIndex = d.length - 1 + this._rightMargin();
|
|
1023
1289
|
}
|
|
1024
1290
|
|
|
@@ -1027,7 +1293,7 @@ class HabChart extends HTMLElementBase {
|
|
|
1027
1293
|
const ly = this._ly;
|
|
1028
1294
|
if (!d.length || !ly) return;
|
|
1029
1295
|
const v = this._view;
|
|
1030
|
-
v.spacing = clamp(v.spacing, this._minSpacing(),
|
|
1296
|
+
v.spacing = clamp(v.spacing, this._minSpacing(), WickChart._MAX_SP);
|
|
1031
1297
|
const visible = ly.plotRight / v.spacing;
|
|
1032
1298
|
const maxRight = d.length - 1 + Math.max(6, visible * 0.5);
|
|
1033
1299
|
const minRight = Math.min(2, d.length - 1);
|
|
@@ -1378,6 +1644,146 @@ class HabChart extends HTMLElementBase {
|
|
|
1378
1644
|
}
|
|
1379
1645
|
}
|
|
1380
1646
|
|
|
1647
|
+
/* server-side overlays: zones & levels (above regime shading, under series).
|
|
1648
|
+
* Zones with `to == null` extend into future space past the last bar. */
|
|
1649
|
+
if (this._overlays.length) {
|
|
1650
|
+
const d = this._data;
|
|
1651
|
+
const idxFor = (t, fallback) => (t == null ? fallback : barIndexForTime(d, t));
|
|
1652
|
+
for (const ov of this._overlays) {
|
|
1653
|
+
const col = resolveOverlayColor(ov.color, pal);
|
|
1654
|
+
if (ov.type === 'zone') {
|
|
1655
|
+
const iA = Math.max(0, idxFor(ov.from, 0));
|
|
1656
|
+
const iB = ov.to == null ? null : Math.max(0, idxFor(ov.to, d.length - 1));
|
|
1657
|
+
const zx0 = clamp(this._xFor(iA) - sp * 0.5, 0, plotRight);
|
|
1658
|
+
const zx1 = iB == null ? plotRight : clamp(this._xFor(iB) + sp * 0.5, 0, plotRight);
|
|
1659
|
+
const zyT = clamp(yOf(ov.priceTo), main.y0, main.y1);
|
|
1660
|
+
const zyB = clamp(yOf(ov.priceFrom), main.y0, main.y1);
|
|
1661
|
+
if (zx1 - zx0 < 1 || zyB - zyT < 1) continue;
|
|
1662
|
+
ctx.save();
|
|
1663
|
+
ctx.globalAlpha = ov.alpha;
|
|
1664
|
+
ctx.fillStyle = col;
|
|
1665
|
+
ctx.fillRect(zx0, zyT, zx1 - zx0, zyB - zyT);
|
|
1666
|
+
if (ov.border) {
|
|
1667
|
+
ctx.globalAlpha = Math.min(1, ov.alpha + 0.4);
|
|
1668
|
+
ctx.lineWidth = 1;
|
|
1669
|
+
ctx.strokeStyle = col;
|
|
1670
|
+
ctx.strokeRect(
|
|
1671
|
+
Math.round(zx0) + 0.5, Math.round(zyT) + 0.5,
|
|
1672
|
+
Math.max(2, Math.round(zx1 - zx0) - 1), Math.max(2, Math.round(zyB - zyT) - 1)
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
if (ov.label) {
|
|
1676
|
+
ctx.globalAlpha = 0.95;
|
|
1677
|
+
ctx.font = pillFont();
|
|
1678
|
+
ctx.fillStyle = col;
|
|
1679
|
+
ctx.textAlign = 'left';
|
|
1680
|
+
ctx.textBaseline = 'top';
|
|
1681
|
+
ctx.fillText(ov.label, zx0 + 6, Math.max(main.y0, zyT) + 4);
|
|
1682
|
+
}
|
|
1683
|
+
ctx.restore();
|
|
1684
|
+
} else {
|
|
1685
|
+
const lx0 = clamp(this._xFor(Math.max(0, idxFor(ov.from, 0))) - sp * 0.5, 0, plotRight);
|
|
1686
|
+
const lx1 =
|
|
1687
|
+
ov.to == null
|
|
1688
|
+
? plotRight
|
|
1689
|
+
: clamp(this._xFor(Math.max(0, idxFor(ov.to, d.length - 1))) + sp * 0.5, 0, plotRight);
|
|
1690
|
+
const ly = Math.round(yOf(ov.price)) + 0.5;
|
|
1691
|
+
if (lx1 - lx0 < 1 || ly < main.y0 || ly > main.y1) continue;
|
|
1692
|
+
ctx.save();
|
|
1693
|
+
ctx.strokeStyle = col;
|
|
1694
|
+
ctx.lineWidth = ov.width;
|
|
1695
|
+
if (ov.dash) ctx.setLineDash([5, 4]);
|
|
1696
|
+
ctx.beginPath();
|
|
1697
|
+
ctx.moveTo(lx0, ly);
|
|
1698
|
+
ctx.lineTo(lx1, ly);
|
|
1699
|
+
ctx.stroke();
|
|
1700
|
+
if (ov.label) {
|
|
1701
|
+
ctx.font = pillFont();
|
|
1702
|
+
ctx.fillStyle = col;
|
|
1703
|
+
ctx.textAlign = 'right';
|
|
1704
|
+
ctx.textBaseline = 'bottom';
|
|
1705
|
+
ctx.fillText(ov.label, Math.min(lx1, plotRight) - 6, ly - 2);
|
|
1706
|
+
}
|
|
1707
|
+
ctx.restore();
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/* scenario projection: ghost path + σ-cone in future space (clipped to
|
|
1713
|
+
* the main plot so out-of-range bands never bleed into the axis) */
|
|
1714
|
+
if (this._scenario && d.length) {
|
|
1715
|
+
const sc = this._scenario;
|
|
1716
|
+
const col = resolveOverlayColor(sc.color, pal);
|
|
1717
|
+
const baseIdx = d.length - 1;
|
|
1718
|
+
const xAt = (h) => this._xFor(baseIdx + h);
|
|
1719
|
+
ctx.save();
|
|
1720
|
+
ctx.beginPath();
|
|
1721
|
+
ctx.rect(0, main.y0, plotRight, main.h);
|
|
1722
|
+
ctx.clip();
|
|
1723
|
+
if (sc.cone) {
|
|
1724
|
+
const cone = this._scenarioConeCache();
|
|
1725
|
+
if (cone) {
|
|
1726
|
+
for (let li = cone.levels.length - 1; li >= 0; li--) {
|
|
1727
|
+
const b = cone.bands[cone.levels[li]];
|
|
1728
|
+
ctx.globalAlpha = li === 0 ? 0.1 : 0.05;
|
|
1729
|
+
ctx.fillStyle = col;
|
|
1730
|
+
ctx.beginPath();
|
|
1731
|
+
ctx.moveTo(xAt(0), yOf(b.up[0]));
|
|
1732
|
+
for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(b.up[h]));
|
|
1733
|
+
for (let h = cone.horizon; h >= 0; h--) ctx.lineTo(xAt(h), yOf(b.down[h]));
|
|
1734
|
+
ctx.closePath();
|
|
1735
|
+
ctx.fill();
|
|
1736
|
+
}
|
|
1737
|
+
const inner = cone.bands[cone.levels[0]];
|
|
1738
|
+
ctx.globalAlpha = 0.4;
|
|
1739
|
+
ctx.strokeStyle = col;
|
|
1740
|
+
ctx.lineWidth = 1;
|
|
1741
|
+
ctx.setLineDash([4, 4]);
|
|
1742
|
+
for (const arr of [inner.up, inner.down]) {
|
|
1743
|
+
ctx.beginPath();
|
|
1744
|
+
ctx.moveTo(xAt(0), yOf(arr[0]));
|
|
1745
|
+
for (let h = 1; h <= cone.horizon; h++) ctx.lineTo(xAt(h), yOf(arr[h]));
|
|
1746
|
+
ctx.stroke();
|
|
1747
|
+
}
|
|
1748
|
+
ctx.setLineDash([]);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
if (sc.path.length) {
|
|
1752
|
+
ctx.globalAlpha = 0.9;
|
|
1753
|
+
ctx.strokeStyle = col;
|
|
1754
|
+
ctx.lineWidth = 1.5;
|
|
1755
|
+
ctx.setLineDash([6, 4]);
|
|
1756
|
+
ctx.beginPath();
|
|
1757
|
+
ctx.moveTo(xAt(0), yOf(d[baseIdx].close));
|
|
1758
|
+
for (const p of sc.path) ctx.lineTo(xAt(p.h), yOf(p.price));
|
|
1759
|
+
ctx.stroke();
|
|
1760
|
+
ctx.setLineDash([]);
|
|
1761
|
+
ctx.fillStyle = col;
|
|
1762
|
+
for (const p of sc.path) {
|
|
1763
|
+
const y = yOf(p.price);
|
|
1764
|
+
if (y >= main.y0 && y <= main.y1) {
|
|
1765
|
+
ctx.beginPath();
|
|
1766
|
+
ctx.arc(xAt(p.h), y, 2.5, 0, Math.PI * 2);
|
|
1767
|
+
ctx.fill();
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
if (sc.label) {
|
|
1771
|
+
const p = sc.path[sc.path.length - 1];
|
|
1772
|
+
ctx.font = pillFont();
|
|
1773
|
+
ctx.globalAlpha = 0.95;
|
|
1774
|
+
ctx.fillStyle = col;
|
|
1775
|
+
ctx.textAlign = 'left';
|
|
1776
|
+
ctx.textBaseline = 'middle';
|
|
1777
|
+
ctx.fillText(
|
|
1778
|
+
sc.label,
|
|
1779
|
+
Math.min(xAt(p.h) + 8, plotRight - 4),
|
|
1780
|
+
clamp(yOf(p.price), main.y0 + 8, main.y1 - 8)
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
ctx.restore();
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1381
1787
|
/* position zones (under series) */
|
|
1382
1788
|
for (const pos of this._positions) {
|
|
1383
1789
|
const yE = clamp(yOf(pos.entry), main.y0, main.y1);
|
|
@@ -1457,9 +1863,7 @@ class HabChart extends HTMLElementBase {
|
|
|
1457
1863
|
this._annoList = detectAnnotations(this._data, i0, i1, this._cachedRSI14());
|
|
1458
1864
|
this._annoKey = akey;
|
|
1459
1865
|
this._legendKey = ''; // legend may now show insights at the hovered bar
|
|
1460
|
-
this.
|
|
1461
|
-
new CustomEvent('hab:annotations', { detail: { annotations: this._annoList } })
|
|
1462
|
-
);
|
|
1866
|
+
this._fire('annotations', { annotations: this._annoList });
|
|
1463
1867
|
}
|
|
1464
1868
|
const A = this._annoList;
|
|
1465
1869
|
if (A.length) {
|
|
@@ -1723,7 +2127,7 @@ class HabChart extends HTMLElementBase {
|
|
|
1723
2127
|
ctx.setLineDash([5, 4]);
|
|
1724
2128
|
ctx.strokeStyle = pal.overlay[0];
|
|
1725
2129
|
for (const a of this._alerts) {
|
|
1726
|
-
if (a.fired) continue;
|
|
2130
|
+
if (a.fired || !isNum(a.price)) continue; // scripted alerts have no line
|
|
1727
2131
|
const y = yOf(a.price);
|
|
1728
2132
|
if (y < main.y0 || y > main.y1) continue;
|
|
1729
2133
|
ctx.globalAlpha = 0.8;
|
|
@@ -2353,7 +2757,7 @@ class HabChart extends HTMLElementBase {
|
|
|
2353
2757
|
const s = clamp(
|
|
2354
2758
|
(this._pinch.spacing * dist) / this._pinch.dist,
|
|
2355
2759
|
this._minSpacing(),
|
|
2356
|
-
|
|
2760
|
+
WickChart._MAX_SP
|
|
2357
2761
|
);
|
|
2358
2762
|
this._view.spacing = s;
|
|
2359
2763
|
this._view.rightIndex = this._pinch.idxAtMid + (ly.plotRight - mid.x) / s;
|
|
@@ -2406,15 +2810,11 @@ class HabChart extends HTMLElementBase {
|
|
|
2406
2810
|
const d = this._data;
|
|
2407
2811
|
const barA = d[clamp(m.iA, 0, d.length - 1)];
|
|
2408
2812
|
const barB = d[clamp(m.iB, 0, d.length - 1)];
|
|
2409
|
-
this.
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
bars: Math.abs(m.iB - m.iA),
|
|
2415
|
-
},
|
|
2416
|
-
})
|
|
2417
|
-
);
|
|
2813
|
+
this._fire('measure', {
|
|
2814
|
+
from: { index: m.iA, time: barA.time, price: m.pA },
|
|
2815
|
+
to: { index: m.iB, time: barB.time, price: m.pB },
|
|
2816
|
+
bars: Math.abs(m.iB - m.iA),
|
|
2817
|
+
});
|
|
2418
2818
|
}
|
|
2419
2819
|
} else if (this._pan && had && !this._pan.moved && this._data.length) {
|
|
2420
2820
|
if (this._measure) {
|
|
@@ -2426,11 +2826,7 @@ class HabChart extends HTMLElementBase {
|
|
|
2426
2826
|
const pt = this._localPoint(e);
|
|
2427
2827
|
const idx = clamp(Math.round(this._indexForX(pt.x)), 0, this._data.length - 1);
|
|
2428
2828
|
const price = this._yToPrice(pt.y);
|
|
2429
|
-
this.
|
|
2430
|
-
new CustomEvent('hab:select', {
|
|
2431
|
-
detail: { index: idx, bar: this._data[idx], price },
|
|
2432
|
-
})
|
|
2433
|
-
);
|
|
2829
|
+
this._fire('select', { index: idx, bar: this._data[idx], price });
|
|
2434
2830
|
}
|
|
2435
2831
|
}
|
|
2436
2832
|
this._pan = null;
|
|
@@ -2456,8 +2852,10 @@ class HabChart extends HTMLElementBase {
|
|
|
2456
2852
|
const dy = e.deltaY * (e.deltaMode === 1 ? 33 : 1);
|
|
2457
2853
|
|
|
2458
2854
|
if (Math.abs(dx) > Math.abs(dy) && !e.ctrlKey) {
|
|
2459
|
-
// trackpad horizontal scroll → pan
|
|
2460
|
-
|
|
2855
|
+
// trackpad horizontal scroll → pan. Wheel deltas are viewport-relative:
|
|
2856
|
+
// deltaX>0 means "scroll right", i.e. reveal newer bars. On natural-scroll
|
|
2857
|
+
// trackpads this makes the content follow the fingers, matching drag.
|
|
2858
|
+
this._view.rightIndex += dx / this._view.spacing;
|
|
2461
2859
|
this._auto = this._atRight();
|
|
2462
2860
|
this._clampView();
|
|
2463
2861
|
this._invalidate();
|
|
@@ -2467,7 +2865,7 @@ class HabChart extends HTMLElementBase {
|
|
|
2467
2865
|
|
|
2468
2866
|
const factor = Math.exp(-dy * (e.ctrlKey ? 0.008 : 0.0016));
|
|
2469
2867
|
const oldSp = this._view.spacing;
|
|
2470
|
-
const newSp = clamp(oldSp * factor, this._minSpacing(),
|
|
2868
|
+
const newSp = clamp(oldSp * factor, this._minSpacing(), WickChart._MAX_SP);
|
|
2471
2869
|
if (newSp === oldSp) return;
|
|
2472
2870
|
const idxAtCursor = this._indexForX(pt.x);
|
|
2473
2871
|
this._view.spacing = newSp;
|
|
@@ -2504,12 +2902,12 @@ class HabChart extends HTMLElementBase {
|
|
|
2504
2902
|
this._invalidate();
|
|
2505
2903
|
this._emitRange();
|
|
2506
2904
|
} else if (key === '+' || key === '=') {
|
|
2507
|
-
this._view.spacing = clamp(this._view.spacing * 1.25, this._minSpacing(),
|
|
2905
|
+
this._view.spacing = clamp(this._view.spacing * 1.25, this._minSpacing(), WickChart._MAX_SP);
|
|
2508
2906
|
this._clampView();
|
|
2509
2907
|
this._invalidate();
|
|
2510
2908
|
this._emitRange();
|
|
2511
2909
|
} else if (key === '-' || key === '_') {
|
|
2512
|
-
this._view.spacing = clamp(this._view.spacing / 1.25, this._minSpacing(),
|
|
2910
|
+
this._view.spacing = clamp(this._view.spacing / 1.25, this._minSpacing(), WickChart._MAX_SP);
|
|
2513
2911
|
this._clampView();
|
|
2514
2912
|
this._invalidate();
|
|
2515
2913
|
this._emitRange();
|
|
@@ -2636,7 +3034,7 @@ class HabChart extends HTMLElementBase {
|
|
|
2636
3034
|
price: this._yToPrice(hover.y),
|
|
2637
3035
|
};
|
|
2638
3036
|
}
|
|
2639
|
-
this.
|
|
3037
|
+
this._fire('crosshair', detail);
|
|
2640
3038
|
|
|
2641
3039
|
// co-view: share the pointer with peer charts (leave events bypass throttle)
|
|
2642
3040
|
if (this._coviewCh) {
|
|
@@ -2678,7 +3076,7 @@ class HabChart extends HTMLElementBase {
|
|
|
2678
3076
|
if (!name || !this._connected || typeof BroadcastChannel === 'undefined') return;
|
|
2679
3077
|
if (!this._coviewPeer) this._coviewPeer = 'p' + Math.random().toString(36).slice(2, 8);
|
|
2680
3078
|
try {
|
|
2681
|
-
const ch = new BroadcastChannel('
|
|
3079
|
+
const ch = new BroadcastChannel('wick-co-view:' + name);
|
|
2682
3080
|
ch.onmessage = (ev) => this._onCoMessage(ev.data);
|
|
2683
3081
|
this._coviewCh = ch;
|
|
2684
3082
|
} catch (_) {}
|
|
@@ -2703,7 +3101,7 @@ class HabChart extends HTMLElementBase {
|
|
|
2703
3101
|
}
|
|
2704
3102
|
if (!isNum(m.time) || !this._data.length) return;
|
|
2705
3103
|
this._ghost = {
|
|
2706
|
-
index:
|
|
3104
|
+
index: WickChart._indexForTime(this._data, m.time),
|
|
2707
3105
|
yFrac: isNum(m.yFrac) ? clamp(m.yFrac, 0, 1) : null,
|
|
2708
3106
|
at: Date.now(),
|
|
2709
3107
|
};
|
|
@@ -2715,15 +3113,31 @@ class HabChart extends HTMLElementBase {
|
|
|
2715
3113
|
this._invalidate();
|
|
2716
3114
|
}
|
|
2717
3115
|
|
|
3116
|
+
/** Dispatch `wick:name` (canonical) plus the deprecated `hab:name` alias,
|
|
3117
|
+
* so 0.x listeners keep working until 2.0. */
|
|
3118
|
+
_fire(name, detail) {
|
|
3119
|
+
this.dispatchEvent(new CustomEvent('wick:' + name, { detail }));
|
|
3120
|
+
this.dispatchEvent(new CustomEvent('hab:' + name, { detail }));
|
|
3121
|
+
}
|
|
3122
|
+
|
|
2718
3123
|
_emitRange() {
|
|
2719
3124
|
const r = this.getVisibleRange();
|
|
2720
3125
|
if (!r) return;
|
|
2721
|
-
this.
|
|
3126
|
+
this._fire('range', r);
|
|
2722
3127
|
}
|
|
2723
3128
|
}
|
|
2724
3129
|
|
|
2725
|
-
if (typeof customElements !== 'undefined'
|
|
2726
|
-
customElements.
|
|
3130
|
+
if (typeof customElements !== 'undefined') {
|
|
3131
|
+
if (!customElements.get('wick-chart')) {
|
|
3132
|
+
customElements.define('wick-chart', WickChart);
|
|
3133
|
+
}
|
|
3134
|
+
// 0.x alias: same element under its old tag name (deprecated, removed in 2.0)
|
|
3135
|
+
if (!customElements.get('hab-chart')) {
|
|
3136
|
+
/** @deprecated use <wick-chart> */
|
|
3137
|
+
class HabChart extends WickChart {}
|
|
3138
|
+
customElements.define('hab-chart', HabChart);
|
|
3139
|
+
}
|
|
2727
3140
|
}
|
|
2728
3141
|
|
|
2729
|
-
export default
|
|
3142
|
+
export default WickChart;
|
|
3143
|
+
export { WickChart };
|