@three-flatland/devtools 0.1.0-alpha.3

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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -0
  3. package/dist/buffers-modal.cjs +491 -0
  4. package/dist/buffers-modal.cjs.map +1 -0
  5. package/dist/buffers-modal.d.cts +62 -0
  6. package/dist/buffers-modal.d.ts +62 -0
  7. package/dist/buffers-modal.js +467 -0
  8. package/dist/buffers-modal.js.map +1 -0
  9. package/dist/buffers-view.cjs +269 -0
  10. package/dist/buffers-view.cjs.map +1 -0
  11. package/dist/buffers-view.d.cts +39 -0
  12. package/dist/buffers-view.d.ts +39 -0
  13. package/dist/buffers-view.js +245 -0
  14. package/dist/buffers-view.js.map +1 -0
  15. package/dist/create-pane.cjs +203 -0
  16. package/dist/create-pane.cjs.map +1 -0
  17. package/dist/create-pane.d.cts +66 -0
  18. package/dist/create-pane.d.ts +66 -0
  19. package/dist/create-pane.js +178 -0
  20. package/dist/create-pane.js.map +1 -0
  21. package/dist/dashboard/app.tsx +42 -0
  22. package/dist/dashboard/client.ts +21 -0
  23. package/dist/dashboard/export.ts +84 -0
  24. package/dist/dashboard/hooks.ts +75 -0
  25. package/dist/dashboard/index.html +1149 -0
  26. package/dist/dashboard/index.tsx +18 -0
  27. package/dist/dashboard/panels/batches.tsx +282 -0
  28. package/dist/dashboard/panels/buffers.tsx +425 -0
  29. package/dist/dashboard/panels/env.tsx +85 -0
  30. package/dist/dashboard/panels/header-stats.tsx +30 -0
  31. package/dist/dashboard/panels/producer-select.tsx +81 -0
  32. package/dist/dashboard/panels/protocol-log.tsx +743 -0
  33. package/dist/dashboard/panels/registry.tsx +642 -0
  34. package/dist/dashboard/panels/stats.tsx +431 -0
  35. package/dist/dashboard/protocol-store.ts +312 -0
  36. package/dist/dashboard/tsconfig.json +19 -0
  37. package/dist/dashboard/vendor/hooks.module.js +1 -0
  38. package/dist/dashboard/vendor/jsx-runtime.js +22 -0
  39. package/dist/dashboard/vendor/preact.module.js +12 -0
  40. package/dist/devtools-client.cjs +870 -0
  41. package/dist/devtools-client.cjs.map +1 -0
  42. package/dist/devtools-client.d.cts +362 -0
  43. package/dist/devtools-client.d.ts +362 -0
  44. package/dist/devtools-client.js +852 -0
  45. package/dist/devtools-client.js.map +1 -0
  46. package/dist/index.cjs +42 -0
  47. package/dist/index.cjs.map +1 -0
  48. package/dist/index.d.cts +8 -0
  49. package/dist/index.d.ts +8 -0
  50. package/dist/index.js +13 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/perf-trace.cjs +103 -0
  53. package/dist/perf-trace.cjs.map +1 -0
  54. package/dist/perf-trace.d.cts +27 -0
  55. package/dist/perf-trace.d.ts +27 -0
  56. package/dist/perf-trace.js +79 -0
  57. package/dist/perf-trace.js.map +1 -0
  58. package/dist/plugins.cjs +44 -0
  59. package/dist/plugins.cjs.map +1 -0
  60. package/dist/plugins.d.cts +7 -0
  61. package/dist/plugins.d.ts +7 -0
  62. package/dist/plugins.js +9 -0
  63. package/dist/plugins.js.map +1 -0
  64. package/dist/provider-switcher.cjs +95 -0
  65. package/dist/provider-switcher.cjs.map +1 -0
  66. package/dist/provider-switcher.d.cts +18 -0
  67. package/dist/provider-switcher.d.ts +18 -0
  68. package/dist/provider-switcher.js +71 -0
  69. package/dist/provider-switcher.js.map +1 -0
  70. package/dist/react/devtools-provider.cjs +59 -0
  71. package/dist/react/devtools-provider.cjs.map +1 -0
  72. package/dist/react/devtools-provider.d.cts +39 -0
  73. package/dist/react/devtools-provider.d.ts +39 -0
  74. package/dist/react/devtools-provider.js +38 -0
  75. package/dist/react/devtools-provider.js.map +1 -0
  76. package/dist/react/use-fps-graph.cjs +49 -0
  77. package/dist/react/use-fps-graph.cjs.map +1 -0
  78. package/dist/react/use-fps-graph.d.cts +15 -0
  79. package/dist/react/use-fps-graph.d.ts +15 -0
  80. package/dist/react/use-fps-graph.js +25 -0
  81. package/dist/react/use-fps-graph.js.map +1 -0
  82. package/dist/react/use-pane-button.cjs +58 -0
  83. package/dist/react/use-pane-button.cjs.map +1 -0
  84. package/dist/react/use-pane-button.d.cts +10 -0
  85. package/dist/react/use-pane-button.d.ts +10 -0
  86. package/dist/react/use-pane-button.js +34 -0
  87. package/dist/react/use-pane-button.js.map +1 -0
  88. package/dist/react/use-pane-folder.cjs +48 -0
  89. package/dist/react/use-pane-folder.cjs.map +1 -0
  90. package/dist/react/use-pane-folder.d.cts +15 -0
  91. package/dist/react/use-pane-folder.d.ts +15 -0
  92. package/dist/react/use-pane-folder.js +24 -0
  93. package/dist/react/use-pane-folder.js.map +1 -0
  94. package/dist/react/use-pane-input.cjs +70 -0
  95. package/dist/react/use-pane-input.cjs.map +1 -0
  96. package/dist/react/use-pane-input.d.cts +44 -0
  97. package/dist/react/use-pane-input.d.ts +44 -0
  98. package/dist/react/use-pane-input.js +46 -0
  99. package/dist/react/use-pane-input.js.map +1 -0
  100. package/dist/react/use-pane-radio-grid.cjs +78 -0
  101. package/dist/react/use-pane-radio-grid.cjs.map +1 -0
  102. package/dist/react/use-pane-radio-grid.d.cts +35 -0
  103. package/dist/react/use-pane-radio-grid.d.ts +35 -0
  104. package/dist/react/use-pane-radio-grid.js +54 -0
  105. package/dist/react/use-pane-radio-grid.js.map +1 -0
  106. package/dist/react/use-pane.cjs +45 -0
  107. package/dist/react/use-pane.cjs.map +1 -0
  108. package/dist/react/use-pane.d.cts +40 -0
  109. package/dist/react/use-pane.d.ts +40 -0
  110. package/dist/react/use-pane.js +21 -0
  111. package/dist/react/use-pane.js.map +1 -0
  112. package/dist/react.cjs +50 -0
  113. package/dist/react.cjs.map +1 -0
  114. package/dist/react.d.cts +12 -0
  115. package/dist/react.d.ts +12 -0
  116. package/dist/react.js +19 -0
  117. package/dist/react.js.map +1 -0
  118. package/dist/registry-view.cjs +371 -0
  119. package/dist/registry-view.cjs.map +1 -0
  120. package/dist/registry-view.d.cts +24 -0
  121. package/dist/registry-view.d.ts +24 -0
  122. package/dist/registry-view.js +347 -0
  123. package/dist/registry-view.js.map +1 -0
  124. package/dist/stats-graph.cjs +297 -0
  125. package/dist/stats-graph.cjs.map +1 -0
  126. package/dist/stats-graph.d.cts +40 -0
  127. package/dist/stats-graph.d.ts +40 -0
  128. package/dist/stats-graph.js +273 -0
  129. package/dist/stats-graph.js.map +1 -0
  130. package/dist/stats-row.cjs +153 -0
  131. package/dist/stats-row.cjs.map +1 -0
  132. package/dist/stats-row.d.cts +24 -0
  133. package/dist/stats-row.d.ts +24 -0
  134. package/dist/stats-row.js +129 -0
  135. package/dist/stats-row.js.map +1 -0
  136. package/dist/theme.cjs +260 -0
  137. package/dist/theme.cjs.map +1 -0
  138. package/dist/theme.d.cts +10 -0
  139. package/dist/theme.d.ts +10 -0
  140. package/dist/theme.js +235 -0
  141. package/dist/theme.js.map +1 -0
  142. package/dist/vite-plugin.cjs +113 -0
  143. package/dist/vite-plugin.cjs.map +1 -0
  144. package/dist/vite-plugin.d.cts +9 -0
  145. package/dist/vite-plugin.d.ts +9 -0
  146. package/dist/vite-plugin.js +88 -0
  147. package/dist/vite-plugin.js.map +1 -0
  148. package/package.json +124 -0
