@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,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
+ }