wickchart 0.3.0 → 1.0.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 +175 -40
- package/package.json +12 -12
- package/src/core.js +734 -7
- package/src/feeds.js +1 -1
- package/src/{hab-chart.js → wick-chart.js} +211 -94
- package/src/{hab-feed.js → wick-feed.js} +34 -19
- package/types/core.d.ts +108 -1
- package/types/{hab-chart.d.ts → wick-chart.d.ts} +106 -7
- package/types/{hab-feed.d.ts → wick-feed.d.ts} +8 -4
package/src/core.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* ==========================================================================
|
|
2
|
-
*
|
|
2
|
+
* WickChart core — pure, DOM-free functions shared by <wick-chart> and tests.
|
|
3
3
|
* Importable in the browser (ESM) and in Node (`node --test`).
|
|
4
4
|
* MIT License.
|
|
5
5
|
* ========================================================================== */
|
|
@@ -252,7 +252,8 @@ export const fmtFull = (t) => {
|
|
|
252
252
|
};
|
|
253
253
|
|
|
254
254
|
/* ------------------------------------------------------------------ *
|
|
255
|
-
* Themes (every key overridable via --
|
|
255
|
+
* Themes (every key overridable via --wick-* CSS custom properties;
|
|
256
|
+
* the 0.x --hab-* names still work as fallbacks)
|
|
256
257
|
* ------------------------------------------------------------------ */
|
|
257
258
|
|
|
258
259
|
export const THEMES = {
|
|
@@ -410,6 +411,23 @@ export function calcStdDev(values, period) {
|
|
|
410
411
|
return out;
|
|
411
412
|
}
|
|
412
413
|
|
|
414
|
+
/** Linear-weighted moving average (most recent bar weighs `period`), aligned like SMA.
|
|
415
|
+
* @param {number[]} values
|
|
416
|
+
* @param {number} period
|
|
417
|
+
* @returns {Array<number|null>}
|
|
418
|
+
*/
|
|
419
|
+
export function calcWMA(values, period) {
|
|
420
|
+
const out = new Array(values.length).fill(null);
|
|
421
|
+
if (period < 1 || values.length < period) return out;
|
|
422
|
+
const denom = (period * (period + 1)) / 2;
|
|
423
|
+
for (let i = period - 1; i < values.length; i++) {
|
|
424
|
+
let sum = 0;
|
|
425
|
+
for (let j = 0; j < period; j++) sum += values[i - j] * (period - j);
|
|
426
|
+
out[i] = sum / denom;
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
413
431
|
/**
|
|
414
432
|
* Bollinger Bands.
|
|
415
433
|
* @param {number[]} closes
|
|
@@ -849,7 +867,8 @@ export const BUILTIN_INDICATORS = new Map(
|
|
|
849
867
|
|
|
850
868
|
/**
|
|
851
869
|
* Parse an `indicators` attribute string against a registry.
|
|
852
|
-
* Token: `name[:param[/param…]][@color]`,
|
|
870
|
+
* Token: `name[:param[/param…]][@color]`, the `volume` keyword, and
|
|
871
|
+
* WickScript blobs `expr:{…}` (overlay) / `pexpr:{…}` (separate pane).
|
|
853
872
|
* @param {string|null|undefined} str
|
|
854
873
|
* @param {Map<string, IndicatorDef>} registry
|
|
855
874
|
* @returns {{overlays: IndicatorEntry[], panes: IndicatorEntry[], volume: boolean, unknown: string[]}}
|
|
@@ -858,8 +877,32 @@ export function parseIndicators(str, registry) {
|
|
|
858
877
|
const out = { overlays: [], panes: [], volume: false, unknown: [] };
|
|
859
878
|
if (str == null || str === '') return out;
|
|
860
879
|
const seen = new Set();
|
|
861
|
-
for (const raw of
|
|
862
|
-
|
|
880
|
+
for (const raw of splitIndicatorTokens(str)) {
|
|
881
|
+
const em = raw.match(/^(p?expr):\{([^{}]*)\}(@\S*)?$/i);
|
|
882
|
+
if (em) {
|
|
883
|
+
const pane = em[1].toLowerCase() === 'pexpr';
|
|
884
|
+
const src = em[2].trim();
|
|
885
|
+
let def;
|
|
886
|
+
try {
|
|
887
|
+
def = scriptIndicator(src, { pane });
|
|
888
|
+
} catch (err) {
|
|
889
|
+
out.unknown.push(em[1] + ':{' + src + '}');
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
const key = (pane ? 'pexpr' : 'expr') + ':{' + src.toLowerCase() + '}';
|
|
893
|
+
if (seen.has(key)) continue;
|
|
894
|
+
seen.add(key);
|
|
895
|
+
const entry = {
|
|
896
|
+
name: pane ? 'pexpr' : 'expr',
|
|
897
|
+
def,
|
|
898
|
+
params: {},
|
|
899
|
+
color: em[3] ? em[3].slice(1) : null,
|
|
900
|
+
key,
|
|
901
|
+
};
|
|
902
|
+
if (pane) out.panes.push(entry);
|
|
903
|
+
else out.overlays.push(entry);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
863
906
|
const m = raw.match(/^([A-Za-z][A-Za-z0-9_]*)(?::([^@]*))?(@.+)?$/);
|
|
864
907
|
if (!m) continue;
|
|
865
908
|
const [, name, paramStr, colorStr] = m;
|
|
@@ -897,6 +940,387 @@ export function parseIndicators(str, registry) {
|
|
|
897
940
|
return out;
|
|
898
941
|
}
|
|
899
942
|
|
|
943
|
+
/* ------------------------------------------------------------------ *
|
|
944
|
+
* WickScript — safe expression mini-language for custom indicators
|
|
945
|
+
*
|
|
946
|
+
* `expr:{(close - sma(close,20)) / sma(close,20)}` compiles through a
|
|
947
|
+
* hand-written tokenizer + recursive-descent parser (no eval / Function)
|
|
948
|
+
* and evaluates element-wise over the bar series. (Called "HabScript" in
|
|
949
|
+
* 0.x releases.)
|
|
950
|
+
* ------------------------------------------------------------------ */
|
|
951
|
+
|
|
952
|
+
const SCRIPT_MAX_LEN = 512;
|
|
953
|
+
const SCRIPT_MAX_TOKENS = 128;
|
|
954
|
+
const SCRIPT_MAX_DEPTH = 24;
|
|
955
|
+
|
|
956
|
+
/** Series variables available inside expressions. */
|
|
957
|
+
const SCRIPT_VARS = ['open', 'high', 'low', 'close', 'volume', 'hl2', 'hlc3', 'ohlc4'];
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Functions available inside expressions. `scalar` lists argument indexes
|
|
961
|
+
* that must be plain whole-number literals (periods / shifts).
|
|
962
|
+
*/
|
|
963
|
+
const SCRIPT_FUNCS = {
|
|
964
|
+
sma: { min: 2, max: 2, scalar: [1] },
|
|
965
|
+
ema: { min: 2, max: 2, scalar: [1] },
|
|
966
|
+
wma: { min: 2, max: 2, scalar: [1] },
|
|
967
|
+
stddev: { min: 2, max: 2, scalar: [1] },
|
|
968
|
+
rsi: { min: 2, max: 2, scalar: [1] },
|
|
969
|
+
hh: { min: 2, max: 2, scalar: [1] },
|
|
970
|
+
ll: { min: 2, max: 2, scalar: [1] },
|
|
971
|
+
prev: { min: 1, max: 2, scalar: [1] },
|
|
972
|
+
change: { min: 1, max: 1 },
|
|
973
|
+
abs: { min: 1, max: 1 },
|
|
974
|
+
sqrt: { min: 1, max: 1 },
|
|
975
|
+
log: { min: 1, max: 1 },
|
|
976
|
+
min: { min: 2, max: 2 },
|
|
977
|
+
max: { min: 2, max: 2 },
|
|
978
|
+
crossup: { min: 2, max: 2 },
|
|
979
|
+
crossdown: { min: 2, max: 2 },
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
const scriptErr = (msg) => new Error('script: ' + msg);
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Split an indicators string into tokens, keeping `expr:{…}` / `pexpr:{…}`
|
|
986
|
+
* blobs atomic — spaces and commas inside the braces are preserved, and an
|
|
987
|
+
* optional `@color` suffix directly after `}` stays attached.
|
|
988
|
+
* Separators are whitespace, `,` and `;`.
|
|
989
|
+
* @param {string|null|undefined} str
|
|
990
|
+
* @returns {string[]}
|
|
991
|
+
*/
|
|
992
|
+
export function splitIndicatorTokens(str) {
|
|
993
|
+
const out = [];
|
|
994
|
+
const s = String(str == null ? '' : str);
|
|
995
|
+
let i = 0;
|
|
996
|
+
while (i < s.length) {
|
|
997
|
+
while (i < s.length && /[\s,;]/.test(s[i])) i++;
|
|
998
|
+
if (i >= s.length) break;
|
|
999
|
+
let j = i;
|
|
1000
|
+
if (/^(p?expr):\{/i.test(s.slice(i))) {
|
|
1001
|
+
const end = s.indexOf('}', i);
|
|
1002
|
+
if (end === -1) {
|
|
1003
|
+
out.push(s.slice(i)); // unterminated → caller rejects the token
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
j = end + 1;
|
|
1007
|
+
if (s[j] === '@') {
|
|
1008
|
+
j++;
|
|
1009
|
+
while (j < s.length && !/[\s,;]/.test(s[j])) j++;
|
|
1010
|
+
}
|
|
1011
|
+
} else {
|
|
1012
|
+
while (j < s.length && !/[\s,;]/.test(s[j])) j++;
|
|
1013
|
+
}
|
|
1014
|
+
out.push(s.slice(i, j));
|
|
1015
|
+
i = j;
|
|
1016
|
+
}
|
|
1017
|
+
return out.filter(Boolean);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** Tokenize an expression (numbers, identifiers, operators, `( ) ,`). */
|
|
1021
|
+
function tokenizeScript(src) {
|
|
1022
|
+
if (typeof src !== 'string' || !src.trim()) throw scriptErr('empty expression');
|
|
1023
|
+
if (src.length > SCRIPT_MAX_LEN) throw scriptErr(`expression longer than ${SCRIPT_MAX_LEN} chars`);
|
|
1024
|
+
const toks = [];
|
|
1025
|
+
let i = 0;
|
|
1026
|
+
while (i < src.length) {
|
|
1027
|
+
const c = src[i];
|
|
1028
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
1029
|
+
i++;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const isDigit = c >= '0' && c <= '9';
|
|
1033
|
+
if (isDigit || (c === '.' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
|
|
1034
|
+
const m = src.slice(i).match(/^\d*\.?\d+/);
|
|
1035
|
+
toks.push({ t: 'num', v: parseFloat(m[0]) });
|
|
1036
|
+
i += m[0].length;
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const isAlpha = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
|
|
1040
|
+
if (isAlpha) {
|
|
1041
|
+
const m = src.slice(i).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
1042
|
+
toks.push({ t: 'id', v: m[0] });
|
|
1043
|
+
i += m[0].length;
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%') {
|
|
1047
|
+
toks.push({ t: 'op', v: c });
|
|
1048
|
+
i++;
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
if (c === '(' || c === ')' || c === ',') {
|
|
1052
|
+
toks.push({ t: c });
|
|
1053
|
+
i++;
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
throw scriptErr(`unexpected character "${c}"`);
|
|
1057
|
+
}
|
|
1058
|
+
if (!toks.length) throw scriptErr('empty expression');
|
|
1059
|
+
if (toks.length > SCRIPT_MAX_TOKENS) throw scriptErr(`more than ${SCRIPT_MAX_TOKENS} tokens`);
|
|
1060
|
+
return toks;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Recursive-descent parse into a small AST; validates identifiers, calls and arities. */
|
|
1064
|
+
function parseScript(src) {
|
|
1065
|
+
const toks = tokenizeScript(src);
|
|
1066
|
+
let p = 0;
|
|
1067
|
+
const peek = () => toks[p];
|
|
1068
|
+
|
|
1069
|
+
function parseAdd(depth) {
|
|
1070
|
+
let l = parseMul(depth);
|
|
1071
|
+
while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
|
|
1072
|
+
const op = toks[p++].v;
|
|
1073
|
+
l = { type: 'bin', op, l, r: parseMul(depth) };
|
|
1074
|
+
}
|
|
1075
|
+
return l;
|
|
1076
|
+
}
|
|
1077
|
+
function parseMul(depth) {
|
|
1078
|
+
let l = parseUnary(depth);
|
|
1079
|
+
while (peek() && peek().t === 'op' && (peek().v === '*' || peek().v === '/' || peek().v === '%')) {
|
|
1080
|
+
const op = toks[p++].v;
|
|
1081
|
+
l = { type: 'bin', op, l, r: parseUnary(depth) };
|
|
1082
|
+
}
|
|
1083
|
+
return l;
|
|
1084
|
+
}
|
|
1085
|
+
function parseUnary(depth) {
|
|
1086
|
+
if (depth > SCRIPT_MAX_DEPTH) throw scriptErr('expression too deeply nested');
|
|
1087
|
+
const t = peek();
|
|
1088
|
+
if (t && t.t === 'op' && (t.v === '-' || t.v === '+')) {
|
|
1089
|
+
p++;
|
|
1090
|
+
const e = parseUnary(depth + 1);
|
|
1091
|
+
return t.v === '-' ? { type: 'neg', e } : e;
|
|
1092
|
+
}
|
|
1093
|
+
return parseAtom(depth + 1);
|
|
1094
|
+
}
|
|
1095
|
+
function parseAtom(depth) {
|
|
1096
|
+
if (depth > SCRIPT_MAX_DEPTH) throw scriptErr('expression too deeply nested');
|
|
1097
|
+
const t = toks[p++];
|
|
1098
|
+
if (!t) throw scriptErr('unexpected end of expression');
|
|
1099
|
+
if (t.t === 'num') return { type: 'num', v: t.v };
|
|
1100
|
+
if (t.t === 'id') {
|
|
1101
|
+
const name = t.v.toLowerCase();
|
|
1102
|
+
if (peek() && peek().t === '(') {
|
|
1103
|
+
p++;
|
|
1104
|
+
const args = [];
|
|
1105
|
+
if (peek() && peek().t !== ')') {
|
|
1106
|
+
args.push(parseAdd(depth));
|
|
1107
|
+
while (peek() && peek().t === ',') {
|
|
1108
|
+
p++;
|
|
1109
|
+
args.push(parseAdd(depth));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const close = toks[p++];
|
|
1113
|
+
if (!close || close.t !== ')') throw scriptErr(`missing ")" after ${name}(`);
|
|
1114
|
+
return { type: 'call', name, args };
|
|
1115
|
+
}
|
|
1116
|
+
if (!SCRIPT_VARS.includes(name)) throw scriptErr(`unknown identifier "${t.v}"`);
|
|
1117
|
+
return { type: 'var', name };
|
|
1118
|
+
}
|
|
1119
|
+
if (t.t === '(') {
|
|
1120
|
+
const e = parseAdd(depth);
|
|
1121
|
+
const close = toks[p++];
|
|
1122
|
+
if (!close || close.t !== ')') throw scriptErr('missing ")"');
|
|
1123
|
+
return e;
|
|
1124
|
+
}
|
|
1125
|
+
throw scriptErr(`unexpected token "${t.t === 'op' ? t.v : t.t}"`);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const ast = parseAdd(0);
|
|
1129
|
+
if (p < toks.length) throw scriptErr('unexpected trailing input');
|
|
1130
|
+
validateScriptNode(ast);
|
|
1131
|
+
return ast;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function validateScriptNode(n) {
|
|
1135
|
+
if (!n || n.type === 'num' || n.type === 'var') return;
|
|
1136
|
+
if (n.type === 'neg') return validateScriptNode(n.e);
|
|
1137
|
+
if (n.type === 'bin') {
|
|
1138
|
+
validateScriptNode(n.l);
|
|
1139
|
+
validateScriptNode(n.r);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (n.type === 'call') {
|
|
1143
|
+
const spec = SCRIPT_FUNCS[n.name];
|
|
1144
|
+
if (!spec) throw scriptErr(`unknown function "${n.name}"`);
|
|
1145
|
+
if (n.args.length < spec.min || n.args.length > spec.max) {
|
|
1146
|
+
const want = spec.min === spec.max ? String(spec.min) : `${spec.min}–${spec.max}`;
|
|
1147
|
+
throw scriptErr(`${n.name}() takes ${want} argument${spec.max === 1 ? '' : 's'} (got ${n.args.length})`);
|
|
1148
|
+
}
|
|
1149
|
+
n.args.forEach((a, i) => {
|
|
1150
|
+
if (spec.scalar && spec.scalar.includes(i)) {
|
|
1151
|
+
if (a.type !== 'num' || !Number.isInteger(a.v) || a.v < 1) {
|
|
1152
|
+
throw scriptErr(`${n.name}() argument ${i + 1} must be a whole number ≥ 1`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
validateScriptNode(a);
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Compile a WickScript expression. Throws a descriptive error on any syntax
|
|
1162
|
+
* or semantic problem — never evaluates strings at runtime.
|
|
1163
|
+
* @param {string} src
|
|
1164
|
+
* @returns {{src: string, ast: object}}
|
|
1165
|
+
*/
|
|
1166
|
+
export function compileScript(src) {
|
|
1167
|
+
const s = String(src == null ? '' : src).trim();
|
|
1168
|
+
return { src: s, ast: parseScript(s) };
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/** null → NaN so sparse calc helpers compose safely inside expressions. */
|
|
1172
|
+
const scriptNum = (x) => (x == null || Number.isFinite(x) ? x : NaN);
|
|
1173
|
+
|
|
1174
|
+
function binOp(op, a, b) {
|
|
1175
|
+
if (a == null || b == null) return NaN;
|
|
1176
|
+
switch (op) {
|
|
1177
|
+
case '+': return a + b;
|
|
1178
|
+
case '-': return a - b;
|
|
1179
|
+
case '*': return a * b;
|
|
1180
|
+
case '/': return a / b;
|
|
1181
|
+
case '%': return a % b;
|
|
1182
|
+
}
|
|
1183
|
+
return NaN;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function evalScriptNode(node, vars, n) {
|
|
1187
|
+
switch (node.type) {
|
|
1188
|
+
case 'num':
|
|
1189
|
+
return node.v;
|
|
1190
|
+
case 'var':
|
|
1191
|
+
return vars[node.name];
|
|
1192
|
+
case 'neg': {
|
|
1193
|
+
const e = evalScriptNode(node.e, vars, n);
|
|
1194
|
+
if (!Array.isArray(e)) return -e;
|
|
1195
|
+
return e.map((x) => (x == null ? NaN : -x));
|
|
1196
|
+
}
|
|
1197
|
+
case 'bin': {
|
|
1198
|
+
const l = evalScriptNode(node.l, vars, n);
|
|
1199
|
+
const r = evalScriptNode(node.r, vars, n);
|
|
1200
|
+
if (!Array.isArray(l) && !Array.isArray(r)) return binOp(node.op, l, r);
|
|
1201
|
+
const a = Array.isArray(l) ? l : new Array(n).fill(l);
|
|
1202
|
+
const b = Array.isArray(r) ? r : new Array(n).fill(r);
|
|
1203
|
+
const out = new Array(n);
|
|
1204
|
+
for (let i = 0; i < n; i++) out[i] = binOp(node.op, a[i], b[i]);
|
|
1205
|
+
return out;
|
|
1206
|
+
}
|
|
1207
|
+
case 'call':
|
|
1208
|
+
return evalScriptCall(node, vars, n);
|
|
1209
|
+
}
|
|
1210
|
+
return NaN;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function evalScriptCall(node, vars, n) {
|
|
1214
|
+
const { name, args } = node;
|
|
1215
|
+
const s0 = evalScriptNode(args[0], vars, n);
|
|
1216
|
+
const a = Array.isArray(s0) ? s0 : new Array(n).fill(s0);
|
|
1217
|
+
// window functions must not read leading nulls as 0 — NaN them so results stay honest
|
|
1218
|
+
const clean = a.map((x) => (x == null ? NaN : x));
|
|
1219
|
+
const p = args.length > 1 && args[1].type === 'num' ? args[1].v : 1;
|
|
1220
|
+
|
|
1221
|
+
switch (name) {
|
|
1222
|
+
case 'sma': return calcSMA(clean, p);
|
|
1223
|
+
case 'ema': return calcEMA(clean, p);
|
|
1224
|
+
case 'wma': return calcWMA(clean, p);
|
|
1225
|
+
case 'stddev': return calcStdDev(clean, p);
|
|
1226
|
+
case 'rsi': return calcRSI(clean, p);
|
|
1227
|
+
case 'hh':
|
|
1228
|
+
case 'll': {
|
|
1229
|
+
const out = new Array(n).fill(null);
|
|
1230
|
+
for (let i = p - 1; i < n; i++) {
|
|
1231
|
+
let v = clean[i];
|
|
1232
|
+
for (let j = i - p + 1; j <= i; j++) {
|
|
1233
|
+
v = name === 'hh' ? Math.max(v, clean[j]) : Math.min(v, clean[j]);
|
|
1234
|
+
}
|
|
1235
|
+
out[i] = v;
|
|
1236
|
+
}
|
|
1237
|
+
return out;
|
|
1238
|
+
}
|
|
1239
|
+
case 'prev': {
|
|
1240
|
+
const out = new Array(n).fill(null);
|
|
1241
|
+
for (let i = p; i < n; i++) out[i] = a[i - p];
|
|
1242
|
+
return out;
|
|
1243
|
+
}
|
|
1244
|
+
case 'change': {
|
|
1245
|
+
const out = new Array(n).fill(null);
|
|
1246
|
+
for (let i = 1; i < n; i++) out[i] = scriptNum(a[i]) - scriptNum(a[i - 1]);
|
|
1247
|
+
return out;
|
|
1248
|
+
}
|
|
1249
|
+
case 'abs': return clean.map((x) => Math.abs(x));
|
|
1250
|
+
case 'sqrt': return clean.map((x) => (x < 0 ? NaN : Math.sqrt(x)));
|
|
1251
|
+
case 'log': return clean.map((x) => (x <= 0 ? NaN : Math.log(x)));
|
|
1252
|
+
case 'min':
|
|
1253
|
+
case 'max': {
|
|
1254
|
+
const b0 = evalScriptNode(args[1], vars, n);
|
|
1255
|
+
const b = Array.isArray(b0) ? b0 : new Array(n).fill(b0);
|
|
1256
|
+
return a.map((x, i) => (name === 'min' ? Math.min(scriptNum(x), scriptNum(b[i])) : Math.max(scriptNum(x), scriptNum(b[i]))));
|
|
1257
|
+
}
|
|
1258
|
+
case 'crossup':
|
|
1259
|
+
case 'crossdown': {
|
|
1260
|
+
const b0 = evalScriptNode(args[1], vars, n);
|
|
1261
|
+
const b = Array.isArray(b0) ? b0 : new Array(n).fill(b0);
|
|
1262
|
+
const out = new Array(n).fill(0);
|
|
1263
|
+
for (let i = 1; i < n; i++) {
|
|
1264
|
+
const x0 = scriptNum(a[i - 1]);
|
|
1265
|
+
const x1 = scriptNum(a[i]);
|
|
1266
|
+
const y0 = scriptNum(b[i - 1]);
|
|
1267
|
+
const y1 = scriptNum(b[i]);
|
|
1268
|
+
if (Number.isNaN(x0) || Number.isNaN(x1) || Number.isNaN(y0) || Number.isNaN(y1)) continue;
|
|
1269
|
+
out[i] = name === 'crossup' ? (x0 <= y0 && x1 > y1 ? 1 : 0) : (x0 >= y0 && x1 < y1 ? 1 : 0);
|
|
1270
|
+
}
|
|
1271
|
+
return out;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return new Array(n).fill(NaN);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Evaluate a compiled script (or a raw expression string) over bars.
|
|
1279
|
+
* @param {{src:string, ast:object}|string} compiled
|
|
1280
|
+
* @param {Bar[]} bars
|
|
1281
|
+
* @returns {number[]} length `bars.length`; non-finite values become NaN
|
|
1282
|
+
*/
|
|
1283
|
+
export function evalScript(compiled, bars) {
|
|
1284
|
+
const c = typeof compiled === 'string' ? compileScript(compiled) : compiled;
|
|
1285
|
+
const n = bars.length;
|
|
1286
|
+
const out = new Array(n).fill(NaN);
|
|
1287
|
+
if (!n) return out;
|
|
1288
|
+
const vars = {
|
|
1289
|
+
open: bars.map((b) => b.open),
|
|
1290
|
+
high: bars.map((b) => b.high),
|
|
1291
|
+
low: bars.map((b) => b.low),
|
|
1292
|
+
close: bars.map((b) => b.close),
|
|
1293
|
+
volume: bars.map((b) => b.volume),
|
|
1294
|
+
hl2: bars.map((b) => (b.high + b.low) / 2),
|
|
1295
|
+
hlc3: bars.map((b) => (b.high + b.low + b.close) / 3),
|
|
1296
|
+
ohlc4: bars.map((b) => (b.open + b.high + b.low + b.close) / 4),
|
|
1297
|
+
};
|
|
1298
|
+
const res = evalScriptNode(c.ast, vars, n);
|
|
1299
|
+
const arr = Array.isArray(res) ? res : new Array(n).fill(res);
|
|
1300
|
+
for (let i = 0; i < n; i++) {
|
|
1301
|
+
const v = arr[i];
|
|
1302
|
+
out[i] = v != null && Number.isFinite(v) ? v : NaN;
|
|
1303
|
+
}
|
|
1304
|
+
return out;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Build an indicator definition from a WickScript expression — used inline by
|
|
1309
|
+
* `indicators="expr:{…}"` / `pexpr:{…}"`, or register it under a name:
|
|
1310
|
+
* `HabChart.registerIndicator('myspread', scriptIndicator('close - ema(close,21)'))`.
|
|
1311
|
+
* @param {string} src
|
|
1312
|
+
* @param {{pane?: boolean}} [opts]
|
|
1313
|
+
* @returns {IndicatorDef}
|
|
1314
|
+
*/
|
|
1315
|
+
export function scriptIndicator(src, opts = {}) {
|
|
1316
|
+
const compiled = compileScript(src);
|
|
1317
|
+
const label = compiled.src.length > 24 ? compiled.src.slice(0, 23) + '…' : compiled.src;
|
|
1318
|
+
return {
|
|
1319
|
+
kind: opts.pane ? 'pane' : 'overlay',
|
|
1320
|
+
compute: (bars) => ({ lines: [{ name: label, values: evalScript(compiled, bars) }] }),
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
900
1324
|
/* ------------------------------------------------------------------ *
|
|
901
1325
|
* Trading overlays
|
|
902
1326
|
* ------------------------------------------------------------------ */
|
|
@@ -998,6 +1422,304 @@ export function computeStats(bars, i0, i1, dtMs) {
|
|
|
998
1422
|
};
|
|
999
1423
|
}
|
|
1000
1424
|
|
|
1425
|
+
/* ------------------------------------------------------------------ *
|
|
1426
|
+
* Volatility-regime shading
|
|
1427
|
+
* ------------------------------------------------------------------ */
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Rolling realized volatility: population stddev of log returns over the
|
|
1431
|
+
* last `period` bars (per-bar value, aligned like SMA — null until the
|
|
1432
|
+
* window fills).
|
|
1433
|
+
* @param {number[]} closes
|
|
1434
|
+
* @param {number} [period=20]
|
|
1435
|
+
* @returns {Array<number|null>}
|
|
1436
|
+
*/
|
|
1437
|
+
export function calcRealizedVol(closes, period = 20) {
|
|
1438
|
+
const n = closes.length;
|
|
1439
|
+
const out = new Array(n).fill(null);
|
|
1440
|
+
if (period < 2 || n < 2) return out;
|
|
1441
|
+
const rets = new Array(n).fill(0);
|
|
1442
|
+
let sum = 0;
|
|
1443
|
+
let sumSq = 0;
|
|
1444
|
+
let cnt = 0;
|
|
1445
|
+
for (let i = 1; i < n; i++) {
|
|
1446
|
+
const r = closes[i - 1] > 0 && closes[i] > 0 ? Math.log(closes[i] / closes[i - 1]) : NaN;
|
|
1447
|
+
rets[i] = r;
|
|
1448
|
+
if (Number.isFinite(r)) {
|
|
1449
|
+
sum += r;
|
|
1450
|
+
sumSq += r * r;
|
|
1451
|
+
cnt++;
|
|
1452
|
+
}
|
|
1453
|
+
const j = i - period; // return that falls out of the window
|
|
1454
|
+
if (j >= 1 && Number.isFinite(rets[j])) {
|
|
1455
|
+
sum -= rets[j];
|
|
1456
|
+
sumSq -= rets[j] * rets[j];
|
|
1457
|
+
cnt--;
|
|
1458
|
+
}
|
|
1459
|
+
if (i >= period && cnt === period) {
|
|
1460
|
+
const mean = sum / period;
|
|
1461
|
+
out[i] = Math.sqrt(Math.max(0, sumSq / period - mean * mean));
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
return out;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Classify a realized-vol series into regimes by empirical percentile over
|
|
1469
|
+
* the whole series: 0 = calm (≤ qLow), 1 = normal, 2 = hot (≥ qHigh),
|
|
1470
|
+
* -1 = unknown (null input). A degenerate spread (qHigh ≤ qLow, e.g. a
|
|
1471
|
+
* flat series) classifies everything as normal.
|
|
1472
|
+
* @param {Array<number|null>} vol
|
|
1473
|
+
* @param {number} [qLow=30]
|
|
1474
|
+
* @param {number} [qHigh=70]
|
|
1475
|
+
* @returns {{regimes:number[], sorted:number[], q1:number, q2:number}}
|
|
1476
|
+
*/
|
|
1477
|
+
export function volRegimeBands(vol, qLow = 30, qHigh = 70) {
|
|
1478
|
+
const n = vol.length;
|
|
1479
|
+
const regimes = new Array(n).fill(-1);
|
|
1480
|
+
const sorted = [];
|
|
1481
|
+
for (let i = 0; i < n; i++) if (isNum(vol[i])) sorted.push(vol[i]);
|
|
1482
|
+
sorted.sort((a, b) => a - b);
|
|
1483
|
+
const q = (p) => {
|
|
1484
|
+
if (!sorted.length) return NaN;
|
|
1485
|
+
const pos = clamp((p / 100) * (sorted.length - 1), 0, sorted.length - 1);
|
|
1486
|
+
const lo = Math.floor(pos);
|
|
1487
|
+
const hi = Math.ceil(pos);
|
|
1488
|
+
return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
|
1489
|
+
};
|
|
1490
|
+
const q1 = q(Math.min(qLow, qHigh));
|
|
1491
|
+
const q2 = q(Math.max(qLow, qHigh));
|
|
1492
|
+
const degenerate = !(q2 > q1);
|
|
1493
|
+
for (let i = 0; i < n; i++) {
|
|
1494
|
+
if (!isNum(vol[i])) continue;
|
|
1495
|
+
regimes[i] = degenerate ? 1 : vol[i] <= q1 ? 0 : vol[i] >= q2 ? 2 : 1;
|
|
1496
|
+
}
|
|
1497
|
+
return { regimes, sorted, q1, q2 };
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Percentile (0–100) of `v` within an ascending `sorted` array.
|
|
1502
|
+
* @param {number[]} sorted
|
|
1503
|
+
* @param {number} v
|
|
1504
|
+
* @returns {number}
|
|
1505
|
+
*/
|
|
1506
|
+
export function percentileOfSorted(sorted, v) {
|
|
1507
|
+
if (!sorted.length || !isNum(v)) return NaN;
|
|
1508
|
+
let lo = 0;
|
|
1509
|
+
let hi = sorted.length;
|
|
1510
|
+
while (lo < hi) {
|
|
1511
|
+
const mid = (lo + hi) >> 1;
|
|
1512
|
+
if (sorted[mid] < v) lo = mid + 1;
|
|
1513
|
+
else hi = mid;
|
|
1514
|
+
}
|
|
1515
|
+
if (sorted.length === 1) return sorted[0] === v ? 50 : sorted[0] < v ? 100 : 0;
|
|
1516
|
+
return clamp((lo / (sorted.length - 1)) * 100, 0, 100);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Parse a `volshading` attribute value: `""` / `"true"` → defaults (30/70,
|
|
1521
|
+
* period 20); `"30/70"` custom cutoffs; `"30/70/14"` cutoffs + period.
|
|
1522
|
+
* Inputs are clamped so qLow always stays at least 2 points below qHigh.
|
|
1523
|
+
* @param {string|null|undefined} val
|
|
1524
|
+
* @returns {{p1:number, p2:number, period:number}}
|
|
1525
|
+
*/
|
|
1526
|
+
export function parseVolShading(val) {
|
|
1527
|
+
const parts = String(val == null ? '' : val).split('/').map((s) => parseFloat(s));
|
|
1528
|
+
let p1 = isNum(parts[0]) ? clamp(parts[0], 0, 98) : 30;
|
|
1529
|
+
const p2 = isNum(parts[1]) ? clamp(parts[1], 2, 100) : 70;
|
|
1530
|
+
p1 = clamp(p1, 0, p2 - 2);
|
|
1531
|
+
const period = isNum(parts[2]) ? Math.round(clamp(parts[2], 2, 500)) : 20;
|
|
1532
|
+
return { p1, p2, period };
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/* ------------------------------------------------------------------ *
|
|
1536
|
+
* AI-ready window summary
|
|
1537
|
+
* ------------------------------------------------------------------ */
|
|
1538
|
+
|
|
1539
|
+
/** Least-squares trend of a value sequence: slope per bar + goodness of fit. */
|
|
1540
|
+
function lsTrend(vals) {
|
|
1541
|
+
const n = vals.length;
|
|
1542
|
+
let sx = 0;
|
|
1543
|
+
let sy = 0;
|
|
1544
|
+
let sxx = 0;
|
|
1545
|
+
let sxy = 0;
|
|
1546
|
+
for (let i = 0; i < n; i++) {
|
|
1547
|
+
sx += i;
|
|
1548
|
+
sy += vals[i];
|
|
1549
|
+
sxx += i * i;
|
|
1550
|
+
sxy += i * vals[i];
|
|
1551
|
+
}
|
|
1552
|
+
const denom = n * sxx - sx * sx;
|
|
1553
|
+
if (!denom) return { slope: 0, r2: 0 };
|
|
1554
|
+
const slope = (n * sxy - sx * sy) / denom;
|
|
1555
|
+
const intercept = (sy - slope * sx) / n;
|
|
1556
|
+
const meanY = sy / n;
|
|
1557
|
+
let ssTot = 0;
|
|
1558
|
+
let ssRes = 0;
|
|
1559
|
+
for (let i = 0; i < n; i++) {
|
|
1560
|
+
ssTot += (vals[i] - meanY) * (vals[i] - meanY);
|
|
1561
|
+
ssRes += (vals[i] - (intercept + slope * i)) * (vals[i] - (intercept + slope * i));
|
|
1562
|
+
}
|
|
1563
|
+
return { slope, r2: ssTot ? Math.max(0, 1 - ssRes / ssTot) : 0 };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
export const tfLabelOf = (dtMs) => {
|
|
1567
|
+
if (!isNum(dtMs) || dtMs <= 0) return '';
|
|
1568
|
+
const s = Math.round(dtMs / 1000);
|
|
1569
|
+
if (s < 60) return s + 's';
|
|
1570
|
+
const m = Math.round(s / 60);
|
|
1571
|
+
if (m < 60) return m + 'm';
|
|
1572
|
+
const h = Math.round(m / 60);
|
|
1573
|
+
if (h < 24) return h + 'h';
|
|
1574
|
+
const d = Math.round(h / 24);
|
|
1575
|
+
if (d < 7) return d + 'd';
|
|
1576
|
+
return Math.round(d / 7) + 'w';
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Compact, LLM-friendly summary of a bar window: structured fields plus a
|
|
1581
|
+
* ready-to-paste markdown rendering (`text`). Built entirely from local
|
|
1582
|
+
* data — nothing leaves the page until the user pastes it somewhere.
|
|
1583
|
+
*
|
|
1584
|
+
* @param {Bar[]} bars full dataset
|
|
1585
|
+
* @param {number} i0 first index of the window
|
|
1586
|
+
* @param {number} i1 last index of the window
|
|
1587
|
+
* @param {{dtMs?: number, label?: string}} [opts] bar spacing (ms) + chart label
|
|
1588
|
+
* @returns {object|null} null when the window is empty or out of range
|
|
1589
|
+
*/
|
|
1590
|
+
export function windowSummary(bars, i0, i1, opts = {}) {
|
|
1591
|
+
const n = i1 - i0 + 1;
|
|
1592
|
+
if (!bars.length || n < 2 || i0 < 0 || i1 >= bars.length) return null;
|
|
1593
|
+
const closes = bars.map((b) => b.close);
|
|
1594
|
+
const stats = computeStats(bars, i0, i1, opts.dtMs || 0);
|
|
1595
|
+
|
|
1596
|
+
let hi = -Infinity;
|
|
1597
|
+
let lo = Infinity;
|
|
1598
|
+
let hiI = i0;
|
|
1599
|
+
let loI = i0;
|
|
1600
|
+
let vMax = -Infinity;
|
|
1601
|
+
let vMaxI = i0;
|
|
1602
|
+
for (let i = i0; i <= i1; i++) {
|
|
1603
|
+
if (bars[i].high > hi) {
|
|
1604
|
+
hi = bars[i].high;
|
|
1605
|
+
hiI = i;
|
|
1606
|
+
}
|
|
1607
|
+
if (bars[i].low < lo) {
|
|
1608
|
+
lo = bars[i].low;
|
|
1609
|
+
loI = i;
|
|
1610
|
+
}
|
|
1611
|
+
if (isNum(bars[i].volume) && bars[i].volume > vMax) {
|
|
1612
|
+
vMax = bars[i].volume;
|
|
1613
|
+
vMaxI = i;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// trend over the window: % drift per bar + fit quality
|
|
1618
|
+
const win = closes.slice(i0, i1 + 1);
|
|
1619
|
+
const t = lsTrend(win);
|
|
1620
|
+
const meanY = win.reduce((a, b) => a + b, 0) / n;
|
|
1621
|
+
const slopePct = meanY ? (t.slope / meanY) * 100 : 0;
|
|
1622
|
+
let trendLabel;
|
|
1623
|
+
if (t.r2 < 0.25) trendLabel = 'range-bound';
|
|
1624
|
+
else if (slopePct >= 0.15) trendLabel = 'strong uptrend';
|
|
1625
|
+
else if (slopePct <= -0.15) trendLabel = 'strong downtrend';
|
|
1626
|
+
else if (slopePct >= 0.05) trendLabel = 'uptrend';
|
|
1627
|
+
else if (slopePct <= -0.05) trendLabel = 'downtrend';
|
|
1628
|
+
else trendLabel = 'mild drift ' + (slopePct >= 0 ? 'up' : 'down');
|
|
1629
|
+
|
|
1630
|
+
// realized-vol percentile of the latest bar within the window itself
|
|
1631
|
+
let volPctile = null;
|
|
1632
|
+
const wvol = calcRealizedVol(win, Math.min(20, Math.max(2, Math.floor(n / 3))));
|
|
1633
|
+
let lastVol = null;
|
|
1634
|
+
for (let i = wvol.length - 1; i >= 0; i--) {
|
|
1635
|
+
if (isNum(wvol[i])) {
|
|
1636
|
+
lastVol = wvol[i];
|
|
1637
|
+
break;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
if (lastVol != null) {
|
|
1641
|
+
const sorted = wvol.filter((x) => isNum(x)).sort((a, b) => a - b);
|
|
1642
|
+
volPctile = Math.round(percentileOfSorted(sorted, lastVol));
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const sma20 = n >= 20 ? calcSMA(win, 20)[n - 1] : null;
|
|
1646
|
+
const rsi14 = n > 15 ? calcRSI(win, 14)[n - 1] : null;
|
|
1647
|
+
|
|
1648
|
+
// notable events (most recent first, capped)
|
|
1649
|
+
const ann = detectAnnotations(bars, i0, i1, calcRSI(closes, 14))
|
|
1650
|
+
.sort((a, b) => b.i - a.i)
|
|
1651
|
+
.slice(0, 8)
|
|
1652
|
+
.map((a) => ({ time: bars[a.i].time, note: a.note }));
|
|
1653
|
+
|
|
1654
|
+
const f = numberFmt(autoPrecision(closes[i1]));
|
|
1655
|
+
const day = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
1656
|
+
const from = bars[i0].time;
|
|
1657
|
+
const to = bars[i1].time;
|
|
1658
|
+
const label = opts.label || 'Chart';
|
|
1659
|
+
const tf = tfLabelOf(opts.dtMs);
|
|
1660
|
+
const showTf = tf && !label.includes(tf) ? ` · ${tf}` : '';
|
|
1661
|
+
|
|
1662
|
+
const out = {
|
|
1663
|
+
label,
|
|
1664
|
+
bars: n,
|
|
1665
|
+
from,
|
|
1666
|
+
to,
|
|
1667
|
+
timeframe: tf,
|
|
1668
|
+
open: closes[i0],
|
|
1669
|
+
close: closes[i1],
|
|
1670
|
+
changePct: stats.changePct,
|
|
1671
|
+
high: hi,
|
|
1672
|
+
highTime: bars[hiI].time,
|
|
1673
|
+
low: lo,
|
|
1674
|
+
lowTime: bars[loI].time,
|
|
1675
|
+
maxDDPct: stats.maxDDPct,
|
|
1676
|
+
upBars: stats.up,
|
|
1677
|
+
downBars: stats.dn,
|
|
1678
|
+
avgVolume: stats.avgVolume,
|
|
1679
|
+
maxVolume: vMax,
|
|
1680
|
+
maxVolumeTime: bars[vMaxI].time,
|
|
1681
|
+
annVolPct: stats.annVolPct,
|
|
1682
|
+
volPctile,
|
|
1683
|
+
trend: { slopePctPerBar: slopePct, r2: t.r2, label: trendLabel },
|
|
1684
|
+
sma20: isNum(sma20) ? { value: sma20, priceAbove: closes[i1] >= sma20 } : null,
|
|
1685
|
+
rsi14: isNum(rsi14) ? rsi14 : null,
|
|
1686
|
+
patterns: ann,
|
|
1687
|
+
};
|
|
1688
|
+
|
|
1689
|
+
const lines = [];
|
|
1690
|
+
lines.push(
|
|
1691
|
+
`CHART SUMMARY — ${label}${showTf} · ${n} bars · ${day(from)} → ${day(to)}`
|
|
1692
|
+
);
|
|
1693
|
+
lines.push(
|
|
1694
|
+
`- Close ${f.format(out.close)} (${out.changePct >= 0 ? '+' : ''}${out.changePct.toFixed(2)}% over window). ` +
|
|
1695
|
+
`High ${f.format(hi)} on ${day(out.highTime)}, low ${f.format(lo)} on ${day(out.lowTime)}. ` +
|
|
1696
|
+
`Max drawdown ${out.maxDDPct.toFixed(1)}%.`
|
|
1697
|
+
);
|
|
1698
|
+
lines.push(
|
|
1699
|
+
`- Trend: ${trendLabel} (drift ${slopePct >= 0 ? '+' : ''}${slopePct.toFixed(3)}%/bar, fit r² ${t.r2.toFixed(2)}).` +
|
|
1700
|
+
(out.sma20 ? ` Price ${out.sma20.priceAbove ? 'above' : 'below'} SMA20 (${f.format(out.sma20.value)}).` : '') +
|
|
1701
|
+
(out.rsi14 != null ? ` RSI(14) ${out.rsi14.toFixed(1)}.` : '')
|
|
1702
|
+
);
|
|
1703
|
+
lines.push(
|
|
1704
|
+
`- Volatility: annualized ${out.annVolPct.toFixed(0)}%` +
|
|
1705
|
+
(volPctile != null
|
|
1706
|
+
? `; latest realized vol at the ${volPctile}th percentile of the window ` +
|
|
1707
|
+
`(${volPctile >= 70 ? 'hot' : volPctile <= 30 ? 'calm' : 'normal'} regime).`
|
|
1708
|
+
: '.')
|
|
1709
|
+
);
|
|
1710
|
+
lines.push(
|
|
1711
|
+
`- Bars: ${out.upBars} up / ${out.downBars} down. Volume avg ${fmtCompact(out.avgVolume)}/bar, ` +
|
|
1712
|
+
`peak ${fmtCompact(vMax)} on ${day(out.maxVolumeTime)}.`
|
|
1713
|
+
);
|
|
1714
|
+
lines.push(
|
|
1715
|
+
out.patterns.length
|
|
1716
|
+
? `- Notable: ${out.patterns.map((a) => `${a.note} (${day(a.time)})`).join('; ')}.`
|
|
1717
|
+
: '- Notable: no gaps, volume spikes or pivots flagged.'
|
|
1718
|
+
);
|
|
1719
|
+
out.text = lines.join('\n');
|
|
1720
|
+
return out;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1001
1723
|
/* ------------------------------------------------------------------ *
|
|
1002
1724
|
* State serialization (shareable URLs)
|
|
1003
1725
|
* ------------------------------------------------------------------ */
|
|
@@ -1017,7 +1739,9 @@ export function encodeStateQuery(state) {
|
|
|
1017
1739
|
if (state.stats) p.set('stats', '1');
|
|
1018
1740
|
if (state.profile) p.set('profile', '1');
|
|
1019
1741
|
if (state.annotations) p.set('ann', '1');
|
|
1020
|
-
if (state.
|
|
1742
|
+
if (state.volshading === true) p.set('vsh', '1');
|
|
1743
|
+
else if (typeof state.volshading === 'string' && state.volshading) p.set('vsh', state.volshading);
|
|
1744
|
+
if (state.indicators) p.set('ind', splitIndicatorTokens(state.indicators).join(','));
|
|
1021
1745
|
if (state.view) {
|
|
1022
1746
|
if (isNum(state.view.from)) p.set('from', String(Math.floor(state.view.from / 1000)));
|
|
1023
1747
|
if (isNum(state.view.to)) p.set('to', String(Math.floor(state.view.to / 1000)));
|
|
@@ -1041,8 +1765,11 @@ export function decodeStateQuery(str) {
|
|
|
1041
1765
|
if (p.get('stats') === '1') state.stats = true;
|
|
1042
1766
|
if (p.get('profile') === '1') state.profile = true;
|
|
1043
1767
|
if (p.get('ann') === '1') state.annotations = true;
|
|
1768
|
+
const vsh = p.get('vsh');
|
|
1769
|
+
if (vsh === '1') state.volshading = true;
|
|
1770
|
+
else if (vsh) state.volshading = vsh;
|
|
1044
1771
|
const ind = p.get('ind');
|
|
1045
|
-
if (ind) state.indicators = ind
|
|
1772
|
+
if (ind) state.indicators = splitIndicatorTokens(ind).join(' ');
|
|
1046
1773
|
const from = p.get('from');
|
|
1047
1774
|
const to = p.get('to');
|
|
1048
1775
|
if (from != null || to != null) {
|