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/feeds.js CHANGED
@@ -111,6 +111,225 @@ export function makeSynthStream(sec, startPrice) {
111
111
  };
112
112
  }
113
113
 
114
+ /* ---------------- information-based bar aggregation ---------------- */
115
+ //
116
+ // Tick / volume / dollar bars ("advanced bars"): a bar closes when a threshold
117
+ // of *information* is reached — N prints, N base units, or $N notional — not
118
+ // when the clock says so. Same discipline as the rest of this file: plain
119
+ // data in, plain data out; the aggregator itself has no DOM and no network.
120
+
121
+ /** Default thresholds per kind. They are per-instrument — there is no
122
+ * universal "one bar" size, treat these as starting points to tune. */
123
+ export const AGG_DEFAULTS = { tick: 100, volume: 10, dollar: 25000 };
124
+
125
+ /**
126
+ * Parse an `aggregate` spec — `tick`, `volume:50`, `dollar:25000` (case and
127
+ * whitespace tolerant; the value is the bar size in trades / base units /
128
+ * quote units respectively).
129
+ * @param {string} spec attribute value
130
+ * @returns {{kind: 'tick'|'volume'|'dollar', threshold: number}|null} null when invalid
131
+ */
132
+ export function parseAggregate(spec) {
133
+ if (spec == null || spec === false) return null;
134
+ const m = /^\s*(tick|volume|dollar)(?:\s*:\s*([0-9]*\.?[0-9]+))?\s*$/i.exec(String(spec));
135
+ if (!m) return null;
136
+ const kind = m[1].toLowerCase();
137
+ const threshold = m[2] != null ? Number(m[2]) : AGG_DEFAULTS[kind];
138
+ if (!Number.isFinite(threshold) || threshold <= 0) return null;
139
+ return { kind, threshold };
140
+ }
141
+
142
+ /**
143
+ * Streaming aggregator: feed it trades, get bars back. `add()` returns a
144
+ * bar the moment the threshold is crossed ({@link current} keeps exposing
145
+ * the forming bar meanwhile). The completing trade belongs entirely to the
146
+ * closing bar — a whale print is never split across two bars.
147
+ */
148
+ export class TickBarAggregator {
149
+ /**
150
+ * @param {'tick'|'volume'|'dollar'} kind what the threshold counts
151
+ * @param {number} threshold bar size (trades / base units / quote units)
152
+ */
153
+ constructor(kind = 'tick', threshold = AGG_DEFAULTS.tick) {
154
+ this.kind = kind === 'volume' || kind === 'dollar' ? kind : 'tick';
155
+ this.threshold = Number.isFinite(+threshold) && +threshold > 0 ? +threshold : AGG_DEFAULTS[this.kind];
156
+ this._bar = null;
157
+ }
158
+
159
+ /**
160
+ * Feed one trade `{ time, price, size }` (seconds auto-upgraded to ms,
161
+ * same heuristic as the chart). Returns the completed bar —
162
+ * `{ time, open, high, low, close, volume, closed: true }` — when the
163
+ * threshold is reached, else null.
164
+ * @returns {object|null}
165
+ */
166
+ add(trade) {
167
+ const price = Number(trade && trade.price);
168
+ const size = Number(trade && trade.size);
169
+ let time = Number(trade && trade.time);
170
+ if (!(price > 0) || !Number.isFinite(price)) return null;
171
+ const qty = Number.isFinite(size) && size > 0 ? size : 0;
172
+ if (!Number.isFinite(time)) time = Date.now();
173
+ if (time < 1e11) time *= 1000;
174
+ if (!this._bar) {
175
+ this._bar = {
176
+ time: Math.round(time), open: price, high: price, low: price, close: price,
177
+ volume: 0, n: 0, notional: 0,
178
+ };
179
+ }
180
+ const b = this._bar;
181
+ if (price > b.high) b.high = price;
182
+ if (price < b.low) b.low = price;
183
+ b.close = price;
184
+ b.volume += qty;
185
+ b.n++;
186
+ b.notional += price * qty;
187
+ const filled = this.kind === 'tick' ? b.n : this.kind === 'volume' ? b.volume : b.notional;
188
+ if (filled >= this.threshold) {
189
+ this._bar = null;
190
+ return {
191
+ time: b.time, open: b.open, high: b.high, low: b.low, close: b.close,
192
+ volume: +b.volume.toFixed(8), closed: true,
193
+ };
194
+ }
195
+ return null;
196
+ }
197
+
198
+ /** The forming bar as plain chart-bar fields (no internals), or null. */
199
+ current() {
200
+ if (!this._bar) return null;
201
+ const { time, open, high, low, close } = this._bar;
202
+ return { time, open, high, low, close, volume: +this._bar.volume.toFixed(8) };
203
+ }
204
+
205
+ /** Close the forming bar as-is (end of tape / teardown), or null. */
206
+ flush() {
207
+ const c = this.current();
208
+ this._bar = null;
209
+ return c ? { ...c, closed: true } : null;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * One-pass batch aggregation of a trade history. Returns the closed bars,
215
+ * the still-forming remainder, and the live aggregator positioned at the end
216
+ * of the tape so streaming can continue without a seam.
217
+ * @param {Array<object>} trades `{ time, price, size }` prints
218
+ * @param {'tick'|'volume'|'dollar'} kind
219
+ * @param {number} threshold
220
+ */
221
+ export function aggregateTrades(trades, kind, threshold) {
222
+ const aggregator = new TickBarAggregator(kind, threshold);
223
+ const bars = [];
224
+ for (const t of Array.isArray(trades) ? trades : []) {
225
+ const closed = aggregator.add(t);
226
+ if (closed) bars.push(closed);
227
+ }
228
+ return { bars, pending: aggregator.current(), aggregator };
229
+ }
230
+
231
+ /**
232
+ * Coerce a generic JSON trades array into plain `{ time, price, size }`
233
+ * (ms, seconds auto-upgraded; `size` also read from `qty`/`amount`).
234
+ * Invalid entries are dropped, never thrown — same contract as the chart's
235
+ * other normalizers. Output is sorted by time.
236
+ */
237
+ export function normalizeTrades(list) {
238
+ if (!Array.isArray(list)) return [];
239
+ const out = [];
240
+ for (const r of list) {
241
+ if (!r || typeof r !== 'object') continue;
242
+ const price = Number(r.price);
243
+ const size = Number(r.size != null ? r.size : r.qty != null ? r.qty : r.amount);
244
+ let time = Number(r.time != null ? r.time : r.t);
245
+ if (!(price > 0) || !(size > 0) || !Number.isFinite(time)) continue;
246
+ if (time < 1e11) time *= 1000;
247
+ out.push({ time: Math.round(time), price, size });
248
+ }
249
+ out.sort((a, b) => a.time - b.time);
250
+ return out;
251
+ }
252
+
253
+ /* ------------- synthetic trade prints (offline aggregate demos) ------------- */
254
+
255
+ // Synthetic prints model human-sized notionals: $20 + U³·$8000, mean ≈ $2,020
256
+ // regardless of instrument — sizes are notional / price, so the tape adapts
257
+ // to a $100 demo stock and a $64k BTC the same way.
258
+ const SYNTH_NOTIONAL_MIN = 20;
259
+ const SYNTH_NOTIONAL_SPAN = 8000;
260
+ const SYNTH_NOTIONAL_MEAN = SYNTH_NOTIONAL_MIN + SYNTH_NOTIONAL_SPAN / 4;
261
+
262
+ /**
263
+ * Deterministic synthetic trade prints (random-walk price with volatility
264
+ * regimes, heavy-tailed sizes). Same key → same tape.
265
+ * @param {string} key seed key
266
+ * @param {number} [n=24000] print count
267
+ * @param {number} [base=100] starting price
268
+ */
269
+ export function genSyntheticTrades(key, n = 24000, base = 100) {
270
+ const rnd = mulberry32(hashStr('trades:' + key) ^ 0x2545f491);
271
+ const dt = 250; // ms between prints
272
+ const t0 = Math.floor(Date.now() / dt) * dt - (n - 1) * dt;
273
+ let price = base;
274
+ let vol = 0.0006;
275
+ let regimeLeft = 0;
276
+ const trades = [];
277
+ for (let i = 0; i < n; i++) {
278
+ if (regimeLeft-- <= 0) {
279
+ regimeLeft = 200 + ((rnd() * 1200) | 0);
280
+ vol = 0.0002 + rnd() * 0.0014;
281
+ }
282
+ price = Math.max(1e-8, price * Math.exp(vol * gauss(rnd)));
283
+ const notional = SYNTH_NOTIONAL_MIN + Math.pow(rnd(), 3) * SYNTH_NOTIONAL_SPAN;
284
+ trades.push({
285
+ time: t0 + i * dt,
286
+ price: +price.toFixed(8),
287
+ size: +Math.max(1e-8, notional / price).toFixed(8),
288
+ });
289
+ }
290
+ return trades;
291
+ }
292
+
293
+ /**
294
+ * Stateful synthetic live tape: one print per call, bridging from
295
+ * `startPrice` (e.g. the last price of a seeded history).
296
+ * @param {number} [startPrice=100]
297
+ */
298
+ export function makeSynthTradeStream(startPrice = 100) {
299
+ const rnd = mulberry32((Math.random() * 1e9) >>> 0);
300
+ let price = startPrice;
301
+ let vol = 0.0006;
302
+ let regimeLeft = 0;
303
+ let t = Date.now();
304
+ return () => {
305
+ if (regimeLeft-- <= 0) {
306
+ regimeLeft = 50 + ((rnd() * 350) | 0);
307
+ vol = 0.0002 + rnd() * 0.0014;
308
+ }
309
+ t += 60 + ((rnd() * 420) | 0);
310
+ price = Math.max(1e-8, price * Math.exp(vol * gauss(rnd)));
311
+ const notional = SYNTH_NOTIONAL_MIN + Math.pow(rnd(), 3) * SYNTH_NOTIONAL_SPAN;
312
+ return {
313
+ time: t,
314
+ price: +price.toFixed(8),
315
+ size: +Math.max(1e-8, notional / price).toFixed(8),
316
+ };
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Expected synthetic prints per bar — used to size the offline seed so a
322
+ * demo chart starts with roughly the requested bar count.
323
+ * @param {{kind: string, threshold: number}} agg
324
+ * @param {number} [base=100]
325
+ */
326
+ export function synthTradesPerBar(agg, base = 100) {
327
+ if (agg.kind === 'tick') return agg.threshold;
328
+ const meanSize = SYNTH_NOTIONAL_MEAN / Math.max(1e-8, base);
329
+ const per = agg.kind === 'volume' ? agg.threshold / meanSize : agg.threshold / SYNTH_NOTIONAL_MEAN;
330
+ return Math.max(1, per);
331
+ }
332
+
114
333
  /* ---------------- Binance public API ---------------- */
115
334
 
116
335
  /**
@@ -168,7 +387,120 @@ export function openBinanceSocket(symbol, tfId, onBar, onDown, timeoutMs = 8000)
168
387
  try {
169
388
  const k = JSON.parse(ev.data).k;
170
389
  if (!k) return;
171
- onBar({ time: k.t, open: +k.o, high: +k.h, low: +k.l, close: +k.c, volume: +k.v });
390
+ // k.x is Binance's "this kline is final" flag pass it through so
391
+ // close-mode alerts can fire the moment the candle closes rather than
392
+ // waiting for the next one to arrive.
393
+ onBar({
394
+ time: k.t, open: +k.o, high: +k.h, low: +k.l, close: +k.c, volume: +k.v,
395
+ closed: k.x === true,
396
+ });
397
+ } catch (_) {}
398
+ };
399
+ ws.onclose = () => {
400
+ if (dead) return;
401
+ dead = true;
402
+ clearTimeout(failTimer);
403
+ onDown(new Error('closed'));
404
+ };
405
+ ws.onerror = () => {};
406
+ return {
407
+ close() {
408
+ dead = true;
409
+ ws.onclose = null;
410
+ ws.onerror = null;
411
+ clearTimeout(failTimer);
412
+ try {
413
+ ws.close();
414
+ } catch (_) {}
415
+ },
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Fetch Binance aggTrades, paging backwards from the newest prints (or from
421
+ * below `beforeId`) until `minTrades` prints / `maxPages` requests / the
422
+ * start of the symbol's history. Returns `{ trades, oldestId }` ascending by
423
+ * time; each print carries its Binance id so live streams can resume without
424
+ * overlaps.
425
+ * @param {string} symbol e.g. 'BTCUSDT'
426
+ * @param {number} [minTrades=1000] stop once at least this many prints are held
427
+ * @param {number} [maxPages=25] hard request cap (1000 prints per page)
428
+ * @param {number} [beforeId] only fetch prints with an id lower than this
429
+ */
430
+ export async function fetchBinanceAggTrades(symbol, minTrades = 1000, maxPages = 25, beforeId) {
431
+ const out = [];
432
+ let oldest = beforeId != null && Number.isFinite(+beforeId) ? +beforeId : null;
433
+ for (let page = 0; page < maxPages && out.length < minTrades; page++) {
434
+ let url =
435
+ `https://api.binance.com/api/v3/aggTrades?symbol=${encodeURIComponent(symbol)}&limit=1000`;
436
+ if (oldest != null) url += `&fromId=${Math.max(0, oldest - 1000)}`;
437
+ const res = await fetch(url);
438
+ if (!res.ok) throw new Error(`Binance HTTP ${res.status}`);
439
+ const rows = await res.json();
440
+ if (!Array.isArray(rows) || !rows.length) break;
441
+ for (const r of rows) {
442
+ if (beforeId != null && r.a >= beforeId) continue; // window overlap guard
443
+ out.push({ id: r.a, time: r.T, price: +r.p, size: +r.q });
444
+ }
445
+ oldest = rows[0].a;
446
+ if (rows.length < 1000) break; // reached the beginning of the tape
447
+ }
448
+ out.sort((a, b) => a.time - b.time || a.id - b.id);
449
+ return { trades: out, oldestId: oldest };
450
+ }
451
+
452
+ /**
453
+ * One forward window of aggTrades starting at `fromId` — the catch-up call
454
+ * for REST polling after a trade socket drops. Returns `{ trades, latestId }`.
455
+ * @param {string} symbol
456
+ * @param {number} fromId first print id to fetch (use lastSeenId + 1)
457
+ */
458
+ export async function fetchBinanceAggTradesSince(symbol, fromId) {
459
+ const url =
460
+ `https://api.binance.com/api/v3/aggTrades?symbol=${encodeURIComponent(symbol)}` +
461
+ `&fromId=${Math.max(0, Number(fromId) || 0)}&limit=1000`;
462
+ const res = await fetch(url);
463
+ if (!res.ok) throw new Error(`Binance HTTP ${res.status}`);
464
+ const rows = await res.json();
465
+ const trades = (Array.isArray(rows) ? rows : []).map((r) => ({
466
+ id: r.a, time: r.T, price: +r.p, size: +r.q,
467
+ }));
468
+ return { trades, latestId: trades.length ? trades[trades.length - 1].id : Number(fromId) || 0 };
469
+ }
470
+
471
+ /**
472
+ * Open a Binance aggTrade WebSocket — raw prints for information-based bar
473
+ * aggregation. Same lifecycle contract as openBinanceSocket: `onDown(err)`
474
+ * fires on error/close/timeout, after which the socket is dead and the
475
+ * caller should fall back.
476
+ * @returns {{close(): void}}
477
+ */
478
+ export function openBinanceTradeSocket(symbol, onTrade, onDown, timeoutMs = 8000) {
479
+ let ws;
480
+ try {
481
+ ws = new WebSocket(
482
+ `wss://stream.binance.com:9443/ws/${symbol.toLowerCase()}@aggTrade`
483
+ );
484
+ } catch (err) {
485
+ onDown(err);
486
+ return { close() {} };
487
+ }
488
+ let dead = false;
489
+ const failTimer = setTimeout(() => {
490
+ if (!dead && ws.readyState !== WebSocket.OPEN) {
491
+ dead = true;
492
+ try {
493
+ ws.close();
494
+ } catch (_) {}
495
+ onDown(new Error('timeout'));
496
+ }
497
+ }, timeoutMs);
498
+ ws.onopen = () => clearTimeout(failTimer);
499
+ ws.onmessage = (ev) => {
500
+ try {
501
+ const t = JSON.parse(ev.data);
502
+ if (!t || t.e !== 'aggTrade') return;
503
+ onTrade({ id: t.a, time: t.T, price: +t.p, size: +t.q });
172
504
  } catch (_) {}
173
505
  };
174
506
  ws.onclose = () => {
package/src/react-core.js CHANGED
@@ -107,7 +107,13 @@ export function applyChartProps(el, split) {
107
107
  }
108
108
  // The element exposes `data` as a getter-only accessor — feed it through
109
109
  // setData() (guarded by identity so unchanged arrays never re-ingest).
110
- if (split.data != null && el.data !== split.data) {
110
+ // setData() normalizes into a *fresh* array, so `el.data` never matches
111
+ // what we passed in; the identity we compare against is tracked on the
112
+ // element, exactly as overlays do below. Comparing `el.data` instead would
113
+ // re-ingest on every render — which resets the viewport, since setData()
114
+ // sets `_needsFit` and clears the hover.
115
+ if (split.data != null && el.__wickDataRef !== split.data) {
116
+ el.__wickDataRef = split.data;
111
117
  if (typeof el.setData === 'function') el.setData(split.data);
112
118
  else el.data = split.data;
113
119
  }
package/src/report.js ADDED
@@ -0,0 +1,309 @@
1
+ /* ==========================================================================
2
+ * <wick-chart> report export — the branded snapshot: chart + visible-range
3
+ * stats + watermark, composed into one shareable PNG.
4
+ *
5
+ * import { exportReport, downloadReport } from 'wickchart/report';
6
+ *
7
+ * const url = await exportReport(chart); // PNG data URL
8
+ * const blob = await exportReport(chart, { as: 'blob' });
9
+ * await downloadReport(chart, 'btc-1h.png'); // triggers a download
10
+ *
11
+ * Composes from public surfaces only — exportPNG() (the DPR-crisp canvas),
12
+ * getVisibleRange(), the chart's own --wick-* CSS variables for theming —
13
+ * so there are zero core changes. The model (title, stats rows, layout,
14
+ * colors, formatting) is plain data built by reportModel() and unit-tested
15
+ * in Node; only the actual canvas composition needs a browser.
16
+ *
17
+ * Options: title (default: the chart's label attribute), source (a string
18
+ * credited in the footer, e.g. 'binance: BTCUSDT'), brand ('WickChart'),
19
+ * theme ('dark' | 'light' | 'auto' — auto reads the chart's CSS variables),
20
+ * scale (1..4, default 2), precision (price decimals, default derived),
21
+ * as ('url' | 'blob' | 'canvas').
22
+ * ========================================================================== */
23
+
24
+ import { computeStats } from './core.js';
25
+
26
+ /* ---------------- layout constants (CSS px, pre-scale) ---------------- */
27
+
28
+ const HEADER_H = 64;
29
+ const STATS_H = 104;
30
+ const FOOTER_H = 40;
31
+ const PAD = 18;
32
+ const GRID_COLS = 4;
33
+ const FONT = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
34
+
35
+ /* ---------------- pure helpers ---------------- */
36
+
37
+ /** Decimals worth showing for a price of this magnitude. */
38
+ export function decimalsFor(price) {
39
+ const p = Math.abs(Number(price));
40
+ if (!Number.isFinite(p) || p === 0) return 2;
41
+ if (p >= 10000) return 2;
42
+ if (p >= 100) return 2;
43
+ if (p >= 1) return 3;
44
+ return 6;
45
+ }
46
+
47
+ /** 1234 → '1.2k', 1234567 → '1.23M', 12 → '12'. */
48
+ export function compact(v) {
49
+ const n = Number(v);
50
+ if (!Number.isFinite(n)) return '—';
51
+ const a = Math.abs(n);
52
+ if (a >= 1e9) return (n / 1e9).toFixed(2) + 'B';
53
+ if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M';
54
+ if (a >= 1e4) return (n / 1e3).toFixed(1) + 'k';
55
+ return String(Math.round(n));
56
+ }
57
+
58
+ export function pct(v, digits = 2) {
59
+ const n = Number(v);
60
+ if (!Number.isFinite(n)) return '—';
61
+ return (n > 0 ? '+' : '') + n.toFixed(digits) + '%';
62
+ }
63
+
64
+ const fmtDate = (t) =>
65
+ Number.isFinite(t)
66
+ ? new Date(t).toLocaleString('en-US', { month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit', minute: '2-digit' })
67
+ : '—';
68
+
69
+ /** Greatest index whose time is <= t (binary search; bars are ascending). */
70
+ export function indexForTime(bars, t) {
71
+ let lo = 0;
72
+ let hi = bars.length - 1;
73
+ if (t < bars[0].time) return 0;
74
+ while (lo < hi) {
75
+ const mid = (lo + hi + 1) >> 1;
76
+ if (bars[mid].time <= t) lo = mid;
77
+ else hi = mid - 1;
78
+ }
79
+ return lo;
80
+ }
81
+
82
+ const PALETTES = {
83
+ dark: {
84
+ bg: '#0d1117', panel: '#11141c', text: '#e6edf3', muted: '#8b949e',
85
+ up: '#16c784', down: '#ea3943', accent: '#4c8dff',
86
+ },
87
+ light: {
88
+ bg: '#ffffff', panel: '#f4f6f9', text: '#111827', muted: '#6b7280',
89
+ up: '#0e9f6e', down: '#e02424', accent: '#2563eb',
90
+ },
91
+ };
92
+
93
+ /** Resolve the report colors: theme palettes, overridden by the chart's own
94
+ * --wick-* variables when readable (browser) and theme is 'auto'. */
95
+ export function reportColors(chart, theme = 'auto') {
96
+ const key = theme === 'light' || theme === 'dark' ? theme : 'auto';
97
+ const pal = { ...PALETTES[key === 'auto' ? 'dark' : key] };
98
+ if (key === 'auto' && typeof getComputedStyle === 'function' && chart && chart.nodeType) {
99
+ const cs = getComputedStyle(chart);
100
+ const v = (name, fb) => cs.getPropertyValue(name).trim() || fb;
101
+ pal.bg = v('--wick-bg', pal.bg);
102
+ pal.text = v('--wick-text-strong', pal.text);
103
+ pal.muted = v('--wick-text', pal.muted);
104
+ pal.up = v('--wick-up', pal.up);
105
+ pal.down = v('--wick-down', pal.down);
106
+ pal.accent = v('--wick-accent', pal.accent);
107
+ }
108
+ // a light chart background flips the panel/band contrast to match
109
+ if (key === 'auto') {
110
+ pal.light = luminance(pal.bg) > 0.5;
111
+ if (pal.light) {
112
+ pal.panel = PALETTES.light.panel;
113
+ pal.muted = PALETTES.light.muted;
114
+ } else {
115
+ pal.panel = PALETTES.dark.panel;
116
+ }
117
+ } else {
118
+ pal.panel = PALETTES[key].panel;
119
+ }
120
+ return pal;
121
+ }
122
+
123
+ /** Relative luminance of a #hex / rgb() string (0..1, best effort). */
124
+ export function luminance(color) {
125
+ const m = /#([0-9a-f]{6})/i.exec(String(color || ''));
126
+ if (!m) return 0;
127
+ const n = parseInt(m[1], 16);
128
+ return (0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;
129
+ }
130
+
131
+ /* ---------------- the model (pure, Node-testable) ---------------- */
132
+
133
+ /**
134
+ * Build the whole report as data: sizes, bands, stat rows, colors, labels.
135
+ * @param {object} chart a <wick-chart> (public API only: data,
136
+ * getVisibleRange, exportPNG, clientWidth/Height, getAttribute)
137
+ * @param {{title?: string, source?: string, brand?: string, theme?: string,
138
+ * scale?: number, precision?: number}} [opts]
139
+ */
140
+ export function reportModel(chart, opts = {}) {
141
+ const d = chart && chart.data;
142
+ if (!Array.isArray(d) || d.length < 2) {
143
+ throw new Error('exportReport(chart): the chart needs data (2+ bars)');
144
+ }
145
+ const range = typeof chart.getVisibleRange === 'function' ? chart.getVisibleRange() : null;
146
+ if (!range) throw new Error('exportReport(chart): the chart needs a rendered view');
147
+ const i0 = indexForTime(d, range.from);
148
+ const i1 = indexForTime(d, range.to);
149
+ const dt = d.length > 1 ? d[i1].time - d[i1 - 1].time : 3600e3;
150
+ const stats = computeStats(d, i0, i1, dt) || computeStats(d, 0, d.length - 1, dt);
151
+ const precision =
152
+ Number.isInteger(opts.precision) && opts.precision >= 0 && opts.precision <= 10
153
+ ? opts.precision
154
+ : decimalsFor(stats.max);
155
+ const last = d[d.length - 1].close;
156
+
157
+ const rows = [
158
+ { label: 'Return', value: pct(stats.changePct), color: stats.changePct >= 0 ? 'up' : 'down' },
159
+ { label: 'Ann. vol', value: stats.annVolPct.toFixed(1) + '%' },
160
+ { label: 'Max drawdown', value: '-' + stats.maxDDPct.toFixed(1) + '%', color: 'down' },
161
+ { label: 'Bars', value: String(stats.n) },
162
+ { label: 'Up / down', value: stats.up + ' / ' + stats.dn },
163
+ { label: 'Avg volume', value: compact(stats.avgVolume) },
164
+ { label: 'High', value: stats.max.toFixed(precision), color: 'up' },
165
+ { label: 'Low', value: stats.min.toFixed(precision), color: 'down' },
166
+ ];
167
+
168
+ const s = Number(opts.scale);
169
+ const scale = Number.isFinite(s) && s > 0 ? Math.max(1, Math.min(4, Math.round(s))) : 2;
170
+ const chartW = Math.max(320, Math.round(chart.clientWidth || 900));
171
+ const chartH = Math.max(160, Math.round(chart.clientHeight || 420));
172
+ const title =
173
+ (typeof opts.title === 'string' && opts.title.trim()) ||
174
+ (typeof chart.getAttribute === 'function' ? chart.getAttribute('label') : '') ||
175
+ 'WickChart';
176
+ return {
177
+ scale,
178
+ width: chartW,
179
+ height: HEADER_H + chartH + STATS_H + FOOTER_H,
180
+ bands: { header: HEADER_H, stats: STATS_H, footer: FOOTER_H, chart: chartH },
181
+ grid: { cols: GRID_COLS },
182
+ title,
183
+ brand: (typeof opts.brand === 'string' && opts.brand.trim()) || 'WickChart',
184
+ source: typeof opts.source === 'string' ? opts.source : '',
185
+ range: { from: range.from, to: range.to },
186
+ last,
187
+ precision,
188
+ rows,
189
+ generatedAt: Date.now(),
190
+ colors: reportColors(chart, opts.theme),
191
+ stats,
192
+ };
193
+ }
194
+
195
+ /* ---------------- the composition (browser) ---------------- */
196
+
197
+ const loadImage = (src) =>
198
+ new Promise((resolve, reject) => {
199
+ const img = new Image();
200
+ img.onload = () => resolve(img);
201
+ img.onerror = () => reject(new Error('exportReport: the chart PNG failed to decode'));
202
+ img.src = src;
203
+ });
204
+
205
+ /**
206
+ * Compose the branded report and return it as a PNG data URL (default), a
207
+ * Blob, or the canvas itself.
208
+ * @returns {Promise<string|Blob|HTMLCanvasElement>}
209
+ */
210
+ export async function exportReport(chart, opts = {}) {
211
+ if (typeof document === 'undefined' || typeof Image === 'undefined') {
212
+ throw new Error('exportReport requires a browser (canvas + Image)');
213
+ }
214
+ const model = reportModel(chart, opts);
215
+ const img = await loadImage(chart.exportPNG());
216
+ const { width, height, scale, colors: pal } = model;
217
+
218
+ const canvas = document.createElement('canvas');
219
+ canvas.width = width * scale;
220
+ canvas.height = height * scale;
221
+ const ctx = canvas.getContext('2d');
222
+ ctx.scale(scale, scale);
223
+ const b = model.bands;
224
+
225
+ // header band
226
+ ctx.fillStyle = pal.panel;
227
+ ctx.fillRect(0, 0, width, b.header);
228
+ ctx.fillStyle = pal.text;
229
+ ctx.font = '700 22px ' + FONT;
230
+ ctx.textBaseline = 'alphabetic';
231
+ ctx.fillText(model.title, PAD, 34);
232
+ ctx.fillStyle = pal.muted;
233
+ ctx.font = '400 12px ' + FONT;
234
+ const sub = fmtDate(model.range.from) + ' → ' + fmtDate(model.range.to);
235
+ ctx.fillText(sub, PAD, 52);
236
+ ctx.textAlign = 'right';
237
+ ctx.fillStyle = pal.accent;
238
+ ctx.font = '700 14px ' + FONT;
239
+ ctx.fillText(model.brand, width - PAD, 30);
240
+ ctx.fillStyle = pal.muted;
241
+ ctx.font = '400 11px ' + FONT;
242
+ ctx.fillText('last ' + model.last.toFixed(model.precision), width - PAD, 48);
243
+ ctx.textAlign = 'left';
244
+
245
+ // chart image (full-res source drawn into the band — stays crisp)
246
+ ctx.drawImage(img, 0, b.header, width, b.chart);
247
+ // watermark over the chart's bottom-right corner
248
+ ctx.save();
249
+ ctx.globalAlpha = 0.5;
250
+ ctx.fillStyle = pal.muted;
251
+ ctx.font = '600 13px ' + FONT;
252
+ ctx.textAlign = 'right';
253
+ ctx.fillText(model.brand, width - PAD - 6, b.header + b.chart - 10);
254
+ ctx.restore();
255
+
256
+ // stats band — a grid of label-over-value cards
257
+ ctx.fillStyle = pal.bg;
258
+ ctx.fillRect(0, b.header + b.chart, width, b.stats);
259
+ const cols = model.grid.cols;
260
+ const cw = (width - PAD * 2) / cols;
261
+ const rows = Math.ceil(model.rows.length / cols);
262
+ const rh = (b.stats - 16) / rows;
263
+ model.rows.forEach((r, i) => {
264
+ const cx = PAD + (i % cols) * cw;
265
+ const cy = b.header + b.chart + 8 + Math.floor(i / cols) * rh;
266
+ ctx.fillStyle = pal.muted;
267
+ ctx.font = '500 10px ' + FONT;
268
+ ctx.fillText(r.label.toUpperCase(), cx, cy + 12);
269
+ ctx.fillStyle = r.color === 'up' ? pal.up : r.color === 'down' ? pal.down : pal.text;
270
+ ctx.font = '700 15px ' + FONT;
271
+ ctx.fillText(r.value, cx, cy + 32);
272
+ });
273
+
274
+ // footer band
275
+ ctx.fillStyle = pal.panel;
276
+ ctx.fillRect(0, height - b.footer, width, b.footer);
277
+ ctx.fillStyle = pal.muted;
278
+ ctx.font = '400 11px ' + FONT;
279
+ const credit = 'Generated with ' + model.brand + (model.source ? ' · ' + model.source : '');
280
+ ctx.fillText(credit + ' · ' + fmtDate(model.generatedAt), PAD, height - 16);
281
+ ctx.textAlign = 'right';
282
+ ctx.fillText(model.stats.n + ' bars · wickchart', width - PAD, height - 16);
283
+ ctx.textAlign = 'left';
284
+
285
+ const as = opts.as || 'url';
286
+ if (as === 'canvas') return canvas;
287
+ if (as === 'blob') {
288
+ return new Promise((resolve, reject) =>
289
+ canvas.toBlob((bl) => (bl ? resolve(bl) : reject(new Error('exportReport: toBlob failed'))), 'image/png')
290
+ );
291
+ }
292
+ return canvas.toDataURL('image/png');
293
+ }
294
+
295
+ /**
296
+ * Compose and trigger a download (browser only).
297
+ * @returns {Promise<void>}
298
+ */
299
+ export async function downloadReport(chart, filename = 'wickchart-report.png', opts = {}) {
300
+ const blob = await exportReport(chart, { ...opts, as: 'blob' });
301
+ const url = URL.createObjectURL(blob);
302
+ const a = document.createElement('a');
303
+ a.href = url;
304
+ a.download = filename;
305
+ document.body.appendChild(a);
306
+ a.click();
307
+ a.remove();
308
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
309
+ }