bobframes 0.1.0__py3-none-any.whl

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 (130) hide show
  1. bobframes/__init__.py +3 -0
  2. bobframes/_version.py +1 -0
  3. bobframes/catalog.py +154 -0
  4. bobframes/cli.py +266 -0
  5. bobframes/derive_post_merge.py +365 -0
  6. bobframes/derives/__init__.py +0 -0
  7. bobframes/derives/pass_class_breakdown.py +102 -0
  8. bobframes/derives/texture_usage.py +121 -0
  9. bobframes/discovery.py +132 -0
  10. bobframes/global_entities.py +99 -0
  11. bobframes/html/__init__.py +0 -0
  12. bobframes/html/template.py +1056 -0
  13. bobframes/lint.py +114 -0
  14. bobframes/manifest.py +127 -0
  15. bobframes/parquetize.py +282 -0
  16. bobframes/parsers/__init__.py +0 -0
  17. bobframes/parsers/derive_program_transitions.py +73 -0
  18. bobframes/parsers/parse_init_state.py +675 -0
  19. bobframes/paths.py +111 -0
  20. bobframes/probes/__init__.py +0 -0
  21. bobframes/probes/whatif.py +165 -0
  22. bobframes/qrd_harness.py +119 -0
  23. bobframes/query_examples.py +222 -0
  24. bobframes/rdcmd.py +72 -0
  25. bobframes/replay/__init__.py +26 -0
  26. bobframes/replay/replay_main.py +2305 -0
  27. bobframes/reports/__init__.py +0 -0
  28. bobframes/reports/_dashboard.py +425 -0
  29. bobframes/reports/ab.py +88 -0
  30. bobframes/reports/base.py +114 -0
  31. bobframes/reports/cache.py +147 -0
  32. bobframes/reports/chrome.py +1306 -0
  33. bobframes/reports/cli.py +99 -0
  34. bobframes/reports/delta.py +167 -0
  35. bobframes/reports/discovery.py +118 -0
  36. bobframes/reports/draws_by_class.py +165 -0
  37. bobframes/reports/formatters.py +122 -0
  38. bobframes/reports/instancing_opportunities.py +276 -0
  39. bobframes/reports/orchestrator.py +59 -0
  40. bobframes/reports/overdraw.py +293 -0
  41. bobframes/reports/pass_gpu.py +190 -0
  42. bobframes/reports/shader_hotlist.py +240 -0
  43. bobframes/reports/trend_table.py +444 -0
  44. bobframes/resource_labels.py +162 -0
  45. bobframes/run.py +480 -0
  46. bobframes/schemas.py +426 -0
  47. bobframes/stable_keys.py +83 -0
  48. bobframes/tests/__init__.py +0 -0
  49. bobframes/tests/_render_util.py +84 -0
  50. bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
  51. bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
  52. bobframes/tests/data/golden/_reports/index.html +264 -0
  53. bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
  54. bobframes/tests/data/golden/_reports/overdraw.html +275 -0
  55. bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
  56. bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
  57. bobframes/tests/data/golden/_reports/trend_table.html +390 -0
  58. bobframes/tests/data/golden/index.html +1175 -0
  59. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
  60. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
  61. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
  62. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
  63. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
  64. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
  65. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
  66. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
  67. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
  68. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
  69. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
  70. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
  71. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
  72. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
  73. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
  74. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
  75. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
  76. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
  77. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
  78. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
  79. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
  80. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
  81. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
  82. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
  83. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
  84. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
  85. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
  86. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
  87. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
  88. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
  89. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
  90. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
  91. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
  92. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
  93. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
  94. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
  95. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
  96. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
  97. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
  98. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
  99. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
  100. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
  101. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
  102. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
  103. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
  104. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
  105. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
  106. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
  107. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
  108. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
  109. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
  110. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
  111. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
  112. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
  113. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
  114. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
  115. bobframes/tests/make_synthetic.py +171 -0
  116. bobframes/tests/smoke.py +199 -0
  117. bobframes/tests/test_determinism.py +19 -0
  118. bobframes/tests/test_discovery.py +97 -0
  119. bobframes/tests/test_hardening.py +142 -0
  120. bobframes/tests/test_parity.py +22 -0
  121. bobframes/tests/test_perf.py +18 -0
  122. bobframes/tests/test_replay_drift.py +115 -0
  123. bobframes/tests/test_schemas.py +26 -0
  124. bobframes/tests/test_schemas_unit.py +55 -0
  125. bobframes/tests/test_stable_keys.py +61 -0
  126. bobframes-0.1.0.dist-info/METADATA +144 -0
  127. bobframes-0.1.0.dist-info/RECORD +130 -0
  128. bobframes-0.1.0.dist-info/WHEEL +4 -0
  129. bobframes-0.1.0.dist-info/entry_points.txt +2 -0
  130. bobframes-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1056 @@