@@ -0,0 +1,431 @@
1
+ /** @jsxImportSource preact */
2
+ /**
3
+ * Stats strip — live sparkline per counter.
4
+ *
5
+ * Each card pairs a value label with a muted canvas sparkline drawn
6
+ * behind the text. Palette matches the Tweakpane pane's retro theme
7
+ * colors so switching between the two UIs stays visually coherent.
8
+ *
9
+ * The provider already keeps per-field rings in `DevtoolsState.series`;
10
+ * this component just samples them on every state update and redraws
11
+ * each canvas. Auto-ranged per series — a perfectly flat metric (e.g.
12
+ * no draw-call churn) renders as a centered line rather than a
13
+ * jittering zero.
14
+ */
15
+ import { useEffect, useRef, useState } from 'preact/hooks'
16
+ import type { DevtoolsSeries, DevtoolsState } from '../../devtools-client.js'
17
+ import { useDevtoolsState } from '../hooks.js'
18
+
19
+ interface StatDef {
20
+ label: string
21
+ pick: (s: DevtoolsState) => number | undefined
22
+ series: (s: DevtoolsState) => DevtoolsSeries
23
+ format: (v: number | undefined) => string
24
+ color: string
25
+ }
26
+
27
+ function fmt0(v: number | undefined): string {
28
+ return v === undefined ? '—' : Math.round(v).toString()
29
+ }
30
+ function fmtMs(v: number | undefined): string {
31
+ return v === undefined ? '—' : `${v.toFixed(1)}ms`
32
+ }
33
+ function fmtMB(v: number | undefined): string {
34
+ return v === undefined ? '—' : `${v.toFixed(0)}MB`
35
+ }
36
+
37
+ const STATS: StatDef[] = [
38
+ { label: 'fps', pick: (s) => s.fps, series: (s) => s.series.fps, format: fmt0, color: '#47cca9' },
39
+ { label: 'cpu', pick: (s) => s.cpuMs, series: (s) => s.series.cpuMs, format: fmtMs, color: '#47cc6a' },
40
+ { label: 'gpu', pick: (s) => s.gpuMs, series: (s) => s.series.gpuMs, format: fmtMs, color: '#ffa347' },
41
+ { label: 'draws', pick: (s) => s.drawCalls, series: (s) => s.series.drawCalls, format: fmt0, color: '#5eb0ff' },
42
+ { label: 'tris', pick: (s) => s.triangles, series: (s) => s.series.triangles, format: fmt0, color: '#c792ea' },
43
+ { label: 'prims', pick: (s) => s.primitives, series: (s) => s.series.primitives, format: fmt0, color: '#ff8fa3' },
44
+ { label: 'geos', pick: (s) => s.geometries, series: (s) => s.series.geometries, format: fmt0, color: '#f78c6c' },
45
+ { label: 'tex', pick: (s) => s.textures, series: (s) => s.series.textures, format: fmt0, color: '#ffc371' },
46
+ { label: 'heap', pick: (s) => s.heapUsedMB, series: (s) => s.series.heapUsedMB, format: fmtMB, color: '#d94c87' },
47
+ ]
48
+
49
+ export function StatsStrip() {
50
+ const s = useDevtoolsState()
51
+ const [expandedKey, setExpandedKey] = useState<string | null>(null)
52
+ // Hide stats the active producer can't actually emit. Without this
53
+ // gate, the GPU card on Safari (or anywhere `timestamp-query` is
54
+ // missing) renders as flat-zero — same shape as the heap card on
55
+ // browsers without `performance.memory`. Both signals are propagated
56
+ // explicitly: `gpuModeEnabled` from the env payload, `heapUsedMB`
57
+ // from the stats payload (omitted when no `performance.memory`).
58
+ const visibleStats = STATS.filter((def) => {
59
+ if (def.label === 'gpu') return s.gpuModeEnabled === true
60
+ if (def.label === 'heap') return s.heapUsedMB !== undefined
61
+ return true
62
+ })
63
+ const expandedDef = visibleStats.find((d) => d.label === expandedKey) ?? null
64
+ return (
65
+ <section class="panel">
66
+ <header class="panel-header">Stats</header>
67
+ <div class="stats-strip">
68
+ {visibleStats.map((def) => (
69
+ <StatCard
70
+ key={def.label}
71
+ def={def}
72
+ state={s}
73
+ selected={def.label === expandedKey}
74
+ onSelect={() => setExpandedKey((k) => (k === def.label ? null : def.label))}
75
+ />
76
+ ))}
77
+ </div>
78
+ {expandedDef !== null && (
79
+ <StatDetail
80
+ def={expandedDef}
81
+ state={s}
82
+ onClose={() => setExpandedKey(null)}
83
+ />
84
+ )}
85
+ </section>
86
+ )
87
+ }
88
+
89
+ function StatCard({
90
+ def,
91
+ state,
92
+ selected,
93
+ onSelect,
94
+ }: {
95
+ def: StatDef
96
+ state: DevtoolsState
97
+ selected: boolean
98
+ onSelect: () => void
99
+ }) {
100
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
101
+ // Persistent per-card smoothed axis range — prevents the "peaks
102
+ // jumping as it scrolls" effect that raw per-frame min/max
103
+ // produces when a tall sample exits the visible window. Grows
104
+ // instantly to match new peaks, decays slowly afterward (see
105
+ // `drawSeries`'s RANGE_DECAY).
106
+ const rangeRef = useRef<{ min: number; max: number } | null>(null)
107
+ const value = def.pick(state)
108
+ const series = def.series(state)
109
+
110
+ useEffect(() => {
111
+ const canvas = canvasRef.current
112
+ if (canvas === null) return
113
+ const ctx = canvas.getContext('2d')
114
+ if (ctx === null) return
115
+ const dpr = window.devicePixelRatio || 1
116
+ const rect = canvas.getBoundingClientRect()
117
+ const w = Math.max(1, Math.round(rect.width * dpr))
118
+ const h = Math.max(1, Math.round(rect.height * dpr))
119
+ if (canvas.width !== w || canvas.height !== h) {
120
+ canvas.width = w
121
+ canvas.height = h
122
+ }
123
+ drawSeries(ctx, canvas.width, canvas.height, series, def.color, rangeRef)
124
+ })
125
+
126
+ return (
127
+ <button
128
+ type="button"
129
+ class={`stat${selected ? ' stat-selected' : ''}`}
130
+ style={{ '--stat-color': def.color }}
131
+ onClick={onSelect}
132
+ title="Click for percentile distribution"
133
+ >
134
+ <canvas class="stat-canvas" ref={canvasRef} />
135
+ <span class="stat-label">{def.label}</span>
136
+ <span class="stat-value">{def.format(value)}</span>
137
+ </button>
138
+ )
139
+ }
140
+
141
+ function StatDetail({ def, state, onClose }: {
142
+ def: StatDef
143
+ state: DevtoolsState
144
+ onClose: () => void
145
+ }) {
146
+ const series = def.series(state)
147
+ const stats = computeStats(series)
148
+ const histoRef = useRef<HTMLCanvasElement | null>(null)
149
+ useEffect(() => {
150
+ const canvas = histoRef.current
151
+ if (canvas === null) return
152
+ const ctx = canvas.getContext('2d')
153
+ if (ctx === null) return
154
+ const dpr = window.devicePixelRatio || 1
155
+ const rect = canvas.getBoundingClientRect()
156
+ const w = Math.max(1, Math.round(rect.width * dpr))
157
+ const h = Math.max(1, Math.round(rect.height * dpr))
158
+ if (canvas.width !== w || canvas.height !== h) {
159
+ canvas.width = w
160
+ canvas.height = h
161
+ }
162
+ drawHistogram(ctx, canvas.width, canvas.height, series, def.color)
163
+ })
164
+ return (
165
+ <div class="stat-detail" style={{ '--stat-color': def.color }}>
166
+ <div class="stat-detail-header">
167
+ <span class="stat-detail-label">{def.label}</span>
168
+ <span class="stat-detail-samples">{stats.count} samples</span>
169
+ <button type="button" class="stat-detail-close" onClick={onClose} aria-label="Close">×</button>
170
+ </div>
171
+ <div class="stat-detail-metrics">
172
+ <Metric label="min" value={def.format(stats.min)} />
173
+ <Metric label="p50" value={def.format(stats.p50)} />
174
+ <Metric label="mean" value={def.format(stats.mean)} />
175
+ <Metric label="p95" value={def.format(stats.p95)} />
176
+ <Metric label="p99" value={def.format(stats.p99)} />
177
+ <Metric label="max" value={def.format(stats.max)} />
178
+ </div>
179
+ <canvas class="stat-histo" ref={histoRef} />
180
+ </div>
181
+ )
182
+ }
183
+
184
+ function Metric({ label, value }: { label: string; value: string }) {
185
+ return (
186
+ <div class="stat-metric">
187
+ <span class="stat-metric-label">{label}</span>
188
+ <span class="stat-metric-value">{value}</span>
189
+ </div>
190
+ )
191
+ }
192
+
193
+ interface SeriesStats {
194
+ count: number
195
+ min: number | undefined
196
+ max: number | undefined
197
+ mean: number | undefined
198
+ p50: number | undefined
199
+ p95: number | undefined
200
+ p99: number | undefined
201
+ }
202
+
203
+ function computeStats(series: DevtoolsSeries): SeriesStats {
204
+ const len = series.length
205
+ if (len === 0) return { count: 0, min: undefined, max: undefined, mean: undefined, p50: undefined, p95: undefined, p99: undefined }
206
+ const size = series.data.length
207
+ const start = (series.write - len + size) % size
208
+ // Copy into a dense array for sorting — len is typically ~256 so
209
+ // this allocation is negligible per expand.
210
+ const arr = new Float64Array(len)
211
+ let sum = 0
212
+ let min = Infinity
213
+ let max = -Infinity
214
+ for (let i = 0; i < len; i++) {
215
+ const v = series.data[(start + i) % size]!
216
+ arr[i] = v
217
+ sum += v
218
+ if (v < min) min = v
219
+ if (v > max) max = v
220
+ }
221
+ arr.sort()
222
+ const pct = (p: number) => arr[Math.min(len - 1, Math.floor(p * len))]!
223
+ return {
224
+ count: len,
225
+ min,
226
+ max,
227
+ mean: sum / len,
228
+ p50: pct(0.5),
229
+ p95: pct(0.95),
230
+ p99: pct(0.99),
231
+ }
232
+ }
233
+
234
+ function drawHistogram(
235
+ ctx: CanvasRenderingContext2D,
236
+ w: number,
237
+ h: number,
238
+ series: DevtoolsSeries,
239
+ color: string,
240
+ ): void {
241
+ ctx.clearRect(0, 0, w, h)
242
+ const len = series.length
243
+ if (len === 0) return
244
+ const size = series.data.length
245
+ const start = (series.write - len + size) % size
246
+ let min = Infinity
247
+ let max = -Infinity
248
+ for (let i = 0; i < len; i++) {
249
+ const v = series.data[(start + i) % size]!
250
+ if (v < min) min = v
251
+ if (v > max) max = v
252
+ }
253
+ const BINS = 32
254
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max - min < 1e-6) {
255
+ // Degenerate: every sample shares one value, no variance across
256
+ // the value axis. Render as a thin full-width muted band rather
257
+ // than a tall single bar — the band communicates "uniform" without
258
+ // implying a distribution that isn't there.
259
+ const bandH = Math.max(3, Math.round(h * 0.15))
260
+ ctx.fillStyle = hexWithAlpha(color, 0.25)
261
+ ctx.fillRect(0, h - bandH, w, bandH)
262
+ return
263
+ }
264
+ const counts = new Uint32Array(BINS)
265
+ const range = max - min
266
+ for (let i = 0; i < len; i++) {
267
+ const v = series.data[(start + i) % size]!
268
+ const t = (v - min) / range
269
+ const bin = Math.min(BINS - 1, Math.max(0, Math.floor(t * BINS)))
270
+ counts[bin] = (counts[bin]! + 1) >>> 0
271
+ }
272
+ let peak = 0
273
+ for (let i = 0; i < BINS; i++) if (counts[i]! > peak) peak = counts[i]!
274
+ if (peak === 0) return
275
+ const binW = w / BINS
276
+ ctx.fillStyle = hexWithAlpha(color, 0.65)
277
+ for (let i = 0; i < BINS; i++) {
278
+ const ch = (counts[i]! / peak) * h * 0.85
279
+ ctx.fillRect(i * binW + 1, h - ch, binW - 2, ch)
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Axis hysteresis via power-of-two buckets + trimmed max.
285
+ *
286
+ * Earlier iterations:
287
+ * - Continuous EMA: peaks/troughs decayed smoothly, but the axis
288
+ * was *always* drifting — the same stable value rendered at a
289
+ * different height each draw, so the line "reshaped" as it
290
+ * scrolled.
291
+ * - Bucket-only: snaps to next 2^n. Stable for steady data, but a
292
+ * single spike crossing the bucket boundary doubles the axis
293
+ * immediately; when that one spike scrolls out, the axis snaps
294
+ * back. With a noisy series like `gpuMs` the bucket flips
295
+ * constantly — visible as the "dance."
296
+ *
297
+ * Bucket + trimmed max: rank the visible window's samples and use
298
+ * the K-th largest (K = 3) as the input to `bucketUp`, instead of
299
+ * raw max. The top 2 outliers no longer drive the axis, so a single
300
+ * spike still draws on the canvas (clipped at the top edge if
301
+ * extreme) but doesn't flip the bucket. Sustained peaks (3+ samples
302
+ * at the high level) still grow the axis correctly. Shrink hysteresis
303
+ * (`* 2.5` below) is unchanged.
304
+ *
305
+ * Min is anchored at 0. For ms / count metrics, 0 is the meaningful
306
+ * floor; auto-smoothing the min just adds another variable axis we
307
+ * don't need.
308
+ */
309
+ function bucketUp(v: number): number {
310
+ if (v <= 1) return 1
311
+ return Math.pow(2, Math.ceil(Math.log2(v)))
312
+ }
313
+
314
+ /**
315
+ * Number of top-end samples ignored when computing the axis-driving
316
+ * max. K=3 means the top 2 outliers are clipped (to the canvas top
317
+ * edge); the 3rd-largest sets the bucket. Tuned for 60Hz, ~256-sample
318
+ * windows: a 2-frame spike doesn't move the axis, but a sustained
319
+ * 3+-frame elevation does.
320
+ */
321
+ const AXIS_TRIM_K = 3
322
+
323
+ /**
324
+ * Draw a muted sparkline of the ring's samples. Uses a bucketed
325
+ * power-of-two max with hysteresis (see `bucketUp` above) and an
326
+ * anchored zero floor — the axis only snaps between discrete
327
+ * `[0, 2^n]` scales, keeping render heights pixel-stable for stable
328
+ * data. Flat series render as a centered line.
329
+ */
330
+ function drawSeries(
331
+ ctx: CanvasRenderingContext2D,
332
+ w: number,
333
+ h: number,
334
+ series: DevtoolsSeries,
335
+ color: string,
336
+ rangeRef: { current: { min: number; max: number } | null },
337
+ ): void {
338
+ ctx.clearRect(0, 0, w, h)
339
+ const len = series.length
340
+ const size = series.data.length
341
+ if (len === 0 || size === 0) return
342
+
343
+ // Walk the last `len` samples, oldest → newest, so the sparkline
344
+ // reads left-to-right. `series.write` is the next-to-write slot, so
345
+ // the oldest valid sample is at `(write - len + size) % size`.
346
+ const start = (series.write - len + size) % size
347
+
348
+ // Track the top-K largest with a tiny ascending sort (K=3 → 3
349
+ // compares per sample, no allocation). Top1 is the max; topK is
350
+ // the K-th-largest = our trimmed observedMax. When fewer than K
351
+ // samples exist (window still warming up), fall back to top1.
352
+ let top1 = -Infinity, top2 = -Infinity, top3 = -Infinity
353
+ for (let i = 0; i < len; i++) {
354
+ const v = series.data[(start + i) % size]!
355
+ if (v > top1) { top3 = top2; top2 = top1; top1 = v }
356
+ else if (v > top2) { top3 = top2; top2 = v }
357
+ else if (v > top3) { top3 = v }
358
+ }
359
+ const observedMax = len < AXIS_TRIM_K ? top1 : top3
360
+ if (!Number.isFinite(observedMax) || observedMax < 0) return
361
+
362
+ // Bucket the max to next 2^n with hysteresis.
363
+ if (rangeRef.current === null) {
364
+ rangeRef.current = { min: 0, max: bucketUp(observedMax) }
365
+ } else {
366
+ const r = rangeRef.current
367
+ if (observedMax > r.max) {
368
+ r.max = bucketUp(observedMax)
369
+ } else if (observedMax * 2.5 < r.max && r.max > 1) {
370
+ // Observed is well under half the current bucket — shrink.
371
+ r.max = bucketUp(observedMax)
372
+ }
373
+ // else: stay in the current bucket — no axis motion.
374
+ }
375
+ const { min, max } = rangeRef.current
376
+
377
+ // Flat line when range is trivial — render at center.
378
+ const range = max - min
379
+ const useCenter = range < 1e-6
380
+
381
+ // Y for a sample, clamped to the canvas's top edge (h*0.075). With
382
+ // the trimmed-max bucket, raw `max` ignores the top 1–2 outliers —
383
+ // so spikes can have `v > max`, which would compute `y < 0` and
384
+ // draw the polyline above the canvas (visible as peaks stretching
385
+ // up past the graph box). Saturating at the top edge keeps spikes
386
+ // visible without bleeding past the card.
387
+ const TOP = h * 0.075
388
+ const sampleY = (v: number): number => {
389
+ if (useCenter) return h * 0.5
390
+ const yRaw = h - ((v - min) / range) * (h * 0.85) - TOP
391
+ return yRaw < TOP ? TOP : yRaw
392
+ }
393
+
394
+ // Translucent fill under the line so it reads as a muted band.
395
+ // Anchor the fill polygon at the bottom-left regardless of where the
396
+ // first sample lands — without this the leading edge floats up/down
397
+ // with the first value, making the fill shape jitter per frame.
398
+ ctx.beginPath()
399
+ ctx.moveTo(0, h)
400
+ for (let i = 0; i < len; i++) {
401
+ const v = series.data[(start + i) % size]!
402
+ const x = (i / Math.max(1, len - 1)) * w
403
+ ctx.lineTo(x, sampleY(v))
404
+ }
405
+ ctx.lineTo(w, h)
406
+ ctx.closePath()
407
+ ctx.fillStyle = hexWithAlpha(color, 0.18)
408
+ ctx.fill()
409
+
410
+ // Crisp line on top.
411
+ ctx.beginPath()
412
+ for (let i = 0; i < len; i++) {
413
+ const v = series.data[(start + i) % size]!
414
+ const x = (i / Math.max(1, len - 1)) * w
415
+ const y = sampleY(v)
416
+ if (i === 0) ctx.moveTo(x, y)
417
+ else ctx.lineTo(x, y)
418
+ }
419
+ ctx.strokeStyle = hexWithAlpha(color, 0.65)
420
+ ctx.lineWidth = Math.max(1, Math.round(window.devicePixelRatio || 1))
421
+ ctx.stroke()
422
+ }
423
+
424
+ function hexWithAlpha(hex: string, alpha: number): string {
425
+ const m = /^#([0-9a-f]{6})$/i.exec(hex)
426
+ if (m === null) return hex
427
+ const r = parseInt(m[1]!.slice(0, 2), 16)
428
+ const g = parseInt(m[1]!.slice(2, 4), 16)
429
+ const b = parseInt(m[1]!.slice(4, 6), 16)
430
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
431
+ }