Files
wireless-docs/controller.js
2026-07-06 13:05:34 -04:00

523 lines
21 KiB
JavaScript

// 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 => `<span>${f}</span>`).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 = `
<div class="btn" id="btn-play-sub" title="Animate Events">S&gt;</div>
<div class="btn" id="btn-prev-sub" title="Previous Event">&lt;</div>
<div class="track-container" id="track-sub-container">
<div class="track-inner" id="track-sub-inner">
<div class="track-bg"></div>
<div class="track-fill" id="fill-sub"></div>
<div id="markers-sub"></div>
<div class="track-handle" id="handle-sub"></div>
</div>
</div>
<div class="btn" id="btn-next-sub" title="Next Event">&gt;</div>
`;
this.uiContainer.appendChild(this.subRow);
}
this.tickRow = document.createElement('div');
this.tickRow.className = 'scrubber-row';
this.tickRow.innerHTML = `
<div class="btn" id="btn-play" title="Play Realtime">R&gt;</div>
<div class="btn" id="btn-loop" title="Toggle Loop">Loop</div>
<div class="btn" id="btn-prev" title="Previous Tick">&lt;&lt;</div>
<div class="track-container" id="track-tick-container">
<div class="track-inner" id="track-tick-inner">
<div class="track-bg"></div>
<div class="track-fill" id="fill-tick"></div>
<div id="markers-tick"></div>
<div class="track-handle" id="handle-tick"></div>
</div>
</div>
<div class="btn" id="btn-next" title="Next Tick">&gt;&gt;</div>
`;
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);
}
}