1
+ """Per-drop data browser HTML + root cross-drop directory HTML.
2
+
3
+ Tables render via virtual scrolling: data ships as JSON in <script> blocks,
4
+ JS renders only the rows visible inside each scroll container, so the DOM
5
+ stays cheap regardless of dataset size. Sort/filter rebuild the visible
6
+ window in O(window-size).
7
+
8
+ Resource ID cells are enriched with labels from `_resource_labels.json`
9
+ (e.g. `tex_id=2184` displays as `2184 SceneDepthZ`).
10
+
11
+ CSS and JS are inlined for self-contained file:// browsing.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import csv
17
+ import html as _html
18
+ import json
19
+ import os
20
+ from typing import Iterable
21
+
22
+ import pyarrow.parquet as papq
23
+
24
+ from ..reports import base as reports_base
25
+
26
+
27
+ _CATEGORY_MAP = {
28
+ 'aggregates': ['passes', 'pass_class_breakdown', 'frame_totals',
29
+ 'texture_usage'],
30
+ 'entities': ['shaders', 'textures', 'render_targets', 'programs',
31
+ 'samplers', 'buffers', 'fbos'],
32
+ 'actions': ['draws', 'draw_bindings', 'events', 'clears', 'dispatches',
33
+ 'state_change_events', 'vertex_inputs', 'indirect_args',
34
+ 'descriptor_access', 'rt_event_timeline',
35
+ 'program_transitions', 'resource_creation',
36
+ 'counters_per_event'],
37
+ 'samples': ['vbo_samples', 'ibo_samples', 'post_vs_samples',
38
+ 'texture_samples', 'pixel_history'],
39
+ }
40
+ _CATEGORY_ORDER = ['aggregates', 'entities', 'actions', 'samples', 'sidecars']
41
+ _DEFAULT_OPEN = {'aggregates'}
42
+
43
+
44
+ # Map of (table_name, column_name) -> resource kind key in _resource_labels.json
45
+ _LABEL_COLS: dict[str, dict[str, str]] = {
46
+ 'draws': {
47
+ 'program_id': 'program',
48
+ 'vs_shader_id': 'shader',
49
+ 'fs_shader_id': 'shader',
50
+ 'depth_rt_id': 'texture',
51
+ 'color_rt_ids': 'texture_list', # semicolon-joined
52
+ 'ibo_id': 'buffer',
53
+ },
54
+ 'draw_bindings': {
55
+ # resource_id meaning depends on slot_kind; resolved in JS
56
+ 'resource_id': 'auto_by_slot_kind',
57
+ 'sampler_id': 'sampler',
58
+ },
59
+ 'passes': {
60
+ 'color_rt_id_first': 'texture',
61
+ 'depth_rt_id_first': 'texture',
62
+ },
63
+ 'shaders': {
64
+ # shader_id self-references; no need.
65
+ },
66
+ 'render_targets': {
67
+ # has its own label column already
68
+ },
69
+ 'textures': {},
70
+ 'programs': {
71
+ 'vs_shader_id': 'shader',
72
+ 'fs_shader_id': 'shader',
73
+ 'cs_shader_id': 'shader',
74
+ },
75
+ 'samplers': {},
76
+ 'fbos': {
77
+ 'resource_id': 'texture',
78
+ },
79
+ 'events': {
80
+ 'output_color_rt_id': 'texture',
81
+ 'output_depth_rt_id': 'texture',
82
+ 'copy_source_id': 'texture',
83
+ 'copy_destination_id': 'texture',
84
+ },
85
+ 'clears': {
86
+ 'fbo_id': 'fbo',
87
+ },
88
+ 'dispatches': {
89
+ 'program_id': 'program',
90
+ 'cs_shader_id': 'shader',
91
+ },
92
+ 'rt_event_timeline': {
93
+ 'rt_id': 'texture',
94
+ },
95
+ 'descriptor_access': {
96
+ 'resource_id': 'auto_by_kind',
97
+ },
98
+ 'pixel_history': {
99
+ 'rt_id': 'texture',
100
+ },
101
+ 'texture_usage': {
102
+ 'tex_id': 'texture',
103
+ },
104
+ 'pass_class_breakdown': {},
105
+ 'program_transitions': {
106
+ 'from_program_id': 'program',
107
+ 'to_program_id': 'program',
108
+ },
109
+ 'state_change_events': {},
110
+ 'indirect_args': {
111
+ 'indirect_buffer_id': 'buffer',
112
+ },
113
+ 'vertex_inputs': {
114
+ 'buffer_id': 'buffer',
115
+ },
116
+ 'resource_creation': {},
117
+ 'counters_per_event': {},
118
+ 'frame_totals': {},
119
+ 'vbo_samples': {'buffer_id': 'buffer'},
120
+ 'ibo_samples': {'buffer_id': 'buffer'},
121
+ 'post_vs_samples': {},
122
+ 'texture_samples': {'tex_id': 'texture'},
123
+ }
124
+
125
+
126
+ _PER_DROP_CSS = """
127
+ :root { --label: #4a6a3a; --th-bg: var(--surface-2); --th-bg-active: var(--row-hover); }
128
+ @media (prefers-color-scheme: dark) {
129
+ :root { --label: #a3d39c; }
130
+ }
131
+
132
+ body { max-width: 1800px; }
133
+
134
+ nav.toc a { display: flex; justify-content: space-between; padding: 2px 0; gap: 1rem; }
135
+ nav.toc a .ct { color: var(--text-3); font-variant-numeric: tabular-nums; }
136
+
137
+ .controls {
138
+ display: flex; gap: var(--sp-3); align-items: center;
139
+ font-size: var(--fs-small); flex-wrap: wrap;
140
+ margin: var(--sp-2) 0 var(--sp-2);
141
+ }
142
+ .controls input[type=search] {
143
+ font: inherit; padding: 4px 8px;
144
+ border: 1px solid var(--border-2); background: var(--surface-0); color: var(--text-1);
145
+ border-radius: 2px; min-width: 22rem;
146
+ }
147
+ .controls input[type=search]:focus { outline: 1px solid var(--accent); }
148
+ .controls .ct { color: var(--text-2); font-variant-numeric: tabular-nums; }
149
+ .controls .dl { font-size: var(--fs-small); color: var(--accent); }
150
+
151
+ .table-scroll {
152
+ height: 60vh; overflow: auto;
153
+ border: 1px solid var(--border-1);
154
+ background: var(--surface-0);
155
+ position: relative;
156
+ }
157
+ .table-scroll.short { height: auto; max-height: 60vh; }
158
+ table.data {
159
+ border-collapse: separate; border-spacing: 0;
160
+ font: var(--fs-mono) ui-monospace, 'Cascadia Code', Consolas, monospace;
161
+ width: max-content; min-width: 100%; table-layout: auto;
162
+ }
163
+ table.data thead th {
164
+ position: sticky; top: 0; z-index: 2;
165
+ background: var(--th-bg);
166
+ text-align: left; cursor: pointer; user-select: none;
167
+ color: var(--accent); font-weight: 600;
168
+ padding: 4px 8px;
169
+ border-bottom: 1px solid var(--border-2);
170
+ white-space: nowrap;
171
+ }
172
+ table.data thead th:hover { background: var(--th-bg-active); }
173
+ table.data thead th .sort-arrow { display: inline-block; width: 10px; color: var(--text-3); }
174
+ table.data thead th.numeric, table.data tbody td.numeric {
175
+ text-align: right; font-variant-numeric: tabular-nums;
176
+ }
177
+ table.data tbody td {
178
+ padding: 2px 8px;
179
+ border-bottom: 1px solid var(--border-1);
180
+ vertical-align: top; white-space: nowrap;
181
+ max-width: 380px; overflow: hidden; text-overflow: ellipsis;
182
+ background: var(--surface-0);
183
+ }
184
+ table.data tbody tr.alt td { background: var(--surface-1); }
185
+ table.data tbody tr:hover td { background: var(--row-hover); }
186
+ table.data tbody td .lbl {
187
+ color: var(--label); margin-left: 6px;
188
+ font-style: italic; opacity: .85;
189
+ }
190
+ table.data tbody td a {
191
+ color: inherit; text-decoration: none;
192
+ border-bottom: 1px dotted var(--accent);
193
+ }
194
+ table.data tbody td a:hover { color: var(--accent); border-bottom-style: solid; }
195
+ .spacer td { padding: 0; border: 0; background: var(--surface-0); }
196
+
197
+ .sidecar-list a { font-family: ui-monospace, monospace; font-size: var(--fs-small); }
198
+ .sidecar-list span { color: var(--text-2); margin-left: .4rem; font-size: var(--fs-small); }
199
+ ul.sidecar-list { list-style: none; padding: 0; margin: var(--sp-2) 0;
200
+ columns: 5; column-gap: var(--sp-6); column-rule: 1px solid var(--border-1); }
201
+ ul.sidecar-list li { padding: 1px 0; break-inside: avoid; }
202
+
203
+ /* Per-drop table sections: inline (no full card chrome) */
204
+ section.table-section {
205
+ margin: 0 0 var(--sp-4);
206
+ }
207
+ section.table-section > header.table-header {
208
+ display: flex; align-items: baseline; justify-content: space-between;
209
+ gap: var(--sp-3); margin: 0 0 var(--sp-2);
210
+ padding: 0 0 0 var(--sp-3);
211
+ border-left: 3px solid var(--border-2);
212
+ }
213
+ section.table-section > header.table-header h2 {
214
+ margin: 0; padding: 0; border: 0;
215
+ font-size: var(--fs-h3); color: var(--text-1);
216
+ }
217
+ section.table-section .table-meta {
218
+ font: var(--fs-small) ui-monospace, monospace;
219
+ color: var(--text-3);
220
+ }
221
+
222
+ details.category > .cat-body {
223
+ background: var(--surface-0);
224
+ }
225
+ """
226
+
227
+
228
+ def _compose_css() -> str:
229
+ return (reports_base.design_tokens_css()
230
+ + reports_base.chrome_css()
231
+ + _PER_DROP_CSS)
232
+
233
+
234
+ _CSS = _compose_css()
235
+
236
+ # Virtual-scroll JS. One VTable per table.
237
+ _JS = r"""
238
+ (function(){
239
+ const ROW_H = 22;
240
+ const BUFFER = 8;
241
+
242
+ // For ID kinds: where to jump when an ID cell is clicked
243
+ const LINK_TARGET = {
244
+ shader: { table: 'shaders', col: 'shader_id' },
245
+ program: { table: 'programs', col: 'program_id' },
246
+ texture: { table: 'textures', col: 'tex_id' },
247
+ sampler: { table: 'samplers', col: 'sampler_id' },
248
+ buffer: { table: 'buffers', col: 'buffer_id' },
249
+ fbo: { table: 'fbos', col: 'fbo_id' },
250
+ };
251
+
252
+ function isNumeric(v){
253
+ return v != null && (typeof v === 'number' || (typeof v === 'string' && /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(v)));
254
+ }
255
+ function fmt(v){
256
+ if (v == null) return '';
257
+ if (typeof v === 'number'){
258
+ if (v === 0) return '0';
259
+ if (Math.abs(v) < 1e-4 || Math.abs(v) >= 1e7) return v.toExponential(4);
260
+ return (Math.round(v * 1e6) / 1e6).toString();
261
+ }
262
+ return String(v);
263
+ }
264
+
265
+ function lookupLabel(labels, kind, id){
266
+ if (!labels || !kind || id == null || id === '' || id === 0 || id === '0') return '';
267
+ const k = String(id);
268
+ const cap = labels.capture;
269
+ if (!cap || !labels.by_capture || !labels.by_capture[cap]) return '';
270
+ const buckets = labels.by_capture[cap];
271
+ if (kind === 'auto_by_slot_kind' || kind === 'auto_by_kind') return '';
272
+ if (kind === 'texture_list') return '';
273
+ return (buckets[kind] && buckets[kind][k]) || '';
274
+ }
275
+
276
+ function autoKindForSlot(slotKind){
277
+ if (slotKind === 'texture') return 'texture';
278
+ if (slotKind === 'sampler') return 'sampler';
279
+ if (slotKind === 'ubo' || slotKind === 'ssbo') return 'buffer';
280
+ return '';
281
+ }
282
+ function autoKindForDescriptor(descriptorKind){
283
+ if (descriptorKind === 'ReadOnlyResource' || descriptorKind === 'ImageSampler' || descriptorKind === 'TypedBuffer') return 'texture';
284
+ if (descriptorKind === 'Sampler') return 'sampler';
285
+ if (descriptorKind === 'ConstantBuffer' || descriptorKind === 'ReadWriteResource' || descriptorKind === 'ReadWriteBuffer') return 'buffer';
286
+ return '';
287
+ }
288
+
289
+ class VTable {
290
+ constructor(host, payload, labels){
291
+ this.host = host;
292
+ this.cols = payload.cols;
293
+ this.rows = payload.rows;
294
+ this.labelCols = payload.labelCols || {};
295
+ this.labels = labels;
296
+ this.view = this.rows.slice();
297
+ this.sortCol = -1;
298
+ this.sortDir = 1;
299
+
300
+ // detect numeric columns from first 50 non-null cells
301
+ this.numericCols = new Set();
302
+ for (let ci = 0; ci < this.cols.length; ci++){
303
+ let count = 0, num = 0;
304
+ for (let ri = 0; ri < this.rows.length && count < 50; ri++){
305
+ const v = this.rows[ri][ci];
306
+ if (v == null || v === '') continue;
307
+ count++;
308
+ if (typeof v === 'number' || (typeof v === 'string' && /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(v))) num++;
309
+ }
310
+ if (count > 0 && num / count > 0.7) this.numericCols.add(ci);
311
+ }
312
+
313
+ // detect slot_kind column for draw_bindings (resource_id auto-label)
314
+ this.slotKindCol = this.cols.indexOf('slot_kind');
315
+ this.descriptorKindCol = this.cols.indexOf('descriptor_kind');
316
+
317
+ this.build();
318
+ }
319
+
320
+ build(){
321
+ const table = document.createElement('table');
322
+ table.className = 'data';
323
+ const thead = table.createTHead();
324
+ const tr = thead.insertRow();
325
+ for (let i = 0; i < this.cols.length; i++){
326
+ const th = document.createElement('th');
327
+ const txt = document.createTextNode(this.cols[i]);
328
+ th.appendChild(txt);
329
+ if (this.numericCols.has(i)) th.classList.add('numeric');
330
+ const arrow = document.createElement('span');
331
+ arrow.className = 'sort-arrow';
332
+ th.appendChild(arrow);
333
+ th.addEventListener('click', () => this.sort(i));
334
+ tr.appendChild(th);
335
+ }
336
+ const tbody = document.createElement('tbody');
337
+ const sTop = document.createElement('tr');
338
+ const sBot = document.createElement('tr');
339
+ sTop.className = 'spacer'; sBot.className = 'spacer';
340
+ const tdTop = document.createElement('td');
341
+ const tdBot = document.createElement('td');
342
+ tdTop.colSpan = this.cols.length;
343
+ tdBot.colSpan = this.cols.length;
344
+ sTop.appendChild(tdTop); sBot.appendChild(tdBot);
345
+ tbody.appendChild(sTop); tbody.appendChild(sBot);
346
+ table.appendChild(tbody);
347
+ this.host.appendChild(table);
348
+
349
+ this.tbody = tbody;
350
+ this.sTop = sTop;
351
+ this.sBot = sBot;
352
+ this.tdTop = tdTop;
353
+ this.tdBot = tdBot;
354
+
355
+ this.host.addEventListener('scroll', () => this.render());
356
+ window.addEventListener('resize', () => this.render());
357
+ // Re-render when a containing <details class="category"> opens.
358
+ const detailsEl = this.host.closest('details');
359
+ if (detailsEl){
360
+ detailsEl.addEventListener('toggle', () => {
361
+ if (detailsEl.open){
362
+ requestAnimationFrame(() => this.render());
363
+ setTimeout(() => this.render(), 50);
364
+ }
365
+ });
366
+ }
367
+ this.render();
368
+ // Re-render after layout settles (initial clientHeight can be 0)
369
+ requestAnimationFrame(() => this.render());
370
+ // And once more after fonts/sizes stabilize
371
+ setTimeout(() => this.render(), 50);
372
+ }
373
+
374
+ sort(ci){
375
+ if (this.sortCol === ci) this.sortDir = -this.sortDir;
376
+ else { this.sortCol = ci; this.sortDir = 1; }
377
+ const dir = this.sortDir;
378
+ const isNum = this.numericCols.has(ci);
379
+ this.view.sort((a, b) => {
380
+ const aa = a[ci], bb = b[ci];
381
+ if (aa == null && bb == null) return 0;
382
+ if (aa == null) return 1;
383
+ if (bb == null) return -1;
384
+ if (isNum){
385
+ const na = +aa, nb = +bb;
386
+ return (na - nb) * dir;
387
+ }
388
+ return String(aa).localeCompare(String(bb)) * dir;
389
+ });
390
+ const headers = this.host.querySelectorAll('thead th');
391
+ for (let i = 0; i < headers.length; i++){
392
+ const a = headers[i].querySelector('.sort-arrow');
393
+ if (a) a.textContent = (i === ci) ? (dir > 0 ? ' ▲' : ' ▼') : '';
394
+ }
395
+ this.host.scrollTop = 0;
396
+ this.render();
397
+ }
398
+
399
+ filter(query){
400
+ const q = (query || '').trim().toLowerCase();
401
+ if (!q){
402
+ this.view = this.rows.slice();
403
+ } else {
404
+ const labels = this.labels;
405
+ const labelCols = this.labelCols;
406
+ const cols = this.cols;
407
+ const slotKindCol = this.slotKindCol;
408
+ const descriptorKindCol = this.descriptorKindCol;
409
+ this.view = this.rows.filter(r => {
410
+ for (let i = 0; i < r.length; i++){
411
+ const v = r[i];
412
+ if (v == null) continue;
413
+ if (String(v).toLowerCase().indexOf(q) >= 0) return true;
414
+ // also match against the resolved label, if any
415
+ const lc = labelCols[cols[i]];
416
+ if (!lc || v === 0 || v === '0' || v === '') continue;
417
+ let kind = lc;
418
+ if (kind === 'auto_by_slot_kind' && slotKindCol >= 0) kind = autoKindForSlot(r[slotKindCol]);
419
+ else if (kind === 'auto_by_kind' && descriptorKindCol >= 0) kind = autoKindForDescriptor(r[descriptorKindCol]);
420
+ if (kind === 'texture_list'){
421
+ const ids = String(v).split(';').filter(x => x);
422
+ for (const id of ids){
423
+ const lbl = lookupLabel(labels, 'texture', id);
424
+ if (lbl && lbl.toLowerCase().indexOf(q) >= 0) return true;
425
+ }
426
+ } else if (kind){
427
+ const lbl = lookupLabel(labels, kind, v);
428
+ if (lbl && lbl.toLowerCase().indexOf(q) >= 0) return true;
429
+ }
430
+ }
431
+ return false;
432
+ });
433
+ }
434
+ // resort if a sort was active
435
+ if (this.sortCol >= 0){
436
+ const ci = this.sortCol, dir = this.sortDir;
437
+ const isNum = this.numericCols.has(ci);
438
+ this.view.sort((a, b) => {
439
+ const aa = a[ci], bb = b[ci];
440
+ if (aa == null && bb == null) return 0;
441
+ if (aa == null) return 1;
442
+ if (bb == null) return -1;
443
+ if (isNum) return (+aa - +bb) * dir;
444
+ return String(aa).localeCompare(String(bb)) * dir;
445
+ });
446
+ }
447
+ this.host.scrollTop = 0;
448
+ this.render();
449
+ }
450
+
451
+ cellNode(value, ri, ci){
452
+ const td = document.createElement('td');
453
+ if (this.numericCols.has(ci)) td.classList.add('numeric');
454
+
455
+ const colName = this.cols[ci];
456
+ const lc = this.labelCols[colName];
457
+ let kind = lc;
458
+ if (kind === 'auto_by_slot_kind' && this.slotKindCol >= 0){
459
+ kind = autoKindForSlot(this.view[ri][this.slotKindCol]);
460
+ } else if (kind === 'auto_by_kind' && this.descriptorKindCol >= 0){
461
+ kind = autoKindForDescriptor(this.view[ri][this.descriptorKindCol]);
462
+ }
463
+
464
+ // primary cell value: cross-link if we have a target for this kind
465
+ const formatted = fmt(value);
466
+ const link = LINK_TARGET[kind];
467
+ if (link && value != null && value !== '' && value !== 0 && value !== '0' && kind !== 'texture_list'){
468
+ const a = document.createElement('a');
469
+ a.href = '#' + link.table;
470
+ a.textContent = formatted;
471
+ a.title = 'jump to ' + link.table + ' filtered to ' + link.col + '=' + value;
472
+ a.addEventListener('click', (ev) => {
473
+ ev.preventDefault();
474
+ jumpToTable(link.table, String(value));
475
+ });
476
+ td.appendChild(a);
477
+ } else {
478
+ td.textContent = formatted;
479
+ }
480
+
481
+ // label enrichment (appears after the value/link)
482
+ if (lc && value != null && value !== '' && value !== 0 && value !== '0'){
483
+ if (kind === 'texture_list'){
484
+ const ids = String(value).split(';').filter(x => x);
485
+ const labels = ids.map(id => lookupLabel(this.labels, 'texture', id))
486
+ .filter(x => x);
487
+ if (labels.length){
488
+ const span = document.createElement('span');
489
+ span.className = 'lbl';
490
+ span.textContent = labels.join(', ');
491
+ td.appendChild(span);
492
+ }
493
+ } else if (kind){
494
+ const label = lookupLabel(this.labels, kind, value);
495
+ if (label){
496
+ const span = document.createElement('span');
497
+ span.className = 'lbl';
498
+ span.textContent = label;
499
+ td.appendChild(span);
500
+ }
501
+ }
502
+ }
503
+ return td;
504
+ }
505
+
506
+ render(){
507
+ const scrollTop = this.host.scrollTop;
508
+ const height = this.host.clientHeight || 600;
509
+ const len = this.view.length;
510
+ const start = Math.max(0, Math.floor(scrollTop / ROW_H) - BUFFER);
511
+ const end = Math.min(len, Math.ceil((scrollTop + height) / ROW_H) + BUFFER);
512
+
513
+ // clear data rows between spacers
514
+ while (this.sTop.nextSibling !== this.sBot){
515
+ this.tbody.removeChild(this.sTop.nextSibling);
516
+ }
517
+ // height on <tr> directly; <td> alone doesn't make a row tall in all browsers
518
+ this.sTop.style.height = (start * ROW_H) + 'px';
519
+ this.sBot.style.height = ((len - end) * ROW_H) + 'px';
520
+ this.tdTop.style.height = (start * ROW_H) + 'px';
521
+ this.tdBot.style.height = ((len - end) * ROW_H) + 'px';
522
+
523
+ const frag = document.createDocumentFragment();
524
+ for (let i = start; i < end; i++){
525
+ const tr = document.createElement('tr');
526
+ tr.style.height = ROW_H + 'px';
527
+ if (i % 2 === 1) tr.className = 'alt';
528
+ const row = this.view[i];
529
+ for (let ci = 0; ci < this.cols.length; ci++){
530
+ tr.appendChild(this.cellNode(row[ci], i, ci));
531
+ }
532
+ frag.appendChild(tr);
533
+ }
534
+ this.tbody.insertBefore(frag, this.sBot);
535
+ }
536
+ }
537
+
538
+ function jumpToTable(tableName, idValue){
539
+ const host = document.querySelector('div.table-scroll[data-table="' + tableName + '"]');
540
+ if (!host || !host._vt) return;
541
+ const section = host.closest('section');
542
+ const input = section ? section.querySelector('input[type=search]') : null;
543
+ if (input){
544
+ input.value = idValue;
545
+ // trigger filter immediately (no debounce on programmatic set)
546
+ host._vt.filter(idValue);
547
+ const counter = section.querySelector('.ct.visible-count');
548
+ if (counter){
549
+ const v = host._vt.view.length, t = host._vt.rows.length;
550
+ counter.textContent = v.toLocaleString() + ' / ' + t.toLocaleString() + ' visible';
551
+ }
552
+ }
553
+ section.scrollIntoView({behavior: 'smooth', block: 'start'});
554
+ }
555
+ window.__jumpToTable = jumpToTable;
556
+
557
+ window.addEventListener('DOMContentLoaded', () => {
558
+ const labels = window.__labels || {};
559
+ document.querySelectorAll('div.table-scroll[data-table]').forEach(host => {
560
+ const name = host.dataset.table;
561
+ const payload = window['__data_' + name];
562
+ if (!payload){ return; }
563
+ const labelsForTable = Object.assign({}, labels);
564
+ // Per-section: capture from data is row-level; use the section's data-capture if set
565
+ labelsForTable.capture = host.dataset.capture || (payload.rows[0] ? payload.rows[0][3] : '');
566
+ const vt = new VTable(host, payload, labelsForTable);
567
+ host._vt = vt;
568
+
569
+ const section = host.closest('section');
570
+ const input = section.querySelector('input[type=search]');
571
+ const counter = section.querySelector('.ct.visible-count');
572
+ function updateCounter(){
573
+ const v = vt.view.length, t = vt.rows.length;
574
+ counter.textContent = v.toLocaleString() + ' / ' + t.toLocaleString() + ' visible';
575
+ }
576
+ updateCounter();
577
+ let timer = null;
578
+ input.addEventListener('input', () => {
579
+ clearTimeout(timer);
580
+ timer = setTimeout(() => { vt.filter(input.value); updateCounter(); }, 80);
581
+ });
582
+ });
583
+ });
584
+ })();
585
+ """
586
+
587
+
588
+ def _h(s) -> str:
589
+ return _html.escape(str(s if s is not None else ''))
590
+
591
+
592
+ def _row_count(pq_path: str) -> int:
593
+ try:
594
+ return papq.read_metadata(pq_path).num_rows
595
+ except Exception:
596
+ return 0
597
+
598
+
599
+ def _file_size_label(path: str) -> str:
600
+ if not os.path.exists(path):
601
+ return ''
602
+ n = os.path.getsize(path)
603
+ if n < 1024:
604
+ return f'{n} B'
605
+ if n < 1024 * 1024:
606
+ return f'{n / 1024:.1f} KB'
607
+ return f'{n / 1024 / 1024:.1f} MB'
608
+
609
+
610
+ def _table_payload(table_name: str, out_dir: str) -> dict | None:
611
+ pq = os.path.join(out_dir, f'{table_name}.parquet')
612
+ if not os.path.exists(pq):
613
+ return None
614
+ t = papq.read_table(pq)
615
+ if t.num_rows == 0:
616
+ return None
617
+ cols = t.column_names
618
+ # Build rows as a list-of-lists. Pyarrow's to_pylist on a Table doesn't
619
+ # exist; use per-column extraction.
620
+ arrays = [t.column(c).to_pylist() for c in cols]
621
+ n = t.num_rows
622
+ rows = [[arrays[ci][ri] for ci in range(len(cols))] for ri in range(n)]
623
+ label_cols = _LABEL_COLS.get(table_name, {})
624
+ return {'cols': cols, 'rows': rows, 'labelCols': label_cols}
625
+
626
+
627
+ def _inline_table_with_data(table_name: str, out_dir: str,
628
+ sidecar_rel: str = '.') -> tuple[str, str] | None:
629
+ """Returns (section_html, script_html) or None if table empty.
630
+
631
+ sidecar_rel: relative path from rendered HTML to the data dir, used to
632
+ construct CSV/parquet download links. Default '.' for legacy callers
633
+ where HTML lives next to data.
634
+ """
635
+ payload = _table_payload(table_name, out_dir)
636
+ if payload is None:
637
+ return None
638
+ n_total = len(payload['rows'])
639
+ n_cols = len(payload['cols'])
640
+ pq_sz = _file_size_label(os.path.join(out_dir, f'{table_name}.parquet'))
641
+ csv_sz = _file_size_label(os.path.join(out_dir, f'{table_name}.csv'))
642
+
643
+ prefix = '' if sidecar_rel in ('.', '') else sidecar_rel.rstrip('/') + '/'
644
+
645
+ section = []
646
+ section.append(f'<section class="table-section" id="{table_name}">')
647
+ section.append('<header class="table-header">')
648
+ section.append(f'<h2>{_h(table_name)}</h2>')
649
+ section.append(f'<span class="table-meta">{n_total:,} rows, {n_cols} cols</span>')
650
+ section.append('</header>')
651
+ section.append('<div class="controls">')
652
+ section.append(f'<input type="search" placeholder="filter {table_name}...">')
653
+ section.append('<span class="ct visible-count"></span>')
654
+ section.append(f'<a class="dl" href="{prefix}{table_name}.csv">CSV ({csv_sz})</a>')
655
+ section.append(f'<a class="dl" href="{prefix}{table_name}.parquet">parquet ({pq_sz})</a>')
656
+ section.append('</div>')
657
+ section.append(f'<div class="table-scroll" data-table="{table_name}"></div>')
658
+ section.append('</section>')
659
+
660
+ script = (f'<script>window.__data_{table_name}='
661
+ f'{json.dumps(payload, separators=(",", ":"))};</script>')
662
+ return '\n'.join(section), script
663
+
664
+
665
+ def _categorize(table_specs: list[tuple[str, int, int]]) -> dict:
666
+ """Return {category: [(name, n_rows, n_cols), ...]} preserving order."""
667
+ by_cat: dict[str, list] = {cat: [] for cat in _CATEGORY_ORDER if cat != 'sidecars'}
668
+ spec_by_name = {name: (name, n_rows, n_cols)
669
+ for (name, n_rows, n_cols) in table_specs}
670
+ used: set = set()
671
+ for cat in _CATEGORY_ORDER:
672
+ if cat == 'sidecars':
673
+ continue
674
+ for name in _CATEGORY_MAP.get(cat, []):
675
+ if name in spec_by_name:
676
+ by_cat[cat].append(spec_by_name[name])
677
+ used.add(name)
678
+ # Anything uncategorized goes into 'actions' as a tail bucket
679
+ for name, n_rows, n_cols in table_specs:
680
+ if name not in used:
681
+ by_cat.setdefault('actions', []).append((name, n_rows, n_cols))
682
+ return by_cat
683
+
684
+
685
+ def _toc(by_cat: dict) -> str:
686
+ """Category-aware TOC. Each category links to its <details> anchor."""
687
+ parts = ['<nav class="toc">']
688
+ for cat in _CATEGORY_ORDER:
689
+ if cat == 'sidecars':
690
+ parts.append(f'<a href="#cat-sidecars"><span>sidecars</span></a>')
691
+ continue
692
+ items = by_cat.get(cat, [])
693
+ if not items:
694
+ continue
695
+ total_rows = sum(r for _, r, _ in items)
696
+ parts.append(f'<a href="#cat-{_h(cat)}"><span>{_h(cat)}</span>'
697
+ f'<span class="ct">{len(items)}t, {total_rows:,}r</span></a>')
698
+ parts.append('</nav>')
699
+ return '\n'.join(parts)
700
+
701
+
702
+ def _category_block(cat: str, items: list, table_sections: dict,
703
+ is_open: bool) -> str:
704
+ """Render <details class='category'> wrapping its table sections."""
705
+ if not items:
706
+ return ''
707
+ total_rows = sum(r for _, r, _ in items)
708
+ open_attr = ' open' if is_open else ''
709
+ parts = [
710
+ f'<details class="category" id="cat-{_h(cat)}"{open_attr}>',
711
+ '<summary>',
712
+ f'<span class="cat-name">{_h(cat)}</span>',
713
+ f'<span class="cat-meta">{len(items)} tables, {total_rows:,} rows</span>',
714
+ '</summary>',
715
+ '<div class="cat-body">',
716
+ ]
717
+ for name, _, _ in items:
718
+ sec_html = table_sections.get(name)
719
+ if sec_html:
720
+ parts.append(sec_html)
721
+ parts.append('</div></details>')
722
+ return '\n'.join(parts)
723
+
724
+
725
+ def _sidecar_category(out_dir: str, sidecar_rel: str = '.') -> str:
726
+ """Render the sidecars category as <details class='category'>.
727
+
728
+ sidecar_rel: relative path from rendered HTML to data dir, used to
729
+ construct download links. Default '.' for legacy callers.
730
+ """
731
+ body_parts = []
732
+ counts = []
733
+ prefix = '' if sidecar_rel in ('.', '') else sidecar_rel.rstrip('/') + '/'
734
+
735
+ src_dir = os.path.join(out_dir, 'shader_src')
736
+ if os.path.isdir(src_dir):
737
+ files = os.listdir(src_dir)
738
+ files.sort(key=lambda f: int(f.split('.', 1)[0]) if f.split('.', 1)[0].isdigit() else 0)
739
+ counts.append(f'shader_src {len(files)}')
740
+ body_parts.append(f'<h3>shader_src ({len(files)} files)</h3>')
741
+ body_parts.append('<ul class="sidecar-list">')
742
+ for f in files:
743
+ sz = os.path.getsize(os.path.join(src_dir, f))
744
+ sz_str = f'{sz // 1024}K' if sz >= 1024 else f'{sz}B'
745
+ body_parts.append(f'<li><a href="{prefix}shader_src/{_h(f)}">{_h(f)}</a><span>{sz_str}</span></li>')
746
+ body_parts.append('</ul>')
747
+
748
+ hist_dir = os.path.join(out_dir, 'histogram')
749
+ if os.path.isdir(hist_dir):
750
+ files = sorted(os.listdir(hist_dir))
751
+ if files:
752
+ counts.append(f'histogram {len(files)}')
753
+ body_parts.append(f'<h3>histogram ({len(files)} files)</h3>')
754
+ body_parts.append('<ul class="sidecar-list">')
755
+ for f in files:
756
+ body_parts.append(f'<li><a href="{prefix}histogram/{_h(f)}">{_h(f)}</a></li>')
757
+ body_parts.append('</ul>')
758
+
759
+ for jsonl in ('frame_metadata.jsonl', 'uniforms_per_pass.jsonl'):
760
+ p = os.path.join(out_dir, jsonl)
761
+ if os.path.exists(p):
762
+ counts.append(jsonl)
763
+ body_parts.append(f'<h3>{_h(jsonl)}</h3>')
764
+ body_parts.append(f'<p><a href="{prefix}{_h(jsonl)}">download</a> '
765
+ f'<span class="ct">{_file_size_label(p)}</span></p>')
766
+
767
+ if not body_parts:
768
+ return ''
769
+
770
+ meta = ' / '.join(counts)
771
+ return ('<details class="category" id="cat-sidecars">'
772
+ '<summary>'
773
+ '<span class="cat-name">sidecars</span>'
774
+ f'<span class="cat-meta">{_h(meta)}</span>'
775
+ '</summary>'
776
+ '<div class="cat-body">'
777
+ + '\n'.join(body_parts)
778
+ + '</div></details>')
779
+
780
+
781
+ def _read_gl_renderer(out_dir: str) -> str:
782
+ p = os.path.join(out_dir, 'frame_metadata.jsonl')
783
+ if not os.path.exists(p):
784
+ return ''
785
+ try:
786
+ with open(p, 'r', encoding='utf-8') as f:
787
+ for line in f:
788
+ try:
789
+ o = json.loads(line)
790
+ except json.JSONDecodeError:
791
+ continue
792
+ s = o.get('gl_renderer_string') or o.get('gl_renderer') or ''
793
+ if s:
794
+ return s
795
+ except OSError:
796
+ pass
797
+ return ''
798
+
799
+
800
+ def render_drop(drill_dir: str, *, data_dir: str,
801
+ area: str, drop_date: str, drop_label: str,
802
+ captures: list[str], schema_version: int, build_timestamp: str,
803
+ row_counts: dict[str, int]) -> str:
804
+ """Render the per-drop browser HTML into drill_dir, reading data from data_dir.
805
+
806
+ drill_dir: <root>/_reports/drill/<area>/<drop>/ (HTML output target)
807
+ data_dir: <root>/_data/<area>/<drop>/ (parquet + sidecars source)
808
+
809
+ Returns path to drill_dir/index.html. Sidecar links (shader_src, histogram)
810
+ point at data_dir via relative path.
811
+ """
812
+ total_rows = sum(row_counts.values())
813
+
814
+ table_specs: list[tuple[str, int, int]] = []
815
+ for table_name, n in sorted(row_counts.items()):
816
+ if n <= 0:
817
+ continue
818
+ pq = os.path.join(data_dir, f'{table_name}.parquet')
819
+ if not os.path.exists(pq):
820
+ continue
821
+ n_cols = len(papq.read_schema(pq).names)
822
+ table_specs.append((table_name, n, n_cols))
823
+
824
+ # Load labels sidecar (small JSON) from data_dir
825
+ labels_path = os.path.join(data_dir, '_resource_labels.json')
826
+ labels_json = '{}'
827
+ if os.path.exists(labels_path):
828
+ with open(labels_path, 'r', encoding='utf-8') as f:
829
+ labels_json = f.read()
830
+
831
+ drop_key = f'{drop_date}_{drop_label}' if drop_label else drop_date
832
+ gl_renderer = _read_gl_renderer(data_dir)
833
+
834
+ kpis = [
835
+ {'label': 'rows total', 'value': reports_base.fmt_int(total_rows)},
836
+ {'label': 'captures', 'value': reports_base.fmt_int(len(captures))},
837
+ {'label': 'tables', 'value': reports_base.fmt_int(len(table_specs))},
838
+ ]
839
+
840
+ parts = ['<!doctype html><html lang="en"><head><meta charset="utf-8">']
841
+ parts.append(f'<title>{_h(area)} {_h(drop_date)}</title>')
842
+ parts.append(f'<link rel="icon" href="{reports_base._FAVICON_HREF}">')
843
+ parts.append(f'<style>{_CSS}</style></head>'
844
+ f'<body data-page-kind="drop-browser" style="--hdr-offset: 120px">')
845
+
846
+ parts.append(f'<h1>{_h(area)} / {_h(drop_key)}</h1>')
847
+ parts.append('<header class="strip">')
848
+ parts.append(f'<span>area <strong>{_h(area)}</strong></span>')
849
+ parts.append(f'<span>drop <strong>{_h(drop_key)}</strong></span>')
850
+ parts.append(f'<span>rows <strong>{total_rows:,}</strong></span>')
851
+ parts.append(f'<span>built <strong>{_h(build_timestamp)}</strong></span>')
852
+ parts.append('</header>')
853
+ # drill_dir = <root>/_reports/drill/<area>/<drop>/ → up 4 to reach <root>
854
+ parts.append('<nav class="crumb">'
855
+ '<a href="../../../../index.html" data-link-kind="crumb">root catalog</a>'
856
+ '<a href="../../../index.html" data-link-kind="crumb">dashboard</a>'
857
+ '</nav>')
858
+
859
+ # Summary bar: per-drop aggregates
860
+ n_passes = row_counts.get('passes', 0)
861
+ n_draws = row_counts.get('draws', 0)
862
+ parts.append(reports_base.summary_bar(
863
+ 'this drop',
864
+ f'{_h(area)} / {_h(drop_key)}',
865
+ sub=f'{reports_base.fmt_int(n_draws)} draws; {reports_base.fmt_int(n_passes)} passes; {reports_base.fmt_int(len(captures))} captures',
866
+ tone='neutral',
867
+ ))
868
+
869
+ if gl_renderer:
870
+ parts.append(f'<div class="device-strip">{_h(gl_renderer)}</div>')
871
+
872
+ parts.append(reports_base.kpi_strip(kpis))
873
+
874
+ by_cat = _categorize(table_specs)
875
+ parts.append(_toc(by_cat))
876
+
877
+ # Compute relative path from drill_dir → data_dir for sidecar/shader_src links.
878
+ data_rel = os.path.relpath(data_dir, drill_dir).replace('\\', '/')
879
+
880
+ table_sections: dict[str, str] = {}
881
+ scripts: list[str] = []
882
+ for name, _, _ in table_specs:
883
+ result = _inline_table_with_data(name, data_dir, sidecar_rel=data_rel)
884
+ if result is None:
885
+ continue
886
+ sec, scr = result
887
+ table_sections[name] = sec
888
+ scripts.append(scr)
889
+
890
+ for cat in _CATEGORY_ORDER:
891
+ if cat == 'sidecars':
892
+ sidecar_html = _sidecar_category(data_dir, sidecar_rel=data_rel)
893
+ if sidecar_html:
894
+ parts.append(sidecar_html)
895
+ continue
896
+ block = _category_block(cat, by_cat.get(cat, []), table_sections,
897
+ is_open=(cat in _DEFAULT_OPEN))
898
+ if block:
899
+ parts.append(block)
900
+
901
+ parts.append(f'<script>window.__labels={labels_json};</script>')
902
+ parts.extend(scripts)
903
+ parts.append(f'<script>{_JS}</script>')
904
+ parts.append('</body></html>')
905
+
906
+ out_path = os.path.join(drill_dir, 'index.html')
907
+ with open(out_path, 'w', encoding='utf-8') as f:
908
+ f.write('\n'.join(parts))
909
+ return out_path
910
+
911
+
912
+ def render_root(root: str) -> str:
913
+ from .. import paths as _paths
914
+ cat_pq = _paths.catalog_parquet(root)
915
+ cat_json = _paths.catalog_json(root)
916
+ root_index = _paths.root_index_html(root)
917
+ if not os.path.exists(cat_pq):
918
+ with open(root_index, 'w', encoding='utf-8') as f:
919
+ f.write('<!doctype html><html><body>no catalog yet</body></html>')
920
+ return root_index
921
+
922
+ t = papq.read_table(cat_pq)
923
+ summary = {}
924
+ if os.path.exists(cat_json):
925
+ with open(cat_json, 'r', encoding='utf-8') as f:
926
+ summary = json.load(f)
927
+
928
+ cols = t.column_names
929
+ arrays = [t.column(c).to_pylist() for c in cols]
930
+ n = t.num_rows
931
+ rows = [[arrays[ci][ri] for ci in range(len(cols))] for ri in range(n)]
932
+
933
+ # analysis_out_path column stored relative under _data/. Transform link
934
+ # target to the per-drop browser at _reports/drill/<area>/<drop>/index.html.
935
+ path_idx = cols.index('analysis_out_path') if 'analysis_out_path' in cols else -1
936
+ if path_idx >= 0:
937
+ for r in rows:
938
+ v = str(r[path_idx])
939
+ # Replace leading "_data/" with "_reports/drill/" and append index.html.
940
+ if v.startswith('_data/') or v.startswith('_data\\'):
941
+ drill_rel = '_reports/drill/' + v[len('_data/'):].replace('\\', '/')
942
+ elif os.path.isabs(v):
943
+ drill_rel = os.path.relpath(
944
+ _paths.drop_dir_to_drill_dir(v), root).replace('\\', '/')
945
+ else:
946
+ drill_rel = v.replace('\\', '/')
947
+ r[path_idx] = drill_rel.rstrip('/') + '/index.html'
948
+
949
+ payload = {'cols': cols, 'rows': rows, 'labelCols': {}}
950
+
951
+ parts = ['<!doctype html><html lang="en"><head><meta charset="utf-8">']
952
+ parts.append('<title>capture analysis catalog</title>')
953
+ parts.append(f'<link rel="icon" href="{reports_base._FAVICON_HREF}">')
954
+ parts.append(f'<style>{_CSS}</style></head><body style="--hdr-offset: 120px">')
955
+ parts.append('<header class="strip">')
956
+ parts.append(f'<span>built <strong>{_h(summary.get("build_timestamp", ""))}</strong></span>')
957
+ parts.append(f'<span>drops <strong>{summary.get("drop_count", 0)}</strong></span>')
958
+ parts.append('</header>')
959
+
960
+ # Summary bar: latest drop + area/capture counts
961
+ area_idx = cols.index('area') if 'area' in cols else -1
962
+ date_idx = cols.index('drop_date') if 'drop_date' in cols else -1
963
+ label_idx = cols.index('drop_label') if 'drop_label' in cols else -1
964
+ unique_areas: set = set()
965
+ latest_date = ''
966
+ latest_label = ''
967
+ for r in rows:
968
+ if area_idx >= 0 and r[area_idx]:
969
+ unique_areas.add(str(r[area_idx]))
970
+ if date_idx >= 0 and r[date_idx]:
971
+ dval = str(r[date_idx])
972
+ if dval > latest_date:
973
+ latest_date = dval
974
+ latest_label = str(r[label_idx]) if label_idx >= 0 else ''
975
+ if latest_date:
976
+ head_text = f'{latest_date}'
977
+ if latest_label:
978
+ head_text = f'{latest_date} / {latest_label}'
979
+ parts.append(reports_base.summary_bar(
980
+ 'latest drop',
981
+ head_text,
982
+ sub=f'{len(unique_areas)} areas; {n} catalog rows',
983
+ link_href='_reports/index.html',
984
+ link_text='dashboard',
985
+ tone='neutral',
986
+ ))
987
+
988
+ # Reports section
989
+ reports_dir = os.path.join(root, '_reports')
990
+ if os.path.isdir(reports_dir):
991
+ dashboard = os.path.join(reports_dir, 'index.html')
992
+ if os.path.exists(dashboard):
993
+ parts.append('<section><h2 id="dashboard">dashboard</h2>'
994
+ '<div class="chip-cluster">'
995
+ '<a href="_reports/index.html" data-link-kind="primary">'
996
+ 'cumulative reports dashboard</a>'
997
+ '</div></section>')
998
+
999
+ report_files = sorted(
1000
+ f for f in os.listdir(reports_dir)
1001
+ if f.endswith('.html') and f != 'index.html'
1002
+ )
1003
+ if report_files:
1004
+ parts.append('<section><h2 id="reports">reports</h2>'
1005
+ '<div class="catalog-grid">')
1006
+ for f in report_files:
1007
+ parts.append(f'<a href="_reports/{_h(f)}" data-link-kind="primary">'
1008
+ f'{_h(f[:-5])}</a>')
1009
+ parts.append('</div></section>')
1010
+
1011
+ ab_dir = os.path.join(reports_dir, 'ab')
1012
+ if os.path.isdir(ab_dir):
1013
+ pairs = sorted(
1014
+ d for d in os.listdir(ab_dir)
1015
+ if os.path.isdir(os.path.join(ab_dir, d))
1016
+ )
1017
+ if pairs:
1018
+ parts.append('<section><h2 id="ab">a/b comparisons</h2>'
1019
+ '<div class="pair-list">')
1020
+ for pair in pairs:
1021
+ pair_files = sorted(
1022
+ f for f in os.listdir(os.path.join(ab_dir, pair))
1023
+ if f.endswith('.html')
1024
+ )
1025
+ chips = ''.join(
1026
+ f'<a href="_reports/ab/{_h(pair)}/{_h(f)}" data-link-kind="primary">'
1027
+ f'{_h(f[:-5])}</a>'
1028
+ for f in pair_files
1029
+ )
1030
+ parts.append(
1031
+ f'<div class="pair-group">'
1032
+ f'<h3>{_h(pair)}</h3>'
1033
+ f'<div class="chip-cluster">{chips}</div>'
1034
+ f'</div>'
1035
+ )
1036
+ parts.append('</div></section>')
1037
+
1038
+ parts.append('<section><h2>catalog</h2>')
1039
+ parts.append('<div class="controls">')
1040
+ parts.append('<input type="search" placeholder="filter">')
1041
+ parts.append('<span class="ct visible-count"></span>')
1042
+ parts.append(f'<a class="dl" href="_data/_catalog.csv" data-link-kind="inline">CSV</a>')
1043
+ parts.append(f'<a class="dl" href="_data/_catalog.parquet" data-link-kind="inline">parquet</a>')
1044
+ parts.append('</div>')
1045
+ parts.append(f'<div class="table-scroll" data-table="catalog"></div>')
1046
+ parts.append('</section>')
1047
+
1048
+ parts.append(f'<script>window.__data_catalog={json.dumps(payload, separators=(",", ":"))};</script>')
1049
+ parts.append(f'<script>window.__labels={{}};</script>')
1050
+ parts.append(f'<script>{_JS}</script>')
1051
+ parts.append('</body></html>')
1052
+
1053
+ out_path = root_index
1054
+ with open(out_path, 'w', encoding='utf-8') as f:
1055
+ f.write('\n'.join(parts))
1056
+ return out_path