wickchart 1.6.0 → 2.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/src/core.js CHANGED
@@ -206,6 +206,106 @@ export const MIN = 60 * SEC;
206
206
  export const HOUR = 60 * MIN;
207
207
  export const DAY = 24 * HOUR;
208
208
 
209
+ /**
210
+ * Below this, a numeric timestamp is read as seconds. 1e11 is the year 5138
211
+ * in seconds but 1973-03-03 in milliseconds, so it sits in the widest quiet
212
+ * gap between the two ranges. (The old 1e12 cutoff was inside the plausible
213
+ * millisecond range and silently multiplied every ms timestamp before
214
+ * 2001-09-09 by 1000 — all pre-2001 equity/index/FX history.)
215
+ */
216
+ const MS_CUTOFF = 1e11;
217
+
218
+ /**
219
+ * Interpret a timestamp as milliseconds. Numbers may be seconds or ms, so
220
+ * some threshold is unavoidable; pass a `Date` for anything before 1973,
221
+ * which is unambiguous. Single source of truth — everything that reads a
222
+ * caller-supplied time goes through here.
223
+ * @param {number|Date} t
224
+ * @returns {number} milliseconds
225
+ */
226
+ export const toMs = (t) =>
227
+ t instanceof Date ? t.getTime() : t < MS_CUTOFF ? t * 1000 : t;
228
+
229
+ /* ------------------------------------------------------------------ *
230
+ * Moved-method stub warnings (used by wick-chart.js; deleted in 3.0)
231
+ * ------------------------------------------------------------------ */
232
+
233
+ const warnedAliases = new Set();
234
+
235
+ /** Warn once per distinct message (used by the 2.0 moved-method stubs in
236
+ * wick-chart.js — deleted with them in 3.0). */
237
+ export function warnDeprecatedAlias(message) {
238
+ if (warnedAliases.has(message)) return;
239
+ warnedAliases.add(message);
240
+ if (typeof console !== 'undefined' && console.warn) {
241
+ console.warn('wickchart: ' + message);
242
+ }
243
+ }
244
+
245
+ /* ------------------------------------------------------------------ *
246
+ * Timezones
247
+ * ------------------------------------------------------------------ */
248
+
249
+ const zoneDtfCache = new Map();
250
+
251
+ /** Cached Intl formatter for a zone; null (→ UTC) if the zone is unusable. */
252
+ function zoneDtf(zone) {
253
+ let f = zoneDtfCache.get(zone);
254
+ if (f === undefined) {
255
+ try {
256
+ f = new Intl.DateTimeFormat('en-US', {
257
+ timeZone: zone,
258
+ hourCycle: 'h23',
259
+ year: 'numeric', month: '2-digit', day: '2-digit',
260
+ hour: '2-digit', minute: '2-digit',
261
+ });
262
+ } catch (_) {
263
+ // unknown zone: read as UTC rather than throwing on a render path, but
264
+ // say so once — a silently-UTC axis from a typo is hard to spot
265
+ f = null;
266
+ if (typeof console !== 'undefined') {
267
+ console.warn('wick-chart: unknown timezone "' + zone + '" — using UTC');
268
+ }
269
+ }
270
+ zoneDtfCache.set(zone, f);
271
+ }
272
+ return f;
273
+ }
274
+
275
+ const zoneOffCache = new Map();
276
+
277
+ /**
278
+ * Milliseconds east of UTC in `zone` at the instant `at`.
279
+ * 'utc' → 0
280
+ * 'local' / null → the browser's zone (DST-correct via Date)
281
+ * number → a fixed offset in ms (exchange sessions)
282
+ * IANA name → DST-correct via Intl
283
+ * Never throws: an unusable zone reads as UTC.
284
+ * @param {number} at epoch ms
285
+ * @param {string|number|null} [zone]
286
+ * @returns {number} offset in ms
287
+ */
288
+ export function zoneOffset(at, zone) {
289
+ if (zone === 'utc' || zone === 'UTC') return 0;
290
+ if (isNum(zone)) return zone;
291
+ if (zone == null || zone === 'local') return -new Date(at).getTimezoneOffset() * 60000;
292
+ const f = zoneDtf(zone);
293
+ if (!f) return 0;
294
+ // DST shifts land on hour/half-hour boundaries, so one Intl lookup per
295
+ // 30-minute bucket is exact — and keeps the slow part off the per-bar path.
296
+ const key = zone + '|' + Math.floor(at / 1800000);
297
+ const hit = zoneOffCache.get(key);
298
+ if (hit !== undefined) return hit;
299
+ const p = {};
300
+ for (const part of f.formatToParts(new Date(at))) p[part.type] = part.value;
301
+ // compare whole minutes: the formatted parts carry no seconds
302
+ const wall = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute);
303
+ const off = wall - Math.floor(at / 60000) * 60000;
304
+ if (zoneOffCache.size > 8192) zoneOffCache.clear();
305
+ zoneOffCache.set(key, off);
306
+ return off;
307
+ }
308
+
209
309
  // Sub-day / day-aligned steps (ms), plus month/year handled separately.
