Spaces:
Running
Running
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>ArchWorld Viewer</title> | |
| <script type="importmap"> | |
| {"imports":{ | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/" | |
| }} | |
| </script> | |
| <script src="showcase_data.js"></script> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: system-ui, sans-serif; background: #0d0d1a; color: #e0e0f0; } | |
| .sc-nav { | |
| display: flex; align-items: center; gap: 8px; flex-wrap: wrap; | |
| padding: 12px 20px; background: #111130; border-bottom: 2px solid #333; | |
| position: sticky; top: 0; z-index: 100; | |
| } | |
| .sc-nav button { | |
| padding: 6px 16px; border: 1px solid #444; border-radius: 4px; | |
| background: #1e1e40; color: #aac; cursor: pointer; font-size: 13px; | |
| } | |
| .sc-nav button.active { background: #3a3aaa; border-color: #88f; color: #fff; } | |
| .mode-wrap { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 13px; color: #aac; } | |
| .mode-btn { | |
| padding: 4px 12px; border: 1px solid #555; border-radius: 3px; | |
| background: #1e1e40; color: #aac; cursor: pointer; font-size: 12px; | |
| } | |
| .mode-btn.active { background: #2a5a2a; border-color: #6f6; color: #cfc; } | |
| .sbs-btn.active { background: #2a4a5a; border-color: #6cf; color: #aef; } | |
| .err-btn.active { background: #5a3a1a; border-color: #fa6; color: #fda; } | |
| .sc-body { padding: 20px; } | |
| .sc-section { display: none; } | |
| .sc-section.active { display: block; } | |
| .sc-group { margin-bottom: 28px; border-left: 3px solid #334; padding-left: 14px; } | |
| .sc-group h3 { margin: 0 0 8px; font-size: 15px; color: #dde; } | |
| .sc-group .count { font-size: 12px; color: #778; font-weight: normal; } | |
| .tier-row { display: flex; align-items: flex-start; gap: 8px; margin: 4px 0; } | |
| .tier-label { | |
| writing-mode: vertical-rl; transform: rotate(180deg); | |
| font-size: 10px; font-weight: bold; letter-spacing: .1em; text-transform: uppercase; | |
| padding: 4px 3px; min-width: 20px; text-align: center; border-radius: 3px; | |
| } | |
| .tier-label.best { background: #1a3a1a; color: #6f6; } | |
| .tier-label.median { background: #1a2a3a; color: #6af; } | |
| .tier-label.worst { background: #3a1a1a; color: #f66; } | |
| .sc-cards { display: flex; flex-wrap: wrap; gap: 12px; } | |
| .sc-card { text-align: center; } | |
| .sc-card-label { | |
| font-size: 11px; color: #aac; margin-bottom: 4px; line-height: 1.5; | |
| word-break: break-word; white-space: normal; max-width: 420px; | |
| } | |
| .sc-score { font-size: 10px; color: #778; } | |
| .sc-missing { font-size: 10px; color: #f66; margin-top: 3px; } | |
| /* Viewers */ | |
| .sc-viewers { display: flex; gap: 6px; } | |
| .sc-vwrap { display: flex; flex-direction: column; align-items: center; } | |
| .sc-vwrap.hidden-vw { display: none; } | |
| .viewer-role-label { | |
| display: none; font-size: 10px; color: #778; margin-top: 2px; letter-spacing: .08em; | |
| } | |
| .sbs-active .viewer-role-label { display: block; } | |
| canvas.glb-canvas { | |
| display: block; background: #1a1a2e; cursor: grab; | |
| width: 420px; height: 380px; | |
| } | |
| canvas.glb-canvas:active { cursor: grabbing; } | |
| .img-btn { | |
| display: block; width: 100%; margin-top: 5px; | |
| padding: 4px 0; font-size: 11px; | |
| border: 1px solid #446; border-radius: 3px; | |
| background: #1a1a40; color: #88c; cursor: pointer; | |
| } | |
| .img-btn:hover { background: #2a2a60; color: #bbf; } | |
| /* Scene picker */ | |
| .sc-picker { | |
| display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap; | |
| margin-bottom: 20px; padding: 14px 16px; | |
| background: #13132a; border: 1px solid #2a2a44; border-radius: 6px; | |
| } | |
| .sc-picker label { font-size: 12px; color: #88a; display: flex; flex-direction: column; gap: 5px; } | |
| .sc-picker select { | |
| background: #1e1e40; color: #dde; border: 1px solid #444; | |
| border-radius: 4px; padding: 6px 10px; font-size: 13px; cursor: pointer; min-width: 320px; | |
| } | |
| .sc-picker .nav-row { display: flex; align-items: center; gap: 6px; } | |
| .sc-picker .nav-row select { flex: 1; min-width: 0; } | |
| .nav-btn { | |
| padding: 6px 14px; border: 1px solid #444; border-radius: 4px; | |
| background: #1e1e40; color: #aac; cursor: pointer; font-size: 13px; | |
| flex-shrink: 0; white-space: nowrap; | |
| } | |
| .nav-btn:hover { background: #2a2a60; color: #bbf; } | |
| .nav-btn:disabled { opacity: 0.35; cursor: default; } | |
| /* Region Γ Arch */ | |
| .rx-selectors { | |
| display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap; | |
| margin-bottom: 20px; padding: 14px 16px; | |
| background: #13132a; border: 1px solid #2a2a44; border-radius: 6px; | |
| } | |
| .rx-selectors label { font-size: 12px; color: #88a; display: flex; flex-direction: column; gap: 5px; } | |
| .rx-selectors select { | |
| background: #1e1e40; color: #dde; border: 1px solid #444; | |
| border-radius: 4px; padding: 6px 10px; font-size: 13px; cursor: pointer; min-width: 160px; | |
| } | |
| .rx-compare { display: flex; gap: 24px; flex-wrap: wrap; } | |
| .rx-col { flex: 1; min-width: 340px; } | |
| .rx-col-header { | |
| font-size: 13px; color: #99c; font-weight: bold; | |
| margin-bottom: 10px; padding: 5px 10px; | |
| background: #151535; border-radius: 4px; border-left: 3px solid #44a; | |
| } | |
| /* Image overlay */ | |
| #img-overlay { | |
| display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.88); | |
| z-index: 200; align-items: center; justify-content: center; | |
| flex-direction: column; gap: 14px; | |
| } | |
| #img-overlay.open { display: flex; } | |
| #img-overlay-title { font-size: 16px; color: #dde; text-align: center; max-width: 90vw; } | |
| #img-overlay-content { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; max-width: 95vw; } | |
| #img-overlay-content img { height: 220px; object-fit: cover; border-radius: 5px; } | |
| .overlay-actions { display: flex; gap: 12px; align-items: center; } | |
| .overlay-btn { padding: 5px 14px; font-size: 12px; border: 1px solid #446; border-radius: 3px; background: #1a1a40; color: #88c; cursor: pointer; } | |
| .overlay-btn:hover { background: #2a2a60; color: #bbf; } | |
| .overlay-close { color: #778; font-size: 12px; cursor: pointer; } | |
| .overlay-close:hover { color: #aac; } | |
| /* Description banner */ | |
| .sc-description { | |
| max-width: 860px; margin: 24px auto 0; padding: 20px 28px; | |
| background: #111130; border: 1px solid #2a2a50; border-radius: 8px; | |
| font-size: 14px; line-height: 1.7; color: #c8c8e8; | |
| } | |
| .sc-description h2 { | |
| font-size: 17px; font-weight: 600; color: #ddeeff; | |
| margin-bottom: 12px; letter-spacing: 0.02em; | |
| } | |
| .sc-description p { margin-bottom: 10px; } | |
| .sc-description p:last-child { margin-bottom: 0; } | |
| .sc-description em { color: #99bbff; font-style: normal; font-weight: 500; } | |
| .sc-controls { margin-top: 14px; } | |
| .sc-controls-title { | |
| font-size: 13px; font-weight: 600; color: #aac; | |
| text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; | |
| } | |
| .sc-controls dl { display: grid; grid-template-columns: auto 1fr; gap: 5px 16px; } | |
| .sc-controls dt { | |
| font-weight: 600; color: #88aadd; font-size: 13px; | |
| white-space: nowrap; padding-top: 1px; | |
| } | |
| .sc-controls dd { font-size: 13px; color: #b0b0cc; margin: 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="img-overlay" onclick="if(event.target===this)closeImages()"> | |
| <div id="img-overlay-title"></div> | |
| <div id="img-overlay-content"></div> | |
| <div class="overlay-actions"> | |
| <span class="overlay-close" onclick="closeImages()">✕ close</span> | |
| </div> | |
| </div> | |
| <div class="sc-nav"> | |
| <button class="active" onclick="scShow('sc-global',this)">Global</button> | |
| <button onclick="scShow('sc-region',this)">By Region</button> | |
| <button onclick="scShow('sc-rxarch',this)">Region × Arch</button> | |
| <span class="mode-wrap"> | |
| View: | |
| <button class="mode-btn active" onclick="scSetMode('pred',this)">Pred</button> | |
| <button class="mode-btn" onclick="scSetMode('gt', this)">GT</button> | |
| <button class="mode-btn sbs-btn" onclick="scToggleSBS(this)">Side by Side</button> | |
| </span> | |
| </div> | |
| <div class="sc-description"> | |
| <h2>ArchWorld 3D Reconstruction Viewer</h2> | |
| <p>This viewer presents interactive 3D point-cloud reconstructions for the ArchWorld benchmark, a dataset of architectural scenes spanning diverse building types and geographical regions. For each scene, we show the output of VGGT alongside the corresponding ground-truth reconstruction so you can directly assess how well the model performs.</p> | |
| <p>Scenes are organized in three ways: <em>Global</em> ranks all scenes by scaled Chamfer Distance; <em>By Region</em> breaks down performance by geographical region; and <em>Region × Arch</em> lets you pick an architecture type and compare two regions head-to-head.</p> | |
| <div class="sc-controls"> | |
| <div class="sc-controls-title">Controls</div> | |
| <dl> | |
| <dt>Pred / GT</dt> | |
| <dd>Switch between the model prediction and ground-truth point cloud.</dd> | |
| <dt>Side by Side</dt> | |
| <dd>Display both the prediction and ground-truth simultaneously for direct visual comparison.</dd> | |
| <dt>Scene dropdown + Prev / Next</dt> | |
| <dd>Step through scenes ordered by Chamfer Distance.</dd> | |
| <dt>Images</dt> | |
| <dd>Browse a representative set of photos from the reconstruction.</dd> | |
| <dt>Mouse</dt> | |
| <dd>Drag to orbit, scroll to zoom.</dd> | |
| </dl> | |
| </div> | |
| </div> | |
| <div class="sc-body"> | |
| <div id="sc-global" class="sc-section active"></div> | |
| <div id="sc-region" class="sc-section"></div> | |
| <div id="sc-rxarch" class="sc-section"></div> | |
| </div> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| // ββ Global state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let scMode = 'pred'; | |
| let scSBS = false; // side-by-side | |
| let scErrMode = false; | |
| let _imgScene = null; | |
| const _loader = new GLTFLoader(); | |
| const _viewers = new WeakMap(); // canvas β viewer | |
| // Circular sprite so points render as discs, not squares | |
| const _ptTex = (() => { | |
| const c = document.createElement('canvas'); | |
| c.width = c.height = 32; | |
| const ctx = c.getContext('2d'); | |
| ctx.beginPath(); | |
| ctx.arc(16, 16, 14, 0, Math.PI * 2); | |
| ctx.fillStyle = '#fff'; | |
| ctx.fill(); | |
| return new THREE.CanvasTexture(c); | |
| })(); | |
| // ββ URL helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function _base() { | |
| return (window.SHOWCASE_DATA?.glb_prefix || '.').replace(/\/$/, ''); | |
| } | |
| function _activeUrl(canvas) { | |
| const role = canvas.dataset.role; // 'pred' or 'gt' | |
| if (scErrMode) { | |
| const eu = role === 'pred' ? canvas.dataset.predErr : canvas.dataset.gtErr; | |
| if (eu) return eu; | |
| } | |
| return role === 'pred' ? canvas.dataset.pred : canvas.dataset.gt; | |
| } | |
| // ββ Three.js viewer per canvas ββββββββββββββββββββββββββββββββββββββββββββ | |
| function _initViewer(canvas) { | |
| const W = canvas.offsetWidth || 420; | |
| const H = canvas.offsetHeight || 380; | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); | |
| renderer.setSize(W, H, false); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); | |
| renderer.setClearColor(0x1a1a2e); | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(45, W / H, 0.001, 100000); | |
| camera.position.set(0, 0, 5); | |
| const controls = new OrbitControls(camera, canvas); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.autoRotate = true; | |
| controls.autoRotateSpeed = 1.0; | |
| let raf = null, running = false, loadedUrl = null, mesh = null; | |
| function _loadUrl(url) { | |
| if (!url || url === loadedUrl) return; | |
| loadedUrl = url; | |
| if (mesh) { | |
| scene.remove(mesh); | |
| mesh.traverse(o => { o.geometry?.dispose(); if (o.material) { [].concat(o.material).forEach(m => m.dispose()); } }); | |
| mesh = null; | |
| } | |
| const stamp = url; | |
| _loader.load(url, gltf => { | |
| if (loadedUrl !== stamp) return; | |
| const grp = gltf.scene; | |
| grp.traverse(o => { | |
| if (o.isPoints) { | |
| o.material.map = _ptTex; | |
| o.material.transparent = true; | |
| o.material.alphaTest = 0.5; | |
| o.material.size = 3; // pixels | |
| o.material.sizeAttenuation = false; | |
| o.material.vertexColors = true; | |
| o.material.needsUpdate = true; | |
| } | |
| }); | |
| const box = new THREE.Box3().setFromObject(grp); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| const size = box.getSize(new THREE.Vector3()); | |
| const maxDim = Math.max(size.x, size.y, size.z) || 1; | |
| grp.position.sub(center); | |
| const dist = maxDim * 2.0; | |
| camera.position.set(0, maxDim * 0.15, dist); | |
| camera.near = dist * 0.001; | |
| camera.far = dist * 100; | |
| camera.updateProjectionMatrix(); | |
| controls.target.set(0, 0, 0); | |
| controls.update(); | |
| scene.add(grp); | |
| mesh = grp; | |
| }, undefined, err => console.warn('GLB load error:', url, err)); | |
| } | |
| function _tick() { | |
| if (!running) return; | |
| raf = requestAnimationFrame(_tick); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| const v = { | |
| loadUrl: _loadUrl, | |
| resume() { if (!running) { running = true; _tick(); } }, | |
| pause() { running = false; if (raf) { cancelAnimationFrame(raf); raf = null; } }, | |
| }; | |
| _viewers.set(canvas, v); | |
| return v; | |
| } | |
| // ββ IntersectionObserver: lazy init + pause/resume ββββββββββββββββββββββββ | |
| const _obs = new IntersectionObserver(entries => { | |
| for (const e of entries) { | |
| const c = e.target; | |
| if (!e.isIntersecting) { _viewers.get(c)?.pause(); continue; } | |
| if (!_viewers.has(c)) _initViewer(c); | |
| const v = _viewers.get(c); | |
| v.loadUrl(_activeUrl(c)); | |
| v.resume(); | |
| } | |
| }, { rootMargin: '100px 0px' }); | |
| function _observeVisible() { | |
| document.querySelectorAll('.sc-vwrap:not(.hidden-vw) canvas.glb-canvas:not([data-obs])') | |
| .forEach(c => { c.dataset.obs = '1'; _obs.observe(c); }); | |
| } | |
| // ββ Mode controls βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function scShow(id, btn) { | |
| document.querySelectorAll('.sc-section').forEach(s => s.classList.remove('active')); | |
| document.querySelectorAll('.sc-nav > button').forEach(b => b.classList.remove('active')); | |
| document.getElementById(id).classList.add('active'); | |
| btn.classList.add('active'); | |
| _observeVisible(); | |
| } | |
| function scSetMode(mode, btn) { | |
| if (scSBS) return; | |
| scMode = mode; | |
| document.querySelectorAll('.mode-btn:not(.sbs-btn):not(.err-btn)') | |
| .forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| document.querySelectorAll('.sc-vwrap').forEach(w => { | |
| const show = w.dataset.role === mode; | |
| w.classList.toggle('hidden-vw', !show); | |
| const c = w.querySelector('canvas.glb-canvas'); | |
| if (show) { | |
| if (!c.dataset.obs) { c.dataset.obs = '1'; _obs.observe(c); } | |
| _viewers.get(c)?.loadUrl(_activeUrl(c)); | |
| } else { | |
| _viewers.get(c)?.pause(); | |
| } | |
| }); | |
| } | |
| function scToggleSBS(btn) { | |
| scSBS = !scSBS; | |
| btn.classList.toggle('active', scSBS); | |
| document.querySelectorAll('.sc-viewers').forEach(el => el.classList.toggle('sbs-active', scSBS)); | |
| document.querySelectorAll('.sc-vwrap').forEach(w => { | |
| if (scSBS) { | |
| w.classList.remove('hidden-vw'); | |
| } else { | |
| w.classList.toggle('hidden-vw', w.dataset.role !== scMode); | |
| } | |
| }); | |
| _observeVisible(); | |
| } | |
| function scToggleError(btn) { | |
| scErrMode = !scErrMode; | |
| btn.classList.toggle('active', scErrMode); | |
| document.querySelectorAll('canvas.glb-canvas[data-obs]').forEach(c => { | |
| _viewers.get(c)?.loadUrl(_activeUrl(c)); | |
| }); | |
| } | |
| // ββ Card builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function scMV(scene, score, predExists, gtExists, predErrExists, gtErrExists) { | |
| const b = _base(); | |
| const pred = predExists ? `${b}/${scene}/${scene}_pred.glb` : ''; | |
| const gt = gtExists ? `${b}/${scene}/${scene}_gt.glb` : ''; | |
| const predErr = predErrExists ? `${b}/error_glbs/${scene}/${scene}_pred_error.glb` : ''; | |
| const gtErr = gtErrExists ? `${b}/error_glbs/${scene}/${scene}_gt_error.glb` : ''; | |
| const images = (window.SHOWCASE_DATA?.images_map || {})[scene] || []; | |
| const imgBtn = images.length | |
| ? `<button class="img-btn" onclick="showImages('${scene}')">📷 Images</button>` : ''; | |
| const hidGT = scSBS ? '' : 'hidden-vw'; | |
| const hidPred = scSBS ? '' : (scMode === 'pred' ? '' : 'hidden-vw'); | |
| return `<div class="sc-card"> | |
| <div class="sc-card-label">${scene.replace(/_/g,' ')}<br> | |
| <span class="sc-score">score ${score.toFixed(4)}</span></div> | |
| <div class="sc-viewers${scSBS?' sbs-active':''}"> | |
| <div class="sc-vwrap ${hidPred}" data-role="pred"> | |
| <canvas class="glb-canvas" | |
| data-role="pred" | |
| data-pred="${pred}" data-pred-err="${predErr}" | |
| data-gt="${gt}" data-gt-err="${gtErr}" | |
| "> | |
| </canvas> | |
| <div class="viewer-role-label">Pred</div> | |
| </div> | |
| <div class="sc-vwrap ${hidGT}" data-role="gt"> | |
| <canvas class="glb-canvas" | |
| data-role="gt" | |
| data-pred="${pred}" data-pred-err="${predErr}" | |
| data-gt="${gt}" data-gt-err="${gtErr}"> | |
| </canvas> | |
| <div class="viewer-role-label">GT</div> | |
| </div> | |
| </div> | |
| ${imgBtn} | |
| ${!predExists && !gtExists ? '<div class="sc-missing">file missing</div>' : ''} | |
| </div>`; | |
| } | |
| function scTierRow(tier, scenes) { | |
| if (!scenes?.length) return ''; | |
| return `<div class="tier-row"> | |
| <div class="tier-label ${tier}">${tier}</div> | |
| <div class="sc-cards">${scenes.map(s => | |
| scMV(s.scene, s.score, s.pred_exists, s.gt_exists, | |
| s.pred_error_exists, s.gt_error_exists)).join('')} | |
| </div> | |
| </div>`; | |
| } | |
| function scGroup(g) { | |
| return `<div class="sc-group"> | |
| <h3>${g.label} <span class="count">(${g.count} scenes)</span></h3> | |
| ${scTierRow('best', g.tiers.best)} | |
| ${scTierRow('median', g.tiers.median)} | |
| ${scTierRow('worst', g.tiers.worst)} | |
| </div>`; | |
| } | |
| // ββ Images overlay ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function showImages(scene) { | |
| _imgScene = scene; _renderImages(scene); | |
| document.getElementById('img-overlay').classList.add('open'); | |
| } | |
| function _renderImages(scene) { | |
| const b = _base(); | |
| const all = (window.SHOWCASE_DATA?.images_map || {})[scene] || []; | |
| const picks = [...all].sort(() => Math.random() - 0.5).slice(0, 5); | |
| document.getElementById('img-overlay-title').textContent = scene.replace(/_/g, ' '); | |
| document.getElementById('img-overlay-content').innerHTML = | |
| picks.map(f => `<img src="${b}/${scene}/${scene}_images/${f}" alt="${f}">`).join(''); | |
| } | |
| function closeImages() { document.getElementById('img-overlay').classList.remove('open'); } | |
| document.addEventListener('keydown', e => { if (e.key === 'Escape') closeImages(); }); | |
| // ββ Single-scene renderer (used by pickers) βββββββββββββββββββββββββββββββ | |
| function renderSceneCard(el, s) { | |
| el.querySelectorAll('canvas.glb-canvas').forEach(c => { | |
| _viewers.get(c)?.pause(); | |
| }); | |
| el.innerHTML = s | |
| ? scMV(s.scene, s.score, s.pred_exists, s.gt_exists, | |
| s.pred_error_exists, s.gt_error_exists) | |
| : '<p style="color:#778;font-size:13px;padding:8px">No scene selected.</p>'; | |
| _observeVisible(); | |
| } | |
| // ββ Global scene picker βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function buildGlobal(el) { | |
| const D = window.SHOWCASE_DATA; | |
| if (!D.all_scenes?.length) { | |
| el.innerHTML = scGroup(D.global); | |
| _observeVisible(); | |
| return; | |
| } | |
| const scenes = D.all_scenes; | |
| el.innerHTML = ` | |
| <div class="sc-picker"> | |
| <label>Scene β sorted by loss (${scenes.length} total) | |
| <div class="nav-row"> | |
| <button class="nav-btn" id="global-prev" onclick="globalNav(-1)">← Prev</button> | |
| <select id="global-scene-sel" onchange="globalSceneChanged()"> | |
| ${scenes.map(s => `<option value="${s.scene}">${s.scene.replace(/_/g,' ')} β ${s.score.toFixed(4)}</option>`).join('')} | |
| </select> | |
| <button class="nav-btn" id="global-next" onclick="globalNav(1)">Next →</button> | |
| </div> | |
| </label> | |
| </div> | |
| <div id="global-viewer"></div>`; | |
| globalSceneChanged(); | |
| } | |
| function globalSceneChanged() { | |
| const D = window.SHOWCASE_DATA; | |
| const sel = document.getElementById('global-scene-sel'); | |
| const name = sel.value; | |
| const idx = sel.selectedIndex; | |
| document.getElementById('global-prev').disabled = idx === 0; | |
| document.getElementById('global-next').disabled = idx === sel.options.length - 1; | |
| const s = D.all_scenes.find(x => x.scene === name); | |
| renderSceneCard(document.getElementById('global-viewer'), s); | |
| } | |
| function globalNav(dir) { | |
| const sel = document.getElementById('global-scene-sel'); | |
| const next = sel.selectedIndex + dir; | |
| if (next < 0 || next >= sel.options.length) return; | |
| sel.selectedIndex = next; | |
| globalSceneChanged(); | |
| } | |
| // ββ By Region picker ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function buildRegion(el) { | |
| const D = window.SHOWCASE_DATA; | |
| const firstRegion = Object.values(D.by_region)[0]; | |
| if (!firstRegion?.scenes?.length) { | |
| el.innerHTML = Object.values(D.by_region).map(scGroup).join(''); | |
| _observeVisible(); | |
| return; | |
| } | |
| const regions = Object.keys(D.by_region).sort(); | |
| el.innerHTML = ` | |
| <div class="sc-picker"> | |
| <label>Region | |
| <select id="region-sel" onchange="regionChanged()"> | |
| ${regions.map(r => `<option value="${r}">${r}</option>`).join('')} | |
| </select> | |
| </label> | |
| <label>Scene β sorted by loss | |
| <div class="nav-row"> | |
| <button class="nav-btn" id="region-prev" onclick="regionNav(-1)">← Prev</button> | |
| <select id="region-scene-sel" onchange="regionSceneChanged()"></select> | |
| <button class="nav-btn" id="region-next" onclick="regionNav(1)">Next →</button> | |
| </div> | |
| </label> | |
| </div> | |
| <div id="region-viewer"></div>`; | |
| regionChanged(); | |
| } | |
| function regionChanged() { | |
| const D = window.SHOWCASE_DATA; | |
| const region = document.getElementById('region-sel').value; | |
| const scenes = D.by_region[region]?.scenes || []; | |
| document.getElementById('region-scene-sel').innerHTML = | |
| scenes.map(s => `<option value="${s.scene}">${s.scene.replace(/_/g,' ')} β ${s.score.toFixed(4)}</option>`).join(''); | |
| regionSceneChanged(); | |
| } | |
| function regionSceneChanged() { | |
| const D = window.SHOWCASE_DATA; | |
| const region = document.getElementById('region-sel').value; | |
| const sel = document.getElementById('region-scene-sel'); | |
| const name = sel.value; | |
| const idx = sel.selectedIndex; | |
| const prev = document.getElementById('region-prev'); | |
| const next = document.getElementById('region-next'); | |
| if (prev) prev.disabled = idx === 0; | |
| if (next) next.disabled = idx === sel.options.length - 1; | |
| const scenes = D.by_region[region]?.scenes || []; | |
| const s = scenes.find(x => x.scene === name); | |
| renderSceneCard(document.getElementById('region-viewer'), s); | |
| } | |
| function regionNav(dir) { | |
| const sel = document.getElementById('region-scene-sel'); | |
| const next = sel.selectedIndex + dir; | |
| if (next < 0 || next >= sel.options.length) return; | |
| sel.selectedIndex = next; | |
| regionSceneChanged(); | |
| } | |
| // ββ Region Γ Arch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function buildRxArch(el) { | |
| const D = window.SHOWCASE_DATA; | |
| const keys = Object.keys(D.region_x_arch); | |
| const archs = [...new Set(keys.map(k => k.split('__')[1]))].sort(); | |
| el.innerHTML = ` | |
| <div class="rx-selectors"> | |
| <label>Architecture Type | |
| <select id="rx-arch" onchange="rxArchChanged()"> | |
| ${archs.map(a => `<option value="${a}">${a}</option>`).join('')} | |
| </select> | |
| </label> | |
| <label>Region A | |
| <select id="rx-regA" onchange="renderRxArch()"></select> | |
| </label> | |
| <label>Region B | |
| <select id="rx-regB" onchange="renderRxArch()"></select> | |
| </label> | |
| </div> | |
| <div id="rx-compare" class="rx-compare"></div>`; | |
| rxArchChanged(); | |
| } | |
| function rxArchChanged() { | |
| const D = window.SHOWCASE_DATA; | |
| const arch = document.getElementById('rx-arch').value; | |
| const keys = Object.keys(D.region_x_arch); | |
| const regions = keys | |
| .filter(k => k.split('__')[1] === arch) | |
| .map(k => k.split('__')[0]) | |
| .sort(); | |
| const prevA = document.getElementById('rx-regA').value; | |
| const prevB = document.getElementById('rx-regB').value; | |
| const opts = regions.map(r => `<option value="${r}">${r}</option>`).join(''); | |
| document.getElementById('rx-regA').innerHTML = opts; | |
| document.getElementById('rx-regB').innerHTML = opts; | |
| if (regions.includes(prevA)) document.getElementById('rx-regA').value = prevA; | |
| if (regions.includes(prevB)) document.getElementById('rx-regB').value = prevB; | |
| else if (regions.length > 1) document.getElementById('rx-regB').value = regions[1]; | |
| renderRxArch(); | |
| } | |
| function renderRxArch() { | |
| const D = window.SHOWCASE_DATA; | |
| const arch = document.getElementById('rx-arch').value; | |
| const regA = document.getElementById('rx-regA').value; | |
| const regB = document.getElementById('rx-regB').value; | |
| const gA = D.region_x_arch[`${regA}__${arch}`]; | |
| const gB = D.region_x_arch[`${regB}__${arch}`]; | |
| const none = `<p style="color:#778;font-size:13px;padding:8px">No data for this combination.</p>`; | |
| const col = (g, label) => | |
| `<div class="rx-col"><div class="rx-col-header">${label}</div>${g ? scGroup(g) : none}</div>`; | |
| document.getElementById('rx-compare').innerHTML = col(gA, regA) + col(gB, regB); | |
| _observeVisible(); | |
| } | |
| // ββ Build βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function build() { | |
| if (!window.SHOWCASE_DATA) { | |
| document.getElementById('sc-global').innerHTML = | |
| '<p style="color:#f66;padding:20px">showcase_data.js not loaded.</p>'; | |
| return; | |
| } | |
| const D = window.SHOWCASE_DATA; | |
| buildGlobal(document.getElementById('sc-global')); | |
| buildRegion(document.getElementById('sc-region')); | |
| buildRxArch(document.getElementById('sc-rxarch')); | |
| _observeVisible(); | |
| } | |
| // Expose onclick-called functions globally | |
| Object.assign(window, { | |
| scShow, scSetMode, scToggleSBS, scToggleError, | |
| showImages, closeImages, rxArchChanged, renderRxArch, | |
| globalSceneChanged, globalNav, | |
| regionChanged, regionSceneChanged, regionNav, | |
| }); | |
| build(); | |
| </script> | |
| </body> | |
| </html> | |