// controller.js const UI_CSS = ` .spacetime-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; flex-direction: column; justify-content: space-between; box-sizing: border-box; padding: 12px; z-index: 10; } .reset-wrapper { display: flex; justify-content: flex-end; width: 100%; } .reset-btn { pointer-events: auto; padding: 6px 12px; background: rgba(0,0,0,0.6); color: white; border: 1px solid rgba(255,255,255,0.3); cursor: pointer; display: none; font-family: sans-serif; border-radius: 4px; font-weight: bold; backdrop-filter: blur(4px); } .reset-btn:hover { background: rgba(0,0,0,0.8); } .visualizer-ui { pointer-events: auto; font-family: sans-serif; background: rgba(20,20,20,0.85); padding: 12px; border-radius: 8px; display: flex; flex-direction: column; gap: 12px; color: white; user-select: none; backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 12px rgba(0,0,0,0.5); } .scrubber-row { display: flex; gap: 12px; align-items: center; } .btn { background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; min-width: 4ch; text-align: center; font-weight: bold; transition: background 0.1s; } .btn:hover { background: rgba(255,255,255,0.2); } .track-container { flex-grow: 1; height: 24px; padding: 0 10px; cursor: pointer; display: flex; align-items: center; } .track-inner { position: relative; width: 100%; height: 100%; pointer-events: none; } .track-bg { position: absolute; left: -10px; right: -10px; top: 8px; height: 8px; background: rgba(255,255,255,0.2); border-radius: 4px; } .track-fill { position: absolute; left: -10px; top: 8px; height: 8px; background: #f00; width: 10px; border-radius: 4px; } .track-handle { position: absolute; width: 16px; height: 16px; background: #fff; border-radius: 50%; top: 12px; transform: translate(-50%, -50%); z-index: 3; box-shadow: 0 0 4px rgba(0,0,0,0.5); } .marker { position: absolute; top: 8px; height: 8px; transform: translateX(-50%); pointer-events: none; z-index: 1; } .marker-event { width: 2px; background: rgba(255,255,255,0.5); } .marker-label { width: 4px; background: gold; z-index: 2; } /* --- Hover Visibility Logic --- */ .spacetime-hover-reveal { opacity: 0; pointer-events: none; transition: opacity 0.2s ease; } .spacetime-container:hover .spacetime-hover-reveal { opacity: 1; pointer-events: auto; } .spacetime-indicator { position: absolute; left: 33px; bottom: 29px; display: flex; gap: 12px; color: rgba(255,255,255,0.4); font-family: monospace; font-size: 14px; pointer-events: none; opacity: 1; transition: opacity 0.2s ease; } .spacetime-container:hover .spacetime-indicator { opacity: 0; } `; export class SpacetimeController { constructor(diorama, options = {}) { this.diorama = diorama; this.frame = diorama.frame; this.container = diorama.element; this.canvas = diorama.canvas; this.opts = { orbit: true, pan: true, zoom: true, timeline: true, subticks: true, autoplay: false, loop: false, speed: 0.5, ...options }; this.hasSpatial = this.opts.orbit || this.opts.pan || this.opts.zoom; // Camera State this.isDragging = false; this.hasDragged = false; this.dragButton = 0; this.lastMouse = {x: 0, y: 0}; // Timeline State this.playMode = this.opts.autoplay ? 'realtime' : 'paused'; this.isLooping = this.opts.loop; this.speed = this.opts.speed; this.lastFrameTime = performance.now(); this.playbackAccumulator = 0; if (getComputedStyle(this.container).position === 'static') { this.container.style.position = 'relative'; } this.container.classList.add('spacetime-container'); if (!document.getElementById('spacetime-ui-styles')) { const style = document.createElement('style'); style.id = 'spacetime-ui-styles'; style.textContent = UI_CSS; document.head.appendChild(style); } this.buildUI(); this.attachEvents(); if (this.opts.timeline || this.opts.autoplay) { this.frame.subscribe(() => this.syncUI()); this._loop = this.loop.bind(this); requestAnimationFrame(this._loop); } } buildUI() { this.overlay = document.createElement('div'); this.overlay.className = 'spacetime-overlay'; // --- Feature Indicator (Bottom Left) --- const activeFeatures = []; if (this.opts.timeline) activeFeatures.push('t'); if (this.opts.pan) activeFeatures.push('p'); if (this.opts.zoom) activeFeatures.push('z'); if (this.opts.orbit) activeFeatures.push('o'); this.indicator = document.createElement('div'); this.indicator.className = 'spacetime-indicator'; this.indicator.innerHTML = activeFeatures.map(f => `${f}`).join(''); this.overlay.appendChild(this.indicator); // --- Top: Reset Button (Only if spatial tools are enabled) --- if (this.hasSpatial) { const topWrapper = document.createElement('div'); topWrapper.className = 'reset-wrapper'; this.resetBtn = document.createElement('button'); this.resetBtn.className = 'reset-btn spacetime-hover-reveal'; this.resetBtn.textContent = 'Reset View'; this.resetBtn.addEventListener('click', () => { this.diorama.loadState(); this.resetBtn.style.display = 'none'; }); topWrapper.appendChild(this.resetBtn); this.overlay.appendChild(topWrapper); } // --- Bottom: Timeline Controls --- if (this.opts.timeline) { this.uiContainer = document.createElement('div'); this.uiContainer.className = 'visualizer-ui spacetime-hover-reveal'; if (this.opts.subticks) { this.subRow = document.createElement('div'); this.subRow.className = 'scrubber-row'; this.subRow.innerHTML = `
S>
<
>
`; this.uiContainer.appendChild(this.subRow); } this.tickRow = document.createElement('div'); this.tickRow.className = 'scrubber-row'; this.tickRow.innerHTML = `
R>
Loop
<<
>>
`; this.uiContainer.appendChild(this.tickRow); this.overlay.appendChild(this.uiContainer); this.bindTimelineEvents(); this.generateTickMarkers(); this.syncUI(); } this.container.appendChild(this.overlay); } // --- SPATIAL CONTROL --- orbit(dx, dy) { if (!this.opts.orbit) return; this.diorama.theta -= dx * 0.4; this.diorama.phi += dy * 0.4; this.diorama.phi = Math.max(-90, Math.min(90, this.diorama.phi)); } pan(dx, dy) { if (!this.opts.pan) return; const t = this.diorama.theta * Math.PI / 180; const p = this.diorama.phi * Math.PI / 180; const rightX = Math.cos(t), rightZ = -Math.sin(t); const upX = -Math.sin(p) * Math.sin(t), upY = Math.cos(p), upZ = -Math.sin(p) * Math.cos(t); const rect = this.canvas.getBoundingClientRect(); const panSpeed = this.diorama.radius / rect.height; this.diorama.target[0] += (-rightX * dx + upX * dy) * panSpeed; this.diorama.target[1] += (upY * dy) * panSpeed; this.diorama.target[2] += (-rightZ * dx + upZ * dy) * panSpeed; } zoomRatio(ratio) { if (!this.opts.zoom) return; this.diorama.radius *= ratio; this.diorama.radius = Math.max(1, Math.min(50, this.diorama.radius)); } zoomLinear(delta) { if (!this.opts.zoom) return; this.diorama.radius += delta * 0.05; this.diorama.radius = Math.max(1, Math.min(50, this.diorama.radius)); } attachEvents() { this.canvas.addEventListener('contextmenu', e => e.preventDefault()); // Correctly set or remove the grab cursor this.canvas.style.cursor = this.hasSpatial ? 'grab' : 'default'; const notifyCameraChange = () => { this.hasDragged = true; if (this.resetBtn && this.diorama.checkpoint) this.resetBtn.style.display = 'block'; this.diorama.requestRender(); }; this.canvas.addEventListener('mousedown', (e) => { // Short-circuit drag logic entirely if no spatial controls are enabled if (!this.hasSpatial) return; this.isDragging = true; this.hasDragged = false; this.lastMouse = {x: e.clientX, y: e.clientY}; this.dragButton = e.button; if (this.dragButton === 0) { if (e.ctrlKey || e.metaKey) this.dragButton = 2; else if (e.shiftKey) this.dragButton = 1; } if (this.dragButton === 1) this.canvas.style.cursor = 'move'; else if (this.dragButton === 2) this.canvas.style.cursor = 'ns-resize'; else this.canvas.style.cursor = 'grabbing'; }); window.addEventListener('mouseup', () => { if (this.isDragging) { this.isDragging = false; if (this.hasSpatial) this.canvas.style.cursor = 'grab'; } }); window.addEventListener('mousemove', (e) => { if (!this.isDragging) return; const dx = e.clientX - this.lastMouse.x; const dy = e.clientY - this.lastMouse.y; if (Math.hypot(dx, dy) > 3) { this.lastMouse = {x: e.clientX, y: e.clientY}; if (this.dragButton === 0) this.orbit(dx, dy); else if (this.dragButton === 1) this.pan(dx, dy); else if (this.dragButton === 2) this.zoomLinear(dy); notifyCameraChange(); } }); this.canvas.addEventListener('click', () => { // hasDragged will always be false if hasSpatial is false, // ensuring static scenes still act as tap-to-play toggles. if (!this.hasDragged && this.opts.timeline) { this.playMode = this.playMode === 'paused' ? 'realtime' : 'paused'; this.syncUI(); } }); } // --- TEMPORAL CONTROL --- getMaxTime() { const evs = this.frame.world.events; return evs.length > 0 ? evs[evs.length - 1].time : 0; } generateTickMarkers() { const markerContainer = this.uiContainer.querySelector('#markers-tick'); const maxTime = this.getMaxTime(); if (maxTime <= 0) return; const labeledTimes = new Set(); for (const idx of this.frame.world.labels.values()) { if (idx < this.frame.world.events.length) { labeledTimes.add(this.frame.world.events[idx].time); } } for (const t of labeledTimes) { if (t < 0) continue; const pct = (t / maxTime) * 100; const marker = document.createElement('div'); marker.className = 'marker marker-label'; marker.style.left = `${pct}%`; markerContainer.appendChild(marker); } } setupDraggableTrack(containerId, innerId, onDrag) { const wrapper = this.uiContainer.querySelector('#' + containerId); const inner = this.uiContainer.querySelector('#' + innerId); let isTrackDragging = false; const update = (e) => { const rect = inner.getBoundingClientRect(); let pct = (e.clientX - rect.left) / rect.width; pct = Math.max(0, Math.min(1, pct)); onDrag(pct); }; wrapper.addEventListener('mousedown', (e) => { isTrackDragging = true; this.playMode = 'paused'; update(e); }); window.addEventListener('mousemove', (e) => { if (isTrackDragging) update(e); }); window.addEventListener('mouseup', () => { isTrackDragging = false; }); } bindTimelineEvents() { const getEl = id => this.uiContainer.querySelector('#' + id); getEl('btn-loop').onclick = () => { this.isLooping = !this.isLooping; this.syncUI(); }; getEl('btn-play').onclick = () => { if (this.playMode === 'realtime') this.playMode = 'paused'; else { if (this.frame.currentTime >= this.getMaxTime() || this.frame.currentTime < 0) this.frame.seek(0); this.playMode = 'realtime'; } this.syncUI(); }; getEl('btn-prev').onclick = () => { this.playMode = 'paused'; let activeTime = -Infinity; for (const ev of this.frame.world.events) if (ev.time <= this.frame.currentTime) activeTime = ev.time; let prevTime = -1; for (let i = this.frame.world.events.length - 1; i >= 0; i--) { if (this.frame.world.events[i].time < activeTime) { prevTime = this.frame.world.events[i].time; break; } } if (prevTime < 0) prevTime = this.isLooping ? this.getMaxTime() : 0; this.frame.seek(prevTime); this.syncUI(); }; getEl('btn-next').onclick = () => { this.playMode = 'paused'; let nextTime = this.getMaxTime() + 1; for (const ev of this.frame.world.events) { if (ev.time > this.frame.currentTime) { nextTime = ev.time; break; } } if (nextTime > this.getMaxTime()) nextTime = this.isLooping ? 0 : this.getMaxTime(); this.frame.seek(nextTime); this.syncUI(); }; this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => { this.frame.seek(Math.round(pct * this.getMaxTime())); this.syncUI(); }); if (this.opts.subticks) { getEl('btn-play-sub').onclick = () => { this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick'; this.syncUI(); }; getEl('btn-prev-sub').onclick = () => { this.playMode = 'paused'; let idx = this.frame.currentIndex - 1; if (idx < this.frame.world.initialIndex) idx = this.isLooping ? this.frame.world.events.length : this.frame.world.initialIndex; this.frame.seekIndex(idx); this.syncUI(); }; getEl('btn-next-sub').onclick = () => { this.playMode = 'paused'; let idx = this.frame.currentIndex + 1; if (idx > this.frame.world.events.length) idx = this.isLooping ? this.frame.world.initialIndex : this.frame.world.events.length; this.frame.seekIndex(idx); this.syncUI(); }; this.setupDraggableTrack('track-sub-container', 'track-sub-inner', (pct) => { if (this.currentTickEventCount === 0) return; this.frame.seekIndex(this.currentTickStartIndex + Math.round(pct * this.currentTickEventCount)); this.syncUI(); }); } } syncUI() { if (!this.opts.timeline) return; const getEl = id => this.uiContainer.querySelector('#' + id); getEl('btn-play').textContent = this.playMode === 'realtime' ? '||' : 'R>'; getEl('btn-loop').style.background = this.isLooping ? 'rgba(102, 204, 102, 0.4)' : 'rgba(255, 255, 255, 0.1)'; getEl('btn-loop').style.borderColor = this.isLooping ? '#6c6' : 'rgba(255, 255, 255, 0.2)'; if (this.opts.subticks) { getEl('btn-play-sub').textContent = this.playMode === 'subtick' ? '||' : 'S>'; this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex'; } const maxTime = this.getMaxTime(); const displayTime = Math.max(0, this.frame.currentTime); const tickPct = maxTime > 0 ? (displayTime / maxTime) * 100 : 0; getEl('fill-tick').style.width = `calc(${tickPct}% + 10px)`; getEl('handle-tick').style.left = `${tickPct}%`; if (!this.opts.subticks) return; const events = this.frame.world.events; let activeTickTime = -Infinity; for (const ev of events) { if (ev.time <= this.frame.currentTime) activeTickTime = ev.time; else break; } let startIdx = 0, count = 0; for (let i = 0; i < events.length; i++) { if (events[i].time === activeTickTime) { if (count === 0) startIdx = i; count++; } } this.currentTickStartIndex = startIdx; this.currentTickEventCount = count; const markerContainer = getEl('markers-sub'); markerContainer.innerHTML = ''; if (count > 0) { const labeledIndices = new Set(this.frame.world.labels.values()); for (let i = 0; i <= count; i++) { const markerPct = (i / count) * 100; const marker = document.createElement('div'); marker.className = 'marker'; marker.style.left = `${markerPct}%`; if (labeledIndices.has(startIdx + i)) marker.classList.add('marker-label'); else marker.classList.add('marker-event'); markerContainer.appendChild(marker); } let localCursor = Math.max(0, Math.min(count, this.frame.currentIndex - startIdx)); const subPct = (localCursor / count) * 100; getEl('fill-sub').style.width = `calc(${subPct}% + 10px)`; getEl('handle-sub').style.left = `${subPct}%`; getEl('handle-sub').style.display = 'block'; } else { getEl('fill-sub').style.width = `0px`; getEl('handle-sub').style.display = 'none'; } } loop(time) { const dt = (time - this.lastFrameTime) / 1000; this.lastFrameTime = time; const maxTime = this.getMaxTime(); if (this.playMode === 'realtime') { let nextTime = this.frame.currentTime + dt * 20 * this.speed; if (nextTime >= maxTime) { if (this.isLooping) { this.frame.seek(nextTime % maxTime); } else { this.frame.seek(maxTime); this.playMode = 'paused'; } } else { this.frame.seek(nextTime); } if (this.opts.timeline) this.syncUI(); } else if (this.playMode === 'subtick') { this.playbackAccumulator += dt * 4 * this.speed; if (this.playbackAccumulator >= 1.0) { const steps = Math.floor(this.playbackAccumulator); this.playbackAccumulator -= steps; const nextIdx = this.frame.currentIndex + steps; if (nextIdx > this.frame.world.events.length) { if (this.isLooping) this.frame.seekIndex(this.frame.world.initialIndex); else { this.frame.seekIndex(this.frame.world.events.length); this.playMode = 'paused'; } } else { this.frame.seekIndex(nextIdx); } } if (this.opts.timeline) this.syncUI(); } else { this.playbackAccumulator = 0; } requestAnimationFrame(this._loop); } }