@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.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/buffers-modal.cjs +491 -0
- package/dist/buffers-modal.cjs.map +1 -0
- package/dist/buffers-modal.d.cts +62 -0
- package/dist/buffers-modal.d.ts +62 -0
- package/dist/buffers-modal.js +467 -0
- package/dist/buffers-modal.js.map +1 -0
- package/dist/buffers-view.cjs +269 -0
- package/dist/buffers-view.cjs.map +1 -0
- package/dist/buffers-view.d.cts +39 -0
- package/dist/buffers-view.d.ts +39 -0
- package/dist/buffers-view.js +245 -0
- package/dist/buffers-view.js.map +1 -0
- package/dist/create-pane.cjs +203 -0
- package/dist/create-pane.cjs.map +1 -0
- package/dist/create-pane.d.cts +66 -0
- package/dist/create-pane.d.ts +66 -0
- package/dist/create-pane.js +178 -0
- package/dist/create-pane.js.map +1 -0
- package/dist/dashboard/app.tsx +42 -0
- package/dist/dashboard/client.ts +21 -0
- package/dist/dashboard/export.ts +84 -0
- package/dist/dashboard/hooks.ts +75 -0
- package/dist/dashboard/index.html +1149 -0
- package/dist/dashboard/index.tsx +18 -0
- package/dist/dashboard/panels/batches.tsx +282 -0
- package/dist/dashboard/panels/buffers.tsx +425 -0
- package/dist/dashboard/panels/env.tsx +85 -0
- package/dist/dashboard/panels/header-stats.tsx +30 -0
- package/dist/dashboard/panels/producer-select.tsx +81 -0
- package/dist/dashboard/panels/protocol-log.tsx +743 -0
- package/dist/dashboard/panels/registry.tsx +642 -0
- package/dist/dashboard/panels/stats.tsx +431 -0
- package/dist/dashboard/protocol-store.ts +312 -0
- package/dist/dashboard/tsconfig.json +19 -0
- package/dist/dashboard/vendor/hooks.module.js +1 -0
- package/dist/dashboard/vendor/jsx-runtime.js +22 -0
- package/dist/dashboard/vendor/preact.module.js +12 -0
- package/dist/devtools-client.cjs +870 -0
- package/dist/devtools-client.cjs.map +1 -0
- package/dist/devtools-client.d.cts +362 -0
- package/dist/devtools-client.d.ts +362 -0
- package/dist/devtools-client.js +852 -0
- package/dist/devtools-client.js.map +1 -0
- package/dist/index.cjs +42 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/perf-trace.cjs +103 -0
- package/dist/perf-trace.cjs.map +1 -0
- package/dist/perf-trace.d.cts +27 -0
- package/dist/perf-trace.d.ts +27 -0
- package/dist/perf-trace.js +79 -0
- package/dist/perf-trace.js.map +1 -0
- package/dist/plugins.cjs +44 -0
- package/dist/plugins.cjs.map +1 -0
- package/dist/plugins.d.cts +7 -0
- package/dist/plugins.d.ts +7 -0
- package/dist/plugins.js +9 -0
- package/dist/plugins.js.map +1 -0
- package/dist/provider-switcher.cjs +95 -0
- package/dist/provider-switcher.cjs.map +1 -0
- package/dist/provider-switcher.d.cts +18 -0
- package/dist/provider-switcher.d.ts +18 -0
- package/dist/provider-switcher.js +71 -0
- package/dist/provider-switcher.js.map +1 -0
- package/dist/react/devtools-provider.cjs +59 -0
- package/dist/react/devtools-provider.cjs.map +1 -0
- package/dist/react/devtools-provider.d.cts +39 -0
- package/dist/react/devtools-provider.d.ts +39 -0
- package/dist/react/devtools-provider.js +38 -0
- package/dist/react/devtools-provider.js.map +1 -0
- package/dist/react/use-fps-graph.cjs +49 -0
- package/dist/react/use-fps-graph.cjs.map +1 -0
- package/dist/react/use-fps-graph.d.cts +15 -0
- package/dist/react/use-fps-graph.d.ts +15 -0
- package/dist/react/use-fps-graph.js +25 -0
- package/dist/react/use-fps-graph.js.map +1 -0
- package/dist/react/use-pane-button.cjs +58 -0
- package/dist/react/use-pane-button.cjs.map +1 -0
- package/dist/react/use-pane-button.d.cts +10 -0
- package/dist/react/use-pane-button.d.ts +10 -0
- package/dist/react/use-pane-button.js +34 -0
- package/dist/react/use-pane-button.js.map +1 -0
- package/dist/react/use-pane-folder.cjs +48 -0
- package/dist/react/use-pane-folder.cjs.map +1 -0
- package/dist/react/use-pane-folder.d.cts +15 -0
- package/dist/react/use-pane-folder.d.ts +15 -0
- package/dist/react/use-pane-folder.js +24 -0
- package/dist/react/use-pane-folder.js.map +1 -0
- package/dist/react/use-pane-input.cjs +70 -0
- package/dist/react/use-pane-input.cjs.map +1 -0
- package/dist/react/use-pane-input.d.cts +44 -0
- package/dist/react/use-pane-input.d.ts +44 -0
- package/dist/react/use-pane-input.js +46 -0
- package/dist/react/use-pane-input.js.map +1 -0
- package/dist/react/use-pane-radio-grid.cjs +78 -0
- package/dist/react/use-pane-radio-grid.cjs.map +1 -0
- package/dist/react/use-pane-radio-grid.d.cts +35 -0
- package/dist/react/use-pane-radio-grid.d.ts +35 -0
- package/dist/react/use-pane-radio-grid.js +54 -0
- package/dist/react/use-pane-radio-grid.js.map +1 -0
- package/dist/react/use-pane.cjs +45 -0
- package/dist/react/use-pane.cjs.map +1 -0
- package/dist/react/use-pane.d.cts +40 -0
- package/dist/react/use-pane.d.ts +40 -0
- package/dist/react/use-pane.js +21 -0
- package/dist/react/use-pane.js.map +1 -0
- package/dist/react.cjs +50 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +12 -0
- package/dist/react.d.ts +12 -0
- package/dist/react.js +19 -0
- package/dist/react.js.map +1 -0
- package/dist/registry-view.cjs +371 -0
- package/dist/registry-view.cjs.map +1 -0
- package/dist/registry-view.d.cts +24 -0
- package/dist/registry-view.d.ts +24 -0
- package/dist/registry-view.js +347 -0
- package/dist/registry-view.js.map +1 -0
- package/dist/stats-graph.cjs +297 -0
- package/dist/stats-graph.cjs.map +1 -0
- package/dist/stats-graph.d.cts +40 -0
- package/dist/stats-graph.d.ts +40 -0
- package/dist/stats-graph.js +273 -0
- package/dist/stats-graph.js.map +1 -0
- package/dist/stats-row.cjs +153 -0
- package/dist/stats-row.cjs.map +1 -0
- package/dist/stats-row.d.cts +24 -0
- package/dist/stats-row.d.ts +24 -0
- package/dist/stats-row.js +129 -0
- package/dist/stats-row.js.map +1 -0
- package/dist/theme.cjs +260 -0
- package/dist/theme.cjs.map +1 -0
- package/dist/theme.d.cts +10 -0
- package/dist/theme.d.ts +10 -0
- package/dist/theme.js +235 -0
- package/dist/theme.js.map +1 -0
- package/dist/vite-plugin.cjs +113 -0
- package/dist/vite-plugin.cjs.map +1 -0
- package/dist/vite-plugin.d.cts +9 -0
- package/dist/vite-plugin.d.ts +9 -0
- package/dist/vite-plugin.js +88 -0
- package/dist/vite-plugin.js.map +1 -0
- 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
|
+
}
|