wip control indicators

This commit is contained in:
David Allemang
2026-07-06 13:05:34 -04:00
parent b59dde47d6
commit 35adf039a6
2 changed files with 74 additions and 42 deletions

View File

@@ -23,6 +23,13 @@ const UI_CSS = `
.marker { position: absolute; top: 8px; height: 8px; transform: translateX(-50%); pointer-events: none; z-index: 1; } .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-event { width: 2px; background: rgba(255,255,255,0.5); }
.marker-label { width: 4px; background: gold; z-index: 2; } .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 { export class SpacetimeController {
@@ -44,13 +51,13 @@ export class SpacetimeController {
...options ...options
}; };
this.hasSpatial = this.opts.orbit || this.opts.pan || this.opts.zoom;
// Camera State // Camera State
this.isDragging = false; this.isDragging = false;
this.hasDragged = false; this.hasDragged = false;
this.dragButton = 0; this.dragButton = 0;
this.lastMouse = { x: 0, y: 0 }; this.lastMouse = {x: 0, y: 0};
this.touchMode = '';
this.lastPinchDist = 0;
// Timeline State // Timeline State
this.playMode = this.opts.autoplay ? 'realtime' : 'paused'; this.playMode = this.opts.autoplay ? 'realtime' : 'paused';
@@ -59,10 +66,10 @@ export class SpacetimeController {
this.lastFrameTime = performance.now(); this.lastFrameTime = performance.now();
this.playbackAccumulator = 0; this.playbackAccumulator = 0;
// Ensure container is absolute-positioning friendly
if (getComputedStyle(this.container).position === 'static') { if (getComputedStyle(this.container).position === 'static') {
this.container.style.position = 'relative'; this.container.style.position = 'relative';
} }
this.container.classList.add('spacetime-container');
if (!document.getElementById('spacetime-ui-styles')) { if (!document.getElementById('spacetime-ui-styles')) {
const style = document.createElement('style'); const style = document.createElement('style');
@@ -85,25 +92,39 @@ export class SpacetimeController {
this.overlay = document.createElement('div'); this.overlay = document.createElement('div');
this.overlay.className = 'spacetime-overlay'; this.overlay.className = 'spacetime-overlay';
// --- Top: Reset Button --- // --- Feature Indicator (Bottom Left) ---
const topWrapper = document.createElement('div'); const activeFeatures = [];
topWrapper.className = 'reset-wrapper'; if (this.opts.timeline) activeFeatures.push('t');
this.resetBtn = document.createElement('button'); if (this.opts.pan) activeFeatures.push('p');
this.resetBtn.className = 'reset-btn'; if (this.opts.zoom) activeFeatures.push('z');
this.resetBtn.textContent = 'Reset View'; if (this.opts.orbit) activeFeatures.push('o');
this.resetBtn.addEventListener('click', () => { this.indicator = document.createElement('div');
this.diorama.loadState(); this.indicator.className = 'spacetime-indicator';
this.resetBtn.style.display = 'none'; this.indicator.innerHTML = activeFeatures.map(f => `<span>${f}</span>`).join('');
}); this.overlay.appendChild(this.indicator);
topWrapper.appendChild(this.resetBtn); // --- Top: Reset Button (Only if spatial tools are enabled) ---
this.overlay.appendChild(topWrapper); 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 --- // --- Bottom: Timeline Controls ---
if (this.opts.timeline) { if (this.opts.timeline) {
this.uiContainer = document.createElement('div'); this.uiContainer = document.createElement('div');
this.uiContainer.className = 'visualizer-ui'; this.uiContainer.className = 'visualizer-ui spacetime-hover-reveal';
if (this.opts.subticks) { if (this.opts.subticks) {
this.subRow = document.createElement('div'); this.subRow = document.createElement('div');
@@ -148,7 +169,6 @@ export class SpacetimeController {
this.syncUI(); this.syncUI();
} }
// Drop the overlay on top of the canvas
this.container.appendChild(this.overlay); this.container.appendChild(this.overlay);
} }
@@ -192,18 +212,22 @@ export class SpacetimeController {
attachEvents() { attachEvents() {
this.canvas.addEventListener('contextmenu', e => e.preventDefault()); this.canvas.addEventListener('contextmenu', e => e.preventDefault());
// Correctly set or remove the grab cursor
this.canvas.style.cursor = this.hasSpatial ? 'grab' : 'default';
const notifyCameraChange = () => { const notifyCameraChange = () => {
this.hasDragged = true; 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(); 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) => { this.canvas.addEventListener('mousedown', (e) => {
// Short-circuit drag logic entirely if no spatial controls are enabled
if (!this.hasSpatial) return;
this.isDragging = true; this.isDragging = true;
this.hasDragged = false; this.hasDragged = false;
this.lastMouse = { x: e.clientX, y: e.clientY }; this.lastMouse = {x: e.clientX, y: e.clientY};
this.dragButton = e.button; this.dragButton = e.button;
if (this.dragButton === 0) { if (this.dragButton === 0) {
@@ -219,7 +243,7 @@ export class SpacetimeController {
window.addEventListener('mouseup', () => { window.addEventListener('mouseup', () => {
if (this.isDragging) { if (this.isDragging) {
this.isDragging = false; 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; if (!this.isDragging) return;
const dx = e.clientX - this.lastMouse.x; const dx = e.clientX - this.lastMouse.x;
const dy = e.clientY - this.lastMouse.y; const dy = e.clientY - this.lastMouse.y;
if (Math.hypot(dx, dy) > 3) { 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); if (this.dragButton === 0) this.orbit(dx, dy);
else if (this.dragButton === 1) this.pan(dx, dy); else if (this.dragButton === 1) this.pan(dx, dy);
else if (this.dragButton === 2) this.zoomLinear(dy); else if (this.dragButton === 2) this.zoomLinear(dy);
@@ -238,8 +262,8 @@ export class SpacetimeController {
}); });
this.canvas.addEventListener('click', () => { this.canvas.addEventListener('click', () => {
// Because UI clicks hit the overlay and container (bypassing canvas entirely), // hasDragged will always be false if hasSpatial is false,
// this will exclusively trigger if the user cleanly taps the 3D scene. // ensuring static scenes still act as tap-to-play toggles.
if (!this.hasDragged && this.opts.timeline) { if (!this.hasDragged && this.opts.timeline) {
this.playMode = this.playMode === 'paused' ? 'realtime' : 'paused'; this.playMode = this.playMode === 'paused' ? 'realtime' : 'paused';
this.syncUI(); this.syncUI();
@@ -247,7 +271,6 @@ export class SpacetimeController {
}); });
} }
// --- TEMPORAL CONTROL --- // --- TEMPORAL CONTROL ---
getMaxTime() { getMaxTime() {
@@ -268,11 +291,11 @@ export class SpacetimeController {
} }
for (const t of labeledTimes) { for (const t of labeledTimes) {
if (t < 0) continue; if (t < 0) continue;
const pct = (t / maxTime) * 100; const pct = (t / maxTime) * 100;
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = 'marker marker-label'; marker.className = 'marker marker-label';
marker.style.left = `${pct}%`; marker.style.left = `${pct}%`;
markerContainer.appendChild(marker); markerContainer.appendChild(marker);
} }
} }
@@ -320,7 +343,7 @@ export class SpacetimeController {
} }
this.syncUI(); this.syncUI();
}; };
getEl('btn-prev').onclick = () => { getEl('btn-prev').onclick = () => {
this.playMode = 'paused'; this.playMode = 'paused';
let activeTime = -Infinity; let activeTime = -Infinity;
@@ -337,16 +360,19 @@ export class SpacetimeController {
this.frame.seek(prevTime); this.frame.seek(prevTime);
this.syncUI(); this.syncUI();
}; };
getEl('btn-next').onclick = () => { getEl('btn-next').onclick = () => {
this.playMode = 'paused'; this.playMode = 'paused';
let nextTime = this.getMaxTime() + 1; let nextTime = this.getMaxTime() + 1;
for (const ev of this.frame.world.events) { 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(); if (nextTime > this.getMaxTime()) nextTime = this.isLooping ? 0 : this.getMaxTime();
this.frame.seek(nextTime); this.frame.seek(nextTime);
this.syncUI(); this.syncUI();
}; };
this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => { this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => {
@@ -359,7 +385,7 @@ export class SpacetimeController {
this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick'; this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick';
this.syncUI(); this.syncUI();
}; };
getEl('btn-prev-sub').onclick = () => { getEl('btn-prev-sub').onclick = () => {
this.playMode = 'paused'; this.playMode = 'paused';
let idx = this.frame.currentIndex - 1; let idx = this.frame.currentIndex - 1;
@@ -367,7 +393,7 @@ export class SpacetimeController {
this.frame.seekIndex(idx); this.frame.seekIndex(idx);
this.syncUI(); this.syncUI();
}; };
getEl('btn-next-sub').onclick = () => { getEl('btn-next-sub').onclick = () => {
this.playMode = 'paused'; this.playMode = 'paused';
let idx = this.frame.currentIndex + 1; let idx = this.frame.currentIndex + 1;
@@ -389,7 +415,7 @@ export class SpacetimeController {
const getEl = id => this.uiContainer.querySelector('#' + id); const getEl = id => this.uiContainer.querySelector('#' + id);
getEl('btn-play').textContent = this.playMode === 'realtime' ? '||' : 'R>'; 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.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)'; 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 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; const tickPct = maxTime > 0 ? (displayTime / maxTime) * 100 : 0;
getEl('fill-tick').style.width = `calc(${tickPct}% + 10px)`; getEl('fill-tick').style.width = `calc(${tickPct}% + 10px)`;
getEl('handle-tick').style.left = `${tickPct}%`; getEl('handle-tick').style.left = `${tickPct}%`;
@@ -469,14 +495,14 @@ export class SpacetimeController {
this.frame.seek(nextTime); this.frame.seek(nextTime);
} }
if (this.opts.timeline) this.syncUI(); if (this.opts.timeline) this.syncUI();
} else if (this.playMode === 'subtick') { } else if (this.playMode === 'subtick') {
this.playbackAccumulator += dt * 4 * this.speed; this.playbackAccumulator += dt * 4 * this.speed;
if (this.playbackAccumulator >= 1.0) { if (this.playbackAccumulator >= 1.0) {
const steps = Math.floor(this.playbackAccumulator); const steps = Math.floor(this.playbackAccumulator);
this.playbackAccumulator -= steps; this.playbackAccumulator -= steps;
const nextIdx = this.frame.currentIndex + steps; const nextIdx = this.frame.currentIndex + steps;
if (nextIdx > this.frame.world.events.length) { if (nextIdx > this.frame.world.events.length) {
if (this.isLooping) this.frame.seekIndex(this.frame.world.initialIndex); if (this.isLooping) this.frame.seekIndex(this.frame.world.initialIndex);
else { else {

View File

@@ -83,6 +83,8 @@
subticks: false, subticks: false,
autoplay: false, autoplay: false,
loop: false, loop: false,
zoom: false,
pan: false,
}); });
const w_pulses = new World(` const w_pulses = new World(`
@@ -116,6 +118,9 @@
subticks: false, subticks: false,
loop: true, loop: true,
autoplay: true, autoplay: true,
pan: false,
zoom: false,
orbit: false,
}) })
const w_piston = new World(` const w_piston = new World(`
@@ -214,6 +219,7 @@
// }; // };
// setInterval(update_pulses, factor * 8 * 50) // setInterval(update_pulses, factor * 8 * 50)
// update_pulses(); // update_pulses();
} catch (err) { } catch (err) {
console.error("Renderer Initialization Failed:", err); console.error("Renderer Initialization Failed:", err);
} }