210
310
  export const TIME_STEPS = [
211
311
  { ms: MIN, label: 'time' },
@@ -229,7 +329,9 @@ const dtfCache = new Map();
229
329
  function dtf(fmt) {
230
330
  let f = dtfCache.get(fmt);
231
331
  if (!f) {
232
- f = new Intl.DateTimeFormat(undefined, fmt);
332
+ // Locale stays the viewer's; the zone is pinned to UTC because callers
333
+ // pass an instant already shifted into the display zone (see _zt()).
334
+ f = new Intl.DateTimeFormat(undefined, { timeZone: 'UTC', ...fmt });
233
335
  dtfCache.set(fmt, f);
234
336
  }
235
337
  return f;
@@ -239,21 +341,24 @@ const MON_FMT = { month: 'short' };
239
341
  const MON_Y_FMT = { month: 'short', year: 'numeric' };
240
342
  const YR_FMT = { year: 'numeric' };
241
343
 
344
+ /* Axis/legend formatters. Each takes an instant ALREADY shifted into the
345
+ * display zone (t + zoneOffset(t, zone)) and renders it as UTC, so one set of
346
+ * cached formatters serves every timezone. With the default 'local' zone the
347
+ * shift equals the browser offset and the output is what it always was. */
242
348
  export const hhmm = (t) => {
243
349
  const d = new Date(t);
244
- return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
350
+ return pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes());
245
351
  };
246
352
  export const fmtDay = (t) => dtf(DAY_FMT).format(t);
247
353
  export const fmtMonth = (t, withYear) => dtf(withYear ? MON_Y_FMT : MON_FMT).format(t);
248
354
  export const fmtYear = (t) => dtf(YR_FMT).format(t);
249
355
  export const fmtFull = (t) => {
250
356
  const d = new Date(t);
251
- return dtf(DAY_FMT).format(d) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
357
+ return dtf(DAY_FMT).format(d) + ' ' + pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes());
252
358
  };
253
359
 
254
360
  /* ------------------------------------------------------------------ *
255
- * Themes (every key overridable via --wick-* CSS custom properties;
256
- * the 0.x --hab-* names still work as fallbacks)
361
+ * Themes (every key overridable via --wick-* CSS custom properties)
257
362
  * ------------------------------------------------------------------ */
258
363
 
