emberwick 0.1.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/index.js ADDED
@@ -0,0 +1,1310 @@
1
+ class Layers {
2
+ constructor(container, names) {
3
+ this.container = container;
4
+ this.names = names;
5
+ this.canvas = {};
6
+ this.ctx = {};
7
+ this.width = 0;
8
+ this.height = 0;
9
+ this.dpr = 0;
10
+ this.onResize = null;
11
+ if (getComputedStyle(container).position === "static") {
12
+ container.style.position = "relative";
13
+ }
14
+ names.forEach((name, i) => {
15
+ const c = document.createElement("canvas");
16
+ Object.assign(c.style, {
17
+ position: "absolute",
18
+ left: "0",
19
+ top: "0",
20
+ width: "100%",
21
+ height: "100%",
22
+ pointerEvents: "none",
23
+ zIndex: String(i + 1)
24
+ });
25
+ container.appendChild(c);
26
+ this.canvas[name] = c;
27
+ this.ctx[name] = c.getContext("2d");
28
+ });
29
+ this._ro = new ResizeObserver(() => this.measure());
30
+ this._ro.observe(container);
31
+ this.measure();
32
+ }
33
+ measure() {
34
+ const r = this.container.getBoundingClientRect();
35
+ const w = Math.max(1, Math.floor(r.width));
36
+ const h = Math.max(1, Math.floor(r.height));
37
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
38
+ if (w === this.width && h === this.height && dpr === this.dpr) return;
39
+ this.width = w;
40
+ this.height = h;
41
+ this.dpr = dpr;
42
+ for (const n of this.names) {
43
+ const c = this.canvas[n];
44
+ c.width = Math.floor(w * dpr);
45
+ c.height = Math.floor(h * dpr);
46
+ this.ctx[n].setTransform(dpr, 0, 0, dpr, 0, 0);
47
+ }
48
+ if (this.onResize) this.onResize(w, h);
49
+ }
50
+ /** Flatten all layers into a single canvas (for toImage/export). */
51
+ composite() {
52
+ const out = document.createElement("canvas");
53
+ out.width = Math.floor(this.width * this.dpr);
54
+ out.height = Math.floor(this.height * this.dpr);
55
+ const c = out.getContext("2d");
56
+ for (const n of this.names) c.drawImage(this.canvas[n], 0, 0);
57
+ return out;
58
+ }
59
+ destroy() {
60
+ this._ro.disconnect();
61
+ for (const n of this.names) this.canvas[n].remove();
62
+ this.canvas = {};
63
+ this.ctx = {};
64
+ }
65
+ }
66
+ class Loop {
67
+ constructor(onFrame) {
68
+ this.onFrame = onFrame;
69
+ this.fps = 0;
70
+ this._raf = 0;
71
+ this._dirty = /* @__PURE__ */ new Set();
72
+ this._last = 0;
73
+ this._running = false;
74
+ this._frames = 0;
75
+ this._fpsAt = 0;
76
+ this._tick = this._tick.bind(this);
77
+ }
78
+ invalidate(...layers) {
79
+ if (!layers.length) this._dirty.add("all");
80
+ else for (const l of layers) this._dirty.add(l);
81
+ this._schedule();
82
+ }
83
+ start() {
84
+ if (this._running) return;
85
+ this._running = true;
86
+ this._last = performance.now();
87
+ this._fpsAt = this._last;
88
+ this.invalidate("all");
89
+ }
90
+ stop() {
91
+ this._running = false;
92
+ if (this._raf) cancelAnimationFrame(this._raf);
93
+ this._raf = 0;
94
+ }
95
+ _schedule() {
96
+ if (this._raf || !this._running) return;
97
+ this._raf = requestAnimationFrame(this._tick);
98
+ }
99
+ _tick(now) {
100
+ this._raf = 0;
101
+ if (!this._running) return;
102
+ const dt = Math.min(Math.max(now - this._last, 1), 64);
103
+ this._last = now;
104
+ this._frames++;
105
+ if (now - this._fpsAt >= 500) {
106
+ this.fps = Math.round(this._frames * 1e3 / (now - this._fpsAt));
107
+ this._frames = 0;
108
+ this._fpsAt = now;
109
+ }
110
+ const dirty = this._dirty;
111
+ this._dirty = /* @__PURE__ */ new Set();
112
+ let wantMore = false;
113
+ try {
114
+ wantMore = this.onFrame(dirty, dt, now) === true;
115
+ } catch (e) {
116
+ console.error("[Emberwick] frame error", e);
117
+ }
118
+ if (wantMore || this._dirty.size) this._schedule();
119
+ }
120
+ }
121
+ const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
122
+ const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
123
+ class Smoothed {
124
+ constructor(value = 0, tau = 90) {
125
+ this.value = value;
126
+ this.target = value;
127
+ this.tau = tau;
128
+ }
129
+ set(target) {
130
+ this.target = target;
131
+ }
132
+ /** Snap with no animation. */
133
+ jump(v) {
134
+ this.value = v;
135
+ this.target = v;
136
+ }
137
+ get settled() {
138
+ const eps = 1e-9 + Math.abs(this.target) * 1e-6;
139
+ return Math.abs(this.target - this.value) <= eps;
140
+ }
141
+ /** @returns {boolean} true while still moving (caller keeps the loop alive) */
142
+ tick(dt) {
143
+ if (this.settled) {
144
+ this.value = this.target;
145
+ return false;
146
+ }
147
+ this.value += (this.target - this.value) * (1 - Math.exp(-dt / this.tau));
148
+ return true;
149
+ }
150
+ }
151
+ class Tween {
152
+ constructor(duration = 200, ease = easeOutCubic) {
153
+ this.duration = duration;
154
+ this.ease = ease;
155
+ this.t = duration;
156
+ }
157
+ restart() {
158
+ this.t = 0;
159
+ }
160
+ get done() {
161
+ return this.t >= this.duration;
162
+ }
163
+ get progress() {
164
+ return this.ease(Math.min(1, this.t / this.duration));
165
+ }
166
+ tick(dt) {
167
+ if (this.done) return false;
168
+ this.t += dt;
169
+ return true;
170
+ }
171
+ }
172
+ const clamp$1 = (v, a, b) => v < a ? a : v > b ? b : v;
173
+ class TimeScale {
174
+ constructor({ spacing = 9, minSpacing = 0.8, maxSpacing = 160, rightOffset = 12 } = {}) {
175
+ this.minSpacing = minSpacing;
176
+ this.maxSpacing = maxSpacing;
177
+ this.rightOffset = rightOffset;
178
+ this.width = 0;
179
+ this.barCount = 0;
180
+ this.follow = true;
181
+ this.timeframeMs = 6e4;
182
+ this._spacing = new Smoothed(spacing, 65);
183
+ this._right = new Smoothed(rightOffset, 65);
184
+ this._initial = spacing;
185
+ }
186
+ get spacing() {
187
+ return this._spacing.value;
188
+ }
189
+ get right() {
190
+ return this._right.value;
191
+ }
192
+ resize(w) {
193
+ this.width = Math.max(1, w);
194
+ }
195
+ setBarCount(n) {
196
+ const grew = n > this.barCount;
197
+ this.barCount = n;
198
+ if (this.follow) {
199
+ const t = n - 1 + this.rightOffset;
200
+ if (grew) this._right.set(t);
201
+ else this._right.jump(t);
202
+ }
203
+ }
204
+ x(i) {
205
+ return this.width - (this._right.value - i) * this._spacing.value;
206
+ }
207
+ /** Centre-x of bar i (bars are drawn centred on their slot). */
208
+ index(x) {
209
+ return this._right.value - (this.width - x) / this._spacing.value;
210
+ }
211
+ barWidth() {
212
+ const s = this._spacing.value;
213
+ return Math.max(1, Math.floor(s * 0.72));
214
+ }
215
+ visibleRange() {
216
+ const first = Math.floor(this.index(0)) - 1;
217
+ const last = Math.ceil(this.index(this.width)) + 1;
218
+ return {
219
+ from: clamp$1(first, 0, Math.max(0, this.barCount - 1)),
220
+ to: clamp$1(last, 0, Math.max(0, this.barCount - 1))
221
+ };
222
+ }
223
+ _clampRight(v) {
224
+ const max = this.barCount - 1 + this.rightOffset + this.width / this._spacing.target;
225
+ const min = Math.min(4, this.barCount - 1 + this.rightOffset);
226
+ return clamp$1(v, min, max);
227
+ }
228
+ /** Immediate pan, in pixels. Positive dx drags content right (back in time). */
229
+ panBy(dxPx) {
230
+ if (!dxPx) return false;
231
+ const next = this._clampRight(this._right.value - dxPx / this._spacing.value);
232
+ this._right.jump(next);
233
+ this.follow = false;
234
+ return true;
235
+ }
236
+ /** Zoom by `factor`, keeping the bar under `x` pinned. */
237
+ zoomAt(x, factor) {
238
+ const s0 = this._spacing.target;
239
+ const s1 = clamp$1(s0 * factor, this.minSpacing, this.maxSpacing);
240
+ if (Math.abs(s1 - s0) < 1e-9) return false;
241
+ const anchorX = this.follow ? this.width : x;
242
+ const r0 = this._right.target;
243
+ const idx = r0 - (this.width - anchorX) / s0;
244
+ const r1 = idx + (this.width - anchorX) / s1;
245
+ this._spacing.set(s1);
246
+ this._right.set(this._clampRight(r1));
247
+ return true;
248
+ }
249
+ snapToRealtime() {
250
+ this.follow = true;
251
+ this._right.set(this.barCount - 1 + this.rightOffset);
252
+ }
253
+ reset() {
254
+ this._spacing.set(this._initial);
255
+ this.snapToRealtime();
256
+ }
257
+ /** True while the view is still easing. */
258
+ tick(dt) {
259
+ const a = this._spacing.tick(dt);
260
+ const b = this._right.tick(dt);
261
+ return a || b;
262
+ }
263
+ get settled() {
264
+ return this._spacing.settled && this._right.settled;
265
+ }
266
+ }
267
+ const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
268
+ class PriceScale {
269
+ constructor({ mode = "linear", tau = 120, marginTop = 0.12, marginBottom = 0.12 } = {}) {
270
+ this.mode = mode;
271
+ this.marginTop = marginTop;
272
+ this.marginBottom = marginBottom;
273
+ this.auto = true;
274
+ this.top = 0;
275
+ this.height = 1;
276
+ this._lo = new Smoothed(0, tau);
277
+ this._hi = new Smoothed(1, tau);
278
+ this._primed = false;
279
+ }
280
+ _fwd(v) {
281
+ return this.mode === "log" ? Math.log(Math.max(v, 1e-9)) : v;
282
+ }
283
+ _inv(v) {
284
+ return this.mode === "log" ? Math.exp(v) : v;
285
+ }
286
+ setMode(mode) {
287
+ if (mode === this.mode) return;
288
+ const lo = this._inv(this._lo.value);
289
+ const hi = this._inv(this._hi.value);
290
+ this.mode = mode;
291
+ this._lo.jump(this._fwd(lo));
292
+ this._hi.jump(this._fwd(hi));
293
+ }
294
+ layout(top, height) {
295
+ this.top = top;
296
+ this.height = Math.max(1, height);
297
+ }
298
+ get lo() {
299
+ return this._inv(this._lo.value);
300
+ }
301
+ get hi() {
302
+ return this._inv(this._hi.value);
303
+ }
304
+ y(price) {
305
+ const a = this._lo.value;
306
+ const b = this._hi.value;
307
+ const t = (this._fwd(price) - a) / (b - a || 1);
308
+ return this.top + this.height * (1 - t);
309
+ }
310
+ price(y) {
311
+ const a = this._lo.value;
312
+ const b = this._hi.value;
313
+ const t = 1 - (y - this.top) / this.height;
314
+ return this._inv(a + t * (b - a));
315
+ }
316
+ /** Fit visible bars. `extra` lets the forming candle influence the range. */
317
+ fit(bars, from, to, extra) {
318
+ if (!this.auto || !bars.length) return;
319
+ let min = Infinity;
320
+ let max = -Infinity;
321
+ for (let i = from; i <= to; i++) {
322
+ const b2 = bars[i];
323
+ if (!b2) continue;
324
+ if (b2.low < min) min = b2.low;
325
+ if (b2.high > max) max = b2.high;
326
+ }
327
+ if (extra) {
328
+ if (extra.low < min) min = extra.low;
329
+ if (extra.high > max) max = extra.high;
330
+ }
331
+ if (!isFinite(min) || !isFinite(max)) return;
332
+ let a = this._fwd(min);
333
+ let b = this._fwd(max);
334
+ let pad = (b - a) * this.marginTop;
335
+ if (!(pad > 0)) pad = Math.abs(b) * 0.01 || 1;
336
+ a -= pad;
337
+ b += (b - a) * 0 + pad;
338
+ this._lo.set(a);
339
+ this._hi.set(b);
340
+ if (!this._primed) {
341
+ this._lo.jump(a);
342
+ this._hi.jump(b);
343
+ this._primed = true;
344
+ }
345
+ }
346
+ /** Manual axis-drag scaling around the vertical centre. */
347
+ scaleBy(factor) {
348
+ this.auto = false;
349
+ const a = this._lo.target;
350
+ const b = this._hi.target;
351
+ const mid = (a + b) / 2;
352
+ const half = (b - a) / 2 * clamp(factor, 0.2, 5);
353
+ this._lo.set(mid - half);
354
+ this._hi.set(mid + half);
355
+ }
356
+ resetAuto() {
357
+ this.auto = true;
358
+ }
359
+ tick(dt) {
360
+ const a = this._lo.tick(dt);
361
+ const b = this._hi.tick(dt);
362
+ return a || b;
363
+ }
364
+ }
365
+ const defaultTheme = {
366
+ background: "#0b0e14",
367
+ grid: "rgba(255,255,255,0.045)",
368
+ axisLine: "rgba(255,255,255,0.10)",
369
+ text: "#8b93a7",
370
+ textStrong: "#e6e9ef",
371
+ up: "#26a69a",
372
+ down: "#ef5350",
373
+ upFill: "#26a69a",
374
+ downFill: "#ef5350",
375
+ wickUp: "#26a69a",
376
+ wickDown: "#ef5350",
377
+ volumeUp: "rgba(38,166,154,0.30)",
378
+ volumeDown: "rgba(239,83,80,0.30)",
379
+ crosshair: "rgba(255,255,255,0.32)",
380
+ labelBg: "#2a3040",
381
+ labelText: "#e6e9ef",
382
+ tagText: "#06080d",
383
+ font: '11px ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif',
384
+ priceAxisWidth: 68,
385
+ timeAxisHeight: 26
386
+ };
387
+ const lightTheme = {
388
+ ...defaultTheme,
389
+ background: "#ffffff",
390
+ grid: "rgba(0,0,0,0.06)",
391
+ axisLine: "rgba(0,0,0,0.14)",
392
+ text: "#6b7280",
393
+ textStrong: "#111827",
394
+ labelBg: "#374151",
395
+ volumeUp: "rgba(38,166,154,0.25)",
396
+ volumeDown: "rgba(239,83,80,0.25)",
397
+ crosshair: "rgba(0,0,0,0.35)",
398
+ tagText: "#ffffff"
399
+ };
400
+ class LiveCandle {
401
+ constructor(tau = 55) {
402
+ this.enabled = true;
403
+ this.o = new Smoothed(0, tau);
404
+ this.h = new Smoothed(0, tau);
405
+ this.l = new Smoothed(0, tau);
406
+ this.c = new Smoothed(0, tau);
407
+ this.vol = new Smoothed(0, tau * 2);
408
+ this.spawn = new Tween(240);
409
+ this._has = false;
410
+ this._time = null;
411
+ }
412
+ setTarget(bar) {
413
+ if (!bar) {
414
+ this._has = false;
415
+ return;
416
+ }
417
+ if (!this._has || bar.time !== this._time) {
418
+ this.o.jump(bar.open);
419
+ this.h.jump(bar.open);
420
+ this.l.jump(bar.open);
421
+ this.c.jump(bar.open);
422
+ this.vol.jump(0);
423
+ this.spawn.restart();
424
+ this._time = bar.time;
425
+ this._has = true;
426
+ }
427
+ this.o.set(bar.open);
428
+ this.h.set(bar.high);
429
+ this.l.set(bar.low);
430
+ this.c.set(bar.close);
431
+ this.vol.set(bar.volume || 0);
432
+ }
433
+ reset() {
434
+ this._has = false;
435
+ this._time = null;
436
+ }
437
+ /** @returns {boolean} true while animating */
438
+ tick(dt) {
439
+ if (!this._has) return false;
440
+ let moving = false;
441
+ if (this.o.tick(dt)) moving = true;
442
+ if (this.h.tick(dt)) moving = true;
443
+ if (this.l.tick(dt)) moving = true;
444
+ if (this.c.tick(dt)) moving = true;
445
+ if (this.vol.tick(dt)) moving = true;
446
+ if (this.spawn.tick(dt)) moving = true;
447
+ return moving;
448
+ }
449
+ /** Interpolated view of `bar`, or `bar` itself when disabled. */
450
+ read(bar) {
451
+ if (!this._has || !this.enabled || bar.time !== this._time) return bar;
452
+ const o = this.o.value;
453
+ const c = this.c.value;
454
+ return {
455
+ time: bar.time,
456
+ open: o,
457
+ close: c,
458
+ // keep the wick consistent while values chase each other
459
+ high: Math.max(this.h.value, o, c),
460
+ low: Math.min(this.l.value, o, c),
461
+ volume: this.vol.value,
462
+ _spawn: this.spawn.progress
463
+ };
464
+ }
465
+ }
466
+ class Inertia {
467
+ constructor({ friction = 0.92, min = 0.015 } = {}) {
468
+ this.friction = friction;
469
+ this.min = min;
470
+ this.v = 0;
471
+ this.active = false;
472
+ }
473
+ sample(dx, dt) {
474
+ if (dt <= 0) return;
475
+ const instant = dx / dt;
476
+ this.v = this.v * 0.6 + instant * 0.4;
477
+ this.active = false;
478
+ }
479
+ release() {
480
+ if (Math.abs(this.v) > this.min) this.active = true;
481
+ }
482
+ stop() {
483
+ this.v = 0;
484
+ this.active = false;
485
+ }
486
+ /** @returns {number} px to pan this frame (0 when idle) */
487
+ tick(dt) {
488
+ if (!this.active) return 0;
489
+ const dx = this.v * dt;
490
+ this.v *= Math.pow(this.friction, dt / 16.6667);
491
+ if (Math.abs(this.v) < this.min) this.stop();
492
+ return dx;
493
+ }
494
+ }
495
+ function niceStep(span, count) {
496
+ const raw = span / Math.max(1, count);
497
+ if (!(raw > 0) || !isFinite(raw)) return 1;
498
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
499
+ const n = raw / mag;
500
+ const s = n < 1.5 ? 1 : n < 3 ? 2 : n < 7 ? 5 : 10;
501
+ return s * mag;
502
+ }
503
+ function priceTicks(lo, hi, count) {
504
+ const step = niceStep(hi - lo, count);
505
+ const ticks = [];
506
+ const start = Math.ceil(lo / step) * step;
507
+ for (let v = start; v <= hi + step * 1e-9; v += step) ticks.push(v);
508
+ return { ticks, step };
509
+ }
510
+ function decimalsFor(step) {
511
+ if (!isFinite(step) || step <= 0) return 2;
512
+ if (step >= 100) return 0;
513
+ if (step >= 1) return 2;
514
+ return Math.min(8, Math.ceil(-Math.log10(step)) + 1);
515
+ }
516
+ function niceBarStep(minBars) {
517
+ const opts = [1, 2, 5, 10, 15, 20, 30, 60, 120, 240, 480, 960, 1920, 3840, 7680];
518
+ for (const o of opts) if (o >= minBars) return o;
519
+ return Math.ceil(minBars / 1e3) * 1e3;
520
+ }
521
+ const p2 = (n) => String(n).padStart(2, "0");
522
+ function fmtAxisTime(ms, tfMs) {
523
+ const d = new Date(ms);
524
+ if (tfMs >= 864e5) return `${d.getDate()} ${d.toLocaleString("en", { month: "short" })}`;
525
+ if (d.getHours() === 0 && d.getMinutes() === 0) {
526
+ return `${d.getDate()} ${d.toLocaleString("en", { month: "short" })}`;
527
+ }
528
+ return `${p2(d.getHours())}:${p2(d.getMinutes())}`;
529
+ }
530
+ function fmtDateTime(ms) {
531
+ const d = new Date(ms);
532
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`;
533
+ }
534
+ function drawGrid(ctx, s) {
535
+ const { theme, ts, ps, plot, bars, width, height } = s;
536
+ ctx.clearRect(0, 0, width, height);
537
+ ctx.fillStyle = theme.background;
538
+ ctx.fillRect(0, 0, width, height);
539
+ ctx.font = theme.font;
540
+ ctx.textBaseline = "middle";
541
+ const rows = Math.max(2, Math.floor(plot.h / 58));
542
+ const { ticks, step } = priceTicks(ps.lo, ps.hi, rows);
543
+ const dec = decimalsFor(step);
544
+ ctx.strokeStyle = theme.grid;
545
+ ctx.lineWidth = 1;
546
+ ctx.beginPath();
547
+ for (const v of ticks) {
548
+ const y = Math.round(ps.y(v)) + 0.5;
549
+ if (y < plot.y || y > plot.y + plot.h) continue;
550
+ ctx.moveTo(0, y);
551
+ ctx.lineTo(plot.w, y);
552
+ }
553
+ ctx.stroke();
554
+ ctx.fillStyle = theme.text;
555
+ ctx.textAlign = "left";
556
+ for (const v of ticks) {
557
+ const y = Math.round(ps.y(v));
558
+ if (y < plot.y + 6 || y > plot.y + plot.h - 6) continue;
559
+ ctx.fillText(v.toFixed(dec), plot.w + 8, y);
560
+ }
561
+ if (bars.length) {
562
+ const minBars = Math.ceil(74 / Math.max(1e-4, ts.spacing));
563
+ const stepBars = niceBarStep(minBars);
564
+ const { from, to } = ts.visibleRange();
565
+ const first = Math.ceil(from / stepBars) * stepBars;
566
+ ctx.strokeStyle = theme.grid;
567
+ ctx.beginPath();
568
+ for (let i = first; i <= to; i += stepBars) {
569
+ const x = Math.round(ts.x(i)) + 0.5;
570
+ if (x < 0 || x > plot.w) continue;
571
+ ctx.moveTo(x, 0);
572
+ ctx.lineTo(x, plot.h);
573
+ }
574
+ ctx.stroke();
575
+ ctx.fillStyle = theme.text;
576
+ ctx.textAlign = "center";
577
+ const ty = plot.h + theme.timeAxisHeight / 2;
578
+ for (let i = first; i <= to; i += stepBars) {
579
+ const bar = bars[i];
580
+ if (!bar) continue;
581
+ const x = Math.round(ts.x(i));
582
+ if (x < 28 || x > plot.w - 28) continue;
583
+ ctx.fillText(fmtAxisTime(bar.time, ts.timeframeMs), x, ty);
584
+ }
585
+ }
586
+ ctx.strokeStyle = theme.axisLine;
587
+ ctx.beginPath();
588
+ ctx.moveTo(plot.w + 0.5, 0);
589
+ ctx.lineTo(plot.w + 0.5, plot.h);
590
+ ctx.moveTo(0, plot.h + 0.5);
591
+ ctx.lineTo(width, plot.h + 0.5);
592
+ ctx.stroke();
593
+ }
594
+ function drawCandles(ctx, s) {
595
+ const { theme, ts, ps, plot, bars, width, height, live, volumeRatio } = s;
596
+ ctx.clearRect(0, 0, width, height);
597
+ if (!bars.length) return;
598
+ const { from, to } = ts.visibleRange();
599
+ const bw = ts.barWidth();
600
+ const half = bw / 2;
601
+ const thin = bw <= 2;
602
+ const volH = plot.h * volumeRatio;
603
+ const volTop = plot.y + plot.h - volH;
604
+ let vmax = 0;
605
+ for (let i = from; i <= to; i++) {
606
+ const b = bars[i];
607
+ if (b && b.volume > vmax) vmax = b.volume;
608
+ }
609
+ if (vmax > 0) {
610
+ for (let i = from; i <= to; i++) {
611
+ let b = bars[i];
612
+ if (!b) continue;
613
+ if (live && i === bars.length - 1) b = live;
614
+ const x = ts.x(i);
615
+ if (x < -bw || x > plot.w + bw) continue;
616
+ const h = b.volume / vmax * volH * 0.9;
617
+ ctx.fillStyle = b.close >= b.open ? theme.volumeUp : theme.volumeDown;
618
+ ctx.fillRect(Math.round(x - half), volTop + (volH - h), Math.max(1, bw), h);
619
+ }
620
+ }
621
+ for (let i = from; i <= to; i++) {
622
+ let b = bars[i];
623
+ if (!b) continue;
624
+ const isLast = i === bars.length - 1;
625
+ if (live && isLast) b = live;
626
+ const x = ts.x(i);
627
+ if (x < -bw || x > plot.w + bw) continue;
628
+ const up = b.close >= b.open;
629
+ const color = up ? theme.up : theme.down;
630
+ const yO = ps.y(b.open);
631
+ const yC = ps.y(b.close);
632
+ const yH = ps.y(b.high);
633
+ const yL = ps.y(b.low);
634
+ let scale = 1;
635
+ if (live && isLast && typeof b._spawn === "number") scale = 0.35 + 0.65 * b._spawn;
636
+ const cx = Math.round(x) + (bw % 2 ? 0.5 : 0);
637
+ ctx.strokeStyle = up ? theme.wickUp : theme.wickDown;
638
+ ctx.lineWidth = Math.max(1, Math.min(2, bw * 0.16));
639
+ ctx.beginPath();
640
+ ctx.moveTo(cx, yH);
641
+ ctx.lineTo(cx, yL);
642
+ ctx.stroke();
643
+ if (thin) continue;
644
+ const top = Math.min(yO, yC);
645
+ const bodyH = Math.max(1, Math.abs(yC - yO));
646
+ const w = Math.max(1, bw * scale);
647
+ ctx.fillStyle = color;
648
+ ctx.fillRect(Math.round(x - w / 2), Math.round(top), Math.round(w), Math.round(bodyH));
649
+ }
650
+ const lastBar = live || bars[bars.length - 1];
651
+ if (lastBar) {
652
+ const y = Math.round(ps.y(lastBar.close)) + 0.5;
653
+ if (y > plot.y && y < plot.y + plot.h) {
654
+ const up = lastBar.close >= lastBar.open;
655
+ ctx.save();
656
+ ctx.setLineDash([3, 3]);
657
+ ctx.strokeStyle = up ? theme.up : theme.down;
658
+ ctx.lineWidth = 1;
659
+ ctx.globalAlpha = 0.7;
660
+ ctx.beginPath();
661
+ ctx.moveTo(0, y);
662
+ ctx.lineTo(plot.w, y);
663
+ ctx.stroke();
664
+ ctx.restore();
665
+ const { step } = priceTicks(ps.lo, ps.hi, Math.max(2, Math.floor(plot.h / 58)));
666
+ const label = lastBar.close.toFixed(decimalsFor(step));
667
+ ctx.font = theme.font;
668
+ ctx.textBaseline = "middle";
669
+ ctx.textAlign = "left";
670
+ const tw = ctx.measureText(label).width;
671
+ ctx.fillStyle = up ? theme.up : theme.down;
672
+ ctx.fillRect(plot.w + 1, y - 9, tw + 14, 18);
673
+ ctx.fillStyle = theme.tagText;
674
+ ctx.fillText(label, plot.w + 8, y);
675
+ }
676
+ }
677
+ }
678
+ function drawCrosshair(ctx, s) {
679
+ const { theme, ts, ps, plot, bars, width, height, cursor, magnet } = s;
680
+ ctx.clearRect(0, 0, width, height);
681
+ if (!cursor || !bars.length) return;
682
+ if (cursor.x < 0 || cursor.x > plot.w || cursor.y < 0 || cursor.y > plot.h) return;
683
+ const i = Math.round(ts.index(cursor.x));
684
+ const bar = bars[i];
685
+ let x = cursor.x;
686
+ let y = cursor.y;
687
+ if (bar) {
688
+ x = ts.x(i);
689
+ if (magnet) {
690
+ const cands = [bar.open, bar.high, bar.low, bar.close];
691
+ let best = null;
692
+ let bestD = Infinity;
693
+ for (const p of cands) {
694
+ const py = ps.y(p);
695
+ const d = Math.abs(py - cursor.y);
696
+ if (d < bestD) {
697
+ bestD = d;
698
+ best = py;
699
+ }
700
+ }
701
+ if (bestD < 22) y = best;
702
+ }
703
+ }
704
+ ctx.save();
705
+ ctx.setLineDash([4, 4]);
706
+ ctx.strokeStyle = theme.crosshair;
707
+ ctx.lineWidth = 1;
708
+ ctx.beginPath();
709
+ ctx.moveTo(Math.round(x) + 0.5, 0);
710
+ ctx.lineTo(Math.round(x) + 0.5, plot.h);
711
+ ctx.moveTo(0, Math.round(y) + 0.5);
712
+ ctx.lineTo(plot.w, Math.round(y) + 0.5);
713
+ ctx.stroke();
714
+ ctx.restore();
715
+ ctx.font = theme.font;
716
+ ctx.textBaseline = "middle";
717
+ const { step } = priceTicks(ps.lo, ps.hi, Math.max(2, Math.floor(plot.h / 58)));
718
+ const priceLabel = ps.price(y).toFixed(decimalsFor(step));
719
+ ctx.textAlign = "left";
720
+ const pw = ctx.measureText(priceLabel).width;
721
+ ctx.fillStyle = theme.labelBg;
722
+ ctx.fillRect(plot.w + 1, y - 9, pw + 14, 18);
723
+ ctx.fillStyle = theme.labelText;
724
+ ctx.fillText(priceLabel, plot.w + 8, y);
725
+ if (bar) {
726
+ const t = fmtDateTime(bar.time);
727
+ ctx.textAlign = "center";
728
+ const tw = ctx.measureText(t).width;
729
+ const bx = Math.min(Math.max(x, tw / 2 + 6), plot.w - tw / 2 - 6);
730
+ ctx.fillStyle = theme.labelBg;
731
+ ctx.fillRect(bx - tw / 2 - 7, plot.h + 3, tw + 14, 18);
732
+ ctx.fillStyle = theme.labelText;
733
+ ctx.fillText(t, bx, plot.h + 12);
734
+ }
735
+ }
736
+ class Chart {
737
+ constructor(container, options = {}) {
738
+ if (!container) throw new Error("Chart: container element is required");
739
+ this.container = container;
740
+ this.theme = { ...defaultTheme, ...options.theme || {} };
741
+ this.options = {
742
+ volumeRatio: 0.18,
743
+ magnet: true,
744
+ animate: true,
745
+ ...options
746
+ };
747
+ this.bars = [];
748
+ this.feed = null;
749
+ this._unsub = null;
750
+ this._loadingHistory = false;
751
+ this._exhausted = false;
752
+ this._listeners = { crosshair: /* @__PURE__ */ new Set(), visibleRange: /* @__PURE__ */ new Set() };
753
+ this.layers = new Layers(container, ["base", "main", "overlay"]);
754
+ this.ts = new TimeScale(options.timeScale);
755
+ this.ps = new PriceScale(options.priceScale);
756
+ this.live = new LiveCandle();
757
+ this.live.enabled = this.options.animate !== false;
758
+ this.inertia = new Inertia();
759
+ this.cursor = null;
760
+ this.plot = { x: 0, y: 0, w: 1, h: 1 };
761
+ this.loop = new Loop((dirty, dt) => this._frame(dirty, dt));
762
+ this.layers.onResize = () => {
763
+ this._layout();
764
+ this.loop.invalidate("all");
765
+ };
766
+ this._layout();
767
+ this._bindEvents();
768
+ this.loop.start();
769
+ }
770
+ // ---------------------------------------------------------------- layout --
771
+ _layout() {
772
+ const { width, height } = this.layers;
773
+ const w = Math.max(1, width - this.theme.priceAxisWidth);
774
+ const h = Math.max(1, height - this.theme.timeAxisHeight);
775
+ this.plot = { x: 0, y: 0, w, h };
776
+ this.ts.resize(w);
777
+ this.ps.layout(0, h);
778
+ }
779
+ // ------------------------------------------------------------------ data --
780
+ setData(bars) {
781
+ this.bars = Array.isArray(bars) ? bars.slice() : [];
782
+ this._exhausted = false;
783
+ if (this.bars.length > 1) {
784
+ this.ts.timeframeMs = this.bars[1].time - this.bars[0].time;
785
+ }
786
+ this.ts.setBarCount(this.bars.length);
787
+ this.ts.snapToRealtime();
788
+ this.live.reset();
789
+ this.ps._primed = false;
790
+ this.loop.invalidate("all");
791
+ }
792
+ /** Merge a tick into the forming candle (animated). */
793
+ update(bar) {
794
+ if (!bar) return;
795
+ const n = this.bars.length;
796
+ if (n && this.bars[n - 1].time === bar.time) {
797
+ this.bars[n - 1] = bar;
798
+ } else {
799
+ this.append(bar);
800
+ return;
801
+ }
802
+ this.live.setTarget(bar);
803
+ this.loop.invalidate("main");
804
+ }
805
+ /** Open a new candle; the previous one is now closed. */
806
+ append(bar) {
807
+ if (!bar) return;
808
+ const n = this.bars.length;
809
+ if (n && bar.time <= this.bars[n - 1].time) {
810
+ this.bars[n - 1] = bar;
811
+ } else {
812
+ this.bars.push(bar);
813
+ this.ts.setBarCount(this.bars.length);
814
+ }
815
+ this.live.setTarget(bar);
816
+ this.loop.invalidate("main");
817
+ }
818
+ async setFeed(feed) {
819
+ this.detachFeed();
820
+ this.feed = feed;
821
+ if (!feed) return;
822
+ this.ts.timeframeMs = feed.timeframe || this.ts.timeframeMs;
823
+ const bars = await feed.getBars({
824
+ symbol: feed.symbol,
825
+ timeframe: feed.timeframe,
826
+ to: null,
827
+ limit: this.options.initialBars || 1500
828
+ });
829
+ this.setData(bars);
830
+ if (typeof feed.prime === "function") feed.prime(bars[bars.length - 1]);
831
+ this._unsub = feed.subscribe((msg) => {
832
+ if (!msg || !msg.bar) return;
833
+ if (msg.type === "append") this.append(msg.bar);
834
+ else this.update(msg.bar);
835
+ });
836
+ }
837
+ detachFeed() {
838
+ if (this._unsub) this._unsub();
839
+ this._unsub = null;
840
+ this.feed = null;
841
+ }
842
+ async _maybeLoadHistory() {
843
+ if (this._loadingHistory || this._exhausted || !this.feed) return;
844
+ const { from } = this.ts.visibleRange();
845
+ if (from > 80 || !this.bars.length) return;
846
+ this._loadingHistory = true;
847
+ try {
848
+ const oldest = this.bars[0].time;
849
+ const older = await this.feed.getBars({
850
+ symbol: this.feed.symbol,
851
+ timeframe: this.feed.timeframe,
852
+ to: oldest,
853
+ limit: 1e3
854
+ });
855
+ if (!older || !older.length) {
856
+ this._exhausted = true;
857
+ } else {
858
+ const added = older.filter((b) => b.time < oldest);
859
+ if (!added.length) {
860
+ this._exhausted = true;
861
+ } else {
862
+ this.bars = added.concat(this.bars);
863
+ const wasFollowing = this.ts.follow;
864
+ this.ts.barCount = this.bars.length;
865
+ this.ts._right.jump(this.ts._right.value + added.length);
866
+ this.ts._right.set(this.ts._right.target + added.length);
867
+ this.ts.follow = wasFollowing;
868
+ this.loop.invalidate("all");
869
+ }
870
+ }
871
+ } catch (e) {
872
+ console.error("[Emberwick] history load failed", e);
873
+ this._exhausted = true;
874
+ } finally {
875
+ this._loadingHistory = false;
876
+ }
877
+ }
878
+ // ---------------------------------------------------------------- events --
879
+ _bindEvents() {
880
+ const el = this.container;
881
+ el.style.touchAction = "none";
882
+ el.style.cursor = "crosshair";
883
+ let dragging = false;
884
+ let mode = null;
885
+ let lastX = 0;
886
+ let lastY = 0;
887
+ let lastT = 0;
888
+ let moved = false;
889
+ const pointers = /* @__PURE__ */ new Map();
890
+ let pinchDist = 0;
891
+ const localPos = (e) => {
892
+ const r = el.getBoundingClientRect();
893
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
894
+ };
895
+ this._onDown = (e) => {
896
+ pointers.set(e.pointerId, localPos(e));
897
+ if (pointers.size === 2) {
898
+ const [a, b] = [...pointers.values()];
899
+ pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
900
+ dragging = false;
901
+ return;
902
+ }
903
+ const p = localPos(e);
904
+ dragging = true;
905
+ moved = false;
906
+ mode = p.x > this.plot.w ? "price" : p.y > this.plot.h ? "time" : "pan";
907
+ lastX = p.x;
908
+ lastY = p.y;
909
+ lastT = performance.now();
910
+ this.inertia.stop();
911
+ el.setPointerCapture(e.pointerId);
912
+ };
913
+ this._onMove = (e) => {
914
+ const p = localPos(e);
915
+ if (pointers.has(e.pointerId)) pointers.set(e.pointerId, p);
916
+ if (pointers.size === 2) {
917
+ const [a, b] = [...pointers.values()];
918
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
919
+ if (pinchDist > 0 && d > 0) {
920
+ const mid = (a.x + b.x) / 2;
921
+ this.ts.zoomAt(mid, d / pinchDist);
922
+ this.loop.invalidate("all");
923
+ }
924
+ pinchDist = d;
925
+ return;
926
+ }
927
+ this.cursor = p;
928
+ this._emitCrosshair(p);
929
+ this.loop.invalidate("overlay");
930
+ if (!dragging) return;
931
+ const now = performance.now();
932
+ const dt = now - lastT;
933
+ const dx = p.x - lastX;
934
+ const dy = p.y - lastY;
935
+ if (Math.abs(dx) > 1 || Math.abs(dy) > 1) moved = true;
936
+ if (mode === "pan") {
937
+ this.ts.panBy(dx);
938
+ this.inertia.sample(dx, dt);
939
+ this.loop.invalidate("all");
940
+ this._maybeLoadHistory();
941
+ } else if (mode === "price") {
942
+ this.ps.scaleBy(1 + dy / 220);
943
+ this.loop.invalidate("all");
944
+ } else if (mode === "time") {
945
+ this.ts.zoomAt(this.plot.w, 1 - dx / 260);
946
+ this.loop.invalidate("all");
947
+ }
948
+ lastX = p.x;
949
+ lastY = p.y;
950
+ lastT = now;
951
+ };
952
+ this._onUp = (e) => {
953
+ pointers.delete(e.pointerId);
954
+ if (pointers.size < 2) pinchDist = 0;
955
+ if (dragging && mode === "pan" && moved) {
956
+ this.inertia.release();
957
+ this.loop.invalidate("all");
958
+ }
959
+ dragging = false;
960
+ mode = null;
961
+ try {
962
+ el.releasePointerCapture(e.pointerId);
963
+ } catch (_) {
964
+ }
965
+ };
966
+ this._onLeave = () => {
967
+ this.cursor = null;
968
+ this._emitCrosshair(null);
969
+ this.loop.invalidate("overlay");
970
+ };
971
+ this._onWheel = (e) => {
972
+ e.preventDefault();
973
+ const r = el.getBoundingClientRect();
974
+ const x = e.clientX - r.left;
975
+ const factor = Math.pow(0.999, e.deltaY);
976
+ this.ts.zoomAt(x, factor);
977
+ this.loop.invalidate("all");
978
+ this._maybeLoadHistory();
979
+ };
980
+ this._onDbl = () => {
981
+ this.ts.reset();
982
+ this.ps.resetAuto();
983
+ this.loop.invalidate("all");
984
+ };
985
+ this._onKey = (e) => {
986
+ const step = e.shiftKey ? 120 : 40;
987
+ if (e.key === "ArrowLeft") {
988
+ this.ts.panBy(step);
989
+ this.loop.invalidate("all");
990
+ this._maybeLoadHistory();
991
+ } else if (e.key === "ArrowRight") {
992
+ this.ts.panBy(-step);
993
+ this.loop.invalidate("all");
994
+ } else if (e.key === "+" || e.key === "=") {
995
+ this.ts.zoomAt(this.plot.w / 2, 1.2);
996
+ this.loop.invalidate("all");
997
+ } else if (e.key === "-" || e.key === "_") {
998
+ this.ts.zoomAt(this.plot.w / 2, 0.8);
999
+ this.loop.invalidate("all");
1000
+ } else return;
1001
+ e.preventDefault();
1002
+ };
1003
+ el.addEventListener("pointerdown", this._onDown);
1004
+ el.addEventListener("pointermove", this._onMove);
1005
+ el.addEventListener("pointerup", this._onUp);
1006
+ el.addEventListener("pointercancel", this._onUp);
1007
+ el.addEventListener("pointerleave", this._onLeave);
1008
+ el.addEventListener("wheel", this._onWheel, { passive: false });
1009
+ el.addEventListener("dblclick", this._onDbl);
1010
+ el.addEventListener("keydown", this._onKey);
1011
+ if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "0");
1012
+ }
1013
+ _emitCrosshair(p) {
1014
+ if (!this._listeners.crosshair.size) return;
1015
+ let payload = null;
1016
+ if (p && this.bars.length && p.x <= this.plot.w && p.y <= this.plot.h) {
1017
+ const i = Math.round(this.ts.index(p.x));
1018
+ const bar = this.bars[i];
1019
+ if (bar) payload = { index: i, bar, price: this.ps.price(p.y) };
1020
+ }
1021
+ for (const fn of this._listeners.crosshair) fn(payload);
1022
+ }
1023
+ subscribe(event, fn) {
1024
+ const set = this._listeners[event];
1025
+ if (!set) throw new Error(`Chart: unknown event "${event}"`);
1026
+ set.add(fn);
1027
+ return () => set.delete(fn);
1028
+ }
1029
+ // ----------------------------------------------------------------- frame --
1030
+ _frame(dirty, dt) {
1031
+ let animating = false;
1032
+ if (this.ts.tick(dt)) animating = true;
1033
+ const dx = this.inertia.tick(dt);
1034
+ if (dx) {
1035
+ this.ts.panBy(dx);
1036
+ animating = true;
1037
+ this._maybeLoadHistory();
1038
+ }
1039
+ if (this.live.tick(dt)) animating = true;
1040
+ const { from, to } = this.ts.visibleRange();
1041
+ const lastIdx = this.bars.length - 1;
1042
+ const liveBar = this.bars.length ? this.live.read(this.bars[lastIdx]) : null;
1043
+ const liveVisible = liveBar && to >= lastIdx ? liveBar : null;
1044
+ this.ps.fit(this.bars, from, to, liveVisible);
1045
+ if (this.ps.tick(dt)) animating = true;
1046
+ const redrawAll = animating || dirty.has("all") || dirty.has("base") || dirty.has("main");
1047
+ const state = {
1048
+ theme: this.theme,
1049
+ ts: this.ts,
1050
+ ps: this.ps,
1051
+ plot: this.plot,
1052
+ bars: this.bars,
1053
+ width: this.layers.width,
1054
+ height: this.layers.height,
1055
+ live: liveVisible,
1056
+ volumeRatio: this.options.volumeRatio,
1057
+ cursor: this.cursor,
1058
+ magnet: this.options.magnet
1059
+ };
1060
+ if (redrawAll) {
1061
+ drawGrid(this.layers.ctx.base, state);
1062
+ drawCandles(this.layers.ctx.main, state);
1063
+ }
1064
+ if (redrawAll || dirty.has("overlay")) {
1065
+ drawCrosshair(this.layers.ctx.overlay, state);
1066
+ }
1067
+ return animating;
1068
+ }
1069
+ // ------------------------------------------------------------------- api --
1070
+ get fps() {
1071
+ return this.loop.fps;
1072
+ }
1073
+ setTheme(theme) {
1074
+ this.theme = { ...this.theme, ...theme };
1075
+ this._layout();
1076
+ this.loop.invalidate("all");
1077
+ }
1078
+ setPriceMode(mode) {
1079
+ this.ps.setMode(mode);
1080
+ this.loop.invalidate("all");
1081
+ }
1082
+ setAnimate(on) {
1083
+ this.live.enabled = !!on;
1084
+ this.loop.invalidate("all");
1085
+ }
1086
+ setMagnet(on) {
1087
+ this.options.magnet = !!on;
1088
+ this.loop.invalidate("overlay");
1089
+ }
1090
+ snapToRealtime() {
1091
+ this.ts.snapToRealtime();
1092
+ this.ps.resetAuto();
1093
+ this.loop.invalidate("all");
1094
+ }
1095
+ toImage() {
1096
+ return this.layers.composite().toDataURL("image/png");
1097
+ }
1098
+ destroy() {
1099
+ const el = this.container;
1100
+ el.removeEventListener("pointerdown", this._onDown);
1101
+ el.removeEventListener("pointermove", this._onMove);
1102
+ el.removeEventListener("pointerup", this._onUp);
1103
+ el.removeEventListener("pointercancel", this._onUp);
1104
+ el.removeEventListener("pointerleave", this._onLeave);
1105
+ el.removeEventListener("wheel", this._onWheel);
1106
+ el.removeEventListener("dblclick", this._onDbl);
1107
+ el.removeEventListener("keydown", this._onKey);
1108
+ this.detachFeed();
1109
+ this.loop.stop();
1110
+ this.layers.destroy();
1111
+ this._listeners.crosshair.clear();
1112
+ this._listeners.visibleRange.clear();
1113
+ this.bars = [];
1114
+ }
1115
+ }
1116
+ class DataFeed {
1117
+ constructor({ symbol = "DEMO", timeframe = 6e4 } = {}) {
1118
+ this.symbol = symbol;
1119
+ this.timeframe = timeframe;
1120
+ }
1121
+ // eslint-disable-next-line no-unused-vars
1122
+ async getBars({ symbol, timeframe, to, limit }) {
1123
+ throw new Error("DataFeed.getBars() not implemented");
1124
+ }
1125
+ // eslint-disable-next-line no-unused-vars
1126
+ subscribe(handler) {
1127
+ return () => {
1128
+ };
1129
+ }
1130
+ destroy() {
1131
+ }
1132
+ }
1133
+ function mulberry32(seed) {
1134
+ let a = seed >>> 0;
1135
+ return function() {
1136
+ a = a + 1831565813 | 0;
1137
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
1138
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
1139
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1140
+ };
1141
+ }
1142
+ class RandomFeed extends DataFeed {
1143
+ constructor({
1144
+ symbol = "EMBR",
1145
+ timeframe = 6e4,
1146
+ seed = 7,
1147
+ start = 100,
1148
+ volatility = 22e-4,
1149
+ drift = 2e-5,
1150
+ ticksPerSecond = 8,
1151
+ speed = 1
1152
+ } = {}) {
1153
+ super({ symbol, timeframe });
1154
+ this.seed = seed;
1155
+ this.start = start;
1156
+ this.volatility = volatility;
1157
+ this.drift = drift;
1158
+ this.ticksPerSecond = ticksPerSecond;
1159
+ this.speed = speed;
1160
+ this._rnd = mulberry32(seed);
1161
+ this._handlers = /* @__PURE__ */ new Set();
1162
+ this._timer = null;
1163
+ this._forming = null;
1164
+ this._last = start;
1165
+ this._vol = volatility;
1166
+ this._anchorTime = Math.floor(Date.now() / timeframe) * timeframe;
1167
+ }
1168
+ _gauss() {
1169
+ let u = 0;
1170
+ let v = 0;
1171
+ while (u === 0) u = this._rnd();
1172
+ while (v === 0) v = this._rnd();
1173
+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
1174
+ }
1175
+ _step(price) {
1176
+ const shock = this._gauss();
1177
+ this._vol += (this.volatility - this._vol) * 0.02 + Math.abs(shock) * this.volatility * 0.015;
1178
+ this._vol = Math.min(this._vol, this.volatility * 6);
1179
+ return Math.max(0.01, price * (1 + this.drift + shock * this._vol));
1180
+ }
1181
+ _makeBar(time, open) {
1182
+ let c = open;
1183
+ const n = 14;
1184
+ let hi = open;
1185
+ let lo = open;
1186
+ for (let i = 0; i < n; i++) {
1187
+ c = this._step(c);
1188
+ if (c > hi) hi = c;
1189
+ if (c < lo) lo = c;
1190
+ }
1191
+ const range = Math.max(1e-9, hi - lo);
1192
+ const volume = Math.round(
1193
+ (300 + this._rnd() * 900) * (1 + range / open * 260)
1194
+ );
1195
+ return { time, open, high: hi, low: lo, close: c, volume };
1196
+ }
1197
+ /**
1198
+ * Historical bars ending just before `to`. Walks BACKWARDS from a synthetic
1199
+ * anchor, so paging further left keeps producing coherent history.
1200
+ */
1201
+ async getBars({ to, limit = 1500, timeframe = this.timeframe } = {}) {
1202
+ const end = to == null ? this._anchorTime : to;
1203
+ const bars = [];
1204
+ const startTime = end - limit * timeframe;
1205
+ const gen = mulberry32(this.seed ^ Math.floor(startTime / timeframe));
1206
+ const saved = this._rnd;
1207
+ this._rnd = gen;
1208
+ let price = this.start * (1 + (gen() - 0.5) * 0.04);
1209
+ for (let i = 0; i < limit; i++) {
1210
+ const t = startTime + i * timeframe;
1211
+ const bar = this._makeBar(t, price);
1212
+ price = bar.close;
1213
+ bars.push(bar);
1214
+ }
1215
+ this._rnd = saved;
1216
+ if (to == null) {
1217
+ this._last = bars.length ? bars[bars.length - 1].close : this.start;
1218
+ }
1219
+ return bars;
1220
+ }
1221
+ subscribe(handler) {
1222
+ this._handlers.add(handler);
1223
+ if (!this._timer) this._start();
1224
+ return () => {
1225
+ this._handlers.delete(handler);
1226
+ if (!this._handlers.size) this.stop();
1227
+ };
1228
+ }
1229
+ _emit(msg) {
1230
+ for (const h of this._handlers) h(msg);
1231
+ }
1232
+ _start() {
1233
+ const interval = Math.max(16, 1e3 / this.ticksPerSecond);
1234
+ this._timer = setInterval(() => this._tick(), interval);
1235
+ }
1236
+ /** Seed the live candle from wherever history ended. */
1237
+ prime(lastBar) {
1238
+ if (lastBar) {
1239
+ this._last = lastBar.close;
1240
+ this._forming = { ...lastBar };
1241
+ }
1242
+ }
1243
+ _tick() {
1244
+ const tf = this.timeframe / this.speed;
1245
+ const now = Date.now();
1246
+ const slot = Math.floor(now / tf) * tf;
1247
+ if (!this._forming || this._forming.time !== slot) {
1248
+ const open = this._last;
1249
+ this._forming = { time: slot, open, high: open, low: open, close: open, volume: 0 };
1250
+ this._emit({ type: "append", bar: { ...this._forming } });
1251
+ return;
1252
+ }
1253
+ const next = this._step(this._last);
1254
+ this._last = next;
1255
+ const f = this._forming;
1256
+ f.close = next;
1257
+ if (next > f.high) f.high = next;
1258
+ if (next < f.low) f.low = next;
1259
+ f.volume += Math.round(20 + this._rnd() * 120);
1260
+ this._emit({ type: "update", bar: { ...f } });
1261
+ }
1262
+ setSpeed(s) {
1263
+ this.speed = s;
1264
+ }
1265
+ setTicksPerSecond(n) {
1266
+ this.ticksPerSecond = n;
1267
+ if (this._timer) {
1268
+ this.stop();
1269
+ this._start();
1270
+ }
1271
+ }
1272
+ setPaused(paused) {
1273
+ if (paused) this.stop();
1274
+ else if (!this._timer && this._handlers.size) this._start();
1275
+ }
1276
+ get paused() {
1277
+ return !this._timer;
1278
+ }
1279
+ stop() {
1280
+ if (this._timer) clearInterval(this._timer);
1281
+ this._timer = null;
1282
+ }
1283
+ destroy() {
1284
+ this.stop();
1285
+ this._handlers.clear();
1286
+ }
1287
+ }
1288
+ function createChart(container, options) {
1289
+ return new Chart(container, options);
1290
+ }
1291
+ const version = "0.1.0";
1292
+ export {
1293
+ Chart,
1294
+ DataFeed,
1295
+ Inertia,
1296
+ LiveCandle,
1297
+ PriceScale,
1298
+ RandomFeed,
1299
+ Smoothed,
1300
+ TimeScale,
1301
+ Tween,
1302
+ createChart,
1303
+ defaultTheme,
1304
+ easeInOutCubic,
1305
+ easeOutCubic,
1306
+ lightTheme,
1307
+ mulberry32,
1308
+ version
1309
+ };
1310
+ //# sourceMappingURL=index.js.map