@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,642 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
/**
|
|
3
|
+
* Registry panel — CPU-array inspection with per-kind visualizers.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the Tweakpane pane's visualizer set so the dashboard renders
|
|
6
|
+
* registry data consistently across UIs:
|
|
7
|
+
* - `float` / `int` → signed bar chart (centered zero)
|
|
8
|
+
* - `uint` → unsigned bar chart
|
|
9
|
+
* - `bits` → packed-bit pixels
|
|
10
|
+
* - `float2/3/4` → per-element magnitude bar chart
|
|
11
|
+
*
|
|
12
|
+
* Left rail: each entry row has a tiny inline sparkline in its kind's
|
|
13
|
+
* color, plus a mean / min / max summary. Right pane: selected entry
|
|
14
|
+
* gets a large version of the same visualizer + the raw-sample grid
|
|
15
|
+
* below. Click entry to select.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
18
|
+
import type { RegistryEntrySnapshot } from '../../devtools-client.js'
|
|
19
|
+
import { getClient } from '../client.js'
|
|
20
|
+
import { useDevtoolsState } from '../hooks.js'
|
|
21
|
+
|
|
22
|
+
const ROW_STRIDE = 8
|
|
23
|
+
|
|
24
|
+
const FILL_BY_KIND: Record<string, string> = {
|
|
25
|
+
float: '#47cca9',
|
|
26
|
+
int: '#47cc6a',
|
|
27
|
+
uint: '#ffa347',
|
|
28
|
+
bits: '#d94c87',
|
|
29
|
+
float2: '#9d7aff',
|
|
30
|
+
float3: '#9d7aff',
|
|
31
|
+
float4: '#9d7aff',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function RegistryPanel() {
|
|
35
|
+
const state = useDevtoolsState()
|
|
36
|
+
const client = getClient()
|
|
37
|
+
const [selected, setSelected] = useState<string | null>(null)
|
|
38
|
+
const [filter, setFilter] = useState('')
|
|
39
|
+
|
|
40
|
+
// Default client state has `registrySelection = []` — subscribed to
|
|
41
|
+
// the registry FEATURE (so metadata arrives) but no specific entry
|
|
42
|
+
// names requested, which tells the producer to skip sample bytes.
|
|
43
|
+
// Opt into the unfiltered drain on mount; reset on unmount.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
client.setRegistry(null)
|
|
46
|
+
return () => { client.setRegistry([]) }
|
|
47
|
+
}, [client])
|
|
48
|
+
|
|
49
|
+
const entries = useMemo(() => {
|
|
50
|
+
const arr = Array.from(state.registry.values())
|
|
51
|
+
arr.sort((a, b) => a.name.localeCompare(b.name))
|
|
52
|
+
return arr
|
|
53
|
+
}, [state.registry, state.registry.size])
|
|
54
|
+
|
|
55
|
+
const needle = filter.trim().toLowerCase()
|
|
56
|
+
const visible = needle.length > 0
|
|
57
|
+
? entries.filter((e) => e.name.toLowerCase().includes(needle) || (e.label?.toLowerCase().includes(needle) ?? false))
|
|
58
|
+
: entries
|
|
59
|
+
|
|
60
|
+
const effectiveSelected = selected !== null && visible.some((e) => e.name === selected)
|
|
61
|
+
? selected
|
|
62
|
+
: (visible[0]?.name ?? null)
|
|
63
|
+
|
|
64
|
+
const selectedEntry = effectiveSelected !== null ? state.registry.get(effectiveSelected) ?? null : null
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<section class="panel registry-panel">
|
|
68
|
+
<header class="panel-header registry-header">
|
|
69
|
+
<span>Registry</span>
|
|
70
|
+
<input
|
|
71
|
+
type="text"
|
|
72
|
+
class="protocol-filter"
|
|
73
|
+
placeholder="filter…"
|
|
74
|
+
value={filter}
|
|
75
|
+
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
|
76
|
+
/>
|
|
77
|
+
<span class="registry-count">{entries.length}</span>
|
|
78
|
+
</header>
|
|
79
|
+
<div class="registry-layout">
|
|
80
|
+
<ul class="registry-list">
|
|
81
|
+
{visible.length === 0 ? (
|
|
82
|
+
<li class="panel-empty">No entries{needle.length > 0 ? ' match' : ' yet'}.</li>
|
|
83
|
+
) : (
|
|
84
|
+
visible.map((e) => (
|
|
85
|
+
<li key={e.name}>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
class={
|
|
89
|
+
'registry-row' +
|
|
90
|
+
(e.name === effectiveSelected ? ' registry-row-selected' : '')
|
|
91
|
+
}
|
|
92
|
+
onClick={() => setSelected(e.name)}
|
|
93
|
+
>
|
|
94
|
+
<span class="registry-kind">{e.kind}</span>
|
|
95
|
+
<span class="registry-name">{e.name}</span>
|
|
96
|
+
<RegistrySparkline entry={e} width={96} height={18} />
|
|
97
|
+
<span class="registry-count-pill">{e.count}</span>
|
|
98
|
+
</button>
|
|
99
|
+
</li>
|
|
100
|
+
))
|
|
101
|
+
)}
|
|
102
|
+
</ul>
|
|
103
|
+
<div class="registry-detail">
|
|
104
|
+
{selectedEntry === null ? (
|
|
105
|
+
<div class="panel-empty">Select an entry.</div>
|
|
106
|
+
) : (
|
|
107
|
+
<RegistryDetail entry={selectedEntry} />
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</section>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function RegistrySparkline({
|
|
116
|
+
entry,
|
|
117
|
+
width,
|
|
118
|
+
height,
|
|
119
|
+
}: {
|
|
120
|
+
entry: RegistryEntrySnapshot
|
|
121
|
+
width: number
|
|
122
|
+
height: number
|
|
123
|
+
}): preact.JSX.Element {
|
|
124
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
125
|
+
// Gate redraw on version bumps — the shared rAF fires on every frame
|
|
126
|
+
// but most entries don't change every frame. Redrawing a 10k-sample
|
|
127
|
+
// canvas at 60Hz is the lag source for entries like tileScores.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const canvas = canvasRef.current
|
|
130
|
+
if (canvas === null) return
|
|
131
|
+
const dpr = window.devicePixelRatio || 1
|
|
132
|
+
const w = Math.max(1, Math.round(width * dpr))
|
|
133
|
+
const h = Math.max(1, Math.round(height * dpr))
|
|
134
|
+
if (canvas.width !== w || canvas.height !== h) {
|
|
135
|
+
canvas.width = w
|
|
136
|
+
canvas.height = h
|
|
137
|
+
}
|
|
138
|
+
const ctx = canvas.getContext('2d')
|
|
139
|
+
if (ctx === null) return
|
|
140
|
+
drawVisualizer(ctx, w, h, entry)
|
|
141
|
+
}, [entry.version, entry.name, width, height])
|
|
142
|
+
return (
|
|
143
|
+
<canvas
|
|
144
|
+
class="registry-sparkline"
|
|
145
|
+
ref={canvasRef}
|
|
146
|
+
style={{ width: `${width}px`, height: `${height}px` }}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function RegistryDetail({ entry }: { entry: RegistryEntrySnapshot }): preact.JSX.Element {
|
|
152
|
+
const { name, kind, count, sample, label, version } = entry
|
|
153
|
+
const isFloat = sample instanceof Float32Array
|
|
154
|
+
const stats = useMemo(() => computeStats(entry), [entry, entry.version])
|
|
155
|
+
|
|
156
|
+
// Element count on the visualizer axis — for vectors this is the
|
|
157
|
+
// number of vectors, for bits it's total bit count, for scalars it's
|
|
158
|
+
// sample.length. The graph cursor and data-viewer highlight both
|
|
159
|
+
// index into this space.
|
|
160
|
+
const axisLen = axisLength(entry)
|
|
161
|
+
|
|
162
|
+
// Cursor is purely a mirror of the data-view's scroll position —
|
|
163
|
+
// initialised to 0 so the graph always shows an anchor line on
|
|
164
|
+
// mount. Click on the graph scrolls the data view; cursor follows.
|
|
165
|
+
// Click on a cell pins there too. Manual scroll updates the cursor
|
|
166
|
+
// in real time during the smooth-scroll animation.
|
|
167
|
+
const [pinnedIdx, setPinnedIdx] = useState<number | null>(0)
|
|
168
|
+
const gridScrollerRef = useRef<HTMLDivElement | null>(null)
|
|
169
|
+
const cursorIdx = pinnedIdx
|
|
170
|
+
|
|
171
|
+
const scrollGridToAxisIdx = (idx: number): void => {
|
|
172
|
+
const el = gridScrollerRef.current
|
|
173
|
+
if (el === null) return
|
|
174
|
+
const perAxisIdx =
|
|
175
|
+
entry.kind === 'float2' ? 2 :
|
|
176
|
+
entry.kind === 'float3' ? 3 :
|
|
177
|
+
entry.kind === 'float4' ? 4 :
|
|
178
|
+
1
|
|
179
|
+
const scalarIdx = entry.kind === 'bits' ? idx : idx * perAxisIdx
|
|
180
|
+
const rowIdx = Math.floor(scalarIdx / ROW_STRIDE)
|
|
181
|
+
const top = rowIdx * SAMPLE_ROW_HEIGHT
|
|
182
|
+
const target = Math.max(0, top - el.clientHeight / 2 + SAMPLE_ROW_HEIGHT / 2)
|
|
183
|
+
el.scrollTo({ top: target, behavior: 'smooth' })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
187
|
+
// Tick bumped whenever the viz-stack resizes (breakpoint swap,
|
|
188
|
+
// sidebar collapse, etc.) so the bars canvas re-sizes + re-draws.
|
|
189
|
+
const [resizeTick, setResizeTick] = useState(0)
|
|
190
|
+
// Bars canvas redraws on version/entry change OR container resize.
|
|
191
|
+
// Cursor lives in the DOM as a positioned <div> (see JSX below) —
|
|
192
|
+
// cheaper than re-drawing a cursor canvas on every mousemove, and
|
|
193
|
+
// the browser compositor handles the line motion without touching
|
|
194
|
+
// React render cost at all.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
const canvas = canvasRef.current
|
|
197
|
+
if (canvas === null) return
|
|
198
|
+
const dpr = window.devicePixelRatio || 1
|
|
199
|
+
const rect = canvas.getBoundingClientRect()
|
|
200
|
+
const w = Math.max(1, Math.round(rect.width * dpr))
|
|
201
|
+
const h = Math.max(1, Math.round(rect.height * dpr))
|
|
202
|
+
if (canvas.width !== w || canvas.height !== h) {
|
|
203
|
+
canvas.width = w
|
|
204
|
+
canvas.height = h
|
|
205
|
+
}
|
|
206
|
+
const ctx = canvas.getContext('2d')
|
|
207
|
+
if (ctx === null) return
|
|
208
|
+
drawVisualizer(ctx, w, h, entry, null)
|
|
209
|
+
}, [entry.version, entry.name, resizeTick])
|
|
210
|
+
|
|
211
|
+
// Imperative hover-tracking: mousemove updates a `style.left` on the
|
|
212
|
+
// cursor <div> directly without going through React. State only
|
|
213
|
+
// changes on click (pin), so a 60 Hz mousemove storm doesn't trigger
|
|
214
|
+
// re-renders for huge samples.
|
|
215
|
+
const stackRef = useRef<HTMLDivElement | null>(null)
|
|
216
|
+
const cursorDivRef = useRef<HTMLDivElement | null>(null)
|
|
217
|
+
|
|
218
|
+
// Watch the viz stack for size changes — single-column / dual-column
|
|
219
|
+
// breakpoint swaps resize the panel without changing the selected
|
|
220
|
+
// entry, so without this the canvas would keep its pre-resize backing
|
|
221
|
+
// buffer and render stretched / clipped.
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const stack = stackRef.current
|
|
224
|
+
if (stack === null) return
|
|
225
|
+
const obs = new ResizeObserver(() => setResizeTick((t) => (t + 1) & 0xffff))
|
|
226
|
+
obs.observe(stack)
|
|
227
|
+
return () => obs.disconnect()
|
|
228
|
+
}, [])
|
|
229
|
+
const clientXToAxisIdx = (clientX: number): number | null => {
|
|
230
|
+
const stack = stackRef.current
|
|
231
|
+
if (stack === null || axisLen === 0) return null
|
|
232
|
+
const rect = stack.getBoundingClientRect()
|
|
233
|
+
const u = (clientX - rect.left) / rect.width
|
|
234
|
+
if (u < 0 || u > 1) return null
|
|
235
|
+
return Math.min(axisLen - 1, Math.max(0, Math.floor(u * axisLen)))
|
|
236
|
+
}
|
|
237
|
+
// When pinnedIdx changes externally (e.g. via cell click) OR the
|
|
238
|
+
// stack resizes, re-position the cursor div so the pixel offset
|
|
239
|
+
// matches the new container width.
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
const cursor = cursorDivRef.current
|
|
242
|
+
const stack = stackRef.current
|
|
243
|
+
if (cursor === null || stack === null || axisLen === 0) return
|
|
244
|
+
const target = cursorIdx
|
|
245
|
+
if (target === null) {
|
|
246
|
+
cursor.style.display = 'none'
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
const rect = stack.getBoundingClientRect()
|
|
250
|
+
const x = ((target + 0.5) / axisLen) * rect.width
|
|
251
|
+
cursor.style.left = `${x}px`
|
|
252
|
+
cursor.style.display = 'block'
|
|
253
|
+
}, [cursorIdx, axisLen, resizeTick])
|
|
254
|
+
|
|
255
|
+
// Reset the cursor to index 0 when the entry changes so the graph
|
|
256
|
+
// always has an anchor visible — matches "default view at top".
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
setPinnedIdx(0)
|
|
259
|
+
}, [name])
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<>
|
|
263
|
+
<div class="registry-detail-header">
|
|
264
|
+
<span class="registry-detail-name">{name}</span>
|
|
265
|
+
{label !== undefined && <span class="registry-detail-label">{label}</span>}
|
|
266
|
+
</div>
|
|
267
|
+
<div class="registry-detail-meta">
|
|
268
|
+
<span>{kind}</span>
|
|
269
|
+
<span>count {count}</span>
|
|
270
|
+
<span>v{version}</span>
|
|
271
|
+
<span>{sample.constructor.name}</span>
|
|
272
|
+
{stats.summary !== '' && <span>{stats.summary}</span>}
|
|
273
|
+
{cursorIdx !== null && (
|
|
274
|
+
<span class="registry-cursor-label">cursor {cursorIdx}</span>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
<div class="registry-viz-wrap">
|
|
278
|
+
<div
|
|
279
|
+
class="registry-viz-stack"
|
|
280
|
+
ref={stackRef}
|
|
281
|
+
onClick={(e) => {
|
|
282
|
+
const idx = clientXToAxisIdx(e.clientX)
|
|
283
|
+
if (idx === null) return
|
|
284
|
+
scrollGridToAxisIdx(idx)
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
<canvas class="registry-viz" ref={canvasRef} />
|
|
288
|
+
<div class="registry-viz-cursor-line" ref={cursorDivRef} />
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
<div class="registry-detail-body">
|
|
292
|
+
{sample.length === 0
|
|
293
|
+
? <div class="panel-empty">No sample yet.</div>
|
|
294
|
+
: (
|
|
295
|
+
<SampleGrid
|
|
296
|
+
entry={entry}
|
|
297
|
+
stride={ROW_STRIDE}
|
|
298
|
+
float={isFloat}
|
|
299
|
+
pinnedIdx={pinnedIdx}
|
|
300
|
+
scrollerRef={gridScrollerRef}
|
|
301
|
+
onPinIdx={(idx) => setPinnedIdx(idx)}
|
|
302
|
+
onScrollIdx={(idx) => setPinnedIdx(idx)}
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
</>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const SAMPLE_ROW_HEIGHT = 20
|
|
311
|
+
const SAMPLE_OVERSCAN = 8
|
|
312
|
+
|
|
313
|
+
function SampleGrid({
|
|
314
|
+
entry,
|
|
315
|
+
stride,
|
|
316
|
+
float,
|
|
317
|
+
pinnedIdx,
|
|
318
|
+
scrollerRef,
|
|
319
|
+
onPinIdx,
|
|
320
|
+
onScrollIdx,
|
|
321
|
+
}: {
|
|
322
|
+
entry: RegistryEntrySnapshot
|
|
323
|
+
stride: number
|
|
324
|
+
float: boolean
|
|
325
|
+
pinnedIdx: number | null
|
|
326
|
+
scrollerRef: { current: HTMLDivElement | null }
|
|
327
|
+
onPinIdx: (i: number | null) => void
|
|
328
|
+
onScrollIdx: (i: number) => void
|
|
329
|
+
}): preact.JSX.Element {
|
|
330
|
+
const { kind, sample } = entry
|
|
331
|
+
const scalarsPerAxisIdx =
|
|
332
|
+
kind === 'float2' ? 2 :
|
|
333
|
+
kind === 'float3' ? 3 :
|
|
334
|
+
kind === 'float4' ? 4 :
|
|
335
|
+
1
|
|
336
|
+
const cursorIdx = pinnedIdx
|
|
337
|
+
|
|
338
|
+
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
const el = scrollerRef.current
|
|
342
|
+
if (el === null) return
|
|
343
|
+
setViewport((v) => ({ ...v, height: el.clientHeight }))
|
|
344
|
+
const obs = new ResizeObserver(() => {
|
|
345
|
+
const h = scrollerRef.current?.clientHeight ?? 0
|
|
346
|
+
setViewport((v) => v.height === h ? v : { ...v, height: h })
|
|
347
|
+
})
|
|
348
|
+
obs.observe(el)
|
|
349
|
+
return () => obs.disconnect()
|
|
350
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
351
|
+
}, [])
|
|
352
|
+
|
|
353
|
+
// User-scroll → update cursor to the axis index at the viewport's
|
|
354
|
+
// center row. Fires every scroll event (including intermediate
|
|
355
|
+
// frames during smooth scroll) so the graph cursor tracks the scroll
|
|
356
|
+
// in real time. Preact dedupes setState when value hasn't changed,
|
|
357
|
+
// keeping the cost bounded.
|
|
358
|
+
const reportScrollIdx = (scrollTop: number, height: number): void => {
|
|
359
|
+
if (height === 0) return
|
|
360
|
+
const centerRow = Math.floor((scrollTop + height / 2) / SAMPLE_ROW_HEIGHT)
|
|
361
|
+
const centerScalarIdx = centerRow * stride
|
|
362
|
+
const axisIdx = kind === 'bits'
|
|
363
|
+
? centerScalarIdx * 32
|
|
364
|
+
: Math.floor(centerScalarIdx / scalarsPerAxisIdx)
|
|
365
|
+
if (axisIdx >= 0) onScrollIdx(axisIdx)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const totalRows = Math.ceil(sample.length / stride)
|
|
369
|
+
const start = Math.max(0, Math.floor(viewport.scrollTop / SAMPLE_ROW_HEIGHT) - SAMPLE_OVERSCAN)
|
|
370
|
+
const visibleCount = Math.max(1, Math.ceil(viewport.height / SAMPLE_ROW_HEIGHT) + SAMPLE_OVERSCAN * 2)
|
|
371
|
+
const end = Math.min(totalRows, start + visibleCount)
|
|
372
|
+
|
|
373
|
+
const rows: preact.JSX.Element[] = []
|
|
374
|
+
for (let r = start; r < end; r++) {
|
|
375
|
+
const i = r * stride
|
|
376
|
+
const cells: preact.JSX.Element[] = []
|
|
377
|
+
for (let j = 0; j < stride && i + j < sample.length; j++) {
|
|
378
|
+
const scalarIdx = i + j
|
|
379
|
+
const v = sample[scalarIdx]!
|
|
380
|
+
const axisIdxForCell = kind === 'bits'
|
|
381
|
+
? scalarIdx * 32
|
|
382
|
+
: Math.floor(scalarIdx / scalarsPerAxisIdx)
|
|
383
|
+
const inCursor = cursorIdx !== null && (
|
|
384
|
+
kind === 'bits'
|
|
385
|
+
? cursorIdx >= axisIdxForCell && cursorIdx < axisIdxForCell + 32
|
|
386
|
+
: axisIdxForCell === cursorIdx
|
|
387
|
+
)
|
|
388
|
+
cells.push(
|
|
389
|
+
<span
|
|
390
|
+
class={`sample-cell${inCursor ? ' sample-cell-cursor' : ''}`}
|
|
391
|
+
title={float ? String(v) : undefined}
|
|
392
|
+
onClick={() => onPinIdx(axisIdxForCell)}
|
|
393
|
+
>
|
|
394
|
+
{float ? fmtFloatFixed(v) : v.toString()}
|
|
395
|
+
</span>,
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
rows.push(
|
|
399
|
+
// Key by slot index (r - start) so DOM nodes recycle as the
|
|
400
|
+
// window scrolls — same pattern as the protocol log, avoids
|
|
401
|
+
// mount/unmount thrash at high scroll speeds.
|
|
402
|
+
<div
|
|
403
|
+
class="sample-row"
|
|
404
|
+
key={r - start}
|
|
405
|
+
style={{ transform: `translateY(${r * SAMPLE_ROW_HEIGHT}px)`, height: `${SAMPLE_ROW_HEIGHT}px` }}
|
|
406
|
+
>
|
|
407
|
+
<span class="sample-index">{i}</span>
|
|
408
|
+
{cells}
|
|
409
|
+
</div>,
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const onScroll = (e: Event): void => {
|
|
414
|
+
const el = e.currentTarget as HTMLDivElement
|
|
415
|
+
setViewport((v) => v.scrollTop === el.scrollTop ? v : { ...v, scrollTop: el.scrollTop })
|
|
416
|
+
reportScrollIdx(el.scrollTop, el.clientHeight)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<div class="sample-grid-scroller" ref={scrollerRef} onScroll={onScroll}>
|
|
421
|
+
<div class="sample-grid" style={{ height: `${totalRows * SAMPLE_ROW_HEIGHT}px` }}>
|
|
422
|
+
{rows}
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function axisLength(entry: RegistryEntrySnapshot): number {
|
|
429
|
+
const { kind, sample, count } = entry
|
|
430
|
+
if (kind === 'bits') return count * 32
|
|
431
|
+
if (kind === 'float2') return Math.floor(sample.length / 2)
|
|
432
|
+
if (kind === 'float3') return Math.floor(sample.length / 3)
|
|
433
|
+
if (kind === 'float4') return Math.floor(sample.length / 4)
|
|
434
|
+
return sample.length
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Fixed-width decimal formatter for the sample grid. Keeps every cell
|
|
439
|
+
* the same character count so the grid reads like a hex dump. The raw
|
|
440
|
+
* value lives in the cell's `title` attribute for tooltip lookup when
|
|
441
|
+
* precision matters.
|
|
442
|
+
*/
|
|
443
|
+
function fmtFloatFixed(v: number): string {
|
|
444
|
+
if (!Number.isFinite(v)) return v > 0 ? ' +∞' : v < 0 ? ' -∞' : ' NaN '
|
|
445
|
+
if (v === 0) return ' 0.000'
|
|
446
|
+
const abs = Math.abs(v)
|
|
447
|
+
if (abs >= 1e4 || abs < 1e-3) {
|
|
448
|
+
// Scientific, 2 sig figs: `±1.23e+4` — 8 chars.
|
|
449
|
+
return v.toExponential(2).padStart(8)
|
|
450
|
+
}
|
|
451
|
+
return v.toFixed(3).padStart(8)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function computeStats(entry: RegistryEntrySnapshot): { summary: string } {
|
|
455
|
+
const { kind, sample, count } = entry
|
|
456
|
+
if (kind === 'bits') {
|
|
457
|
+
const data = sample as Uint32Array
|
|
458
|
+
let ones = 0
|
|
459
|
+
for (let i = 0; i < data.length; i++) {
|
|
460
|
+
let w = data[i]! >>> 0
|
|
461
|
+
while (w !== 0) { ones += w & 1; w >>>= 1 }
|
|
462
|
+
}
|
|
463
|
+
return { summary: `${ones} / ${count * 32} set` }
|
|
464
|
+
}
|
|
465
|
+
if (kind === 'float2' || kind === 'float3' || kind === 'float4') {
|
|
466
|
+
const stride = kind === 'float2' ? 2 : kind === 'float3' ? 3 : 4
|
|
467
|
+
const n = Math.floor(sample.length / stride)
|
|
468
|
+
let sum = 0
|
|
469
|
+
for (let i = 0; i < n; i++) {
|
|
470
|
+
let sq = 0
|
|
471
|
+
for (let c = 0; c < stride; c++) sq += sample[i * stride + c]! ** 2
|
|
472
|
+
sum += Math.sqrt(sq)
|
|
473
|
+
}
|
|
474
|
+
return { summary: n > 0 ? `μ|v|=${(sum / n).toFixed(2)}` : '' }
|
|
475
|
+
}
|
|
476
|
+
const n = sample.length
|
|
477
|
+
if (n === 0) return { summary: '' }
|
|
478
|
+
let min = Infinity
|
|
479
|
+
let max = -Infinity
|
|
480
|
+
let sum = 0
|
|
481
|
+
for (let i = 0; i < n; i++) {
|
|
482
|
+
const v = sample[i]!
|
|
483
|
+
if (v < min) min = v
|
|
484
|
+
if (v > max) max = v
|
|
485
|
+
sum += v
|
|
486
|
+
}
|
|
487
|
+
const mean = sum / n
|
|
488
|
+
const f = (v: number) => kind === 'uint' || kind === 'int'
|
|
489
|
+
? Math.round(v).toString()
|
|
490
|
+
: v.toFixed(2)
|
|
491
|
+
return { summary: `μ=${f(mean)} ${f(min)}–${f(max)}` }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Dispatch to a kind-appropriate visualizer. Bars for numeric arrays,
|
|
496
|
+
* per-element magnitudes for vector arrays, bit pixels for bitmasks.
|
|
497
|
+
* All renderers assume the canvas has already been DPR-sized by the
|
|
498
|
+
* caller.
|
|
499
|
+
*/
|
|
500
|
+
function drawVisualizer(
|
|
501
|
+
ctx: CanvasRenderingContext2D,
|
|
502
|
+
w: number,
|
|
503
|
+
h: number,
|
|
504
|
+
entry: RegistryEntrySnapshot,
|
|
505
|
+
cursorIdx: number | null = null,
|
|
506
|
+
): void {
|
|
507
|
+
ctx.clearRect(0, 0, w, h)
|
|
508
|
+
const { kind, sample } = entry
|
|
509
|
+
if (sample.length === 0) return
|
|
510
|
+
const color = FILL_BY_KIND[kind] ?? '#47cca9'
|
|
511
|
+
|
|
512
|
+
if (kind === 'bits') {
|
|
513
|
+
drawBits(ctx, w, h, sample as Uint32Array, entry.count, color)
|
|
514
|
+
} else if (kind === 'float2' || kind === 'float3' || kind === 'float4') {
|
|
515
|
+
const stride = kind === 'float2' ? 2 : kind === 'float3' ? 3 : 4
|
|
516
|
+
const n = Math.floor(sample.length / stride)
|
|
517
|
+
if (n > 0) {
|
|
518
|
+
const mags = new Float32Array(n)
|
|
519
|
+
for (let i = 0; i < n; i++) {
|
|
520
|
+
let sq = 0
|
|
521
|
+
for (let c = 0; c < stride; c++) sq += sample[i * stride + c]! ** 2
|
|
522
|
+
mags[i] = Math.sqrt(sq)
|
|
523
|
+
}
|
|
524
|
+
drawBars(ctx, w, h, mags, false, color)
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
const isSigned = kind === 'int' || kind === 'float'
|
|
528
|
+
drawBars(ctx, w, h, sample, isSigned, color)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Cursor playhead on top of the bars. Drawn once per render so it
|
|
532
|
+
// stays in sync with whichever sample the data viewer is showing.
|
|
533
|
+
if (cursorIdx !== null) {
|
|
534
|
+
const axis = axisLength(entry)
|
|
535
|
+
if (axis > 0) {
|
|
536
|
+
const x = Math.floor(((cursorIdx + 0.5) / axis) * w)
|
|
537
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'
|
|
538
|
+
// Thin wash behind the line so the cursor reads even when the
|
|
539
|
+
// underlying bar at that index is tall.
|
|
540
|
+
const band = Math.max(2, Math.ceil(w / axis))
|
|
541
|
+
ctx.fillRect(x - Math.floor(band / 2), 0, band, h)
|
|
542
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'
|
|
543
|
+
ctx.fillRect(x, 0, Math.max(1, Math.round(window.devicePixelRatio || 1)), h)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function drawBars(
|
|
549
|
+
ctx: CanvasRenderingContext2D,
|
|
550
|
+
w: number,
|
|
551
|
+
h: number,
|
|
552
|
+
sample: Float32Array | Int32Array | Uint32Array,
|
|
553
|
+
isSigned: boolean,
|
|
554
|
+
color: string,
|
|
555
|
+
): void {
|
|
556
|
+
let min = Infinity
|
|
557
|
+
let max = -Infinity
|
|
558
|
+
const n = sample.length
|
|
559
|
+
for (let i = 0; i < n; i++) {
|
|
560
|
+
const v = sample[i]!
|
|
561
|
+
if (v < min) min = v
|
|
562
|
+
if (v > max) max = v
|
|
563
|
+
}
|
|
564
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return
|
|
565
|
+
|
|
566
|
+
const zeroY = isSigned ? h / 2 : h
|
|
567
|
+
const scale = isSigned
|
|
568
|
+
? (h / 2) / Math.max(Math.abs(min), Math.abs(max) || 1)
|
|
569
|
+
: h / (max || 1)
|
|
570
|
+
|
|
571
|
+
ctx.fillStyle = color
|
|
572
|
+
|
|
573
|
+
// If samples outnumber pixels (e.g. tileScores with 10k+ entries on a
|
|
574
|
+
// ~500px canvas) draw one bar per pixel column with the column's
|
|
575
|
+
// min/max envelope. Straight 1-bar-per-sample would mean tens of
|
|
576
|
+
// thousands of overlapping fillRect calls per frame and tanked perf
|
|
577
|
+
// in practice.
|
|
578
|
+
if (n > w) {
|
|
579
|
+
for (let x = 0; x < w; x++) {
|
|
580
|
+
const lo = Math.floor((x / w) * n)
|
|
581
|
+
const hi = Math.max(lo + 1, Math.floor(((x + 1) / w) * n))
|
|
582
|
+
let cmin = Infinity
|
|
583
|
+
let cmax = -Infinity
|
|
584
|
+
for (let i = lo; i < hi; i++) {
|
|
585
|
+
const v = sample[i]!
|
|
586
|
+
if (v < cmin) cmin = v
|
|
587
|
+
if (v > cmax) cmax = v
|
|
588
|
+
}
|
|
589
|
+
if (!Number.isFinite(cmin)) continue
|
|
590
|
+
if (isSigned) {
|
|
591
|
+
const yTop = zeroY - cmax * scale
|
|
592
|
+
const yBot = zeroY - cmin * scale
|
|
593
|
+
ctx.fillRect(x, Math.min(yTop, yBot), 1, Math.max(1, Math.abs(yBot - yTop)))
|
|
594
|
+
} else {
|
|
595
|
+
const bh = cmax * scale
|
|
596
|
+
ctx.fillRect(x, zeroY - bh, 1, bh)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
const bw = Math.max(1, w / n)
|
|
601
|
+
for (let i = 0; i < n; i++) {
|
|
602
|
+
const v = sample[i]!
|
|
603
|
+
const x = Math.floor((i / n) * w)
|
|
604
|
+
const bh = v * scale
|
|
605
|
+
if (isSigned) {
|
|
606
|
+
if (bh >= 0) ctx.fillRect(x, zeroY - bh, Math.ceil(bw), bh)
|
|
607
|
+
else ctx.fillRect(x, zeroY, Math.ceil(bw), -bh)
|
|
608
|
+
} else {
|
|
609
|
+
ctx.fillRect(x, zeroY - bh, Math.ceil(bw), bh)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (isSigned) {
|
|
615
|
+
ctx.fillStyle = 'rgba(240, 237, 216, 0.2)'
|
|
616
|
+
ctx.fillRect(0, Math.floor(zeroY), w, 1)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function drawBits(
|
|
621
|
+
ctx: CanvasRenderingContext2D,
|
|
622
|
+
w: number,
|
|
623
|
+
h: number,
|
|
624
|
+
sample: Uint32Array,
|
|
625
|
+
count: number,
|
|
626
|
+
color: string,
|
|
627
|
+
): void {
|
|
628
|
+
const totalBits = count * 32
|
|
629
|
+
if (totalBits === 0) return
|
|
630
|
+
const px = w / totalBits
|
|
631
|
+
ctx.fillStyle = color
|
|
632
|
+
for (let i = 0; i < count; i++) {
|
|
633
|
+
const word = sample[i]! >>> 0
|
|
634
|
+
for (let b = 0; b < 32; b++) {
|
|
635
|
+
if ((word >>> b) & 1) {
|
|
636
|
+
const bitIdx = i * 32 + b
|
|
637
|
+
const x = Math.floor(bitIdx * px)
|
|
638
|
+
ctx.fillRect(x, 2, Math.max(1, Math.ceil(px)), h - 4)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|