diff --git a/controller.js b/controller.js index fb72030f..2eda33f0 100644 --- a/controller.js +++ b/controller.js @@ -23,6 +23,13 @@ const UI_CSS = ` .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 { @@ -44,13 +51,13 @@ export class SpacetimeController { ...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 }; - this.touchMode = ''; - this.lastPinchDist = 0; + this.lastMouse = {x: 0, y: 0}; // Timeline State this.playMode = this.opts.autoplay ? 'realtime' : 'paused'; @@ -59,10 +66,10 @@ export class SpacetimeController { this.lastFrameTime = performance.now(); this.playbackAccumulator = 0; - // Ensure container is absolute-positioning friendly 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'); @@ -85,25 +92,39 @@ export class SpacetimeController { this.overlay = document.createElement('div'); this.overlay.className = 'spacetime-overlay'; - // --- Top: Reset Button --- - const topWrapper = document.createElement('div'); - topWrapper.className = 'reset-wrapper'; - this.resetBtn = document.createElement('button'); - this.resetBtn.className = 'reset-btn'; - 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); + // --- 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'; + this.uiContainer.className = 'visualizer-ui spacetime-hover-reveal'; if (this.opts.subticks) { this.subRow = document.createElement('div'); @@ -148,7 +169,6 @@ export class SpacetimeController { this.syncUI(); } - // Drop the overlay on top of the canvas this.container.appendChild(this.overlay); } @@ -192,18 +212,22 @@ export class SpacetimeController { 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.diorama.checkpoint) this.resetBtn.style.display = 'block'; + if (this.resetBtn && this.diorama.checkpoint) this.resetBtn.style.display = 'block'; this.diorama.requestRender(); }; - // All of these drag events are strictly bound to the canvas element. - // Clicking the UI container will NEVER trigger these because they are siblings. 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.lastMouse = {x: e.clientX, y: e.clientY}; this.dragButton = e.button; if (this.dragButton === 0) { @@ -219,7 +243,7 @@ export class SpacetimeController { window.addEventListener('mouseup', () => { if (this.isDragging) { this.isDragging = false; - this.canvas.style.cursor = 'grab'; + if (this.hasSpatial) this.canvas.style.cursor = 'grab'; } }); @@ -227,9 +251,9 @@ export class SpacetimeController { 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 }; + 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); @@ -238,8 +262,8 @@ export class SpacetimeController { }); this.canvas.addEventListener('click', () => { - // Because UI clicks hit the overlay and container (bypassing canvas entirely), - // this will exclusively trigger if the user cleanly taps the 3D scene. + // 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(); @@ -247,7 +271,6 @@ export class SpacetimeController { }); } - // --- TEMPORAL CONTROL --- getMaxTime() { @@ -268,11 +291,11 @@ export class SpacetimeController { } for (const t of labeledTimes) { - if (t < 0) continue; + if (t < 0) continue; const pct = (t / maxTime) * 100; const marker = document.createElement('div'); marker.className = 'marker marker-label'; - marker.style.left = `${pct}%`; + marker.style.left = `${pct}%`; markerContainer.appendChild(marker); } } @@ -320,7 +343,7 @@ export class SpacetimeController { } this.syncUI(); }; - + getEl('btn-prev').onclick = () => { this.playMode = 'paused'; let activeTime = -Infinity; @@ -337,16 +360,19 @@ export class SpacetimeController { 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 (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.syncUI(); }; this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => { @@ -359,7 +385,7 @@ export class SpacetimeController { this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick'; this.syncUI(); }; - + getEl('btn-prev-sub').onclick = () => { this.playMode = 'paused'; let idx = this.frame.currentIndex - 1; @@ -367,7 +393,7 @@ export class SpacetimeController { this.frame.seekIndex(idx); this.syncUI(); }; - + getEl('btn-next-sub').onclick = () => { this.playMode = 'paused'; let idx = this.frame.currentIndex + 1; @@ -389,7 +415,7 @@ export class SpacetimeController { 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)'; @@ -399,7 +425,7 @@ export class SpacetimeController { } const maxTime = this.getMaxTime(); - const displayTime = Math.max(0, this.frame.currentTime); + 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}%`; @@ -469,14 +495,14 @@ export class SpacetimeController { 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 { diff --git a/index.html b/index.html index b69a29d3..a420036d 100644 --- a/index.html +++ b/index.html @@ -83,6 +83,8 @@ subticks: false, autoplay: false, loop: false, + zoom: false, + pan: false, }); const w_pulses = new World(` @@ -116,6 +118,9 @@ subticks: false, loop: true, autoplay: true, + pan: false, + zoom: false, + orbit: false, }) const w_piston = new World(` @@ -214,6 +219,7 @@ // }; // setInterval(update_pulses, factor * 8 * 50) // update_pulses(); + } catch (err) { console.error("Renderer Initialization Failed:", err); }