ArchWorldVisualizer / index.html
ArchWorld's picture
remove error coloring, add description banner
04a1a70 verified
<!DOCTYPE html>
<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()">&#10005; 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 &times; 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 &times; 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}')">&#128247; 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)">&#8592; 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 &#8594;</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)">&#8592; Prev</button>
<select id="region-scene-sel" onchange="regionSceneChanged()"></select>
<button class="nav-btn" id="region-next" onclick="regionNav(1)">Next &#8594;</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>