codebase-nav-env / static /viz3d.html
Chirag0123's picture
feat(ui): Detailed 3D upgrades - typed geometries, glowing tubes, dynamic camera tracking
b75c304
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent Trajectory 3D Visualizer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: #0a0e1a;
color: #e0e6f0;
font-family: 'Segoe UI', system-ui, sans-serif;
overflow: hidden;
}
#three-canvas {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
display: block;
}
/* Header */
#header {
position: fixed;
top: 10px; left: 50%;
transform: translateX(-50%);
text-align: center;
z-index: 20;
pointer-events: none;
}
#header h1 {
font-size: 14px; font-weight: 700;
color: #7dd3fc;
letter-spacing: 0.05em;
text-shadow: 0 0 16px rgba(125,211,252,0.6);
}
/* Panel base */
.panel {
position: fixed;
background: rgba(10,14,26,0.88);
border: 1px solid rgba(125,211,252,0.2);
border-radius: 10px;
padding: 10px 14px;
font-size: 11px;
z-index: 20;
backdrop-filter: blur(6px);
}
.panel h3 {
font-size: 10px; letter-spacing: 0.1em;
color: #7dd3fc; margin-bottom: 8px;
text-transform: uppercase;
}
/* Info panel */
#info-panel { top: 10px; left: 14px; min-width: 190px; }
.info-row { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 4px; color: #94a3b8; }
.info-val { color: #e0e6f0; font-weight: 600; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Legend */
#legend { top: 10px; right: 14px; }
.leg { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
.leg-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.leg-line { width: 18px; height: 3px; border-radius: 2px; flex-shrink: 0; }
/* Score ring */
#score-ring { position: fixed; bottom: 150px; left: 14px; z-index: 20; }
/* Step log */
#step-log {
position: fixed; bottom: 150px; right: 14px;
width: 230px; max-height: 200px; overflow-y: auto;
z-index: 20;
}
.log-e { display: flex; gap: 5px; margin-bottom: 5px; padding-bottom: 5px; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 10px; }
.log-e:last-child { border-bottom: none; }
.log-s { color: #475569; min-width: 24px; }
.log-a { font-weight: 600; flex: 1; }
.rp { color: #4ade80; } .rn { color: #f87171; } .rz { color: #94a3b8; }
/* Timeline */
#timeline {
position: fixed; bottom: 16px; left: 50%;
transform: translateX(-50%);
width: min(680px, 92vw);
z-index: 20;
}
#tl-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
#tl-header h3 { font-size: 10px; color: #7dd3fc; letter-spacing: 0.1em; }
#step-label { font-size: 11px; color: #f0abfc; font-weight: 700; }
#slider {
width: 100%; -webkit-appearance: none; appearance: none; height: 4px;
background: linear-gradient(to right, #7dd3fc 0%, #7dd3fc var(--pct,0%), #1e293b var(--pct,0%));
border-radius: 4px; outline: none; cursor: pointer;
}
#slider::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 15px; height: 15px;
border-radius: 50%; background: #7dd3fc; cursor: pointer;
box-shadow: 0 0 8px rgba(125,211,252,0.8);
}
#tl-btns { display: flex; gap: 7px; margin-top: 8px; justify-content: center; }
.tb {
background: rgba(125,211,252,0.1);
border: 1px solid rgba(125,211,252,0.3);
color: #7dd3fc; padding: 4px 12px;
border-radius: 6px; cursor: pointer; font-size: 10px;
transition: all 0.15s;
}
.tb:hover { background: rgba(125,211,252,0.25); }
.tb.active { background: rgba(125,211,252,0.3); }
/* Tooltip */
#tooltip {
position: fixed; z-index: 30;
background: rgba(10,14,26,0.95);
border: 1px solid rgba(125,211,252,0.4);
border-radius: 6px; padding: 7px 11px;
font-size: 10px; pointer-events: none;
opacity: 0; transition: opacity 0.1s;
max-width: 180px;
}
#tt-title { color: #7dd3fc; margin-bottom: 3px; font-weight: 700; }
/* Loader */
#loader {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%,-50%);
text-align: center; z-index: 50; color: #7dd3fc; font-size: 13px;
}
.spin {
width: 36px; height: 36px; margin: 0 auto 10px;
border: 3px solid rgba(125,211,252,0.15);
border-top-color: #7dd3fc;
border-radius: 50%;
animation: sp 0.7s linear infinite;
}
@keyframes sp { to { transform: rotate(360deg); } }
#no-data {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%,-50%);
text-align: center; color: #475569; font-size: 13px;
display: none;
}
/* Node Labels Overlay */
#labels-container { position: fixed; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index: 15; }
.node-label {
position: absolute; color: rgba(224,230,240,0.9);
font-size: 10px; font-weight: 600; padding: 3px 7px;
background: rgba(10,14,26,0.7); border: 1px solid rgba(125,211,252,0.25);
border-radius: 4px; transform: translate(-50%, -200%);
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
pointer-events: auto; white-space: nowrap;
opacity: 0; transition: opacity 0.2s, box-shadow 0.2s;
}
.node-label:hover { box-shadow: 0 0 10px rgba(125,211,252,0.6); z-index: 100; }
</style>
</head>
<body>
<canvas id="three-canvas"></canvas>
<div id="labels-container"></div>
<div id="loader"><div class="spin"></div><p>Loading 3D...</p></div>
<div id="no-data">
<p style="font-size:28px;margin-bottom:12px">πŸ”</p>
<p style="color:#7dd3fc;font-weight:700;margin-bottom:6px">No Episode Loaded</p>
<p>Run an episode first, then click<br><strong style="color:#7dd3fc">Load Trajectory</strong></p>
</div>
<div id="header"><h1>πŸ” Agent Trajectory Visualizer β€” 3D</h1></div>
<!-- Info panel -->
<div class="panel" id="info-panel">
<h3>Episode Stats</h3>
<div class="info-row"><span>Task</span><span class="info-val" id="st-task">β€”</span></div>
<div class="info-row"><span>Variant</span><span class="info-val" id="st-var">β€”</span></div>
<div class="info-row"><span>Steps</span><span class="info-val" id="st-steps">β€”</span></div>
<div class="info-row"><span>Score</span><span class="info-val" id="st-score">β€”</span></div>
<div class="info-row"><span>Strategy</span><span class="info-val" id="st-strat">β€”</span></div>
</div>
<!-- Legend -->
<div class="panel" id="legend">
<h3>Legend</h3>
<div class="leg"><div class="leg-dot" style="background:#f97316"></div><span>Source file</span></div>
<div class="leg"><div class="leg-dot" style="background:#3b82f6"></div><span>Test file</span></div>
<div class="leg"><div class="leg-dot" style="background:#a855f7"></div><span>Spec / Docs</span></div>
<div class="leg"><div class="leg-dot" style="background:#22c55e"></div><span>Visited</span></div>
<div class="leg"><div class="leg-dot" style="background:#ef4444"></div><span>Bug / Modified</span></div>
<div class="leg"><div class="leg-line" style="background:#facc15"></div><span>Agent path</span></div>
</div>
<!-- Score ring -->
<div id="score-ring">
<svg width="76" height="76" viewBox="0 0 76 76">
<circle cx="38" cy="38" r="30" fill="none" stroke="rgba(125,211,252,0.12)" stroke-width="6"/>
<circle id="score-arc" cx="38" cy="38" r="30" fill="none"
stroke="#7dd3fc" stroke-width="6"
stroke-dasharray="0 188"
stroke-linecap="round"
transform="rotate(-90 38 38)"
style="transition:stroke-dasharray 1.2s ease"/>
<text id="score-txt" x="38" y="43" text-anchor="middle"
fill="#e0e6f0" font-size="13" font-weight="700"
font-family="'Segoe UI',sans-serif">0.0</text>
</svg>
</div>
<!-- Step log -->
<div class="panel" id="step-log">
<h3>Step Log</h3>
<div id="log-list"></div>
</div>
<!-- Tooltip -->
<div id="tooltip"><div id="tt-title"></div><div id="tt-body"></div></div>
<!-- Timeline -->
<div class="panel" id="timeline">
<div id="tl-header">
<h3>Timeline Replay</h3>
<span id="step-label">Step 0 / 0</span>
</div>
<input type="range" id="slider" min="0" max="0" value="0"
oninput="onSlider(this.value)">
<div id="tl-btns">
<button class="tb" onclick="stepBack()">β—€ Back</button>
<button class="tb" id="play-btn" onclick="togglePlay()">β–Ά Play</button>
<button class="tb" onclick="stepFwd()">Forward β–Ά</button>
<button class="tb" onclick="resetView()">β†Ί Reset</button>
<button class="tb" id="orbit-btn" onclick="toggleOrbit()">πŸ”„ Orbit</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ── Renderer ──────────────────────────────────────────────────────────────────
const canvas = document.getElementById('three-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x0a0e1a, 1);
function resize() {
renderer.setSize(window.innerWidth, window.innerHeight, false);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
// ── Scene + Camera ─────────────────────────────────────────────────────────────
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(58, 1, 0.1, 1000);
camera.position.set(0, 8, 24);
camera.lookAt(0, 0, 0);
resize();
// Lights
scene.add(new THREE.AmbientLight(0x1a2040, 1.2));
const dl = new THREE.DirectionalLight(0x7dd3fc, 0.5);
dl.position.set(5, 12, 5);
scene.add(dl);
// Grid
const grid = new THREE.GridHelper(50, 25, 0x1e293b, 0x0f172a);
grid.position.y = -3.5;
scene.add(grid);
// Stars
(function() {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(900 * 3);
for (let i = 0; i < 900 * 3; i++) pos[i] = (Math.random() - 0.5) * 220;
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
scene.add(new THREE.Points(geo, new THREE.PointsMaterial({ color: 0x1e3a5f, size: 0.25 })));
})();
// ── Orbit controls (manual) ───────────────────────────────────────────────────
let sph = { theta: 0, phi: 1.1, r: 24 };
let orbitAuto = false, dragging = false, lastX = 0, lastY = 0;
let lookTarget = new THREE.Vector3(0,0,0);
canvas.addEventListener('mousedown', e => { dragging = true; lastX = e.clientX; lastY = e.clientY; });
window.addEventListener('mouseup', () => { dragging = false; });
window.addEventListener('mousemove', e => {
if (dragging) {
sph.theta -= (e.clientX - lastX) * 0.006;
sph.phi = Math.max(0.15, Math.min(1.55, sph.phi - (e.clientY - lastY) * 0.006));
lastX = e.clientX; lastY = e.clientY;
} else { checkHover(e.clientX, e.clientY); }
});
canvas.addEventListener('wheel', e => {
sph.r = Math.max(8, Math.min(55, sph.r + e.deltaY * 0.025));
});
function updateCamera() {
if (orbitAuto) sph.theta += 0.004;
const sin_p = Math.sin(sph.phi);
camera.position.set(
sph.r * sin_p * Math.sin(sph.theta) + lookTarget.x,
sph.r * Math.cos(sph.phi) + (lookTarget.y * 0.3),
sph.r * sin_p * Math.cos(sph.theta) + lookTarget.z
);
camera.lookAt(lookTarget);
}
// ── Scene state ───────────────────────────────────────────────────────────────
const COLS = { src:0xf97316, test:0x3b82f6, spec:0xa855f7, visited:0x22c55e, bug:0xef4444, agent:0xfbbf24, path:0xfacc15, edge:0x334155 };
let nodeMap = {}; // filename β†’ { mesh, basePos, labelEl }
let pathLines = [], edgeLines = [];
let agentMesh = null;
let targetAgentPos = new THREE.Vector3(0, 3.5, 0); // For smooth lerp interpolation
let vizData = null;
let curStep = 0, maxStep = 0;
let playing = false, playTimer = null;
let frame = 0;
// ── Build scene ───────────────────────────────────────────────────────────────
function clearScene() {
Object.values(nodeMap).forEach(o => {
scene.remove(o.mesh);
if (o.labelEl && o.labelEl.parentNode) o.labelEl.parentNode.removeChild(o.labelEl);
});
pathLines.forEach(l => scene.remove(l));
edgeLines.forEach(l => scene.remove(l));
if (agentMesh) scene.remove(agentMesh);
nodeMap = {}; pathLines = []; edgeLines = []; agentMesh = null;
}
function buildScene(data) {
clearScene();
vizData = data;
const files = data.files || [];
const n = files.length;
if (!n) return;
// Layout: circle
files.forEach((f, i) => {
const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
const R = Math.max(5, n * 1.0);
const x = Math.cos(angle) * R;
const z = Math.sin(angle) * R;
const pos = new THREE.Vector3(x, 0, z);
const baseColor = f.is_bug_file ? COLS.bug :
f.type === 'test' ? COLS.test :
f.type === 'spec' ? COLS.spec : COLS.src;
const col = new THREE.Color(baseColor);
// Diverse Geometries
let geo;
if (f.type === 'test') { geo = new THREE.CylinderGeometry(0.5, 0.5, 0.8, 6); } // Hex prism
else if (f.type === 'spec') { geo = new THREE.OctahedronGeometry(0.65); } // Diamond
else { geo = new THREE.BoxGeometry(0.85, 0.85, 0.85); } // Code block Cube
const mat = new THREE.MeshPhongMaterial({
color: col, emissive: col.clone().multiplyScalar(0.25),
shininess: 90, transparent: true, opacity: 0.85, flatShading: true
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
if (f.type !== 'src') { mesh.rotation.x = 0.2; mesh.rotation.z = Math.random() * 0.5; }
mesh.userData = { file: f, basePos: pos.clone() };
scene.add(mesh);
// Ring halo
const rg = new THREE.RingGeometry(0.7, 0.82, 32);
const rm = new THREE.MeshBasicMaterial({ color: col, transparent: true, opacity: 0.2, side: THREE.DoubleSide });
const ring = new THREE.Mesh(rg, rm);
ring.rotation.x = Math.PI / 2;
mesh.add(ring);
// HTML Label Overlay
const labelEl = document.createElement('div');
labelEl.className = 'node-label';
labelEl.textContent = f.name;
document.getElementById('labels-container').appendChild(labelEl);
nodeMap[f.name] = { mesh, basePos: pos.clone(), labelEl };
});
// Dependency edges
(data.dependencies || []).forEach(dep => {
const a = nodeMap[dep.from], b = nodeMap[dep.to];
if (!a || !b) return;
const geo = new THREE.BufferGeometry().setFromPoints([a.basePos.clone(), b.basePos.clone()]);
const mat = new THREE.LineBasicMaterial({ color: COLS.edge, transparent: true, opacity: 0.35 });
const line = new THREE.Line(geo, mat);
scene.add(line);
edgeLines.push(line);
});
// Agent sphere
const ag = new THREE.SphereGeometry(0.32, 14, 14);
const am = new THREE.MeshPhongMaterial({ color: COLS.agent, emissive: 0xfbbf24, emissiveIntensity: 0.9, shininess: 120 });
agentMesh = new THREE.Mesh(ag, am);
agentMesh.position.set(0, 3, 0);
scene.add(agentMesh);
// Update UI stats
document.getElementById('st-task').textContent = data.task || 'β€”';
document.getElementById('st-var').textContent = (data.variant_id || 'β€”').slice(0, 12);
document.getElementById('st-steps').textContent = (data.steps || []).length;
document.getElementById('st-strat').textContent = data.strategy || 'β€”';
updateScore(data.final_score || 0);
maxStep = (data.steps || []).length;
const sl = document.getElementById('slider');
sl.max = maxStep; sl.value = 0;
curStep = 0;
updateLabel(0, maxStep);
applyStep(0);
}
// ── Apply step ────────────────────────────────────────────────────────────────
function applyStep(idx) {
if (!vizData) return;
const steps = vizData.steps || [];
// Reset all nodes
Object.values(nodeMap).forEach(({ mesh, basePos: _ }) => {
const f = mesh.userData.file;
const bc = f.is_bug_file ? COLS.bug : f.type === 'test' ? COLS.test : f.type === 'spec' ? COLS.spec : COLS.src;
mesh.material.color.set(bc);
mesh.material.emissive.set(new THREE.Color(bc).multiplyScalar(0.2));
mesh.material.opacity = 0.55;
mesh.scale.setScalar(1);
});
// Remove old path lines
pathLines.forEach(l => scene.remove(l));
pathLines = [];
// Collect positions for path
const pathPts = [];
const visited = new Set(), modified = new Set();
for (let i = 0; i < idx; i++) {
const s = steps[i];
if (!s) continue;
if (s.path && nodeMap[s.path]) {
const p = nodeMap[s.path].basePos.clone().add(new THREE.Vector3(0, 0.15, 0));
pathPts.push(p);
}
if (s.action === 'read_file' && s.path) visited.add(s.path);
if (s.action === 'write_file' && s.path) modified.add(s.path);
}
// Color visited/modified
visited.forEach(name => {
if (nodeMap[name]) {
nodeMap[name].mesh.material.color.set(COLS.visited);
nodeMap[name].mesh.material.emissive.set(new THREE.Color(COLS.visited).multiplyScalar(0.4));
nodeMap[name].mesh.material.opacity = 1;
nodeMap[name].mesh.scale.setScalar(1.25);
}
});
modified.forEach(name => {
if (nodeMap[name]) {
nodeMap[name].mesh.material.color.set(COLS.bug);
nodeMap[name].mesh.material.emissive.set(new THREE.Color(COLS.bug).multiplyScalar(0.5));
nodeMap[name].mesh.material.opacity = 1;
nodeMap[name].mesh.scale.setScalar(1.45);
}
});
// Highlight current node
if (idx > 0 && idx <= steps.length) {
const cur = steps[idx - 1];
if (cur && cur.path && nodeMap[cur.path]) {
nodeMap[cur.path].mesh.scale.setScalar(1.65);
}
}
// Draw precision tube path
if (pathPts.length >= 2) {
// Add an exact curve mapping
const curve = new THREE.CatmullRomCurve3(pathPts);
const geo = new THREE.TubeGeometry(curve, pathPts.length * 8, 0.045, 8, false);
const mat = new THREE.MeshPhongMaterial({
color: COLS.path, emissive: COLS.path, emissiveIntensity: 0.6,
transparent: true, opacity: 0.8
});
const line = new THREE.Mesh(geo, mat);
scene.add(line); pathLines.push(line);
}
// Move agent target (actual animation uses lerp in animate loop)
if (idx > 0 && idx <= steps.length) {
const cur = steps[idx - 1];
if (cur && cur.path && nodeMap[cur.path]) {
const tp = nodeMap[cur.path].basePos;
targetAgentPos.set(tp.x, tp.y + 1.6, tp.z);
} else {
targetAgentPos.set(0, 2.5, 0);
}
} else {
targetAgentPos.set(0, 3.5, 0);
}
updateLog(steps, idx - 1);
updateLabel(idx, maxStep);
const sl = document.getElementById('slider');
sl.style.setProperty('--pct', (maxStep > 0 ? idx / maxStep * 100 : 0) + '%');
}
// ── Score ring ─────────────────────────────────────────────────────────────────
function updateScore(s) {
const c = 2 * Math.PI * 30;
const arc = c * Math.min(1, Math.max(0, s));
document.getElementById('score-arc').setAttribute('stroke-dasharray', `${arc} ${c}`);
document.getElementById('score-txt').textContent = s.toFixed(2);
document.getElementById('st-score').textContent = s.toFixed(3);
const col = s >= 0.7 ? '#4ade80' : s >= 0.4 ? '#fbbf24' : '#f87171';
document.getElementById('score-arc').setAttribute('stroke', col);
}
// ── Step log ───────────────────────────────────────────────────────────────────
function updateLog(steps, curIdx) {
const em = { read_file:'πŸ“–', write_file:'✏️', run_tests:'πŸ§ͺ', search_code:'πŸ”', submit:'🏁' };
const container = document.getElementById('log-list');
container.innerHTML = '';
steps.forEach((s, i) => {
const e = document.createElement('div');
e.className = 'log-e';
e.style.opacity = i < curIdx ? '0.55' : i === curIdx ? '1' : '0.3';
if (i === curIdx) e.style.background = 'rgba(125,211,252,0.07)';
const r = s.reward || 0;
const rc = r > 0 ? 'rp' : r < 0 ? 'rn' : 'rz';
const name = (s.path || s.action || '').split('/').pop() || s.action;
e.innerHTML = `<span class="log-s">S${s.step}</span><span class="log-a">${em[s.action]||'β€’'} ${name}</span><span class="${rc}">${r>0?'+':''}${r.toFixed(2)}</span>`;
container.appendChild(e);
});
if (curIdx >= 0 && container.children[curIdx]) {
container.children[curIdx].scrollIntoView({ block: 'nearest' });
}
}
// ── Hover tooltip ──────────────────────────────────────────────────────────────
const ray = new THREE.Raycaster();
const mv = new THREE.Vector2();
const tt = document.getElementById('tooltip');
function checkHover(mx, my) {
mv.x = (mx / window.innerWidth) * 2 - 1;
mv.y = -(my / window.innerHeight) * 2 + 1;
ray.setFromCamera(mv, camera);
const meshes = Object.values(nodeMap).map(o => o.mesh);
const hits = ray.intersectObjects(meshes);
if (hits.length) {
const f = hits[0].object.userData.file;
tt.style.opacity = '1';
tt.style.left = (mx + 12) + 'px';
tt.style.top = (my - 8) + 'px';
document.getElementById('tt-title').textContent = f.name;
document.getElementById('tt-body').innerHTML =
`Type: ${f.type}${f.is_bug_file ? '<br>⚠️ Bug location' : ''}${f.visited ? '<br>βœ… Visited' : ''}`;
} else {
tt.style.opacity = '0';
}
}
// ── Controls ───────────────────────────────────────────────────────────────────
function onSlider(v) { curStep = +v; applyStep(curStep); }
function stepFwd() { if (curStep < maxStep) { curStep++; document.getElementById('slider').value = curStep; applyStep(curStep); } }
function stepBack() { if (curStep > 0) { curStep--; document.getElementById('slider').value = curStep; applyStep(curStep); } }
function togglePlay() {
playing = !playing;
document.getElementById('play-btn').textContent = playing ? '⏸ Pause' : 'β–Ά Play';
if (playing) {
if (curStep >= maxStep) curStep = 0;
playTimer = setInterval(() => {
if (curStep >= maxStep) { playing = false; document.getElementById('play-btn').textContent = 'β–Ά Play'; clearInterval(playTimer); return; }
stepFwd();
}, 850);
} else {
clearInterval(playTimer);
}
}
function toggleOrbit() {
orbitAuto = !orbitAuto;
const btn = document.getElementById('orbit-btn');
btn.textContent = orbitAuto ? '⏹ Stop' : 'πŸ”„ Orbit';
btn.classList.toggle('active', orbitAuto);
}
function resetView() {
sph = { theta: 0, phi: 1.1, r: 24 };
curStep = 0;
document.getElementById('slider').value = 0;
applyStep(0);
}
function updateLabel(s, m) { document.getElementById('step-label').textContent = `Step ${s} / ${m}`; }
// ── Animation loop ─────────────────────────────────────────────────────────────
function animate() {
requestAnimationFrame(animate);
frame++;
// Dynamic camera focal tracking
lookTarget.lerp(targetAgentPos, 0.03);
updateCamera();
// Pulsing & moving agent (seamless flow)
if (agentMesh) {
agentMesh.position.lerp(targetAgentPos, 0.08); // Smooth transition
const p = 1 + Math.sin(frame * 0.09) * 0.18;
agentMesh.scale.setScalar(p);
agentMesh.rotation.y += 0.04;
}
// Subtle node float & Update HTML overaly positions
Object.values(nodeMap).forEach(({ mesh, basePos, labelEl }, i) => {
mesh.position.y = basePos.y + Math.sin(frame * 0.018 + i * 1.1) * 0.12;
// Project 3D vector to 2D screen space
if (labelEl) {
const pos = mesh.position.clone();
pos.project(camera);
const x = (pos.x * 0.5 + 0.5) * window.innerWidth;
const y = (pos.y * -0.5 + 0.5) * window.innerHeight;
// Hide if behind camera or very close to edges
if (pos.z > 0.99 || Math.abs(pos.x) > 1.2 || Math.abs(pos.y) > 1.2) {
labelEl.style.opacity = '0';
} else {
labelEl.style.left = `${x}px`;
labelEl.style.top = `${y}px`;
labelEl.style.opacity = '1';
}
}
});
renderer.render(scene, camera);
}
animate();
// ── Load data from API ─────────────────────────────────────────────────────────
async function fetchAndLoad() {
document.getElementById('loader').style.display = 'block';
document.getElementById('no-data').style.display = 'none';
try {
// Try to determine base URL from window location
const base = window.location.origin;
const res = await fetch(`${base}/viz-data`, { cache: 'no-store' });
if (!res.ok) throw new Error('no data');
const data = await res.json();
if (data.error || !data.files || data.files.length === 0) {
document.getElementById('loader').style.display = 'none';
document.getElementById('no-data').style.display = 'block';
return;
}
buildScene(data);
document.getElementById('loader').style.display = 'none';
} catch(e) {
document.getElementById('loader').style.display = 'none';
document.getElementById('no-data').style.display = 'block';
}
}
// ── Public API (can be called from parent window) ─────────────────────────────
window.loadData = function(data) {
if (typeof data === 'string') { try { data = JSON.parse(data); } catch(e) { return; } }
buildScene(data);
document.getElementById('loader').style.display = 'none';
document.getElementById('no-data').style.display = 'none';
};
// Auto-load on init
window.addEventListener('load', fetchAndLoad);
</script>
</body>
</html>