wickchart 1.0.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 +200 -1
- package/package.json +20 -3
- package/src/core.js +443 -5
- package/src/react-core.js +191 -0
- package/src/react.js +21 -0
- package/src/wick-chart.js +436 -31
- package/types/core.d.ts +206 -0
- package/types/react-core.d.ts +64 -0
- package/types/react.d.ts +3 -0
- package/types/wick-chart.d.ts +125 -8
package/src/wick-chart.js
CHANGED
|
@@ -28,7 +28,10 @@ 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,
|
|
32
35
|
} from './core.js';
|
|
33
36
|
|
|
34
37
|
/* ------------------------------------------------------------------ *
|
|
@@ -45,7 +48,7 @@ const HTMLElementBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class
|
|
|
45
48
|
|
|
46
49
|
class WickChart extends HTMLElementBase {
|
|
47
50
|
static get observedAttributes() {
|
|
48
|
-
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'];
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
constructor() {
|
|
@@ -225,6 +228,12 @@ class WickChart extends HTMLElementBase {
|
|
|
225
228
|
this._alerts = [];
|
|
226
229
|
this._seq = 0;
|
|
227
230
|
|
|
231
|
+
// server-side overlays (zones & levels)
|
|
232
|
+
this._overlays = [];
|
|
233
|
+
|
|
234
|
+
// scenario projection (ghost path + vol cone)
|
|
235
|
+
this._scenario = null;
|
|
236
|
+
|
|
228
237
|
this._onResize = () => this._invalidate();
|
|
229
238
|
this._onPointerDown = (e) => this._pointerDown(e);
|
|
230
239
|
this._onPointerMove = (e) => this._pointerMove(e);
|
|
@@ -330,6 +339,18 @@ class WickChart extends HTMLElementBase {
|
|
|
330
339
|
this._volshade = val != null && val !== 'false' ? parseVolShading(val) : null;
|
|
331
340
|
this._legendKey = '';
|
|
332
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
|
+
}
|
|
333
354
|
case 'co-view':
|
|
334
355
|
this._coviewName = val || null;
|
|
335
356
|
this._setupCoView();
|
|
@@ -629,7 +650,11 @@ class WickChart extends HTMLElementBase {
|
|
|
629
650
|
})),
|
|
630
651
|
alerts: this._alerts
|
|
631
652
|
.filter((a) => !a.fired)
|
|
632
|
-
.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
|
+
),
|
|
633
658
|
};
|
|
634
659
|
}
|
|
635
660
|
|
|
@@ -667,14 +692,31 @@ class WickChart extends HTMLElementBase {
|
|
|
667
692
|
}
|
|
668
693
|
if (Array.isArray(state.alerts)) {
|
|
669
694
|
this._alerts = state.alerts
|
|
670
|
-
.filter((a) => a && isNum(a.price))
|
|
671
|
-
.map((a) =>
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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);
|
|
678
720
|
}
|
|
679
721
|
if (state.view && state.view.from != null && state.view.to != null) {
|
|
680
722
|
if (this._ly && this._data.length > 1) {
|
|
@@ -727,22 +769,46 @@ class WickChart extends HTMLElementBase {
|
|
|
727
769
|
}
|
|
728
770
|
|
|
729
771
|
/**
|
|
730
|
-
* Price alert.
|
|
731
|
-
* (
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
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)
|
|
736
783
|
*/
|
|
737
784
|
addAlert(alert) {
|
|
738
|
-
if (!alert
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
+
}
|
|
746
812
|
const i = this._alerts.findIndex((x) => x.id === a.id);
|
|
747
813
|
if (i >= 0) this._alerts[i] = a;
|
|
748
814
|
else this._alerts.push(a);
|
|
@@ -760,11 +826,194 @@ class WickChart extends HTMLElementBase {
|
|
|
760
826
|
this._invalidate();
|
|
761
827
|
}
|
|
762
828
|
|
|
763
|
-
/**
|
|
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. */
|
|
764
1000
|
_checkAlerts(prevClose, bar) {
|
|
765
|
-
if (!this._alerts.length
|
|
1001
|
+
if (!this._alerts.length) return;
|
|
766
1002
|
for (const a of [...this._alerts]) {
|
|
767
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;
|
|
768
1017
|
if (checkAlertCross(a, prevClose, bar.close)) {
|
|
769
1018
|
a.fired = true;
|
|
770
1019
|
this._fire('alert', { id: a.id, price: a.price, bar });
|
|
@@ -773,6 +1022,18 @@ class WickChart extends HTMLElementBase {
|
|
|
773
1022
|
}
|
|
774
1023
|
}
|
|
775
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
|
+
|
|
776
1037
|
/* reflected properties */
|
|
777
1038
|
get theme() { return this._theme; }
|
|
778
1039
|
set theme(v) { this.setAttribute('theme', v); }
|
|
@@ -1013,7 +1274,9 @@ class WickChart extends HTMLElementBase {
|
|
|
1013
1274
|
_rightMargin() {
|
|
1014
1275
|
const ly = this._ly;
|
|
1015
1276
|
const w = ly ? ly.plotRight : 600;
|
|
1016
|
-
|
|
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;
|
|
1017
1280
|
}
|
|
1018
1281
|
|
|
1019
1282
|
_applyFit() {
|
|
@@ -1381,6 +1644,146 @@ class WickChart extends HTMLElementBase {
|
|
|
1381
1644
|
}
|
|
1382
1645
|
}
|
|
1383
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
|
+
|
|
1384
1787
|
/* position zones (under series) */
|
|
1385
1788
|
for (const pos of this._positions) {
|
|
1386
1789
|
const yE = clamp(yOf(pos.entry), main.y0, main.y1);
|
|
@@ -1724,7 +2127,7 @@ class WickChart extends HTMLElementBase {
|
|
|
1724
2127
|
ctx.setLineDash([5, 4]);
|
|
1725
2128
|
ctx.strokeStyle = pal.overlay[0];
|
|
1726
2129
|
for (const a of this._alerts) {
|
|
1727
|
-
if (a.fired) continue;
|
|
2130
|
+
if (a.fired || !isNum(a.price)) continue; // scripted alerts have no line
|
|
1728
2131
|
const y = yOf(a.price);
|
|
1729
2132
|
if (y < main.y0 || y > main.y1) continue;
|
|
1730
2133
|
ctx.globalAlpha = 0.8;
|
|
@@ -2449,8 +2852,10 @@ class WickChart extends HTMLElementBase {
|
|
|
2449
2852
|
const dy = e.deltaY * (e.deltaMode === 1 ? 33 : 1);
|
|
2450
2853
|
|
|
2451
2854
|
if (Math.abs(dx) > Math.abs(dy) && !e.ctrlKey) {
|
|
2452
|
-
// trackpad horizontal scroll → pan
|
|
2453
|
-
|
|
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;
|
|
2454
2859
|
this._auto = this._atRight();
|
|
2455
2860
|
this._clampView();
|
|
2456
2861
|
this._invalidate();
|