259
364
  export const THEMES = {
@@ -501,20 +606,31 @@ export function calcATR(bars, period = 14) {
501
606
  }
502
607
 
503
608
  /**
504
- * Volume-weighted average price over the hlc3 typical price, anchored to
505
- * each UTC day (resets at the session boundary).
609
+ * Volume-weighted average price over the hlc3 typical price, resetting at
610
+ * each session boundary.
611
+ *
612
+ * The anchor defaults to the UTC day — the crypto convention, and what this
613
+ * has always done. Equities, futures and FX rarely open at UTC midnight, so
614
+ * pass the session's zone (or a fixed offset) to move the reset. Note this is
615
+ * deliberately independent of the chart's `timezone`, which only governs how
616
+ * times are displayed: changing the axis to Stockholm should not silently
617
+ * re-anchor a BTC chart's VWAP.
618
+ *
506
619
  * @param {Bar[]} bars
620
+ * @param {string|number} [anchor='utc'] 'utc' | 'local' | IANA zone | fixed
621
+ * offset in ms — see zoneOffset()
507
622
  * @returns {Array<number|null>}
508
623
  */
509
- export function calcVWAP(bars) {
624
+ export function calcVWAP(bars, anchor) {
625
+ if (anchor == null) anchor = 'utc';
510
626
  const out = new Array(bars.length).fill(null);
511
627
  let pv = 0;
512
628
  let vv = 0;
513
629
  let day = null;
514
630
  for (let i = 0; i < bars.length; i++) {
515
631
  const b = bars[i];
516
- const ms = b.time < 1e12 ? b.time * 1000 : b.time;
517
- const d = Math.floor(ms / DAY);
632
+ const ms = toMs(b.time);
633
+ const d = Math.floor((ms + zoneOffset(ms, anchor)) / DAY);
518
634
  if (d !== day) {
519
635
  day = d;
520
636
  pv = 0;
@@ -905,28 +1021,6 @@ export function detectAnnotations(bars, i0, i1, rsi, opts = {}) {
905
1021
  return out.length > 80 ? out.slice(0, 80) : out;
906
1022
  }
907
1023
 
908
- /**
909
- * Map a price to a sonification frequency over the visible scale.
910
- * Logarithmic scales map through log-space; result clamped to [lo, hi] Hz.
911
- * @param {number} price
912
- * @param {{min: number, max: number, useLog?: boolean}} scale
913
- * @param {number} [freqLo=180]
914
- * @param {number} [freqHi=880]
915
- * @returns {number} frequency in Hz
916
- */
917
- export function priceToFreq(price, scale, freqLo = 180, freqHi = 880) {
918
- if (!scale || !(scale.max > scale.min)) return (freqLo + freqHi) / 2;
919
- let t;
920
- if (scale.useLog) {
921
- // scale.min/max are already log10-transformed in this mode
922
- t = (Math.log10(Math.max(price, 1e-12)) - scale.min) / (scale.max - scale.min || 1);
923
- } else {
924
- t = (price - scale.min) / (scale.max - scale.min);
925
- }
926
- t = t < 0 ? 0 : t > 1 ? 1 : t;
927
- return freqLo + t * (freqHi - freqLo);
928
- }
929
-
930
1024
  /**
931
1025
  * Volume profile over a visible bar range: volume distributed into price
932
1026
  * rows, with POC and the value area (greedy expansion around the POC).
@@ -1079,7 +1173,9 @@ export const BUILTIN_INDICATORS = new Map(
1079
1173
  vwap: {
1080
1174
  kind: 'overlay',
1081
1175
  params: {},
1082
- compute: (bars) => calcVWAP(bars),
1176
+ // `anchor` rides in on the params the chart builds, from its
1177
+ // `vwap-anchor` attribute; absent, calcVWAP defaults to the UTC day.
1178
+ compute: (bars, p) => calcVWAP(bars, p && p.anchor),
1083
1179
  },
1084
1180
  supertrend: {
1085
1181
  kind: 'overlay',
@@ -1604,13 +1700,27 @@ function evalScriptCall(node, vars, n, bars) {
1604
1700
  case 'rsi': return calcRSI(clean, p);
1605
1701
  case 'hh':
1606
1702
  case 'll': {
1703
+ // Sliding window in O(n) via a monotonic deque of indices. The naive
1704
+ // nested loop was O(n*p), which a shared chart URL could weaponize:
1705
+ // hh(close,50000) over 100k bars blocked the main thread for ~1.7s.
1706
+ // A separate NaN count reproduces the old behaviour of propagating a
1707
+ // warm-up gap through the whole window (Math.max/min do that for free,
1708
+ // a deque does not).
1607
1709
  const out = new Array(n).fill(null);
1608
- for (let i = p - 1; i < n; i++) {
1609
- let v = clean[i];
1610
- for (let j = i - p + 1; j <= i; j++) {
1611
- v = name === 'hh' ? Math.max(v, clean[j]) : Math.min(v, clean[j]);
1710
+ const isMax = name === 'hh';
1711
+ const dq = []; // indices; their values decrease (hh) / increase (ll)
1712
+ let nan = 0;
1713
+ for (let i = 0; i < n; i++) {
1714
+ const v = clean[i];
1715
+ if (Number.isNaN(v)) nan++;
1716
+ if (i >= p && Number.isNaN(clean[i - p])) nan--;
1717
+ // drop values that can never win again while v is in the window
1718
+ while (dq.length && (isMax ? clean[dq[dq.length - 1]] <= v : clean[dq[dq.length - 1]] >= v)) {
1719
+ dq.pop();
1612
1720
  }
1613
- out[i] = v;
1721
+ dq.push(i);
1722
+ while (dq[0] < i - p + 1) dq.shift();
1723
+ if (i >= p - 1) out[i] = nan > 0 ? NaN : clean[dq[0]];
1614
1724
  }
1615
1725
  return out;
1616
1726
  }
@@ -1685,7 +1795,7 @@ export function evalScript(compiled, bars) {
1685
1795
  /**
1686
1796
  * Build an indicator definition from a WickScript expression — used inline by
1687
1797
  * `indicators="expr:{…}"` / `pexpr:{…}"`, or register it under a name:
1688
- * `HabChart.registerIndicator('myspread', scriptIndicator('close - ema(close,21)'))`.
1798
+ * `WickChart.registerIndicator('myspread', scriptIndicator('close - ema(close,21)'))`.
1689
1799
  * @param {string} src
1690
1800
  * @param {{pane?: boolean}} [opts]
1691
1801
  * @returns {IndicatorDef}
@@ -1716,6 +1826,20 @@ export function positionPnl(pos, price) {
1716
1826
  return (price - pos.entry) * dir * qty;
1717
1827
  }
1718
1828
 
1829
+ /**
1830
+ * Percent return of a position at `price` — the move per unit, so it does
1831
+ * NOT scale with `qty` the way positionPnl() does. Deriving this by dividing
1832
+ * positionPnl() by the entry price reports qty × the true return.
1833
+ * @param {{side?: 'long'|'short', entry: number}} pos
1834
+ * @param {number} price
1835
+ * @returns {number} percent (10 means +10%)
1836
+ */
1837
+ export function positionPnlPct(pos, price) {
1838
+ if (!pos || !isNum(pos.entry) || !pos.entry || !isNum(price)) return 0;
1839
+ const dir = pos.side === 'short' ? -1 : 1;
1840
+ return (((price - pos.entry) * dir) / pos.entry) * 100;
1841
+ }
1842
+
1719
1843
  /**
1720
1844
  * Edge-triggered alert crossing test between two consecutive prices.
1721
1845
  * @param {{price: number, direction?: 'above'|'below'|'cross'}} alert
@@ -1942,9 +2066,8 @@ export function parseVolShading(val) {
1942
2066
  * Server-side overlays (zones & levels)
1943
2067
  * ------------------------------------------------------------------ */
1944
2068
 
1945
- /** Normalize a timestamp to milliseconds (bar times and query times both
1946
- * auto-detect seconds — anything below 1e12 is treated as seconds). */
1947
- const normMs = (t) => (t < 1e12 ? t * 1000 : t);
2069
+ /** Normalize a timestamp to milliseconds see toMs(). */
2070
+ const normMs = toMs;
1948
2071
 
1949
2072
  /**
1950
2073
  * Index of the last bar whose time is <= `t` (binary search). Clamps to
@@ -2058,45 +2181,6 @@ export function resolveOverlayColor(raw, pal) {
2058
2181
  * Scenario mode — ghost paths + volatility cones
2059
2182
  * ------------------------------------------------------------------ */
2060
2183
 
2061
- /**
2062
- * σ-cone projection from realized per-bar volatility: price bands widening
2063
- * with √h (GBM-style, exp(±z·σ·√h)) over `horizon` future bars.
2064
- * @param {number} lastClose anchor price (bar 0)
2065
- * @param {number} volPerBar per-bar stddev of log returns (from calcRealizedVol)
2066
- * @param {number} horizon future bars (clamped 1–500, default 48)
2067
- * @param {number[]} [levels] σ multipliers, e.g. [1, 2] (each clamped to 0–5)
2068
- * @returns {{horizon: number, levels: number[], bands: Record<string, {up: number[], down: number[]}>}}
2069
- * bands[z].up/.down are arrays indexed by h = 0…horizon ([0] === lastClose)
2070
- */
2071
- export function calcVolCone(lastClose, volPerBar, horizon, levels) {
2072
- const zs = (Array.isArray(levels) && levels.length ? levels : [1, 2])
2073
- .map((z) => +z)
2074
- .filter((z) => Number.isFinite(z) && z > 0 && z <= 5)
2075
- .sort((a, b) => a - b);
2076
- const lv = zs.length ? zs : [1];
2077
- const H = Math.max(1, Math.min(500, Math.round(+horizon || 48)));
2078
- const c = +lastClose;
2079
- const v = +volPerBar;
2080
- const bands = {};
2081
- const flat = !Number.isFinite(c) || c <= 0 || !Number.isFinite(v) || v < 0;
2082
- for (const z of lv) {
2083
- const up = new Array(H + 1);
2084
- const down = new Array(H + 1);
2085
- for (let h = 0; h <= H; h++) {
2086
- if (flat) {
2087
- up[h] = c || 0;
2088
- down[h] = c || 0;
2089
- } else {
2090
- const k = Math.exp(z * v * Math.sqrt(h));
2091
- up[h] = c * k;
2092
- down[h] = c / k;
2093
- }
2094
- }
2095
- bands[z] = { up, down };
2096
- }
2097
- return { horizon: H, levels: lv, bands };
2098
- }
2099
-
2100
2184
  /**
2101
2185
  * Validate a scenario spec: a ghost path of future prices (bars or API data)
2102
2186
  * plus optional cone settings. Invalid entries are dropped, never thrown.
@@ -2210,61 +2294,6 @@ export function normalizeRiskPlan(spec) {
2210
2294
  };
2211
2295
  }
2212
2296
 
2213
- /* ------------------------------------------------------------------ *
2214
- * Bar-walk narrator — a timeline of what happened
2215
- * ------------------------------------------------------------------ */
2216
-
2217
- /**
2218
- * Turn a bar window into an ordered story: the annotation events (pivot
2219
- * highs/lows, volume spikes, gaps, RSI divergences) plus derived **legs** —
2220
- * the move between consecutive opposite pivots ("+12.4% over 38 bars").
2221
- * The timeline drives the bar-walk player and any caption UI.
2222
- *
2223
- * @param {Bar[]} bars full dataset
2224
- * @param {number} i0 first index of the window
2225
- * @param {number} i1 last index of the window
2226
- * @param {{pivot?: number, volMult?: number, gapMult?: number, rsiPeriod?: number}} [opts]
2227
- * pivot window defaults to 8 (denser than the annotations overlay's 20)
2228
- * @returns {{i: number, time: number, type: string, side: string, note: string,
2229
- * legPct?: number, legBars?: number}[]} sorted by index, capped at 60
2230
- */
2231
- export function narrateWindow(bars, i0, i1, opts = {}) {
2232
- if (!bars.length || i0 < 0 || i1 < i0 || i1 >= bars.length) return [];
2233
- const rsi = calcRSI(bars.map((b) => b.close), Math.min(50, Math.max(2, +opts.rsiPeriod || 14)));
2234
- const ann = detectAnnotations(bars, i0, i1, rsi, {
2235
- pivot: opts.pivot ?? 8,
2236
- volMult: opts.volMult,
2237
- gapMult: opts.gapMult,
2238
- });
2239
- // legs: the move between consecutive opposite pivots, stamped at the
2240
- // ending pivot so a walk player can speak it as it arrives
2241
- const pivots = ann
2242
- .filter((a) => a.type === 'pivothigh' || a.type === 'pivotlow')
2243
- .sort((a, b) => a.i - b.i);
2244
- const legs = [];
2245
- for (let k = 1; k < pivots.length; k++) {
2246
- const a = pivots[k - 1];
2247
- const b = pivots[k];
2248
- if (a.type === b.type) continue;
2249
- const pa = a.type === 'pivothigh' ? bars[a.i].high : bars[a.i].low;
2250
- const pb = b.type === 'pivothigh' ? bars[b.i].high : bars[b.i].low;
2251
- if (!(pa > 0) || !Number.isFinite(pb)) continue;
2252
- const pct = ((pb - pa) / pa) * 100;
2253
- legs.push({
2254
- type: 'leg',
2255
- side: pct >= 0 ? 'high' : 'low',
2256
- i: b.i,
2257
- note: `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}% over ${b.i - a.i} bars`,
2258
- legPct: Math.round(pct * 100) / 100,
2259
- legBars: b.i - a.i,
2260
- });
2261
- }
2262
- return [...ann, ...legs]
2263
- .sort((a, b) => a.i - b.i)
2264
- .slice(0, 60)
2265
- .map((e) => ({ ...e, time: bars[e.i].time }));
2266
- }
2267
-
2268
2297
  /* ------------------------------------------------------------------ *
2269
2298
  * Delta brush — selection statistics
2270
2299
  * ------------------------------------------------------------------ */
@@ -2310,165 +2339,6 @@ export function brushStats(bars, i0, i1) {
2310
2339
  };
2311
2340
  }
2312
2341
 
2313
- /* ------------------------------------------------------------------ *
2314
- * Story mode — guided tours of chart state
2315
- * ------------------------------------------------------------------ */
2316
-
2317
- /** Smoothest cheap easing for viewport pans: slow in, slow out. */
2318
- export function easeInOutCubic(t) {
2319
- const x = clamp(+t || 0, 0, 1);
2320
- return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
2321
- }
2322
-
2323
- /**
2324
- * Validate one story scene. Every field is optional except that a scene
2325
- * must be an object; omitted fields simply don't change that aspect of
2326
- * the chart when played. `scenario`/`riskPlan` use a 'clear' sentinel for
2327
- * explicit "remove it" (null input means clear too when the KEY is present).
2328
- *
2329
- * { title: 'The breakout', note: 'What happened…',
2330
- * range: { from, to }, // times (s or ms) — the camera pans there
2331
- * indicators: 'sma:20 rsi:14', // optional indicator string
2332
- * type: 'candles', // optional series type
2333
- * overlays: [...], // optional zones/levels (normalizeOverlays)
2334
- * scenario: {...} | null, // set / clear a scenario
2335
- * riskPlan: {...} | null, // set / clear a risk plan
2336
- * dwell: 2200 } // ms to hold after the pan (500–30000)
2337
- *
2338
- * @returns {object|null} normalized scene, or null for non-objects
2339
- */
2340
- export function normalizeScene(scene) {
2341
- if (!scene || typeof scene !== 'object') return null;
2342
- const out = {
2343
- title: scene.title != null ? String(scene.title).slice(0, 60) : '',
2344
- note: scene.note != null ? String(scene.note).slice(0, 200) : '',
2345
- dwell: clamp(Math.round(+scene.dwell || 2200), 500, 30000),
2346
- };
2347
- if (scene.range && Number.isFinite(+scene.range.from) && Number.isFinite(+scene.range.to)) {
2348
- out.range = { from: +scene.range.from, to: +scene.range.to };
2349
- }
2350
- if (scene.indicators != null) {
2351
- const s = String(scene.indicators).trim();
2352
- if (s) out.indicators = s.slice(0, 200);
2353
- }
2354
- if (scene.type != null && SERIES_TYPES.includes(scene.type)) out.type = scene.type;
2355
- if (scene.overlays != null) {
2356
- const ovs = normalizeOverlays(scene.overlays);
2357
- if (ovs.length) out.overlays = ovs;
2358
- }
2359
- if ('scenario' in scene) {
2360
- if (scene.scenario == null) out.scenario = 'clear';
2361
- else {
2362
- const sc = normalizeScenario(scene.scenario);
2363
- if (sc) out.scenario = sc;
2364
- }
2365
- }
2366
- if ('riskPlan' in scene) {
2367
- if (scene.riskPlan == null) out.riskPlan = 'clear';
2368
- else {
2369
- const rp = normalizeRiskPlan(scene.riskPlan);
2370
- if (rp) out.riskPlan = rp;
2371
- }
2372
- }
2373
- return out;
2374
- }
2375
-
2376
- /**
2377
- * Validate a whole story: normalize each scene, drop junk, cap at 20.
2378
- * @returns {object[]} possibly empty
2379
- */
2380
- export function sceneList(story) {
2381
- if (!Array.isArray(story)) return [];
2382
- const out = [];
2383
- for (const s of story) {
2384
- const n = normalizeScene(s);
2385
- if (n) out.push(n);
2386
- if (out.length >= 20) break;
2387
- }
2388
- return out;
2389
- }
2390
-
2391
- /* ------------------------------------------------------------------ *
2392
- * Co-view presence — peer viewport tracking with TTL expiry
2393
- * ------------------------------------------------------------------ */
2394
-
2395
- /**
2396
- * Tracks other charts viewing the same room: last-sighting timestamps per
2397
- * peer plus the viewport each one is looking at. Pure bookkeeping — the
2398
- * transport (BroadcastChannel, WebSocket, …) lives in the component/app.
2399
- *
2400
- * Peers expire `ttl` ms after their last sighting, so a closed tab fades
2401
- * out of the room without an explicit goodbye.
2402
- */
2403
- export class PresenceTracker {
2404
- /** @param {number} [ttl=12000] ms a peer survives without a sighting */
2405
- constructor(ttl = 12000) {
2406
- this.ttl = Math.max(1000, +ttl || 12000);
2407
- /** @type {Map<string, {id: string, name: string|null, range: {from:number,to:number}|null, at: number}>} */
2408
- this.peers = new Map();
2409
- }
2410
-
2411
- /**
2412
- * Record a sighting. `patch.range` ({from,to} times) is validated and
2413
- * normalized; a sighting without a range keeps the previous one.
2414
- * @returns {boolean} true when this sighting is a join (new peer)
2415
- */
2416
- track(id, patch = {}, now = Date.now()) {
2417
- if (!id || typeof id !== 'string') return false;
2418
- const existing = this.peers.get(id);
2419
- if (existing) {
2420
- if (patch && patch.range) {
2421
- const f = +patch.range.from;
2422
- const t = +patch.range.to;
2423
- if (Number.isFinite(f) && Number.isFinite(t)) {
2424
- existing.range = { from: Math.min(f, t), to: Math.max(f, t) };
2425
- }
2426
- }
2427
- if (patch && patch.name != null) existing.name = String(patch.name).slice(0, 24) || null;
2428
- existing.at = now;
2429
- return false;
2430
- }
2431
- const f = patch && patch.range ? +patch.range.from : NaN;
2432
- const t = patch && patch.range ? +patch.range.to : NaN;
2433
- this.peers.set(id, {
2434
- id,
2435
- name: patch && patch.name != null ? (String(patch.name).slice(0, 24) || null) : null,
2436
- range: Number.isFinite(f) && Number.isFinite(t)
2437
- ? { from: Math.min(f, t), to: Math.max(f, t) }
2438
- : null,
2439
- at: now,
2440
- });
2441
- return true;
2442
- }
2443
-
2444
- /** @returns {object|null} the removed peer entry, or null when unknown */
2445
- drop(id) {
2446
- const p = this.peers.get(id);
2447
- this.peers.delete(id);
2448
- return p || null;
2449
- }
2450
-
2451
- /** Expire peers not seen within the ttl.
2452
- * @returns {object[]} the peer entries that left */
2453
- sweep(now = Date.now()) {
2454
- const left = [];
2455
- for (const [id, p] of this.peers) {
2456
- if (now - p.at > this.ttl) {
2457
- this.peers.delete(id);
2458
- left.push(p);
2459
- }
2460
- }
2461
- return left;
2462
- }
2463
-
2464
- /** @returns {{id: string, name: string|null, range: object|null, at: number}[]} copies, oldest sighting first */
2465
- list() {
2466
- return [...this.peers.values()]
2467
- .sort((a, b) => a.at - b.at)
2468
- .map((p) => ({ ...p, range: p.range ? { ...p.range } : null }));
2469
- }
2470
- }
2471
-
2472
2342
  /* ------------------------------------------------------------------ *
2473
2343
  * AI-ready window summary
2474
2344
  * ------------------------------------------------------------------ */
@@ -2718,165 +2588,3 @@ export function decodeStateQuery(str) {
2718
2588
  return state;
2719
2589
  }
2720
2590
 
2721
- /* ------------------------------------------------------------------ *
2722
- * AI agent interface — the chart as a tool surface
2723
- * ------------------------------------------------------------------ */
2724
-
2725
- /**
2726
- * Tool manifest for LLM/agent control of a chart. Tools map 1:1 onto the
2727
- * public element API; every op through applyChartOps is validated before it
2728
- * touches the chart (LLM output is untrusted input).
2729
- */
2730
- export const AI_TOOLS = [
2731
- {
2732
- tool: 'get_data_window',
2733
- description:
2734
- 'Read the visible chart window: OHLC stats, trend (slope + fit), volatility percentile, indicator snapshots, detected patterns. Returns structured fields plus a markdown summary.',
2735
- args: {},
2736
- },
2737
- {
2738
- tool: 'set_indicators',
2739
- description:
2740
- 'Replace the indicators. Tokens: sma:20 ema:50 bb:20 vwap supertrend:10/3 donchian:20 keltner:20 rsi:14 macd:12/26/9 stoch:14/3 atr:14 obv cci:20 wr:14 volume, @hexcolor suffixes, or WickScript expressions like expr:{close - sma(close,20)} / pexpr:{rsi(close,14)}. Empty string clears all.',
2741
- args: { indicators: 'string — space/comma-separated tokens' },
2742
- },
2743
- {
2744
- tool: 'set_overlays',
2745
- description:
2746
- 'Draw zones & levels behind the candles (support/resistance, supply/demand). Zone: {type:"zone", from?, to?, priceFrom, priceTo, color?, alpha?, label?} — omit `to` (or pass null) to extend into future space past the last bar. Level: {type:"level", price, color?, dash?, label?}. Invalid entries are dropped.',
2747
- args: { overlays: 'array of overlay objects' },
2748
- },
2749
- { tool: 'clear_overlays', description: 'Remove all overlays.', args: {} },
2750
- {
2751
- tool: 'add_alert',
2752
- description:
2753
- 'Price alert {price, direction:"above"|"below"|"cross"} or scripted predicate {when:"<WickScript>"} — e.g. when:"crossup(rsi(close,14), 30)" or when:"volume > sma(volume,20) * 3". Fires wick:alert.',
2754
- args: {},
2755
- },
2756
- {
2757
- tool: 'set_view',
2758
- description: 'Set the visible time range (unix seconds or ms).',
2759
- args: { from: 'timestamp', to: 'timestamp' },
2760
- },
2761
- { tool: 'reset_view', description: 'Fit all loaded data.', args: {} },
2762
- {
2763
- tool: 'set_type',
2764
- description: 'Change the series type.',
2765
- args: { type: '"candles" | "line" | "area" | "bars" | "hollow" | "heikin"' },
2766
- },
2767
- {
2768
- tool: 'set_volshading',
2769
- description: 'Volatility-regime background shading (calm/normal/hot percentiles).',
2770
- args: { enabled: 'boolean', low: 'percentile 0–98 (default 30)', high: 'percentile (default 70)' },
2771
- },
2772
- ];
2773
-
2774
- /**
2775
- * Compact system prompt for agent control: paste into any LLM alongside the
2776
- * tool manifest. The model answers with a JSON array of {tool, args} ops.
2777
- * @returns {string}
2778
- */
2779
- export function aiPromptText() {
2780
- const lines = AI_TOOLS.map(
2781
- (t) => `- ${t.tool}${Object.keys(t.args).length ? '(' + Object.keys(t.args).join(', ') + ')' : '()'}: ${t.description}`
2782
- );
2783
- return [
2784
- 'You are controlling a WickChart financial charting element through tool calls.',
2785
- 'Reply with ONLY a JSON array of operations to apply, each {"tool": name, "args": {...}}.',
2786
- 'Use get_data_window first when you need to see the chart before deciding.',
2787
- 'Available tools:',
2788
- ...lines,
2789
- ].join('\n');
2790
- }
2791
-
2792
- const AI_CHART_TYPES = SERIES_TYPES;
2793
-
2794
- /**
2795
- * Validate + apply a list of {tool, args} ops (typically LLM output) to a
2796
- * chart-like target. Ops are whitelisted and their args validated — an op
2797
- * never throws; it returns {ok: false, error} instead so the agent can
2798
- * self-correct. Target contract: getDataWindow(), setAttribute(k, v),
2799
- * setOverlays(list), clearOverlays(), addAlert(a), setVisibleRange(r),
2800
- * fit(), and (static) _registry() for indicator name checks.
2801
- * @param {object} target chart element (or test double)
2802
- * @param {any} ops
2803
- * @returns {Array<{ok: boolean, tool?: string, result?: any, error?: string}>}
2804
- */
2805
- export function applyChartOps(target, ops) {
2806
- if (!target) return [{ ok: false, error: 'no target' }];
2807
- if (!Array.isArray(ops)) return [{ ok: false, error: 'ops must be an array of {tool, args} objects' }];
2808
- return ops.map((op) => {
2809
- if (!op || typeof op !== 'object' || Array.isArray(op)) {
2810
- return { ok: false, error: 'each op must be an object: {tool, args}' };
2811
- }
2812
- const tool = String(op.tool || '');
2813
- const args = op.args && typeof op.args === 'object' && !Array.isArray(op.args) ? op.args : {};
2814
- const fail = (error) => ({ ok: false, tool, error });
2815
- try {
2816
- switch (tool) {
2817
- case 'get_data_window':
2818
- return { ok: true, tool, result: target.getDataWindow() };
2819
- case 'set_indicators': {
2820
- if (typeof args.indicators !== 'string') return fail('args.indicators must be a string');
2821
- const reg = target.constructor && target.constructor._registry ? target.constructor._registry() : null;
2822
- const parsed = parseIndicators(args.indicators, reg);
2823
- if (parsed.unknown.length) {
2824
- return fail(`unknown indicators: ${parsed.unknown.join(', ')}`);
2825
- }
2826
- target.setAttribute('indicators', args.indicators);
2827
- return { ok: true, tool, result: { applied: args.indicators || '(cleared)' } };
2828
- }
2829
- case 'set_overlays': {
2830
- if (!Array.isArray(args.overlays)) return fail('args.overlays must be an array');
2831
- const norm = normalizeOverlays(args.overlays);
2832
- if (!norm.length) return fail('no valid overlays in args.overlays');
2833
- const ids = target.setOverlays(args.overlays);
2834
- return { ok: true, tool, result: { applied: ids.length, dropped: args.overlays.length - ids.length } };
2835
- }
2836
- case 'clear_overlays':
2837
- target.clearOverlays();
2838
- return { ok: true, tool, result: { cleared: true } };
2839
- case 'add_alert': {
2840
- if (!isNum(args.price) && typeof args.when !== 'string') {
2841
- return fail('args needs either price (number) or when (WickScript string)');
2842
- }
2843
- const id = target.addAlert(args);
2844
- return id ? { ok: true, tool, result: { id } } : fail('invalid alert (bad predicate?)');
2845
- }
2846
- case 'set_view': {
2847
- const r = {};
2848
- if (isNum(args.from)) r.from = normMs(args.from);
2849
- if (isNum(args.to)) r.to = normMs(args.to);
2850
- if (!('from' in r) && !('to' in r)) return fail('args needs from and/or to timestamps');
2851
- target.setVisibleRange(r);
2852
- return { ok: true, tool, result: r };
2853
- }
2854
- case 'reset_view':
2855
- target.fit();
2856
- return { ok: true, tool, result: { reset: true } };
2857
- case 'set_type': {
2858
- if (!AI_CHART_TYPES.includes(args.type)) {
2859
- return fail(`args.type must be one of ${AI_CHART_TYPES.join(' | ')}`);
2860
- }
2861
- target.setAttribute('type', args.type);
2862
- return { ok: true, tool, result: { type: args.type } };
2863
- }
2864
- case 'set_volshading': {
2865
- if (args.enabled === false) {
2866
- target.setAttribute('volshading', 'false');
2867
- return { ok: true, tool, result: { enabled: false } };
2868
- }
2869
- const p = parseVolShading(
2870
- isNum(args.low) && isNum(args.high) ? `${args.low}/${args.high}` : ''
2871
- );
2872
- target.setAttribute('volshading', `${p.p1}/${p.p2}`);
2873
- return { ok: true, tool, result: { enabled: true, low: p.p1, high: p.p2 } };
2874
- }
2875
- default:
2876
- return fail(`unknown tool "${tool}"`);
2877
- }
2878
- } catch (err) {
2879
- return fail(err && err.message ? err.message : String(err));
2880
- }
2881
- });
2882
- }