liveline 0.0.0 → 0.0.2

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/dist/index.js ADDED
@@ -0,0 +1,1769 @@
1
+ // src/Liveline.tsx
2
+ import { useRef as useRef2, useState, useLayoutEffect } from "react";
3
+
4
+ // src/theme.ts
5
+ function hexToRgb(hex) {
6
+ const h = hex.replace("#", "");
7
+ const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
8
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
9
+ }
10
+ function rgba(r, g, b, a) {
11
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
12
+ }
13
+ function resolveTheme(color, mode) {
14
+ const [r, g, b] = hexToRgb(color);
15
+ const isDark = mode === "dark";
16
+ return {
17
+ // Line
18
+ line: color,
19
+ lineWidth: 2,
20
+ // Fill gradient
21
+ fillTop: rgba(r, g, b, isDark ? 0.12 : 0.08),
22
+ fillBottom: rgba(r, g, b, 0),
23
+ // Grid
24
+ gridLine: isDark ? "rgba(255, 255, 255, 0.06)" : "rgba(0, 0, 0, 0.06)",
25
+ gridLabel: isDark ? "rgba(255, 255, 255, 0.4)" : "rgba(0, 0, 0, 0.35)",
26
+ // Dot — always semantic
27
+ dotUp: "#22c55e",
28
+ dotDown: "#ef4444",
29
+ dotFlat: color,
30
+ glowUp: "rgba(34, 197, 94, 0.18)",
31
+ glowDown: "rgba(239, 68, 68, 0.18)",
32
+ glowFlat: rgba(r, g, b, 0.12),
33
+ // Badge
34
+ badgeOuterBg: isDark ? "rgba(40, 40, 40, 0.95)" : "rgba(255, 255, 255, 0.95)",
35
+ badgeOuterShadow: isDark ? "rgba(0, 0, 0, 0.4)" : "rgba(0, 0, 0, 0.15)",
36
+ badgeBg: color,
37
+ badgeText: "#ffffff",
38
+ // Dash line
39
+ dashLine: rgba(r, g, b, 0.4),
40
+ // Reference line
41
+ refLine: isDark ? "rgba(255, 255, 255, 0.15)" : "rgba(0, 0, 0, 0.12)",
42
+ refLabel: isDark ? "rgba(255, 255, 255, 0.45)" : "rgba(0, 0, 0, 0.4)",
43
+ // Time axis
44
+ timeLabel: isDark ? "rgba(255, 255, 255, 0.35)" : "rgba(0, 0, 0, 0.3)",
45
+ // Crosshair
46
+ crosshairLine: isDark ? "rgba(255, 255, 255, 0.2)" : "rgba(0, 0, 0, 0.12)",
47
+ tooltipBg: isDark ? "rgba(30, 30, 30, 0.95)" : "rgba(255, 255, 255, 0.95)",
48
+ tooltipText: isDark ? "#e5e5e5" : "#1a1a1a",
49
+ tooltipBorder: isDark ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.08)",
50
+ // Background
51
+ bgRgb: isDark ? [10, 10, 10] : [255, 255, 255],
52
+ // Fonts
53
+ labelFont: '11px "SF Mono", Menlo, Monaco, "Cascadia Code", monospace',
54
+ valueFont: '600 11px "SF Mono", Menlo, monospace',
55
+ badgeFont: '500 11px "SF Mono", Menlo, monospace'
56
+ };
57
+ }
58
+
59
+ // src/useLivelineEngine.ts
60
+ import { useRef, useEffect, useCallback } from "react";
61
+
62
+ // src/math/lerp.ts
63
+ function lerp(current, target, speed, dt = 16.67) {
64
+ const factor = 1 - Math.pow(1 - speed, dt / 16.67);
65
+ return current + (target - current) * factor;
66
+ }
67
+
68
+ // src/math/range.ts
69
+ function computeRange(visible, currentValue, referenceValue, exaggerate) {
70
+ let targetMin = Infinity;
71
+ let targetMax = -Infinity;
72
+ for (const p of visible) {
73
+ if (p.value < targetMin) targetMin = p.value;
74
+ if (p.value > targetMax) targetMax = p.value;
75
+ }
76
+ if (currentValue < targetMin) targetMin = currentValue;
77
+ if (currentValue > targetMax) targetMax = currentValue;
78
+ if (referenceValue !== void 0) {
79
+ if (referenceValue < targetMin) targetMin = referenceValue;
80
+ if (referenceValue > targetMax) targetMax = referenceValue;
81
+ }
82
+ const rawRange = targetMax - targetMin;
83
+ const marginFactor = exaggerate ? 0.01 : 0.12;
84
+ const minRange = rawRange * (exaggerate ? 0.02 : 0.1) || (exaggerate ? 0.04 : 0.4);
85
+ if (rawRange < minRange) {
86
+ const mid = (targetMin + targetMax) / 2;
87
+ targetMin = mid - minRange / 2;
88
+ targetMax = mid + minRange / 2;
89
+ } else {
90
+ const margin = rawRange * marginFactor;
91
+ targetMin -= margin;
92
+ targetMax += margin;
93
+ }
94
+ return { min: targetMin, max: targetMax };
95
+ }
96
+
97
+ // src/math/momentum.ts
98
+ function detectMomentum(points, lookback = 20) {
99
+ if (points.length < 5) return "flat";
100
+ const recent = points.slice(-lookback);
101
+ let min = Infinity;
102
+ let max = -Infinity;
103
+ for (const p of recent) {
104
+ if (p.value < min) min = p.value;
105
+ if (p.value > max) max = p.value;
106
+ }
107
+ const range = max - min;
108
+ if (range === 0) return "flat";
109
+ const tail = recent.slice(-5);
110
+ const first = tail[0].value;
111
+ const last = tail[tail.length - 1].value;
112
+ const delta = last - first;
113
+ const threshold = range * 0.12;
114
+ if (delta > threshold) return "up";
115
+ if (delta < -threshold) return "down";
116
+ return "flat";
117
+ }
118
+
119
+ // src/math/interpolate.ts
120
+ function interpolateAtTime(points, time) {
121
+ if (points.length === 0) return null;
122
+ if (time <= points[0].time) return points[0].value;
123
+ if (time >= points[points.length - 1].time) return points[points.length - 1].value;
124
+ let lo = 0;
125
+ let hi = points.length - 1;
126
+ while (hi - lo > 1) {
127
+ const mid = lo + hi >> 1;
128
+ if (points[mid].time <= time) lo = mid;
129
+ else hi = mid;
130
+ }
131
+ const p1 = points[lo];
132
+ const p2 = points[hi];
133
+ const t = (time - p1.time) / (p2.time - p1.time);
134
+ return p1.value + (p2.value - p1.value) * t;
135
+ }
136
+
137
+ // src/canvas/dpr.ts
138
+ function getDpr() {
139
+ if (typeof window === "undefined") return 1;
140
+ return Math.min(window.devicePixelRatio || 1, 3);
141
+ }
142
+ function applyDpr(ctx, dpr, w, h) {
143
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
144
+ ctx.clearRect(0, 0, w, h);
145
+ }
146
+
147
+ // src/draw/grid.ts
148
+ function pickInterval(valRange, pxPerUnit, minGap, prev) {
149
+ if (prev > 0) {
150
+ const px = prev * pxPerUnit;
151
+ if (px >= minGap * 0.5 && px <= minGap * 4) return prev;
152
+ }
153
+ const divisorSets = [[2, 2.5, 2], [2, 2, 2.5], [2.5, 2, 2]];
154
+ let best = Infinity;
155
+ for (const divs of divisorSets) {
156
+ let span = Math.pow(10, Math.ceil(Math.log10(valRange)));
157
+ let i = 0;
158
+ while (span / divs[i % 3] * pxPerUnit >= minGap) {
159
+ span /= divs[i % 3];
160
+ i++;
161
+ }
162
+ if (span < best) best = span;
163
+ }
164
+ return best === Infinity ? valRange / 5 : best;
165
+ }
166
+ function divisible(val, interval) {
167
+ const ratio = val / interval;
168
+ return Math.abs(ratio - Math.round(ratio)) < 0.01;
169
+ }
170
+ var FADE_IN = 0.18;
171
+ var FADE_OUT = 0.12;
172
+ function drawGrid(ctx, layout, palette, formatValue, state, dt) {
173
+ const { w, h, pad, valRange, minVal, maxVal, toY } = layout;
174
+ const chartH = h - pad.top - pad.bottom;
175
+ if (chartH <= 0 || valRange <= 0) return;
176
+ const pxPerUnit = chartH / valRange;
177
+ const coarse = pickInterval(valRange, pxPerUnit, 36, state.interval);
178
+ state.interval = coarse;
179
+ const fine = coarse / 2;
180
+ const finePx = fine * pxPerUnit;
181
+ const fineTarget = finePx < 40 ? 0 : finePx >= 60 ? 1 : (finePx - 40) / 20;
182
+ const fadeZone = 32;
183
+ const edgeAlpha = (y) => {
184
+ const fromEdge = Math.min(y - pad.top, h - pad.bottom - y);
185
+ if (fromEdge >= fadeZone) return 1;
186
+ if (fromEdge <= 0) return 0;
187
+ return fromEdge / fadeZone;
188
+ };
189
+ const targets = /* @__PURE__ */ new Map();
190
+ const first = Math.ceil(minVal / fine) * fine;
191
+ for (let val = first; val <= maxVal; val += fine) {
192
+ const y = toY(val);
193
+ if (y < pad.top - 2 || y > h - pad.bottom + 2) continue;
194
+ const isCoarse = divisible(val, coarse);
195
+ const target = (isCoarse ? 1 : fineTarget) * edgeAlpha(y);
196
+ const key = Math.round(val * 1e3);
197
+ targets.set(key, target);
198
+ }
199
+ for (const [key, alpha] of state.labels) {
200
+ const target = targets.get(key) ?? 0;
201
+ const speed = target >= alpha ? FADE_IN : FADE_OUT;
202
+ let next = lerp(alpha, target, speed, dt);
203
+ if (Math.abs(next - target) < 0.02) next = target;
204
+ if (next < 0.01 && target === 0) {
205
+ state.labels.delete(key);
206
+ } else {
207
+ state.labels.set(key, next);
208
+ }
209
+ }
210
+ for (const [key, target] of targets) {
211
+ if (!state.labels.has(key)) {
212
+ state.labels.set(key, target * FADE_IN);
213
+ }
214
+ }
215
+ ctx.setLineDash([1, 3]);
216
+ ctx.lineWidth = 1;
217
+ ctx.font = palette.labelFont;
218
+ ctx.textAlign = "left";
219
+ for (const [key, alpha] of state.labels) {
220
+ if (alpha < 0.02) continue;
221
+ const val = key / 1e3;
222
+ const y = toY(val);
223
+ if (y < pad.top - 10 || y > h - pad.bottom + 10) continue;
224
+ ctx.save();
225
+ ctx.globalAlpha = alpha;
226
+ ctx.strokeStyle = palette.gridLine;
227
+ ctx.beginPath();
228
+ ctx.moveTo(pad.left, y);
229
+ ctx.lineTo(w - pad.right, y);
230
+ ctx.stroke();
231
+ ctx.fillStyle = palette.gridLabel;
232
+ ctx.fillText(formatValue(val), w - pad.right + 8, y + 4);
233
+ ctx.restore();
234
+ }
235
+ ctx.setLineDash([]);
236
+ }
237
+
238
+ // src/math/spline.ts
239
+ function drawSpline(ctx, pts, tension = 0.15, maxPoints = 300) {
240
+ if (pts.length < 2) return;
241
+ if (pts.length === 2) {
242
+ ctx.lineTo(pts[1][0], pts[1][1]);
243
+ return;
244
+ }
245
+ let sampled = pts;
246
+ if (pts.length > maxPoints) {
247
+ const step = pts.length / maxPoints;
248
+ sampled = [];
249
+ for (let i = 0; i < maxPoints; i++) {
250
+ sampled.push(pts[Math.round(i * step)]);
251
+ }
252
+ sampled.push(pts[pts.length - 1]);
253
+ }
254
+ for (let i = 0; i < sampled.length - 1; i++) {
255
+ const p0 = sampled[Math.max(0, i - 1)];
256
+ const p1 = sampled[i];
257
+ const p2 = sampled[i + 1];
258
+ const p3 = sampled[Math.min(sampled.length - 1, i + 2)];
259
+ ctx.bezierCurveTo(
260
+ p1[0] + (p2[0] - p0[0]) * tension,
261
+ p1[1] + (p2[1] - p0[1]) * tension,
262
+ p2[0] - (p3[0] - p1[0]) * tension,
263
+ p2[1] - (p3[1] - p1[1]) * tension,
264
+ p2[0],
265
+ p2[1]
266
+ );
267
+ }
268
+ }
269
+
270
+ // src/draw/line.ts
271
+ function renderCurve(ctx, layout, palette, pts, showFill, maxSplinePts) {
272
+ const { h, pad } = layout;
273
+ if (showFill) {
274
+ const grad = ctx.createLinearGradient(0, pad.top, 0, h - pad.bottom);
275
+ grad.addColorStop(0, palette.fillTop);
276
+ grad.addColorStop(1, palette.fillBottom);
277
+ ctx.beginPath();
278
+ ctx.moveTo(pts[0][0], h - pad.bottom);
279
+ ctx.lineTo(pts[0][0], pts[0][1]);
280
+ drawSpline(ctx, pts, 0.15, maxSplinePts);
281
+ ctx.lineTo(pts[pts.length - 1][0], h - pad.bottom);
282
+ ctx.closePath();
283
+ ctx.fillStyle = grad;
284
+ ctx.fill();
285
+ }
286
+ ctx.beginPath();
287
+ ctx.moveTo(pts[0][0], pts[0][1]);
288
+ drawSpline(ctx, pts, 0.15, maxSplinePts);
289
+ ctx.strokeStyle = palette.line;
290
+ ctx.lineWidth = palette.lineWidth;
291
+ ctx.lineJoin = "round";
292
+ ctx.lineCap = "round";
293
+ ctx.stroke();
294
+ }
295
+ function drawLine(ctx, layout, palette, visible, smoothValue, now, showFill, scrubX, scrubAmount = 0) {
296
+ const { w, h, pad, toX, toY, chartW, chartH } = layout;
297
+ const yMin = pad.top;
298
+ const yMax = h - pad.bottom;
299
+ const clampY = (y) => Math.max(yMin, Math.min(yMax, y));
300
+ const pts = visible.map(
301
+ (p, i) => i === visible.length - 1 ? [toX(p.time), clampY(toY(smoothValue))] : [toX(p.time), clampY(toY(p.value))]
302
+ );
303
+ pts.push([toX(now), clampY(toY(smoothValue))]);
304
+ if (pts.length < 2) return;
305
+ const isScrubbing = scrubX !== null;
306
+ const maxSplinePts = Math.max(300, Math.ceil(chartW));
307
+ ctx.save();
308
+ ctx.beginPath();
309
+ ctx.rect(pad.left - 1, pad.top, chartW + 2, chartH);
310
+ ctx.clip();
311
+ if (isScrubbing) {
312
+ ctx.save();
313
+ ctx.beginPath();
314
+ ctx.rect(0, 0, scrubX, h);
315
+ ctx.clip();
316
+ renderCurve(ctx, layout, palette, pts, showFill, maxSplinePts);
317
+ ctx.restore();
318
+ ctx.save();
319
+ ctx.beginPath();
320
+ ctx.rect(scrubX, 0, layout.w - scrubX, h);
321
+ ctx.clip();
322
+ ctx.globalAlpha = 1 - scrubAmount * 0.6;
323
+ renderCurve(ctx, layout, palette, pts, showFill, maxSplinePts);
324
+ ctx.restore();
325
+ } else {
326
+ renderCurve(ctx, layout, palette, pts, showFill, maxSplinePts);
327
+ }
328
+ ctx.restore();
329
+ const currentY = Math.max(pad.top, Math.min(h - pad.bottom, toY(smoothValue)));
330
+ ctx.setLineDash([4, 4]);
331
+ ctx.strokeStyle = palette.dashLine;
332
+ ctx.lineWidth = 1;
333
+ if (isScrubbing) ctx.globalAlpha = 1 - scrubAmount * 0.2;
334
+ ctx.beginPath();
335
+ ctx.moveTo(pad.left, currentY);
336
+ ctx.lineTo(layout.w - pad.right, currentY);
337
+ ctx.stroke();
338
+ ctx.setLineDash([]);
339
+ ctx.globalAlpha = 1;
340
+ const last = pts[pts.length - 1];
341
+ last[1] = Math.max(10, Math.min(h - 10, last[1]));
342
+ return pts;
343
+ }
344
+
345
+ // src/draw/dot.ts
346
+ var PULSE_INTERVAL = 1500;
347
+ var PULSE_DURATION = 900;
348
+ function parseColor(color) {
349
+ const hex = color.match(/^#([0-9a-f]{3,8})$/i);
350
+ if (hex) {
351
+ let h = hex[1];
352
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
353
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
354
+ }
355
+ const rgb = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
356
+ if (rgb) return [+rgb[1], +rgb[2], +rgb[3]];
357
+ return null;
358
+ }
359
+ function lerpColor(a, b, t) {
360
+ const r = Math.round(a[0] + (b[0] - a[0]) * t);
361
+ const g = Math.round(a[1] + (b[1] - a[1]) * t);
362
+ const bl = Math.round(a[2] + (b[2] - a[2]) * t);
363
+ return `rgb(${r},${g},${bl})`;
364
+ }
365
+ function drawDot(ctx, x, y, palette, pulse = true, scrubAmount = 0) {
366
+ const dim = scrubAmount * 0.7;
367
+ if (pulse && dim < 0.3) {
368
+ const t = Date.now() % PULSE_INTERVAL / PULSE_DURATION;
369
+ if (t < 1) {
370
+ const radius = 9 + t * 12;
371
+ const pulseAlpha = 0.35 * (1 - t) * (1 - dim * 3);
372
+ ctx.beginPath();
373
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
374
+ ctx.strokeStyle = palette.line;
375
+ ctx.lineWidth = 1.5;
376
+ ctx.globalAlpha = pulseAlpha;
377
+ ctx.stroke();
378
+ }
379
+ }
380
+ const outerRgb = parseColor(palette.badgeOuterBg) ?? [255, 255, 255];
381
+ ctx.save();
382
+ ctx.globalAlpha = 1;
383
+ ctx.shadowColor = palette.badgeOuterShadow;
384
+ ctx.shadowBlur = 6 * (1 - dim);
385
+ ctx.shadowOffsetY = 1;
386
+ ctx.beginPath();
387
+ ctx.arc(x, y, 6.5, 0, Math.PI * 2);
388
+ ctx.fillStyle = palette.badgeOuterBg;
389
+ ctx.fill();
390
+ ctx.restore();
391
+ ctx.globalAlpha = 1;
392
+ ctx.beginPath();
393
+ ctx.arc(x, y, 3.5, 0, Math.PI * 2);
394
+ if (dim > 0.01) {
395
+ const lineRgb = parseColor(palette.line) ?? [100, 100, 255];
396
+ ctx.fillStyle = lerpColor(lineRgb, outerRgb, dim);
397
+ } else {
398
+ ctx.fillStyle = palette.line;
399
+ }
400
+ ctx.fill();
401
+ }
402
+ function drawArrows(ctx, x, y, momentum, palette, arrows, dt) {
403
+ const upTarget = momentum === "up" ? 1 : 0;
404
+ const downTarget = momentum === "down" ? 1 : 0;
405
+ const canFadeInUp = arrows.down < 0.02;
406
+ const canFadeInDown = arrows.up < 0.02;
407
+ arrows.up = lerp(arrows.up, canFadeInUp ? upTarget : 0, upTarget > arrows.up ? 0.08 : 0.04, dt);
408
+ arrows.down = lerp(arrows.down, canFadeInDown ? downTarget : 0, downTarget > arrows.down ? 0.08 : 0.04, dt);
409
+ if (arrows.up < 0.01) arrows.up = 0;
410
+ if (arrows.down < 0.01) arrows.down = 0;
411
+ if (arrows.up > 0.99) arrows.up = 1;
412
+ if (arrows.down > 0.99) arrows.down = 1;
413
+ const cycle = Date.now() % 1400 / 1400;
414
+ const drawChevrons = (dir, opacity) => {
415
+ if (opacity < 0.01) return;
416
+ const baseX = x + 19;
417
+ const baseY = y;
418
+ ctx.save();
419
+ ctx.strokeStyle = palette.gridLabel;
420
+ ctx.lineWidth = 2.5;
421
+ ctx.lineCap = "round";
422
+ ctx.lineJoin = "round";
423
+ for (let i = 0; i < 2; i++) {
424
+ const start = i * 0.2;
425
+ const dur = 0.35;
426
+ const localT = cycle - start;
427
+ const wave = localT >= 0 && localT < dur ? Math.sin(localT / dur * Math.PI) : 0;
428
+ const pulse = 0.3 + 0.7 * wave;
429
+ ctx.globalAlpha = opacity * pulse;
430
+ const nudge = dir === -1 ? -3 : 3;
431
+ const cy = baseY + dir * (i * 8 - 4) + nudge;
432
+ ctx.beginPath();
433
+ ctx.moveTo(baseX - 5, cy - dir * 3.5);
434
+ ctx.lineTo(baseX, cy);
435
+ ctx.lineTo(baseX + 5, cy - dir * 3.5);
436
+ ctx.stroke();
437
+ }
438
+ ctx.restore();
439
+ };
440
+ drawChevrons(-1, arrows.up);
441
+ drawChevrons(1, arrows.down);
442
+ ctx.globalAlpha = 1;
443
+ }
444
+
445
+ // src/draw/crosshair.ts
446
+ function drawCrosshair(ctx, layout, palette, hoverX, hoverValue, hoverTime, formatValue, formatTime, scrubOpacity, tooltipY, liveDotX, tooltipOutline) {
447
+ if (scrubOpacity < 0.01) return;
448
+ const { w, h, pad, toY } = layout;
449
+ const y = toY(hoverValue);
450
+ ctx.save();
451
+ ctx.globalAlpha = scrubOpacity * 0.5;
452
+ ctx.strokeStyle = palette.crosshairLine;
453
+ ctx.lineWidth = 1;
454
+ ctx.beginPath();
455
+ ctx.moveTo(hoverX, pad.top);
456
+ ctx.lineTo(hoverX, h - pad.bottom);
457
+ ctx.stroke();
458
+ ctx.restore();
459
+ const dotRadius = 4 * Math.min(scrubOpacity * 3, 1);
460
+ if (dotRadius > 0.5) {
461
+ ctx.globalAlpha = 1;
462
+ ctx.beginPath();
463
+ ctx.arc(hoverX, y, dotRadius, 0, Math.PI * 2);
464
+ ctx.fillStyle = palette.line;
465
+ ctx.fill();
466
+ }
467
+ if (scrubOpacity < 0.1 || layout.w < 300) return;
468
+ const valueText = formatValue(hoverValue);
469
+ const timeText = formatTime(hoverTime);
470
+ const separator = " \xB7 ";
471
+ ctx.save();
472
+ ctx.globalAlpha = scrubOpacity;
473
+ ctx.font = '400 13px "SF Mono", Menlo, monospace';
474
+ const valueW = ctx.measureText(valueText).width;
475
+ const sepW = ctx.measureText(separator).width;
476
+ const timeW = ctx.measureText(timeText).width;
477
+ const totalW = valueW + sepW + timeW;
478
+ let tx = hoverX - totalW / 2;
479
+ const minX = pad.left + 4;
480
+ const dotRightEdge = liveDotX != null ? liveDotX + 7 : w - pad.right;
481
+ const maxX = dotRightEdge - totalW;
482
+ if (tx < minX) tx = minX;
483
+ if (tx > maxX) tx = maxX;
484
+ const ty = pad.top + (tooltipY ?? 14) + 10;
485
+ ctx.textAlign = "left";
486
+ if (tooltipOutline) {
487
+ ctx.strokeStyle = palette.tooltipBg;
488
+ ctx.lineWidth = 3;
489
+ ctx.lineJoin = "round";
490
+ ctx.strokeText(valueText, tx, ty);
491
+ ctx.strokeText(separator + timeText, tx + valueW, ty);
492
+ }
493
+ ctx.fillStyle = palette.tooltipText;
494
+ ctx.fillText(valueText, tx, ty);
495
+ ctx.fillStyle = palette.gridLabel;
496
+ ctx.fillText(separator + timeText, tx + valueW, ty);
497
+ ctx.restore();
498
+ }
499
+
500
+ // src/draw/referenceLine.ts
501
+ function drawReferenceLine(ctx, layout, palette, ref) {
502
+ const { w, h, pad, toY, chartW } = layout;
503
+ const y = toY(ref.value);
504
+ if (y < pad.top - 10 || y > h - pad.bottom + 10) return;
505
+ const label = ref.label ?? "";
506
+ if (label) {
507
+ ctx.font = "500 11px system-ui, sans-serif";
508
+ const textW = ctx.measureText(label).width;
509
+ const centerX = pad.left + chartW / 2;
510
+ const gapPad = 8;
511
+ ctx.strokeStyle = palette.refLine;
512
+ ctx.lineWidth = 1;
513
+ ctx.beginPath();
514
+ ctx.moveTo(pad.left, y);
515
+ ctx.lineTo(centerX - textW / 2 - gapPad, y);
516
+ ctx.stroke();
517
+ ctx.beginPath();
518
+ ctx.moveTo(centerX + textW / 2 + gapPad, y);
519
+ ctx.lineTo(w - pad.right, y);
520
+ ctx.stroke();
521
+ ctx.fillStyle = palette.refLabel;
522
+ ctx.textAlign = "center";
523
+ ctx.fillText(label, centerX, y + 4);
524
+ } else {
525
+ ctx.strokeStyle = palette.refLine;
526
+ ctx.lineWidth = 1;
527
+ ctx.setLineDash([4, 4]);
528
+ ctx.beginPath();
529
+ ctx.moveTo(pad.left, y);
530
+ ctx.lineTo(w - pad.right, y);
531
+ ctx.stroke();
532
+ ctx.setLineDash([]);
533
+ }
534
+ }
535
+
536
+ // src/math/intervals.ts
537
+ function niceTimeInterval(windowSecs) {
538
+ if (windowSecs <= 15) return 2;
539
+ if (windowSecs <= 30) return 5;
540
+ if (windowSecs <= 60) return 10;
541
+ if (windowSecs <= 120) return 15;
542
+ if (windowSecs <= 300) return 30;
543
+ if (windowSecs <= 600) return 60;
544
+ if (windowSecs <= 1800) return 300;
545
+ if (windowSecs <= 3600) return 600;
546
+ if (windowSecs <= 14400) return 1800;
547
+ if (windowSecs <= 43200) return 3600;
548
+ if (windowSecs <= 86400) return 7200;
549
+ if (windowSecs <= 604800) return 86400;
550
+ return 604800;
551
+ }
552
+
553
+ // src/draw/timeAxis.ts
554
+ var FADE = 0.08;
555
+ function drawTimeAxis(ctx, layout, palette, windowSecs, _targetWindowSecs, formatTime, state, dt) {
556
+ const { h, pad, leftEdge, rightEdge, toX } = layout;
557
+ const chartLeft = pad.left;
558
+ const chartRight = layout.w - pad.right;
559
+ const chartW = chartRight - chartLeft;
560
+ const fadeZone = 50;
561
+ const edgeAlpha = (x) => {
562
+ const fromLeft = x - chartLeft;
563
+ const fromRight = chartRight - x;
564
+ const fromEdge = Math.min(fromLeft, fromRight);
565
+ if (fromEdge >= fadeZone) return 1;
566
+ if (fromEdge <= 0) return 0;
567
+ return fromEdge / fadeZone;
568
+ };
569
+ ctx.font = palette.labelFont;
570
+ const targetPxPerSec = chartW / _targetWindowSecs;
571
+ let interval = niceTimeInterval(_targetWindowSecs);
572
+ while (interval * targetPxPerSec < 60 && interval < _targetWindowSecs) {
573
+ interval *= 2;
574
+ }
575
+ const useLocalDays = interval >= 86400;
576
+ let firstTime;
577
+ if (useLocalDays) {
578
+ const d = new Date((leftEdge - interval) * 1e3);
579
+ d.setHours(0, 0, 0, 0);
580
+ firstTime = d.getTime() / 1e3;
581
+ } else {
582
+ firstTime = Math.ceil((leftEdge - interval) / interval) * interval;
583
+ }
584
+ const targets = /* @__PURE__ */ new Set();
585
+ for (let t = firstTime; t <= rightEdge + interval && targets.size < 30; t += interval) {
586
+ targets.add(Math.round(t * 100));
587
+ }
588
+ for (const key of targets) {
589
+ const text = formatTime(key / 100);
590
+ const existing = state.labels.get(key);
591
+ if (!existing) {
592
+ state.labels.set(key, { alpha: 0, text });
593
+ } else {
594
+ existing.text = text;
595
+ }
596
+ }
597
+ for (const [key, label] of state.labels) {
598
+ const x = toX(key / 100);
599
+ const isTarget = targets.has(key);
600
+ const target = isTarget ? edgeAlpha(x) : 0;
601
+ let next = lerp(label.alpha, target, FADE, dt);
602
+ if (Math.abs(next - target) < 0.02) next = target;
603
+ if (next < 0.01 && target === 0) {
604
+ state.labels.delete(key);
605
+ } else {
606
+ label.alpha = next;
607
+ }
608
+ }
609
+ const lineY = h - pad.bottom;
610
+ const tickLen = 5;
611
+ ctx.strokeStyle = palette.gridLine;
612
+ ctx.lineWidth = 1;
613
+ ctx.beginPath();
614
+ ctx.moveTo(chartLeft, lineY);
615
+ ctx.lineTo(chartRight, lineY);
616
+ ctx.stroke();
617
+ ctx.textAlign = "center";
618
+ const labels = [];
619
+ for (const [key, label] of state.labels) {
620
+ if (label.alpha < 0.02) continue;
621
+ const x = toX(key / 100);
622
+ if (x < chartLeft - 20 || x > chartRight) continue;
623
+ const w = ctx.measureText(label.text).width;
624
+ labels.push({ x, alpha: label.alpha, text: label.text, w });
625
+ }
626
+ labels.sort((a, b) => a.x - b.x);
627
+ const drawn = [];
628
+ for (const label of labels) {
629
+ const left = label.x - label.w / 2;
630
+ if (drawn.length > 0) {
631
+ const prev = drawn[drawn.length - 1];
632
+ const prevRight = prev.x + prev.w / 2;
633
+ if (left < prevRight + 8) {
634
+ if (label.alpha > prev.alpha) {
635
+ drawn[drawn.length - 1] = label;
636
+ }
637
+ continue;
638
+ }
639
+ }
640
+ drawn.push(label);
641
+ }
642
+ for (const label of drawn) {
643
+ ctx.save();
644
+ ctx.globalAlpha = label.alpha;
645
+ ctx.strokeStyle = palette.gridLine;
646
+ ctx.lineWidth = 1;
647
+ ctx.beginPath();
648
+ ctx.moveTo(label.x, lineY);
649
+ ctx.lineTo(label.x, lineY + tickLen);
650
+ ctx.stroke();
651
+ ctx.fillStyle = palette.timeLabel;
652
+ ctx.fillText(label.text, label.x, lineY + tickLen + 14);
653
+ ctx.restore();
654
+ }
655
+ }
656
+
657
+ // src/draw/orderbook.ts
658
+ var GREEN = [34, 197, 94];
659
+ var RED = [239, 68, 68];
660
+ function createOrderbookState() {
661
+ return {
662
+ labels: [],
663
+ spawnTimer: 0,
664
+ smoothSpeed: BASE_SPEED,
665
+ prevBidTotal: 0,
666
+ prevAskTotal: 0,
667
+ churnRate: 0
668
+ };
669
+ }
670
+ var MAX_LABELS = 50;
671
+ var LABEL_LIFETIME = 6;
672
+ var SPAWN_INTERVAL = 40;
673
+ var MIN_LABEL_GAP = 22;
674
+ var BASE_SPEED = 60;
675
+ var MAX_SPEED = 160;
676
+ function mixColor(from, to, t) {
677
+ const r = Math.round(from[0] + (to[0] - from[0]) * t);
678
+ const g = Math.round(from[1] + (to[1] - from[1]) * t);
679
+ const b = Math.round(from[2] + (to[2] - from[2]) * t);
680
+ return `rgb(${r},${g},${b})`;
681
+ }
682
+ function drawOrderbook(ctx, layout, palette, orderbook, dt, state, swingMagnitude) {
683
+ const { pad, h, chartH } = layout;
684
+ const dtSec = dt / 1e3;
685
+ if (orderbook.bids.length === 0 && orderbook.asks.length === 0) return;
686
+ let maxSize = 0;
687
+ let bidTotal = 0;
688
+ let askTotal = 0;
689
+ for (const [, size] of orderbook.bids) {
690
+ bidTotal += size;
691
+ if (size > maxSize) maxSize = size;
692
+ }
693
+ for (const [, size] of orderbook.asks) {
694
+ askTotal += size;
695
+ if (size > maxSize) maxSize = size;
696
+ }
697
+ if (maxSize === 0) return;
698
+ const totalSize = bidTotal + askTotal;
699
+ const prevTotal = state.prevBidTotal + state.prevAskTotal;
700
+ let churnSignal = 0;
701
+ if (prevTotal > 0) {
702
+ const delta = Math.abs(bidTotal - state.prevBidTotal) + Math.abs(askTotal - state.prevAskTotal);
703
+ churnSignal = Math.min(delta / prevTotal, 1);
704
+ }
705
+ state.prevBidTotal = bidTotal;
706
+ state.prevAskTotal = askTotal;
707
+ const churnLerp = churnSignal > state.churnRate ? 0.3 : 0.05;
708
+ state.churnRate += (churnSignal - state.churnRate) * churnLerp;
709
+ const activity = Math.max(Math.min(swingMagnitude * 5, 1), state.churnRate);
710
+ const targetSpeed = BASE_SPEED + activity * (MAX_SPEED - BASE_SPEED);
711
+ const speedLerp = 1 - Math.pow(0.95, dt / 16.67);
712
+ state.smoothSpeed += (targetSpeed - state.smoothSpeed) * speedLerp;
713
+ const speed = state.smoothSpeed;
714
+ const labelX = pad.left + 8;
715
+ const bottomY = h - pad.bottom - 6;
716
+ const topY = pad.top;
717
+ const bg = palette.bgRgb;
718
+ state.spawnTimer += dt;
719
+ while (state.spawnTimer >= SPAWN_INTERVAL && state.labels.length < MAX_LABELS) {
720
+ state.spawnTimer -= SPAWN_INTERVAL;
721
+ let tooClose = false;
722
+ for (let j = 0; j < state.labels.length; j++) {
723
+ if (Math.abs(state.labels[j].y - bottomY) < MIN_LABEL_GAP) {
724
+ tooClose = true;
725
+ break;
726
+ }
727
+ }
728
+ if (tooClose) break;
729
+ const allLevels = [];
730
+ for (const [, size] of orderbook.bids) allLevels.push({ size, green: true });
731
+ for (const [, size] of orderbook.asks) allLevels.push({ size, green: false });
732
+ let totalWeight = 0;
733
+ for (const l of allLevels) totalWeight += l.size;
734
+ let r = Math.random() * totalWeight;
735
+ let picked = allLevels[0];
736
+ for (const l of allLevels) {
737
+ r -= l.size;
738
+ if (r <= 0) {
739
+ picked = l;
740
+ break;
741
+ }
742
+ }
743
+ const sizeRatio = picked.size / maxSize;
744
+ state.labels.push({
745
+ y: bottomY,
746
+ text: `+ ${formatSize(picked.size)}`,
747
+ green: picked.green,
748
+ life: LABEL_LIFETIME,
749
+ maxLife: LABEL_LIFETIME,
750
+ intensity: 0.5 + sizeRatio * 0.5
751
+ });
752
+ }
753
+ const range = bottomY - topY;
754
+ let writeIdx = 0;
755
+ for (let i = 0; i < state.labels.length; i++) {
756
+ const l = state.labels[i];
757
+ l.life -= dtSec;
758
+ if (l.life <= 0) continue;
759
+ const yProgress = range > 0 ? (l.y - topY) / range : 1;
760
+ l.y -= speed * (0.7 + 0.3 * yProgress) * dtSec;
761
+ if (l.y < topY - 14) continue;
762
+ state.labels[writeIdx++] = l;
763
+ }
764
+ state.labels.length = writeIdx;
765
+ ctx.save();
766
+ ctx.font = '600 13px "SF Mono", Menlo, monospace';
767
+ ctx.textAlign = "left";
768
+ ctx.textBaseline = "middle";
769
+ ctx.globalAlpha = 1;
770
+ const outlineColor = `rgb(${bg[0]},${bg[1]},${bg[2]})`;
771
+ for (let i = 0; i < state.labels.length; i++) {
772
+ const l = state.labels[i];
773
+ const lifeRatio = l.life / l.maxLife;
774
+ const fadeIn = Math.min((1 - lifeRatio) * 10, 1);
775
+ const yRatio = (l.y - topY) / chartH;
776
+ const fadeOut = yRatio < 0.45 ? yRatio / 0.45 : 1;
777
+ const colorStrength = l.intensity * fadeIn * fadeOut;
778
+ const baseColor = l.green ? GREEN : RED;
779
+ const fillColor = mixColor(baseColor, bg, 1 - colorStrength);
780
+ ctx.strokeStyle = outlineColor;
781
+ ctx.lineWidth = 4;
782
+ ctx.lineJoin = "round";
783
+ ctx.strokeText(l.text, labelX, l.y);
784
+ ctx.fillStyle = fillColor;
785
+ ctx.fillText(l.text, labelX, l.y);
786
+ }
787
+ ctx.restore();
788
+ }
789
+ function formatSize(size) {
790
+ if (size >= 10) return `$${Math.round(size)}`;
791
+ if (size >= 1) return `$${size.toFixed(1)}`;
792
+ return `$${size.toFixed(2)}`;
793
+ }
794
+
795
+ // src/draw/particles.ts
796
+ function createParticleState() {
797
+ return { particles: [], cooldown: 0, burstCount: 0 };
798
+ }
799
+ var MAX_PARTICLES = 80;
800
+ var PARTICLE_LIFETIME = 1;
801
+ var COOLDOWN_MS = 400;
802
+ var MAGNITUDE_THRESHOLD = 0.08;
803
+ var MAX_BURSTS = 3;
804
+ function spawnOnSwing(state, momentum, dotX, dotY, swingMagnitude, accentColor, dt, options) {
805
+ state.cooldown = Math.max(0, state.cooldown - dt);
806
+ if (momentum === "flat") return 0;
807
+ if (state.cooldown > 0) return 0;
808
+ if (swingMagnitude < MAGNITUDE_THRESHOLD) {
809
+ state.burstCount = 0;
810
+ return 0;
811
+ }
812
+ if (momentum === "down" && options?.downMomentum !== true) return 0;
813
+ if (state.burstCount >= MAX_BURSTS) return 0;
814
+ state.cooldown = COOLDOWN_MS;
815
+ const scale = options?.scale ?? 1;
816
+ const isUp = momentum === "up";
817
+ const mag = Math.min(swingMagnitude * 5, 1);
818
+ const burstFalloff = mag > 0.6 ? 1 : [1, 0.6, 0.35][state.burstCount] ?? 0.35;
819
+ state.burstCount++;
820
+ const count = Math.round((12 + mag * 20) * scale * burstFalloff);
821
+ const speedMultiplier = 1 + mag * 0.8;
822
+ for (let i = 0; i < count && state.particles.length < MAX_PARTICLES; i++) {
823
+ const baseAngle = isUp ? -Math.PI / 2 : Math.PI / 2;
824
+ const spread = Math.PI * 1.2;
825
+ const angle = baseAngle + (Math.random() - 0.5) * spread;
826
+ const speed = (60 + Math.random() * 100) * speedMultiplier;
827
+ state.particles.push({
828
+ x: dotX + (Math.random() - 0.5) * 24,
829
+ y: dotY + (Math.random() - 0.5) * 8,
830
+ vx: Math.cos(angle) * speed,
831
+ vy: Math.sin(angle) * speed,
832
+ life: 1,
833
+ size: (1 + Math.random() * 1.2) * scale * burstFalloff,
834
+ color: accentColor
835
+ });
836
+ }
837
+ return burstFalloff;
838
+ }
839
+ function drawParticles(ctx, state, dt) {
840
+ if (state.particles.length === 0) return;
841
+ const dtSec = dt / 1e3;
842
+ ctx.save();
843
+ let writeIdx = 0;
844
+ for (let i = 0; i < state.particles.length; i++) {
845
+ const p = state.particles[i];
846
+ p.life -= dtSec / PARTICLE_LIFETIME;
847
+ if (p.life <= 0) continue;
848
+ p.x += p.vx * dtSec;
849
+ p.y += p.vy * dtSec;
850
+ p.vx *= 0.95;
851
+ p.vy *= 0.95;
852
+ ctx.globalAlpha = p.life * 0.55;
853
+ ctx.fillStyle = p.color;
854
+ ctx.beginPath();
855
+ ctx.arc(p.x, p.y, p.size * (0.5 + p.life * 0.5), 0, Math.PI * 2);
856
+ ctx.fill();
857
+ state.particles[writeIdx++] = p;
858
+ }
859
+ state.particles.length = writeIdx;
860
+ ctx.restore();
861
+ }
862
+
863
+ // src/draw/index.ts
864
+ var SHAKE_DECAY_RATE = 2e-3;
865
+ var SHAKE_MIN_AMPLITUDE = 0.2;
866
+ var FADE_EDGE_WIDTH = 40;
867
+ var CROSSHAIR_FADE_MIN_PX = 5;
868
+ function createShakeState() {
869
+ return { amplitude: 0 };
870
+ }
871
+ function drawFrame(ctx, layout, palette, opts) {
872
+ const shake = opts.shakeState;
873
+ let shakeX = 0;
874
+ let shakeY = 0;
875
+ if (shake && shake.amplitude > SHAKE_MIN_AMPLITUDE) {
876
+ shakeX = (Math.random() - 0.5) * 2 * shake.amplitude;
877
+ shakeY = (Math.random() - 0.5) * 2 * shake.amplitude;
878
+ ctx.save();
879
+ ctx.translate(shakeX, shakeY);
880
+ }
881
+ if (shake) {
882
+ const decayRate = Math.pow(SHAKE_DECAY_RATE, opts.dt / 1e3);
883
+ shake.amplitude *= decayRate;
884
+ if (shake.amplitude < SHAKE_MIN_AMPLITUDE) shake.amplitude = 0;
885
+ }
886
+ if (opts.referenceLine) {
887
+ drawReferenceLine(ctx, layout, palette, opts.referenceLine);
888
+ }
889
+ if (opts.showGrid) {
890
+ drawGrid(ctx, layout, palette, opts.formatValue, opts.gridState, opts.dt);
891
+ }
892
+ if (opts.orderbookData && opts.orderbookState) {
893
+ drawOrderbook(ctx, layout, palette, opts.orderbookData, opts.dt, opts.orderbookState, opts.swingMagnitude);
894
+ }
895
+ const scrubX = opts.scrubAmount > 0.05 ? opts.hoverX : null;
896
+ const pts = drawLine(ctx, layout, palette, opts.visible, opts.smoothValue, opts.now, opts.showFill, scrubX, opts.scrubAmount);
897
+ drawTimeAxis(ctx, layout, palette, opts.windowSecs, opts.targetWindowSecs, opts.formatTime, opts.timeAxisState, opts.dt);
898
+ if (pts && pts.length > 0) {
899
+ const lastPt = pts[pts.length - 1];
900
+ let dotScrub = opts.scrubAmount;
901
+ if (opts.hoverX !== null && dotScrub > 0) {
902
+ const distToLive = lastPt[0] - opts.hoverX;
903
+ const fadeStart = Math.min(80, layout.chartW * 0.3);
904
+ dotScrub = distToLive < CROSSHAIR_FADE_MIN_PX ? 0 : distToLive >= fadeStart ? opts.scrubAmount : (distToLive - CROSSHAIR_FADE_MIN_PX) / (fadeStart - CROSSHAIR_FADE_MIN_PX) * opts.scrubAmount;
905
+ }
906
+ drawDot(ctx, lastPt[0], lastPt[1], palette, opts.showPulse, dotScrub);
907
+ if (opts.showMomentum) {
908
+ drawArrows(
909
+ ctx,
910
+ lastPt[0],
911
+ lastPt[1],
912
+ opts.momentum,
913
+ palette,
914
+ opts.arrowState,
915
+ opts.dt
916
+ );
917
+ }
918
+ if (opts.particleState) {
919
+ const burstIntensity = spawnOnSwing(
920
+ opts.particleState,
921
+ opts.momentum,
922
+ lastPt[0],
923
+ lastPt[1],
924
+ opts.swingMagnitude,
925
+ palette.line,
926
+ opts.dt,
927
+ opts.particleOptions
928
+ );
929
+ if (burstIntensity > 0 && shake) {
930
+ shake.amplitude = (3 + opts.swingMagnitude * 4) * burstIntensity;
931
+ }
932
+ drawParticles(ctx, opts.particleState, opts.dt);
933
+ }
934
+ }
935
+ const fadeW = FADE_EDGE_WIDTH;
936
+ ctx.save();
937
+ ctx.globalCompositeOperation = "destination-out";
938
+ const fadeGrad = ctx.createLinearGradient(layout.pad.left, 0, layout.pad.left + fadeW, 0);
939
+ fadeGrad.addColorStop(0, "rgba(0, 0, 0, 1)");
940
+ fadeGrad.addColorStop(1, "rgba(0, 0, 0, 0)");
941
+ ctx.fillStyle = fadeGrad;
942
+ ctx.fillRect(0, 0, layout.pad.left + fadeW, layout.h);
943
+ ctx.restore();
944
+ if (opts.hoverX !== null && opts.hoverValue !== null && opts.hoverTime !== null && pts && pts.length > 0) {
945
+ const lastPt = pts[pts.length - 1];
946
+ const distToLive = lastPt[0] - opts.hoverX;
947
+ const fadeStart = Math.min(80, layout.chartW * 0.3);
948
+ const scrubOpacity = distToLive < CROSSHAIR_FADE_MIN_PX ? 0 : distToLive >= fadeStart ? opts.scrubAmount : (distToLive - CROSSHAIR_FADE_MIN_PX) / (fadeStart - CROSSHAIR_FADE_MIN_PX) * opts.scrubAmount;
949
+ if (scrubOpacity > 0.01) {
950
+ drawCrosshair(
951
+ ctx,
952
+ layout,
953
+ palette,
954
+ opts.hoverX,
955
+ opts.hoverValue,
956
+ opts.hoverTime,
957
+ opts.formatValue,
958
+ opts.formatTime,
959
+ scrubOpacity,
960
+ opts.tooltipY,
961
+ lastPt[0],
962
+ // liveDotX — tooltip right edge stops here
963
+ opts.tooltipOutline
964
+ );
965
+ }
966
+ }
967
+ if (shake && (shakeX !== 0 || shakeY !== 0)) {
968
+ ctx.restore();
969
+ }
970
+ }
971
+
972
+ // src/draw/badge.ts
973
+ function badgeSvgPath(pillW, pillH, tailLen, tailSpread) {
974
+ const r = pillH / 2;
975
+ const cx = tailLen + pillW - r;
976
+ const tl = tailLen + r;
977
+ return [
978
+ `M${tl},0`,
979
+ `L${cx},0`,
980
+ `A${r},${r},0,0,1,${cx},${pillH}`,
981
+ `L${tl},${pillH}`,
982
+ `C${tailLen + 2},${pillH},${3},${r + tailSpread},0,${r}`,
983
+ `C${3},${r - tailSpread},${tailLen + 2},0,${tl},0`,
984
+ "Z"
985
+ ].join(" ");
986
+ }
987
+ function badgePillOnly(pillW, pillH) {
988
+ const r = pillH / 2;
989
+ return [
990
+ `M${r},0`,
991
+ `L${pillW - r},0`,
992
+ `A${r},${r},0,0,1,${pillW - r},${pillH}`,
993
+ `L${r},${pillH}`,
994
+ `A${r},${r},0,0,1,${r},0`,
995
+ "Z"
996
+ ].join(" ");
997
+ }
998
+ var BADGE_PAD_X = 10;
999
+ var BADGE_PAD_Y = 3;
1000
+ var BADGE_TAIL_LEN = 5;
1001
+ var BADGE_TAIL_SPREAD = 2.5;
1002
+ var BADGE_LINE_H = 16;
1003
+
1004
+ // src/useLivelineEngine.ts
1005
+ var SVG_NS = "http://www.w3.org/2000/svg";
1006
+ var MAX_DELTA_MS = 50;
1007
+ var SCRUB_LERP_SPEED = 0.12;
1008
+ var BADGE_WIDTH_LERP = 0.15;
1009
+ var BADGE_Y_LERP = 0.35;
1010
+ var BADGE_Y_LERP_TRANSITIONING = 0.5;
1011
+ var MOMENTUM_COLOR_LERP = 0.12;
1012
+ var WINDOW_TRANSITION_MS = 750;
1013
+ var WINDOW_BUFFER = 0.05;
1014
+ var VALUE_SNAP_THRESHOLD = 1e-3;
1015
+ var ADAPTIVE_SPEED_BOOST = 0.2;
1016
+ var MOMENTUM_GREEN = [34, 197, 94];
1017
+ var MOMENTUM_RED = [239, 68, 68];
1018
+ function computeAdaptiveSpeed(value, displayValue, displayMin, displayMax, lerpSpeed, noMotion) {
1019
+ const valGap = Math.abs(value - displayValue);
1020
+ const prevRange = displayMax - displayMin || 1;
1021
+ const gapRatio = Math.min(valGap / prevRange, 1);
1022
+ return noMotion ? 1 : lerpSpeed + (1 - gapRatio) * ADAPTIVE_SPEED_BOOST;
1023
+ }
1024
+ function updateWindowTransition(cfg, wt, displayWindow, displayMin, displayMax, noMotion, now_ms, now, points, smoothValue, buffer) {
1025
+ if (wt.to !== cfg.windowSecs) {
1026
+ wt.from = displayWindow;
1027
+ wt.to = cfg.windowSecs;
1028
+ wt.startMs = now_ms;
1029
+ wt.rangeFromMin = displayMin;
1030
+ wt.rangeFromMax = displayMax;
1031
+ const targetRightEdge = now + cfg.windowSecs * buffer;
1032
+ const targetLeftEdge = targetRightEdge - cfg.windowSecs;
1033
+ const targetVisible = [];
1034
+ for (const p of points) {
1035
+ if (p.time >= targetLeftEdge - 2 && p.time <= targetRightEdge) {
1036
+ targetVisible.push(p);
1037
+ }
1038
+ }
1039
+ if (targetVisible.length > 0) {
1040
+ const targetRange = computeRange(targetVisible, smoothValue, cfg.referenceLine?.value, cfg.exaggerate);
1041
+ wt.rangeToMin = targetRange.min;
1042
+ wt.rangeToMax = targetRange.max;
1043
+ }
1044
+ }
1045
+ let windowTransProgress = 0;
1046
+ let resultWindow;
1047
+ if (noMotion || wt.startMs === 0) {
1048
+ resultWindow = cfg.windowSecs;
1049
+ } else {
1050
+ const elapsed = now_ms - wt.startMs;
1051
+ const duration = WINDOW_TRANSITION_MS;
1052
+ const t = Math.min(elapsed / duration, 1);
1053
+ const eased = (1 - Math.cos(t * Math.PI)) / 2;
1054
+ windowTransProgress = eased;
1055
+ const logFrom = Math.log(wt.from);
1056
+ const logTo = Math.log(wt.to);
1057
+ resultWindow = Math.exp(logFrom + (logTo - logFrom) * eased);
1058
+ if (t >= 1) {
1059
+ resultWindow = cfg.windowSecs;
1060
+ wt.startMs = 0;
1061
+ windowTransProgress = 0;
1062
+ }
1063
+ }
1064
+ return { windowSecs: resultWindow, windowTransProgress };
1065
+ }
1066
+ function updateRange(computedRange, rangeInited, targetMin, targetMax, displayMin, displayMax, isTransitioning, windowTransProgress, wt, adaptiveSpeed, chartH, dt) {
1067
+ if (!rangeInited) {
1068
+ return {
1069
+ minVal: computedRange.min,
1070
+ maxVal: computedRange.max,
1071
+ valRange: computedRange.max - computedRange.min || 1e-3,
1072
+ targetMin: computedRange.min,
1073
+ targetMax: computedRange.max,
1074
+ displayMin: computedRange.min,
1075
+ displayMax: computedRange.max,
1076
+ rangeInited: true
1077
+ };
1078
+ }
1079
+ if (isTransitioning) {
1080
+ displayMin = wt.rangeFromMin + (wt.rangeToMin - wt.rangeFromMin) * windowTransProgress;
1081
+ displayMax = wt.rangeFromMax + (wt.rangeToMax - wt.rangeFromMax) * windowTransProgress;
1082
+ targetMin = computedRange.min;
1083
+ targetMax = computedRange.max;
1084
+ } else {
1085
+ const curRange = displayMax - displayMin;
1086
+ targetMin = computedRange.min;
1087
+ targetMax = computedRange.max;
1088
+ displayMin = lerp(displayMin, targetMin, adaptiveSpeed, dt);
1089
+ displayMax = lerp(displayMax, targetMax, adaptiveSpeed, dt);
1090
+ const pxThreshold = 0.5 * curRange / chartH || 1e-3;
1091
+ if (Math.abs(displayMin - targetMin) < pxThreshold) displayMin = targetMin;
1092
+ if (Math.abs(displayMax - targetMax) < pxThreshold) displayMax = targetMax;
1093
+ }
1094
+ return {
1095
+ minVal: displayMin,
1096
+ maxVal: displayMax,
1097
+ valRange: displayMax - displayMin || 1e-3,
1098
+ targetMin,
1099
+ targetMax,
1100
+ displayMin,
1101
+ displayMax,
1102
+ rangeInited: true
1103
+ };
1104
+ }
1105
+ function updateHoverState(hoverPixelX, pad, w, layout, now, visible, scrubAmount, lastHover, cfg, noMotion, leftEdge, rightEdge, chartW, dt) {
1106
+ let hoverValue = null;
1107
+ let hoverTime = null;
1108
+ let hoverChartX = null;
1109
+ let isActiveHover = false;
1110
+ if (hoverPixelX !== null && hoverPixelX >= pad.left && hoverPixelX <= w - pad.right) {
1111
+ const maxHoverX = layout.toX(now);
1112
+ const clampedX = Math.min(hoverPixelX, maxHoverX);
1113
+ const t = leftEdge + (clampedX - pad.left) / chartW * (rightEdge - leftEdge);
1114
+ const v = interpolateAtTime(visible, t);
1115
+ if (v !== null) {
1116
+ hoverValue = v;
1117
+ hoverTime = t;
1118
+ hoverChartX = clampedX;
1119
+ isActiveHover = true;
1120
+ lastHover = { x: clampedX, value: v, time: t };
1121
+ cfg.onHover?.({ time: t, value: v, x: clampedX, y: layout.toY(v) });
1122
+ }
1123
+ }
1124
+ const scrubTarget = isActiveHover ? 1 : 0;
1125
+ if (noMotion) {
1126
+ scrubAmount = scrubTarget;
1127
+ } else {
1128
+ scrubAmount += (scrubTarget - scrubAmount) * SCRUB_LERP_SPEED;
1129
+ if (scrubAmount < 0.01) scrubAmount = 0;
1130
+ if (scrubAmount > 0.99) scrubAmount = 1;
1131
+ }
1132
+ let drawHoverX = hoverChartX;
1133
+ let drawHoverValue = hoverValue;
1134
+ let drawHoverTime = hoverTime;
1135
+ if (!isActiveHover && scrubAmount > 0 && lastHover) {
1136
+ drawHoverX = lastHover.x;
1137
+ drawHoverValue = lastHover.value;
1138
+ drawHoverTime = lastHover.time;
1139
+ }
1140
+ return {
1141
+ hoverX: drawHoverX,
1142
+ hoverValue: drawHoverValue,
1143
+ hoverTime: drawHoverTime,
1144
+ scrubAmount,
1145
+ isActiveHover,
1146
+ lastHover
1147
+ };
1148
+ }
1149
+ function updateBadgeDOM(badge, cfg, smoothValue, layout, momentum, badgeY, badgeColor, isWindowTransitioning, noMotion, ctx, dt) {
1150
+ if (!cfg.showBadge) {
1151
+ badge.container.style.display = "none";
1152
+ return badgeY;
1153
+ }
1154
+ badge.container.style.display = "";
1155
+ const { w, h, pad } = layout;
1156
+ const text = cfg.formatValue(smoothValue);
1157
+ badge.text.textContent = text;
1158
+ badge.text.style.font = cfg.palette.labelFont;
1159
+ badge.text.style.lineHeight = `${BADGE_LINE_H}px`;
1160
+ const tailLen = cfg.badgeTail ? BADGE_TAIL_LEN : 0;
1161
+ badge.text.style.padding = `${BADGE_PAD_Y}px ${BADGE_PAD_X}px ${BADGE_PAD_Y}px ${tailLen + BADGE_PAD_X}px`;
1162
+ ctx.font = cfg.palette.labelFont;
1163
+ const template = text.replace(/[0-9]/g, "8");
1164
+ const targetTextW = ctx.measureText(template).width;
1165
+ badge.targetW = targetTextW;
1166
+ if (badge.displayW === 0) badge.displayW = targetTextW;
1167
+ badge.displayW = lerp(badge.displayW, badge.targetW, BADGE_WIDTH_LERP, dt);
1168
+ if (Math.abs(badge.displayW - badge.targetW) < 0.3) badge.displayW = badge.targetW;
1169
+ const textW = badge.displayW;
1170
+ const pillW = textW + BADGE_PAD_X * 2;
1171
+ const pillH = BADGE_LINE_H + BADGE_PAD_Y * 2;
1172
+ const totalW = tailLen + pillW;
1173
+ badge.svg.setAttribute("width", String(Math.ceil(totalW)));
1174
+ badge.svg.setAttribute("height", String(pillH));
1175
+ badge.svg.setAttribute("viewBox", `0 0 ${totalW} ${pillH}`);
1176
+ badge.path.setAttribute("d", cfg.badgeTail ? badgeSvgPath(pillW, pillH, BADGE_TAIL_LEN, BADGE_TAIL_SPREAD) : badgePillOnly(pillW, pillH));
1177
+ const targetBadgeY = Math.max(pad.top, Math.min(h - pad.bottom, layout.toY(smoothValue)));
1178
+ if (badgeY === null || noMotion) {
1179
+ badgeY = targetBadgeY;
1180
+ } else {
1181
+ const badgeSpeed = isWindowTransitioning ? BADGE_Y_LERP_TRANSITIONING : BADGE_Y_LERP;
1182
+ badgeY = lerp(badgeY, targetBadgeY, badgeSpeed, dt);
1183
+ }
1184
+ const badgeLeft = w - pad.right + 8 - BADGE_PAD_X - tailLen;
1185
+ const badgeTop = badgeY - pillH / 2;
1186
+ badge.container.style.transform = `translate3d(${badgeLeft}px, ${badgeTop}px, 0)`;
1187
+ if (cfg.badgeVariant === "minimal") {
1188
+ badge.path.setAttribute("fill", cfg.palette.badgeOuterBg);
1189
+ badge.text.style.color = cfg.palette.tooltipText;
1190
+ badge.container.style.filter = `drop-shadow(0 1px 4px ${cfg.palette.badgeOuterShadow})`;
1191
+ } else {
1192
+ badge.container.style.filter = "";
1193
+ badge.text.style.color = "#fff";
1194
+ const bs = badgeColor;
1195
+ let fillColor;
1196
+ if (!cfg.showMomentum) {
1197
+ fillColor = cfg.palette.line;
1198
+ } else {
1199
+ const target = momentum === "up" ? 1 : momentum === "down" ? 0 : bs.green;
1200
+ bs.green = noMotion ? target : lerp(bs.green, target, MOMENTUM_COLOR_LERP, dt);
1201
+ if (bs.green > 0.99) bs.green = 1;
1202
+ if (bs.green < 0.01) bs.green = 0;
1203
+ const g = bs.green;
1204
+ const rr = Math.round(MOMENTUM_RED[0] + (MOMENTUM_GREEN[0] - MOMENTUM_RED[0]) * g);
1205
+ const gg = Math.round(MOMENTUM_RED[1] + (MOMENTUM_GREEN[1] - MOMENTUM_RED[1]) * g);
1206
+ const bb = Math.round(MOMENTUM_RED[2] + (MOMENTUM_GREEN[2] - MOMENTUM_RED[2]) * g);
1207
+ fillColor = `rgb(${rr},${gg},${bb})`;
1208
+ }
1209
+ badge.path.setAttribute("fill", fillColor);
1210
+ }
1211
+ return badgeY;
1212
+ }
1213
+ function useLivelineEngine(canvasRef, containerRef, config) {
1214
+ const configRef = useRef(config);
1215
+ configRef.current = config;
1216
+ const displayValueRef = useRef(config.value);
1217
+ const displayMinRef = useRef(0);
1218
+ const displayMaxRef = useRef(0);
1219
+ const targetMinRef = useRef(0);
1220
+ const targetMaxRef = useRef(0);
1221
+ const rangeInitedRef = useRef(false);
1222
+ const displayWindowRef = useRef(config.windowSecs);
1223
+ const windowTransitionRef = useRef({
1224
+ from: config.windowSecs,
1225
+ to: config.windowSecs,
1226
+ startMs: 0,
1227
+ rangeFromMin: 0,
1228
+ rangeFromMax: 0,
1229
+ rangeToMin: 0,
1230
+ rangeToMax: 0
1231
+ });
1232
+ const arrowStateRef = useRef({ up: 0, down: 0 });
1233
+ const gridStateRef = useRef({ interval: 0, labels: /* @__PURE__ */ new Map() });
1234
+ const timeAxisStateRef = useRef({ labels: /* @__PURE__ */ new Map() });
1235
+ const orderbookStateRef = useRef(createOrderbookState());
1236
+ const particleStateRef = useRef(createParticleState());
1237
+ const shakeStateRef = useRef(createShakeState());
1238
+ const badgeColorRef = useRef({ green: 1 });
1239
+ const badgeYRef = useRef(null);
1240
+ const reducedMotionRef = useRef(false);
1241
+ const sizeRef = useRef({ w: 0, h: 0 });
1242
+ const rafRef = useRef(0);
1243
+ const lastFrameRef = useRef(0);
1244
+ const badgeRef = useRef(null);
1245
+ const hoverXRef = useRef(null);
1246
+ const scrubAmountRef = useRef(0);
1247
+ const lastHoverRef = useRef(null);
1248
+ useEffect(() => {
1249
+ const container = containerRef.current;
1250
+ if (!container) return;
1251
+ const el = document.createElement("div");
1252
+ el.style.cssText = "position:absolute;top:0;left:0;pointer-events:none;will-change:transform;display:none;z-index:1;";
1253
+ const svg = document.createElementNS(SVG_NS, "svg");
1254
+ svg.style.cssText = "position:absolute;top:0;left:0;";
1255
+ const path = document.createElementNS(SVG_NS, "path");
1256
+ svg.appendChild(path);
1257
+ const text = document.createElement("span");
1258
+ text.style.cssText = "position:relative;display:block;color:#fff;white-space:nowrap;";
1259
+ el.appendChild(svg);
1260
+ el.appendChild(text);
1261
+ container.appendChild(el);
1262
+ badgeRef.current = { container: el, svg, path, text, displayW: 0, targetW: 0 };
1263
+ return () => {
1264
+ container.removeChild(el);
1265
+ badgeRef.current = null;
1266
+ };
1267
+ }, [containerRef]);
1268
+ useEffect(() => {
1269
+ const container = containerRef.current;
1270
+ if (!container) return;
1271
+ const ro = new ResizeObserver((entries) => {
1272
+ const entry = entries[0];
1273
+ if (!entry) return;
1274
+ const { width, height } = entry.contentRect;
1275
+ sizeRef.current = { w: width, h: height };
1276
+ });
1277
+ ro.observe(container);
1278
+ const rect = container.getBoundingClientRect();
1279
+ sizeRef.current = { w: rect.width, h: rect.height };
1280
+ return () => ro.disconnect();
1281
+ }, [containerRef]);
1282
+ useEffect(() => {
1283
+ const container = containerRef.current;
1284
+ if (!container) return;
1285
+ const onMove = (e) => {
1286
+ if (!configRef.current.scrub) return;
1287
+ const rect = container.getBoundingClientRect();
1288
+ hoverXRef.current = e.clientX - rect.left;
1289
+ };
1290
+ const onLeave = () => {
1291
+ hoverXRef.current = null;
1292
+ configRef.current.onHover?.(null);
1293
+ };
1294
+ const onTouchStart = (e) => {
1295
+ if (!configRef.current.scrub) return;
1296
+ if (e.touches.length !== 1) return;
1297
+ const rect = container.getBoundingClientRect();
1298
+ hoverXRef.current = e.touches[0].clientX - rect.left;
1299
+ };
1300
+ const onTouchMove = (e) => {
1301
+ if (!configRef.current.scrub) return;
1302
+ if (e.touches.length !== 1) return;
1303
+ e.preventDefault();
1304
+ const rect = container.getBoundingClientRect();
1305
+ hoverXRef.current = e.touches[0].clientX - rect.left;
1306
+ };
1307
+ const onTouchEnd = () => {
1308
+ hoverXRef.current = null;
1309
+ configRef.current.onHover?.(null);
1310
+ };
1311
+ container.addEventListener("mousemove", onMove);
1312
+ container.addEventListener("mouseleave", onLeave);
1313
+ container.addEventListener("touchstart", onTouchStart, { passive: true });
1314
+ container.addEventListener("touchmove", onTouchMove, { passive: false });
1315
+ container.addEventListener("touchend", onTouchEnd);
1316
+ container.addEventListener("touchcancel", onTouchEnd);
1317
+ return () => {
1318
+ container.removeEventListener("mousemove", onMove);
1319
+ container.removeEventListener("mouseleave", onLeave);
1320
+ container.removeEventListener("touchstart", onTouchStart);
1321
+ container.removeEventListener("touchmove", onTouchMove);
1322
+ container.removeEventListener("touchend", onTouchEnd);
1323
+ container.removeEventListener("touchcancel", onTouchEnd);
1324
+ };
1325
+ }, [containerRef]);
1326
+ useEffect(() => {
1327
+ const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
1328
+ reducedMotionRef.current = mql.matches;
1329
+ const onChange = (e) => {
1330
+ reducedMotionRef.current = e.matches;
1331
+ };
1332
+ mql.addEventListener("change", onChange);
1333
+ return () => mql.removeEventListener("change", onChange);
1334
+ }, []);
1335
+ useEffect(() => {
1336
+ const onVisibility = () => {
1337
+ if (!document.hidden && !rafRef.current) {
1338
+ rafRef.current = requestAnimationFrame(draw);
1339
+ }
1340
+ };
1341
+ document.addEventListener("visibilitychange", onVisibility);
1342
+ return () => document.removeEventListener("visibilitychange", onVisibility);
1343
+ }, []);
1344
+ const draw = useCallback(() => {
1345
+ if (document.hidden) {
1346
+ rafRef.current = 0;
1347
+ return;
1348
+ }
1349
+ const canvas = canvasRef.current;
1350
+ const { w, h } = sizeRef.current;
1351
+ if (!canvas || w === 0 || h === 0) {
1352
+ rafRef.current = requestAnimationFrame(draw);
1353
+ return;
1354
+ }
1355
+ const cfg = configRef.current;
1356
+ const dpr = getDpr();
1357
+ const now_ms = performance.now();
1358
+ const dt = lastFrameRef.current ? Math.min(now_ms - lastFrameRef.current, MAX_DELTA_MS) : 16.67;
1359
+ lastFrameRef.current = now_ms;
1360
+ const targetW = Math.round(w * dpr);
1361
+ const targetH = Math.round(h * dpr);
1362
+ if (canvas.width !== targetW || canvas.height !== targetH) {
1363
+ canvas.width = targetW;
1364
+ canvas.height = targetH;
1365
+ canvas.style.width = `${w}px`;
1366
+ canvas.style.height = `${h}px`;
1367
+ }
1368
+ const ctx = canvas.getContext("2d");
1369
+ if (!ctx) {
1370
+ rafRef.current = requestAnimationFrame(draw);
1371
+ return;
1372
+ }
1373
+ applyDpr(ctx, dpr, w, h);
1374
+ const noMotion = reducedMotionRef.current;
1375
+ const points = cfg.data;
1376
+ if (points.length < 2) {
1377
+ if (badgeRef.current) badgeRef.current.container.style.display = "none";
1378
+ rafRef.current = requestAnimationFrame(draw);
1379
+ return;
1380
+ }
1381
+ const adaptiveSpeed = computeAdaptiveSpeed(
1382
+ cfg.value,
1383
+ displayValueRef.current,
1384
+ displayMinRef.current,
1385
+ displayMaxRef.current,
1386
+ cfg.lerpSpeed,
1387
+ noMotion
1388
+ );
1389
+ displayValueRef.current = lerp(displayValueRef.current, cfg.value, adaptiveSpeed, dt);
1390
+ const prevRange = displayMaxRef.current - displayMinRef.current || 1;
1391
+ if (Math.abs(displayValueRef.current - cfg.value) < prevRange * VALUE_SNAP_THRESHOLD) {
1392
+ displayValueRef.current = cfg.value;
1393
+ }
1394
+ const smoothValue = displayValueRef.current;
1395
+ const pad = cfg.padding;
1396
+ const chartW = w - pad.left - pad.right;
1397
+ const needsArrowRoom = cfg.showMomentum;
1398
+ const buffer = needsArrowRoom ? Math.max(WINDOW_BUFFER, 37 / Math.max(chartW, 1)) : WINDOW_BUFFER;
1399
+ const transition = windowTransitionRef.current;
1400
+ const now = Date.now() / 1e3;
1401
+ const windowResult = updateWindowTransition(
1402
+ cfg,
1403
+ transition,
1404
+ displayWindowRef.current,
1405
+ displayMinRef.current,
1406
+ displayMaxRef.current,
1407
+ noMotion,
1408
+ now_ms,
1409
+ now,
1410
+ points,
1411
+ smoothValue,
1412
+ buffer
1413
+ );
1414
+ displayWindowRef.current = windowResult.windowSecs;
1415
+ const windowSecs = windowResult.windowSecs;
1416
+ const windowTransProgress = windowResult.windowTransProgress;
1417
+ const rightEdge = now + windowSecs * buffer;
1418
+ const leftEdge = rightEdge - windowSecs;
1419
+ const visible = [];
1420
+ for (const p of points) {
1421
+ if (p.time >= leftEdge - 2 && p.time <= rightEdge) {
1422
+ visible.push(p);
1423
+ }
1424
+ }
1425
+ if (visible.length < 2) {
1426
+ if (badgeRef.current) badgeRef.current.container.style.display = "none";
1427
+ rafRef.current = requestAnimationFrame(draw);
1428
+ return;
1429
+ }
1430
+ const chartH = h - pad.top - pad.bottom;
1431
+ const computedRange = computeRange(visible, smoothValue, cfg.referenceLine?.value, cfg.exaggerate);
1432
+ const isWindowTransitioning = transition.startMs > 0;
1433
+ const rangeResult = updateRange(
1434
+ computedRange,
1435
+ rangeInitedRef.current,
1436
+ targetMinRef.current,
1437
+ targetMaxRef.current,
1438
+ displayMinRef.current,
1439
+ displayMaxRef.current,
1440
+ isWindowTransitioning,
1441
+ windowTransProgress,
1442
+ transition,
1443
+ adaptiveSpeed,
1444
+ chartH,
1445
+ dt
1446
+ );
1447
+ rangeInitedRef.current = rangeResult.rangeInited;
1448
+ targetMinRef.current = rangeResult.targetMin;
1449
+ targetMaxRef.current = rangeResult.targetMax;
1450
+ displayMinRef.current = rangeResult.displayMin;
1451
+ displayMaxRef.current = rangeResult.displayMax;
1452
+ const { minVal, maxVal, valRange } = rangeResult;
1453
+ const layout = {
1454
+ w,
1455
+ h,
1456
+ pad,
1457
+ chartW,
1458
+ chartH,
1459
+ leftEdge,
1460
+ rightEdge,
1461
+ minVal,
1462
+ maxVal,
1463
+ valRange,
1464
+ toX: (t) => pad.left + (t - leftEdge) / (rightEdge - leftEdge) * chartW,
1465
+ toY: (v) => pad.top + (1 - (v - minVal) / valRange) * chartH
1466
+ };
1467
+ const momentum = cfg.momentumOverride ?? detectMomentum(visible);
1468
+ const hoverResult = updateHoverState(
1469
+ hoverXRef.current,
1470
+ pad,
1471
+ w,
1472
+ layout,
1473
+ now,
1474
+ visible,
1475
+ scrubAmountRef.current,
1476
+ lastHoverRef.current,
1477
+ cfg,
1478
+ noMotion,
1479
+ leftEdge,
1480
+ rightEdge,
1481
+ chartW,
1482
+ dt
1483
+ );
1484
+ scrubAmountRef.current = hoverResult.scrubAmount;
1485
+ lastHoverRef.current = hoverResult.lastHover;
1486
+ const { hoverX: drawHoverX, hoverValue: drawHoverValue, hoverTime: drawHoverTime } = hoverResult;
1487
+ const lookback = Math.min(5, visible.length - 1);
1488
+ const recentDelta = lookback > 0 ? Math.abs(visible[visible.length - 1].value - visible[visible.length - 1 - lookback].value) : 0;
1489
+ const swingMagnitude = valRange > 0 ? Math.min(recentDelta / valRange, 1) : 0;
1490
+ drawFrame(ctx, layout, cfg.palette, {
1491
+ visible,
1492
+ smoothValue,
1493
+ now,
1494
+ momentum,
1495
+ arrowState: arrowStateRef.current,
1496
+ showGrid: cfg.showGrid,
1497
+ showMomentum: cfg.showMomentum,
1498
+ showPulse: cfg.showPulse,
1499
+ showFill: cfg.showFill,
1500
+ referenceLine: cfg.referenceLine,
1501
+ hoverX: drawHoverX,
1502
+ hoverValue: drawHoverValue,
1503
+ hoverTime: drawHoverTime,
1504
+ scrubAmount: scrubAmountRef.current,
1505
+ windowSecs,
1506
+ formatValue: cfg.formatValue,
1507
+ formatTime: cfg.formatTime,
1508
+ gridState: gridStateRef.current,
1509
+ timeAxisState: timeAxisStateRef.current,
1510
+ dt,
1511
+ targetWindowSecs: cfg.windowSecs,
1512
+ tooltipY: cfg.tooltipY,
1513
+ tooltipOutline: cfg.tooltipOutline,
1514
+ orderbookData: cfg.orderbookData,
1515
+ orderbookState: cfg.orderbookData ? orderbookStateRef.current : void 0,
1516
+ particleState: cfg.degenOptions ? particleStateRef.current : void 0,
1517
+ particleOptions: cfg.degenOptions,
1518
+ swingMagnitude,
1519
+ shakeState: cfg.degenOptions ? shakeStateRef.current : void 0
1520
+ });
1521
+ const badge = badgeRef.current;
1522
+ if (badge) {
1523
+ badgeYRef.current = updateBadgeDOM(
1524
+ badge,
1525
+ cfg,
1526
+ smoothValue,
1527
+ layout,
1528
+ momentum,
1529
+ badgeYRef.current,
1530
+ badgeColorRef.current,
1531
+ isWindowTransitioning,
1532
+ noMotion,
1533
+ ctx,
1534
+ dt
1535
+ );
1536
+ }
1537
+ const valEl = cfg.valueDisplayRef?.current;
1538
+ if (valEl) {
1539
+ const displayVal = cfg.valueMomentumColor ? Math.abs(smoothValue) : smoothValue;
1540
+ valEl.textContent = cfg.formatValue(displayVal);
1541
+ if (cfg.valueMomentumColor) {
1542
+ const mc = momentum === "up" ? "#22c55e" : momentum === "down" ? "#ef4444" : "";
1543
+ if (mc) valEl.style.color = mc;
1544
+ else valEl.style.removeProperty("color");
1545
+ }
1546
+ }
1547
+ rafRef.current = requestAnimationFrame(draw);
1548
+ }, [canvasRef]);
1549
+ useEffect(() => {
1550
+ rafRef.current = requestAnimationFrame(draw);
1551
+ return () => cancelAnimationFrame(rafRef.current);
1552
+ }, [draw]);
1553
+ }
1554
+
1555
+ // src/Liveline.tsx
1556
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1557
+ var defaultFormatValue = (v) => v.toFixed(2);
1558
+ var defaultFormatTime = (t) => {
1559
+ const d = new Date(t * 1e3);
1560
+ const h = d.getHours().toString().padStart(2, "0");
1561
+ const m = d.getMinutes().toString().padStart(2, "0");
1562
+ const s = d.getSeconds().toString().padStart(2, "0");
1563
+ return `${h}:${m}:${s}`;
1564
+ };
1565
+ function Liveline({
1566
+ data,
1567
+ value,
1568
+ theme = "dark",
1569
+ color = "#3b82f6",
1570
+ window: windowSecs = 30,
1571
+ grid = true,
1572
+ badge = true,
1573
+ momentum = true,
1574
+ fill = true,
1575
+ scrub = true,
1576
+ exaggerate = false,
1577
+ degen: degenProp,
1578
+ badgeTail = true,
1579
+ badgeVariant = "default",
1580
+ showValue = false,
1581
+ valueMomentumColor = false,
1582
+ windows,
1583
+ onWindowChange,
1584
+ windowStyle,
1585
+ tooltipY = 14,
1586
+ tooltipOutline = true,
1587
+ orderbook,
1588
+ referenceLine,
1589
+ formatValue = defaultFormatValue,
1590
+ formatTime = defaultFormatTime,
1591
+ lerpSpeed = 0.08,
1592
+ padding: paddingOverride,
1593
+ onHover,
1594
+ cursor = "crosshair",
1595
+ pulse = true,
1596
+ className,
1597
+ style
1598
+ }) {
1599
+ const canvasRef = useRef2(null);
1600
+ const containerRef = useRef2(null);
1601
+ const valueDisplayRef = useRef2(null);
1602
+ const windowBarRef = useRef2(null);
1603
+ const windowBtnRefs = useRef2(/* @__PURE__ */ new Map());
1604
+ const [indicatorStyle, setIndicatorStyle] = useState(null);
1605
+ const palette = resolveTheme(color, theme);
1606
+ const isDark = theme === "dark";
1607
+ const showMomentum = momentum !== false;
1608
+ const momentumOverride = typeof momentum === "string" ? momentum : void 0;
1609
+ const pad = {
1610
+ top: paddingOverride?.top ?? 12,
1611
+ right: paddingOverride?.right ?? 80,
1612
+ bottom: paddingOverride?.bottom ?? 28,
1613
+ left: paddingOverride?.left ?? 12
1614
+ };
1615
+ const degenEnabled = degenProp != null ? degenProp !== false : false;
1616
+ const degenOptions = degenEnabled ? typeof degenProp === "object" ? degenProp : {} : void 0;
1617
+ const [activeWindowSecs, setActiveWindowSecs] = useState(
1618
+ windows && windows.length > 0 ? windows[0].secs : windowSecs
1619
+ );
1620
+ const effectiveWindowSecs = windows ? activeWindowSecs : windowSecs;
1621
+ useLayoutEffect(() => {
1622
+ if (!windows || windows.length === 0) return;
1623
+ const btn = windowBtnRefs.current.get(activeWindowSecs);
1624
+ const bar = windowBarRef.current;
1625
+ if (btn && bar) {
1626
+ const barRect = bar.getBoundingClientRect();
1627
+ const btnRect = btn.getBoundingClientRect();
1628
+ setIndicatorStyle({
1629
+ left: btnRect.left - barRect.left,
1630
+ width: btnRect.width
1631
+ });
1632
+ }
1633
+ }, [activeWindowSecs, windows]);
1634
+ const ws = windowStyle ?? "default";
1635
+ useLivelineEngine(canvasRef, containerRef, {
1636
+ data,
1637
+ value,
1638
+ palette,
1639
+ windowSecs: effectiveWindowSecs,
1640
+ lerpSpeed,
1641
+ showGrid: grid,
1642
+ showBadge: badge,
1643
+ showMomentum,
1644
+ momentumOverride,
1645
+ showFill: fill,
1646
+ referenceLine,
1647
+ formatValue,
1648
+ formatTime,
1649
+ padding: pad,
1650
+ onHover,
1651
+ showPulse: pulse,
1652
+ scrub,
1653
+ exaggerate,
1654
+ degenOptions,
1655
+ badgeTail,
1656
+ badgeVariant,
1657
+ tooltipY,
1658
+ tooltipOutline,
1659
+ valueMomentumColor,
1660
+ valueDisplayRef: showValue ? valueDisplayRef : void 0,
1661
+ orderbookData: orderbook
1662
+ });
1663
+ const cursorStyle = scrub ? cursor : "default";
1664
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1665
+ showValue && /* @__PURE__ */ jsx(
1666
+ "span",
1667
+ {
1668
+ ref: valueDisplayRef,
1669
+ style: {
1670
+ display: "block",
1671
+ fontSize: 20,
1672
+ fontWeight: 500,
1673
+ fontFamily: '"SF Mono", Menlo, monospace',
1674
+ color: isDark ? "rgba(255,255,255,0.85)" : "#111",
1675
+ transition: "color 0.3s",
1676
+ letterSpacing: "-0.01em",
1677
+ marginBottom: 8,
1678
+ paddingTop: 4,
1679
+ paddingLeft: pad.left
1680
+ }
1681
+ }
1682
+ ),
1683
+ windows && windows.length > 0 && /* @__PURE__ */ jsxs(
1684
+ "div",
1685
+ {
1686
+ ref: windowBarRef,
1687
+ style: {
1688
+ position: "relative",
1689
+ display: "inline-flex",
1690
+ gap: ws === "text" ? 4 : 2,
1691
+ background: ws === "text" ? "transparent" : isDark ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.02)",
1692
+ borderRadius: ws === "rounded" ? 999 : 6,
1693
+ padding: ws === "text" ? 0 : ws === "rounded" ? 3 : 2,
1694
+ marginBottom: 6,
1695
+ marginLeft: pad.left
1696
+ },
1697
+ children: [
1698
+ ws !== "text" && indicatorStyle && /* @__PURE__ */ jsx("div", { style: {
1699
+ position: "absolute",
1700
+ top: ws === "rounded" ? 3 : 2,
1701
+ left: indicatorStyle.left,
1702
+ width: indicatorStyle.width,
1703
+ height: ws === "rounded" ? "calc(100% - 6px)" : "calc(100% - 4px)",
1704
+ background: isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.035)",
1705
+ borderRadius: ws === "rounded" ? 999 : 4,
1706
+ transition: "left 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1)",
1707
+ pointerEvents: "none"
1708
+ } }),
1709
+ windows.map((w) => {
1710
+ const isActive = w.secs === activeWindowSecs;
1711
+ return /* @__PURE__ */ jsx(
1712
+ "button",
1713
+ {
1714
+ ref: (el) => {
1715
+ if (el) windowBtnRefs.current.set(w.secs, el);
1716
+ else windowBtnRefs.current.delete(w.secs);
1717
+ },
1718
+ onClick: () => {
1719
+ setActiveWindowSecs(w.secs);
1720
+ onWindowChange?.(w.secs);
1721
+ },
1722
+ style: {
1723
+ position: "relative",
1724
+ zIndex: 1,
1725
+ fontSize: 11,
1726
+ padding: ws === "text" ? "2px 6px" : "3px 10px",
1727
+ borderRadius: ws === "rounded" ? 999 : 4,
1728
+ border: "none",
1729
+ cursor: "pointer",
1730
+ fontFamily: "system-ui, -apple-system, sans-serif",
1731
+ fontWeight: isActive ? 600 : 400,
1732
+ background: "transparent",
1733
+ color: isActive ? isDark ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.55)" : isDark ? "rgba(255,255,255,0.25)" : "rgba(0,0,0,0.22)",
1734
+ transition: "color 0.2s, background 0.15s",
1735
+ lineHeight: "16px"
1736
+ },
1737
+ children: w.label
1738
+ },
1739
+ w.secs
1740
+ );
1741
+ })
1742
+ ]
1743
+ }
1744
+ ),
1745
+ /* @__PURE__ */ jsx(
1746
+ "div",
1747
+ {
1748
+ ref: containerRef,
1749
+ className,
1750
+ style: {
1751
+ width: "100%",
1752
+ height: "100%",
1753
+ position: "relative",
1754
+ ...style
1755
+ },
1756
+ children: /* @__PURE__ */ jsx(
1757
+ "canvas",
1758
+ {
1759
+ ref: canvasRef,
1760
+ style: { display: "block", cursor: cursorStyle }
1761
+ }
1762
+ )
1763
+ }
1764
+ )
1765
+ ] });
1766
+ }
1767
+ export {
1768
+ Liveline
1769
+ };