ArchWorld commited on
Commit
895dbe7
Β·
verified Β·
1 Parent(s): 4b38970

update viewer

Browse files
Files changed (1) hide show
  1. index.html +618 -0
index.html ADDED
@@ -0,0 +1,618 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>GLB Showcase</title>
6
+ <script type="importmap">
7
+ {"imports":{
8
+ "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
9
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
10
+ }}
11
+ </script>
12
+ <script src="showcase_data.js"></script>
13
+ <style>
14
+ * { box-sizing: border-box; margin: 0; padding: 0; }
15
+ body { font-family: system-ui, sans-serif; background: #0d0d1a; color: #e0e0f0; }
16
+ .sc-nav {
17
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
18
+ padding: 12px 20px; background: #111130; border-bottom: 2px solid #333;
19
+ position: sticky; top: 0; z-index: 100;
20
+ }
21
+ .sc-nav button {
22
+ padding: 6px 16px; border: 1px solid #444; border-radius: 4px;
23
+ background: #1e1e40; color: #aac; cursor: pointer; font-size: 13px;
24
+ }
25
+ .sc-nav button.active { background: #3a3aaa; border-color: #88f; color: #fff; }
26
+ .mode-wrap { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 13px; color: #aac; }
27
+ .mode-btn {
28
+ padding: 4px 12px; border: 1px solid #555; border-radius: 3px;
29
+ background: #1e1e40; color: #aac; cursor: pointer; font-size: 12px;
30
+ }
31
+ .mode-btn.active { background: #2a5a2a; border-color: #6f6; color: #cfc; }
32
+ .sbs-btn.active { background: #2a4a5a; border-color: #6cf; color: #aef; }
33
+ .err-btn.active { background: #5a3a1a; border-color: #fa6; color: #fda; }
34
+ .sc-body { padding: 20px; }
35
+ .sc-section { display: none; }
36
+ .sc-section.active { display: block; }
37
+ .sc-group { margin-bottom: 28px; border-left: 3px solid #334; padding-left: 14px; }
38
+ .sc-group h3 { margin: 0 0 8px; font-size: 15px; color: #dde; }
39
+ .sc-group .count { font-size: 12px; color: #778; font-weight: normal; }
40
+ .tier-row { display: flex; align-items: flex-start; gap: 8px; margin: 4px 0; }
41
+ .tier-label {
42
+ writing-mode: vertical-rl; transform: rotate(180deg);
43
+ font-size: 10px; font-weight: bold; letter-spacing: .1em; text-transform: uppercase;
44
+ padding: 4px 3px; min-width: 20px; text-align: center; border-radius: 3px;
45
+ }
46
+ .tier-label.best { background: #1a3a1a; color: #6f6; }
47
+ .tier-label.median { background: #1a2a3a; color: #6af; }
48
+ .tier-label.worst { background: #3a1a1a; color: #f66; }
49
+ .sc-cards { display: flex; flex-wrap: wrap; gap: 12px; }
50
+ .sc-card { text-align: center; }
51
+ .sc-card-label {
52
+ font-size: 11px; color: #aac; margin-bottom: 4px; line-height: 1.5;
53
+ word-break: break-word; white-space: normal; max-width: 420px;
54
+ }
55
+ .sc-score { font-size: 10px; color: #778; }
56
+ .sc-missing { font-size: 10px; color: #f66; margin-top: 3px; }
57
+ /* Viewers */
58
+ .sc-viewers { display: flex; gap: 6px; }
59
+ .sc-vwrap { display: flex; flex-direction: column; align-items: center; }
60
+ .sc-vwrap.hidden-vw { display: none; }
61
+ .viewer-role-label {
62
+ display: none; font-size: 10px; color: #778; margin-top: 2px; letter-spacing: .08em;
63
+ }
64
+ .sbs-active .viewer-role-label { display: block; }
65
+ canvas.glb-canvas {
66
+ display: block; background: #1a1a2e; cursor: grab;
67
+ width: 420px; height: 380px;
68
+ }
69
+ canvas.glb-canvas:active { cursor: grabbing; }
70
+ .img-btn {
71
+ display: block; width: 100%; margin-top: 5px;
72
+ padding: 4px 0; font-size: 11px;
73
+ border: 1px solid #446; border-radius: 3px;
74
+ background: #1a1a40; color: #88c; cursor: pointer;
75
+ }
76
+ .img-btn:hover { background: #2a2a60; color: #bbf; }
77
+ /* Scene picker */
78
+ .sc-picker {
79
+ display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;
80
+ margin-bottom: 20px; padding: 14px 16px;
81
+ background: #13132a; border: 1px solid #2a2a44; border-radius: 6px;
82
+ }
83
+ .sc-picker label { font-size: 12px; color: #88a; display: flex; flex-direction: column; gap: 5px; }
84
+ .sc-picker select {
85
+ background: #1e1e40; color: #dde; border: 1px solid #444;
86
+ border-radius: 4px; padding: 6px 10px; font-size: 13px; cursor: pointer; min-width: 320px;
87
+ }
88
+ .sc-picker .nav-row { display: flex; align-items: center; gap: 6px; }
89
+ .sc-picker .nav-row select { flex: 1; min-width: 0; }
90
+ .nav-btn {
91
+ padding: 6px 14px; border: 1px solid #444; border-radius: 4px;
92
+ background: #1e1e40; color: #aac; cursor: pointer; font-size: 13px;
93
+ flex-shrink: 0; white-space: nowrap;
94
+ }
95
+ .nav-btn:hover { background: #2a2a60; color: #bbf; }
96
+ .nav-btn:disabled { opacity: 0.35; cursor: default; }
97
+ /* Region Γ— Arch */
98
+ .rx-selectors {
99
+ display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap;
100
+ margin-bottom: 20px; padding: 14px 16px;
101
+ background: #13132a; border: 1px solid #2a2a44; border-radius: 6px;
102
+ }
103
+ .rx-selectors label { font-size: 12px; color: #88a; display: flex; flex-direction: column; gap: 5px; }
104
+ .rx-selectors select {
105
+ background: #1e1e40; color: #dde; border: 1px solid #444;
106
+ border-radius: 4px; padding: 6px 10px; font-size: 13px; cursor: pointer; min-width: 160px;
107
+ }
108
+ .rx-compare { display: flex; gap: 24px; flex-wrap: wrap; }
109
+ .rx-col { flex: 1; min-width: 340px; }
110
+ .rx-col-header {
111
+ font-size: 13px; color: #99c; font-weight: bold;
112
+ margin-bottom: 10px; padding: 5px 10px;
113
+ background: #151535; border-radius: 4px; border-left: 3px solid #44a;
114
+ }
115
+ /* Image overlay */
116
+ #img-overlay {
117
+ display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.88);
118
+ z-index: 200; align-items: center; justify-content: center;
119
+ flex-direction: column; gap: 14px;
120
+ }
121
+ #img-overlay.open { display: flex; }
122
+ #img-overlay-title { font-size: 16px; color: #dde; text-align: center; max-width: 90vw; }
123
+ #img-overlay-content { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; max-width: 95vw; }
124
+ #img-overlay-content img { height: 220px; object-fit: cover; border-radius: 5px; }
125
+ .overlay-actions { display: flex; gap: 12px; align-items: center; }
126
+ .overlay-btn { padding: 5px 14px; font-size: 12px; border: 1px solid #446; border-radius: 3px; background: #1a1a40; color: #88c; cursor: pointer; }
127
+ .overlay-btn:hover { background: #2a2a60; color: #bbf; }
128
+ .overlay-close { color: #778; font-size: 12px; cursor: pointer; }
129
+ .overlay-close:hover { color: #aac; }
130
+ </style>
131
+ </head>
132
+ <body>
133
+
134
+ <div id="img-overlay" onclick="if(event.target===this)closeImages()">
135
+ <div id="img-overlay-title"></div>
136
+ <div id="img-overlay-content"></div>
137
+ <div class="overlay-actions">
138
+ <span class="overlay-close" onclick="closeImages()">&#10005; close</span>
139
+ </div>
140
+ </div>
141
+
142
+ <div class="sc-nav">
143
+ <button class="active" onclick="scShow('sc-global',this)">Global</button>
144
+ <button onclick="scShow('sc-region',this)">By Region</button>
145
+ <button onclick="scShow('sc-rxarch',this)">Region &times; Arch</button>
146
+ <span class="mode-wrap">
147
+ View:
148
+ <button class="mode-btn active" onclick="scSetMode('pred',this)">Pred</button>
149
+ <button class="mode-btn" onclick="scSetMode('gt', this)">GT</button>
150
+ <button class="mode-btn sbs-btn" onclick="scToggleSBS(this)">Side by Side</button>
151
+ <button class="mode-btn err-btn" onclick="scToggleError(this)">Error Coloring</button>
152
+ </span>
153
+ </div>
154
+ <div class="sc-body">
155
+ <div id="sc-global" class="sc-section active"></div>
156
+ <div id="sc-region" class="sc-section"></div>
157
+ <div id="sc-rxarch" class="sc-section"></div>
158
+ </div>
159
+
160
+ <script type="module">
161
+ import * as THREE from 'three';
162
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
163
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
164
+
165
+ // ── Global state ──────────────────────────────────────────────────────────
166
+ let scMode = 'pred';
167
+ let scSBS = false; // side-by-side
168
+ let scErrMode = false;
169
+ let _imgScene = null;
170
+
171
+ const _loader = new GLTFLoader();
172
+ const _viewers = new WeakMap(); // canvas β†’ viewer
173
+
174
+ // Circular sprite so points render as discs, not squares
175
+ const _ptTex = (() => {
176
+ const c = document.createElement('canvas');
177
+ c.width = c.height = 32;
178
+ const ctx = c.getContext('2d');
179
+ ctx.beginPath();
180
+ ctx.arc(16, 16, 14, 0, Math.PI * 2);
181
+ ctx.fillStyle = '#fff';
182
+ ctx.fill();
183
+ return new THREE.CanvasTexture(c);
184
+ })();
185
+
186
+ // ── URL helpers ───────────────────────────────────────────────────────────
187
+ function _base() {
188
+ return (window.SHOWCASE_DATA?.glb_prefix || '.').replace(/\/$/, '');
189
+ }
190
+
191
+ function _activeUrl(canvas) {
192
+ const role = canvas.dataset.role; // 'pred' or 'gt'
193
+ if (scErrMode) {
194
+ const eu = role === 'pred' ? canvas.dataset.predErr : canvas.dataset.gtErr;
195
+ if (eu) return eu;
196
+ }
197
+ return role === 'pred' ? canvas.dataset.pred : canvas.dataset.gt;
198
+ }
199
+
200
+ // ── Three.js viewer per canvas ────────────────────────────────────────────
201
+ function _initViewer(canvas) {
202
+ const W = canvas.offsetWidth || 420;
203
+ const H = canvas.offsetHeight || 380;
204
+
205
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
206
+ renderer.setSize(W, H, false);
207
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
208
+ renderer.setClearColor(0x1a1a2e);
209
+
210
+ const scene = new THREE.Scene();
211
+ const camera = new THREE.PerspectiveCamera(45, W / H, 0.001, 100000);
212
+ camera.position.set(0, 0, 5);
213
+
214
+ const controls = new OrbitControls(camera, canvas);
215
+ controls.enableDamping = true;
216
+ controls.dampingFactor = 0.05;
217
+ controls.autoRotate = true;
218
+ controls.autoRotateSpeed = 1.0;
219
+
220
+ let raf = null, running = false, loadedUrl = null, mesh = null;
221
+
222
+ function _loadUrl(url) {
223
+ if (!url || url === loadedUrl) return;
224
+ loadedUrl = url;
225
+ if (mesh) {
226
+ scene.remove(mesh);
227
+ mesh.traverse(o => { o.geometry?.dispose(); if (o.material) { [].concat(o.material).forEach(m => m.dispose()); } });
228
+ mesh = null;
229
+ }
230
+ const stamp = url;
231
+ _loader.load(url, gltf => {
232
+ if (loadedUrl !== stamp) return;
233
+ const grp = gltf.scene;
234
+ grp.traverse(o => {
235
+ if (o.isPoints) {
236
+ o.material.map = _ptTex;
237
+ o.material.transparent = true;
238
+ o.material.alphaTest = 0.5;
239
+ o.material.size = 3; // pixels
240
+ o.material.sizeAttenuation = false;
241
+ o.material.vertexColors = true;
242
+ o.material.needsUpdate = true;
243
+ }
244
+ });
245
+ const box = new THREE.Box3().setFromObject(grp);
246
+ const center = box.getCenter(new THREE.Vector3());
247
+ const size = box.getSize(new THREE.Vector3());
248
+ const maxDim = Math.max(size.x, size.y, size.z) || 1;
249
+ grp.position.sub(center);
250
+ const dist = maxDim * 2.0;
251
+ camera.position.set(0, maxDim * 0.15, dist);
252
+ camera.near = dist * 0.001;
253
+ camera.far = dist * 100;
254
+ camera.updateProjectionMatrix();
255
+ controls.target.set(0, 0, 0);
256
+ controls.update();
257
+ scene.add(grp);
258
+ mesh = grp;
259
+ }, undefined, err => console.warn('GLB load error:', url, err));
260
+ }
261
+
262
+ function _tick() {
263
+ if (!running) return;
264
+ raf = requestAnimationFrame(_tick);
265
+ controls.update();
266
+ renderer.render(scene, camera);
267
+ }
268
+
269
+ const v = {
270
+ loadUrl: _loadUrl,
271
+ resume() { if (!running) { running = true; _tick(); } },
272
+ pause() { running = false; if (raf) { cancelAnimationFrame(raf); raf = null; } },
273
+ };
274
+ _viewers.set(canvas, v);
275
+ return v;
276
+ }
277
+
278
+ // ── IntersectionObserver: lazy init + pause/resume ────────────────────────
279
+ const _obs = new IntersectionObserver(entries => {
280
+ for (const e of entries) {
281
+ const c = e.target;
282
+ if (!e.isIntersecting) { _viewers.get(c)?.pause(); continue; }
283
+ if (!_viewers.has(c)) _initViewer(c);
284
+ const v = _viewers.get(c);
285
+ v.loadUrl(_activeUrl(c));
286
+ v.resume();
287
+ }
288
+ }, { rootMargin: '100px 0px' });
289
+
290
+ function _observeVisible() {
291
+ document.querySelectorAll('.sc-vwrap:not(.hidden-vw) canvas.glb-canvas:not([data-obs])')
292
+ .forEach(c => { c.dataset.obs = '1'; _obs.observe(c); });
293
+ }
294
+
295
+ // ── Mode controls ─────────────────────────────────────────────────────────
296
+ function scShow(id, btn) {
297
+ document.querySelectorAll('.sc-section').forEach(s => s.classList.remove('active'));
298
+ document.querySelectorAll('.sc-nav > button').forEach(b => b.classList.remove('active'));
299
+ document.getElementById(id).classList.add('active');
300
+ btn.classList.add('active');
301
+ _observeVisible();
302
+ }
303
+
304
+ function scSetMode(mode, btn) {
305
+ if (scSBS) return;
306
+ scMode = mode;
307
+ document.querySelectorAll('.mode-btn:not(.sbs-btn):not(.err-btn)')
308
+ .forEach(b => b.classList.remove('active'));
309
+ btn.classList.add('active');
310
+ document.querySelectorAll('.sc-vwrap').forEach(w => {
311
+ const show = w.dataset.role === mode;
312
+ w.classList.toggle('hidden-vw', !show);
313
+ const c = w.querySelector('canvas.glb-canvas');
314
+ if (show) {
315
+ if (!c.dataset.obs) { c.dataset.obs = '1'; _obs.observe(c); }
316
+ _viewers.get(c)?.loadUrl(_activeUrl(c));
317
+ } else {
318
+ _viewers.get(c)?.pause();
319
+ }
320
+ });
321
+ }
322
+
323
+ function scToggleSBS(btn) {
324
+ scSBS = !scSBS;
325
+ btn.classList.toggle('active', scSBS);
326
+ document.querySelectorAll('.sc-viewers').forEach(el => el.classList.toggle('sbs-active', scSBS));
327
+ document.querySelectorAll('.sc-vwrap').forEach(w => {
328
+ if (scSBS) {
329
+ w.classList.remove('hidden-vw');
330
+ } else {
331
+ w.classList.toggle('hidden-vw', w.dataset.role !== scMode);
332
+ }
333
+ });
334
+ _observeVisible();
335
+ }
336
+
337
+ function scToggleError(btn) {
338
+ scErrMode = !scErrMode;
339
+ btn.classList.toggle('active', scErrMode);
340
+ document.querySelectorAll('canvas.glb-canvas[data-obs]').forEach(c => {
341
+ _viewers.get(c)?.loadUrl(_activeUrl(c));
342
+ });
343
+ }
344
+
345
+ // ── Card builder ──────────────────────────────────────────────────────────
346
+ function scMV(scene, score, predExists, gtExists, predErrExists, gtErrExists) {
347
+ const b = _base();
348
+ const pred = predExists ? `${b}/${scene}/${scene}_pred.glb` : '';
349
+ const gt = gtExists ? `${b}/${scene}/${scene}_gt.glb` : '';
350
+ const predErr = predErrExists ? `${b}/error_glbs/${scene}/${scene}_pred_error.glb` : '';
351
+ const gtErr = gtErrExists ? `${b}/error_glbs/${scene}/${scene}_gt_error.glb` : '';
352
+ const images = (window.SHOWCASE_DATA?.images_map || {})[scene] || [];
353
+ const imgBtn = images.length
354
+ ? `<button class="img-btn" onclick="showImages('${scene}')">&#128247; Images</button>` : '';
355
+ const hidGT = scSBS ? '' : 'hidden-vw';
356
+ const hidPred = scSBS ? '' : (scMode === 'pred' ? '' : 'hidden-vw');
357
+ return `<div class="sc-card">
358
+ <div class="sc-card-label">${scene.replace(/_/g,' ')}<br>
359
+ <span class="sc-score">score ${score.toFixed(4)}</span></div>
360
+ <div class="sc-viewers${scSBS?' sbs-active':''}">
361
+ <div class="sc-vwrap ${hidPred}" data-role="pred">
362
+ <canvas class="glb-canvas"
363
+ data-role="pred"
364
+ data-pred="${pred}" data-pred-err="${predErr}"
365
+ data-gt="${gt}" data-gt-err="${gtErr}"
366
+ ">
367
+ </canvas>
368
+ <div class="viewer-role-label">Pred</div>
369
+ </div>
370
+ <div class="sc-vwrap ${hidGT}" data-role="gt">
371
+ <canvas class="glb-canvas"
372
+ data-role="gt"
373
+ data-pred="${pred}" data-pred-err="${predErr}"
374
+ data-gt="${gt}" data-gt-err="${gtErr}">
375
+ </canvas>
376
+ <div class="viewer-role-label">GT</div>
377
+ </div>
378
+ </div>
379
+ ${imgBtn}
380
+ ${!predExists && !gtExists ? '<div class="sc-missing">file missing</div>' : ''}
381
+ </div>`;
382
+ }
383
+
384
+ function scTierRow(tier, scenes) {
385
+ if (!scenes?.length) return '';
386
+ return `<div class="tier-row">
387
+ <div class="tier-label ${tier}">${tier}</div>
388
+ <div class="sc-cards">${scenes.map(s =>
389
+ scMV(s.scene, s.score, s.pred_exists, s.gt_exists,
390
+ s.pred_error_exists, s.gt_error_exists)).join('')}
391
+ </div>
392
+ </div>`;
393
+ }
394
+
395
+ function scGroup(g) {
396
+ return `<div class="sc-group">
397
+ <h3>${g.label} <span class="count">(${g.count} scenes)</span></h3>
398
+ ${scTierRow('best', g.tiers.best)}
399
+ ${scTierRow('median', g.tiers.median)}
400
+ ${scTierRow('worst', g.tiers.worst)}
401
+ </div>`;
402
+ }
403
+
404
+ // ── Images overlay ────────────────────────────────────────────────────────
405
+ function showImages(scene) {
406
+ _imgScene = scene; _renderImages(scene);
407
+ document.getElementById('img-overlay').classList.add('open');
408
+ }
409
+ function _renderImages(scene) {
410
+ const b = _base();
411
+ const all = (window.SHOWCASE_DATA?.images_map || {})[scene] || [];
412
+ const picks = [...all].sort(() => Math.random() - 0.5).slice(0, 5);
413
+ document.getElementById('img-overlay-title').textContent = scene.replace(/_/g, ' ');
414
+ document.getElementById('img-overlay-content').innerHTML =
415
+ picks.map(f => `<img src="${b}/${scene}/${scene}_images/${f}" alt="${f}">`).join('');
416
+ }
417
+ function closeImages() { document.getElementById('img-overlay').classList.remove('open'); }
418
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeImages(); });
419
+
420
+ // ── Single-scene renderer (used by pickers) ───────────────────────────────
421
+ function renderSceneCard(el, s) {
422
+ el.querySelectorAll('canvas.glb-canvas').forEach(c => {
423
+ _viewers.get(c)?.pause();
424
+ });
425
+ el.innerHTML = s
426
+ ? scMV(s.scene, s.score, s.pred_exists, s.gt_exists,
427
+ s.pred_error_exists, s.gt_error_exists)
428
+ : '<p style="color:#778;font-size:13px;padding:8px">No scene selected.</p>';
429
+ _observeVisible();
430
+ }
431
+
432
+ // ── Global scene picker ───────────────────────────────────────────────────
433
+ function buildGlobal(el) {
434
+ const D = window.SHOWCASE_DATA;
435
+ if (!D.all_scenes?.length) {
436
+ el.innerHTML = scGroup(D.global);
437
+ _observeVisible();
438
+ return;
439
+ }
440
+ const scenes = D.all_scenes;
441
+ el.innerHTML = `
442
+ <div class="sc-picker">
443
+ <label>Scene β€” sorted by loss (${scenes.length} total)
444
+ <div class="nav-row">
445
+ <button class="nav-btn" id="global-prev" onclick="globalNav(-1)">&#8592; Prev</button>
446
+ <select id="global-scene-sel" onchange="globalSceneChanged()">
447
+ ${scenes.map(s => `<option value="${s.scene}">${s.scene.replace(/_/g,' ')} β€” ${s.score.toFixed(4)}</option>`).join('')}
448
+ </select>
449
+ <button class="nav-btn" id="global-next" onclick="globalNav(1)">Next &#8594;</button>
450
+ </div>
451
+ </label>
452
+ </div>
453
+ <div id="global-viewer"></div>`;
454
+ globalSceneChanged();
455
+ }
456
+
457
+ function globalSceneChanged() {
458
+ const D = window.SHOWCASE_DATA;
459
+ const sel = document.getElementById('global-scene-sel');
460
+ const name = sel.value;
461
+ const idx = sel.selectedIndex;
462
+ document.getElementById('global-prev').disabled = idx === 0;
463
+ document.getElementById('global-next').disabled = idx === sel.options.length - 1;
464
+ const s = D.all_scenes.find(x => x.scene === name);
465
+ renderSceneCard(document.getElementById('global-viewer'), s);
466
+ }
467
+
468
+ function globalNav(dir) {
469
+ const sel = document.getElementById('global-scene-sel');
470
+ const next = sel.selectedIndex + dir;
471
+ if (next < 0 || next >= sel.options.length) return;
472
+ sel.selectedIndex = next;
473
+ globalSceneChanged();
474
+ }
475
+
476
+ // ── By Region picker ──────────────────────────────────────────────────────
477
+ function buildRegion(el) {
478
+ const D = window.SHOWCASE_DATA;
479
+ const firstRegion = Object.values(D.by_region)[0];
480
+ if (!firstRegion?.scenes?.length) {
481
+ el.innerHTML = Object.values(D.by_region).map(scGroup).join('');
482
+ _observeVisible();
483
+ return;
484
+ }
485
+ const regions = Object.keys(D.by_region).sort();
486
+ el.innerHTML = `
487
+ <div class="sc-picker">
488
+ <label>Region
489
+ <select id="region-sel" onchange="regionChanged()">
490
+ ${regions.map(r => `<option value="${r}">${r}</option>`).join('')}
491
+ </select>
492
+ </label>
493
+ <label>Scene β€” sorted by loss
494
+ <div class="nav-row">
495
+ <button class="nav-btn" id="region-prev" onclick="regionNav(-1)">&#8592; Prev</button>
496
+ <select id="region-scene-sel" onchange="regionSceneChanged()"></select>
497
+ <button class="nav-btn" id="region-next" onclick="regionNav(1)">Next &#8594;</button>
498
+ </div>
499
+ </label>
500
+ </div>
501
+ <div id="region-viewer"></div>`;
502
+ regionChanged();
503
+ }
504
+
505
+ function regionChanged() {
506
+ const D = window.SHOWCASE_DATA;
507
+ const region = document.getElementById('region-sel').value;
508
+ const scenes = D.by_region[region]?.scenes || [];
509
+ document.getElementById('region-scene-sel').innerHTML =
510
+ scenes.map(s => `<option value="${s.scene}">${s.scene.replace(/_/g,' ')} β€” ${s.score.toFixed(4)}</option>`).join('');
511
+ regionSceneChanged();
512
+ }
513
+
514
+ function regionSceneChanged() {
515
+ const D = window.SHOWCASE_DATA;
516
+ const region = document.getElementById('region-sel').value;
517
+ const sel = document.getElementById('region-scene-sel');
518
+ const name = sel.value;
519
+ const idx = sel.selectedIndex;
520
+ const prev = document.getElementById('region-prev');
521
+ const next = document.getElementById('region-next');
522
+ if (prev) prev.disabled = idx === 0;
523
+ if (next) next.disabled = idx === sel.options.length - 1;
524
+ const scenes = D.by_region[region]?.scenes || [];
525
+ const s = scenes.find(x => x.scene === name);
526
+ renderSceneCard(document.getElementById('region-viewer'), s);
527
+ }
528
+
529
+ function regionNav(dir) {
530
+ const sel = document.getElementById('region-scene-sel');
531
+ const next = sel.selectedIndex + dir;
532
+ if (next < 0 || next >= sel.options.length) return;
533
+ sel.selectedIndex = next;
534
+ regionSceneChanged();
535
+ }
536
+
537
+ // ── Region Γ— Arch ─────────────────────────────────────────────────────────
538
+ function buildRxArch(el) {
539
+ const D = window.SHOWCASE_DATA;
540
+ const keys = Object.keys(D.region_x_arch);
541
+ const archs = [...new Set(keys.map(k => k.split('__')[1]))].sort();
542
+ el.innerHTML = `
543
+ <div class="rx-selectors">
544
+ <label>Architecture Type
545
+ <select id="rx-arch" onchange="rxArchChanged()">
546
+ ${archs.map(a => `<option value="${a}">${a}</option>`).join('')}
547
+ </select>
548
+ </label>
549
+ <label>Region A
550
+ <select id="rx-regA" onchange="renderRxArch()"></select>
551
+ </label>
552
+ <label>Region B
553
+ <select id="rx-regB" onchange="renderRxArch()"></select>
554
+ </label>
555
+ </div>
556
+ <div id="rx-compare" class="rx-compare"></div>`;
557
+ rxArchChanged();
558
+ }
559
+
560
+ function rxArchChanged() {
561
+ const D = window.SHOWCASE_DATA;
562
+ const arch = document.getElementById('rx-arch').value;
563
+ const keys = Object.keys(D.region_x_arch);
564
+ const regions = keys
565
+ .filter(k => k.split('__')[1] === arch)
566
+ .map(k => k.split('__')[0])
567
+ .sort();
568
+ const prevA = document.getElementById('rx-regA').value;
569
+ const prevB = document.getElementById('rx-regB').value;
570
+ const opts = regions.map(r => `<option value="${r}">${r}</option>`).join('');
571
+ document.getElementById('rx-regA').innerHTML = opts;
572
+ document.getElementById('rx-regB').innerHTML = opts;
573
+ if (regions.includes(prevA)) document.getElementById('rx-regA').value = prevA;
574
+ if (regions.includes(prevB)) document.getElementById('rx-regB').value = prevB;
575
+ else if (regions.length > 1) document.getElementById('rx-regB').value = regions[1];
576
+ renderRxArch();
577
+ }
578
+
579
+ function renderRxArch() {
580
+ const D = window.SHOWCASE_DATA;
581
+ const arch = document.getElementById('rx-arch').value;
582
+ const regA = document.getElementById('rx-regA').value;
583
+ const regB = document.getElementById('rx-regB').value;
584
+ const gA = D.region_x_arch[`${regA}__${arch}`];
585
+ const gB = D.region_x_arch[`${regB}__${arch}`];
586
+ const none = `<p style="color:#778;font-size:13px;padding:8px">No data for this combination.</p>`;
587
+ const col = (g, label) =>
588
+ `<div class="rx-col"><div class="rx-col-header">${label}</div>${g ? scGroup(g) : none}</div>`;
589
+ document.getElementById('rx-compare').innerHTML = col(gA, regA) + col(gB, regB);
590
+ _observeVisible();
591
+ }
592
+
593
+ // ── Build ─────────────────────────────────────────────────────────────────
594
+ function build() {
595
+ if (!window.SHOWCASE_DATA) {
596
+ document.getElementById('sc-global').innerHTML =
597
+ '<p style="color:#f66;padding:20px">showcase_data.js not loaded.</p>';
598
+ return;
599
+ }
600
+ const D = window.SHOWCASE_DATA;
601
+ buildGlobal(document.getElementById('sc-global'));
602
+ buildRegion(document.getElementById('sc-region'));
603
+ buildRxArch(document.getElementById('sc-rxarch'));
604
+ _observeVisible();
605
+ }
606
+
607
+ // Expose onclick-called functions globally
608
+ Object.assign(window, {
609
+ scShow, scSetMode, scToggleSBS, scToggleError,
610
+ showImages, closeImages, rxArchChanged, renderRxArch,
611
+ globalSceneChanged, globalNav,
612
+ regionChanged, regionSceneChanged, regionNav,
613
+ });
614
+
615
+ build();
616
+ </script>
617
+ </body>
618
+ </html>