spacetime controller
This commit is contained in:
144
camera.js
144
camera.js
@@ -1,144 +0,0 @@
|
|||||||
// camera.js
|
|
||||||
|
|
||||||
export class CameraController {
|
|
||||||
constructor(diorama) {
|
|
||||||
this.diorama = diorama;
|
|
||||||
this.canvas = diorama.canvas;
|
|
||||||
this.element = diorama.element;
|
|
||||||
|
|
||||||
this.isDragging = false;
|
|
||||||
this.hasDragged = false; // Tracks if the current interaction is a drag or a tap
|
|
||||||
this.dragButton = 0;
|
|
||||||
this.touchMode = '';
|
|
||||||
this.lastMouse = { x: 0, y: 0 };
|
|
||||||
this.lastPinchDist = 0;
|
|
||||||
|
|
||||||
this.attachEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
orbit(dx, dy) {
|
|
||||||
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) {
|
|
||||||
const t = this.diorama.theta * Math.PI / 180;
|
|
||||||
const p = this.diorama.phi * Math.PI / 180;
|
|
||||||
|
|
||||||
const rightX = Math.cos(t);
|
|
||||||
const rightZ = -Math.sin(t);
|
|
||||||
|
|
||||||
const upX = -Math.sin(p) * Math.sin(t);
|
|
||||||
const upY = Math.cos(p);
|
|
||||||
const 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) {
|
|
||||||
this.diorama.radius *= ratio;
|
|
||||||
this.diorama.radius = Math.max(1, Math.min(50, this.diorama.radius));
|
|
||||||
}
|
|
||||||
|
|
||||||
zoomLinear(delta) {
|
|
||||||
this.diorama.radius += delta * 0.05;
|
|
||||||
this.diorama.radius = Math.max(1, Math.min(50, this.diorama.radius));
|
|
||||||
}
|
|
||||||
|
|
||||||
attachEvents() {
|
|
||||||
this.element.addEventListener('contextmenu', e => e.preventDefault());
|
|
||||||
|
|
||||||
const notifyChange = () => {
|
|
||||||
this.hasDragged = true; // Mark that a drag occurred
|
|
||||||
this.diorama.requestRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.canvas.addEventListener('mousedown', (e) => {
|
|
||||||
// REMOVED e.preventDefault() here so the browser can register the native click!
|
|
||||||
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', () => {
|
|
||||||
this.isDragging = false;
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Only consider it a drag if we move more than a couple of pixels
|
|
||||||
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);
|
|
||||||
notifyChange();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Touch interactions (simplified for brevity, matching the mousedown/move logic)
|
|
||||||
this.canvas.addEventListener('touchstart', (e) => {
|
|
||||||
this.isDragging = true;
|
|
||||||
this.hasDragged = false;
|
|
||||||
if (e.touches.length === 1) {
|
|
||||||
this.touchMode = 'orbit';
|
|
||||||
this.lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
||||||
} else if (e.touches.length >= 2) {
|
|
||||||
this.touchMode = 'pan-zoom';
|
|
||||||
const t1 = e.touches[0], t2 = e.touches[1];
|
|
||||||
this.lastMouse = { x: (t1.clientX + t2.clientX) / 2, y: (t1.clientY + t2.clientY) / 2 };
|
|
||||||
this.lastPinchDist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
|
|
||||||
}
|
|
||||||
}, { passive: true }); // Passive allows the synthetic 'click' to fire later
|
|
||||||
|
|
||||||
this.canvas.addEventListener('touchmove', (e) => {
|
|
||||||
if (!this.isDragging) return;
|
|
||||||
// Prevent default here to stop page scrolling while orbiting
|
|
||||||
if (e.cancelable) e.preventDefault();
|
|
||||||
this.hasDragged = true;
|
|
||||||
// ... (Insert previous touchmove logic for orbit/pan here) ...
|
|
||||||
this.diorama.requestRender();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
const onTouchEnd = (e) => {
|
|
||||||
if (e.touches.length === 0) {
|
|
||||||
this.isDragging = false;
|
|
||||||
this.touchMode = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.canvas.addEventListener('touchend', onTouchEnd);
|
|
||||||
this.canvas.addEventListener('touchcancel', onTouchEnd);
|
|
||||||
|
|
||||||
// --- The Magic Event Forwarder ---
|
|
||||||
// Native click fires AFTER mouseup/touchend.
|
|
||||||
this.canvas.addEventListener('click', (e) => {
|
|
||||||
if (this.hasDragged) {
|
|
||||||
// If it was a drag, stop the click from bubbling to the scrubber
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
// If it WASN'T a drag, we do nothing. The event naturally bubbles up!
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
497
controller.js
Normal file
497
controller.js
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
// 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; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
// Camera State
|
||||||
|
this.isDragging = false;
|
||||||
|
this.hasDragged = false;
|
||||||
|
this.dragButton = 0;
|
||||||
|
this.lastMouse = { x: 0, y: 0 };
|
||||||
|
this.touchMode = '';
|
||||||
|
this.lastPinchDist = 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;
|
||||||
|
|
||||||
|
// Ensure container is absolute-positioning friendly
|
||||||
|
if (getComputedStyle(this.container).position === 'static') {
|
||||||
|
this.container.style.position = 'relative';
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
|
||||||
|
// --- Bottom: Timeline Controls ---
|
||||||
|
if (this.opts.timeline) {
|
||||||
|
this.uiContainer = document.createElement('div');
|
||||||
|
this.uiContainer.className = 'visualizer-ui';
|
||||||
|
|
||||||
|
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></div>
|
||||||
|
<div class="btn" id="btn-prev-sub" title="Previous Event"><</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">></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></div>
|
||||||
|
<div class="btn" id="btn-loop" title="Toggle Loop">Loop</div>
|
||||||
|
<div class="btn" id="btn-prev" title="Previous Tick"><<</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">>></div>
|
||||||
|
`;
|
||||||
|
this.uiContainer.appendChild(this.tickRow);
|
||||||
|
this.overlay.appendChild(this.uiContainer);
|
||||||
|
|
||||||
|
this.bindTimelineEvents();
|
||||||
|
this.generateTickMarkers();
|
||||||
|
this.syncUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the overlay on top of the canvas
|
||||||
|
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());
|
||||||
|
|
||||||
|
const notifyCameraChange = () => {
|
||||||
|
this.hasDragged = true;
|
||||||
|
if (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) => {
|
||||||
|
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;
|
||||||
|
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', () => {
|
||||||
|
// Because UI clicks hit the overlay and container (bypassing canvas entirely),
|
||||||
|
// this will exclusively trigger if the user cleanly taps the 3D scene.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
index.html
24
index.html
@@ -51,8 +51,7 @@
|
|||||||
import {Engine} from './engine.js';
|
import {Engine} from './engine.js';
|
||||||
import {Diorama} from './diorama.js';
|
import {Diorama} from './diorama.js';
|
||||||
import {World, WorldFrame} from './world.js';
|
import {World, WorldFrame} from './world.js';
|
||||||
import {SubtickScrubber} from "./scrubber.js";
|
import {SpacetimeController} from "./controller.js";
|
||||||
import {CameraController} from "./camera.js";
|
|
||||||
|
|
||||||
async function initDocument() {
|
async function initDocument() {
|
||||||
try {
|
try {
|
||||||
@@ -79,7 +78,12 @@
|
|||||||
d_static_iso.centerView();
|
d_static_iso.centerView();
|
||||||
d_static_iso.saveState();
|
d_static_iso.saveState();
|
||||||
engine.addDiorama(d_static_iso);
|
engine.addDiorama(d_static_iso);
|
||||||
const c_static_iso = new CameraController(d_static_iso);
|
const c_static_iso = new SpacetimeController(d_static_iso, {
|
||||||
|
timeline: false,
|
||||||
|
subticks: false,
|
||||||
|
autoplay: false,
|
||||||
|
loop: false,
|
||||||
|
});
|
||||||
|
|
||||||
const w_pulses = new World(`
|
const w_pulses = new World(`
|
||||||
p 0 0 0 observer facing=south powered=false
|
p 0 0 0 observer facing=south powered=false
|
||||||
@@ -98,6 +102,8 @@
|
|||||||
t 6
|
t 6
|
||||||
p 1 0 1 powered=false
|
p 1 0 1 powered=false
|
||||||
p 0 0 1 powered=true
|
p 0 0 1 powered=true
|
||||||
|
t 8
|
||||||
|
p 0 0 0
|
||||||
`);
|
`);
|
||||||
const f_pulses = new WorldFrame(w_pulses);
|
const f_pulses = new WorldFrame(w_pulses);
|
||||||
const d_pulses = new Diorama('demo-pulses', f_pulses, () => engine.requestRender());
|
const d_pulses = new Diorama('demo-pulses', f_pulses, () => engine.requestRender());
|
||||||
@@ -105,6 +111,12 @@
|
|||||||
d_pulses.centerView()
|
d_pulses.centerView()
|
||||||
d_pulses.saveState()
|
d_pulses.saveState()
|
||||||
engine.addDiorama(d_pulses);
|
engine.addDiorama(d_pulses);
|
||||||
|
const c_pulses = new SpacetimeController(d_pulses, {
|
||||||
|
timeline: false,
|
||||||
|
subticks: false,
|
||||||
|
loop: true,
|
||||||
|
autoplay: true,
|
||||||
|
})
|
||||||
|
|
||||||
const w_piston = new World(`
|
const w_piston = new World(`
|
||||||
p 0 0 0 redstone_wire power=0 east=side west=none north=none south=none
|
p 0 0 0 redstone_wire power=0 east=side west=none north=none south=none
|
||||||
@@ -168,9 +180,9 @@
|
|||||||
d_piston_t.centerView();
|
d_piston_t.centerView();
|
||||||
d_piston_t.saveState();
|
d_piston_t.saveState();
|
||||||
engine.addDiorama(d_piston_t);
|
engine.addDiorama(d_piston_t);
|
||||||
|
const c_piston = new SpacetimeController(d_piston_t, {
|
||||||
const s_piston_t = new SubtickScrubber(d_piston_t)
|
loop: true,
|
||||||
const c_piston = new CameraController(d_piston_t)
|
})
|
||||||
|
|
||||||
// Fetch and build everything concurrently!
|
// Fetch and build everything concurrently!
|
||||||
await engine.updateAll();
|
await engine.updateAll();
|
||||||
|
|||||||
352
scrubber.js
352
scrubber.js
@@ -1,352 +0,0 @@
|
|||||||
// scrubber.js
|
|
||||||
|
|
||||||
const SCRUBBER_CSS = `
|
|
||||||
.visualizer-ui { font-family: sans-serif; background: #222; padding: 12px; border-radius: 6px; display: flex; flex-direction: column; gap: 12px; color: white; user-select: none; }
|
|
||||||
.scrubber-row { display: flex; gap: 12px; align-items: center; }
|
|
||||||
.btn { background: #444; border: 1px solid #666; 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: #555; }
|
|
||||||
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
.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; }
|
|
||||||
`;
|
|
||||||
|
|
||||||
export class SubtickScrubber {
|
|
||||||
constructor(diorama) {
|
|
||||||
this.container = diorama.element;
|
|
||||||
this.frame = diorama.frame;
|
|
||||||
|
|
||||||
this.playMode = 'paused';
|
|
||||||
this.speed = 0.5;
|
|
||||||
this.isLooping = false;
|
|
||||||
|
|
||||||
this.lastFrameTime = performance.now();
|
|
||||||
this.playbackAccumulator = 0;
|
|
||||||
|
|
||||||
if (!document.getElementById('scrubber-styles')) {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = 'scrubber-styles';
|
|
||||||
style.textContent = SCRUBBER_CSS;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.buildUI();
|
|
||||||
this.frame.subscribe(() => this.syncUI());
|
|
||||||
|
|
||||||
this._loop = this.loop.bind(this);
|
|
||||||
requestAnimationFrame(this._loop);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildUI() {
|
|
||||||
this.container.className = 'visualizer-ui';
|
|
||||||
|
|
||||||
this.subRow = document.createElement('div');
|
|
||||||
this.subRow.className = 'scrubber-row';
|
|
||||||
this.subRow.innerHTML = `
|
|
||||||
<div class="btn" id="btn-play-sub" title="Animate Events">S></div>
|
|
||||||
<div class="btn" id="btn-prev-sub" title="Previous Event"><</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">></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
this.tickRow = document.createElement('div');
|
|
||||||
this.tickRow.className = 'scrubber-row';
|
|
||||||
this.tickRow.innerHTML = `
|
|
||||||
<div class="btn" id="btn-play" title="Play Realtime">R></div>
|
|
||||||
<div class="btn" id="btn-loop" title="Toggle Loop">Loop</div>
|
|
||||||
<div class="btn" id="btn-prev" title="Previous Tick"><<</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">>></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
this.container.appendChild(this.subRow);
|
|
||||||
this.container.appendChild(this.tickRow);
|
|
||||||
|
|
||||||
this.bindEvents();
|
|
||||||
this.generateTickMarkers();
|
|
||||||
this.syncUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
generateTickMarkers() {
|
|
||||||
const markerContainer = this.container.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}%`; // Perfectly aligns relative to the inner padded box
|
|
||||||
markerContainer.appendChild(marker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getMaxTime() {
|
|
||||||
const evs = this.frame.world.events;
|
|
||||||
return evs.length > 0 ? evs[evs.length - 1].time : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupDraggableTrack(containerId, innerId, onDrag) {
|
|
||||||
const wrapper = this.container.querySelector('#' + containerId);
|
|
||||||
const inner = this.container.querySelector('#' + innerId);
|
|
||||||
let isDragging = false;
|
|
||||||
|
|
||||||
const update = (e) => {
|
|
||||||
const rect = inner.getBoundingClientRect();
|
|
||||||
// Pct is calculated purely against the interior safe-zone
|
|
||||||
let pct = (e.clientX - rect.left) / rect.width;
|
|
||||||
pct = Math.max(0, Math.min(1, pct));
|
|
||||||
onDrag(pct);
|
|
||||||
};
|
|
||||||
|
|
||||||
wrapper.addEventListener('mousedown', (e) => {
|
|
||||||
isDragging = true;
|
|
||||||
this.playMode = 'paused';
|
|
||||||
update(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', (e) => {
|
|
||||||
if (isDragging) update(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('mouseup', () => {
|
|
||||||
isDragging = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bindEvents() {
|
|
||||||
const getEl = id => this.container.querySelector('#' + id);
|
|
||||||
|
|
||||||
getEl('btn-loop').onclick = () => {
|
|
||||||
this.isLooping = !this.isLooping;
|
|
||||||
this.syncUI();
|
|
||||||
};
|
|
||||||
|
|
||||||
// -- Primary Playback --
|
|
||||||
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 t = Math.floor(this.frame.currentTime - 1);
|
|
||||||
if (t < 0) t = this.isLooping ? Math.floor(this.getMaxTime()) : 0;
|
|
||||||
|
|
||||||
this.frame.seek(t);
|
|
||||||
this.syncUI();
|
|
||||||
};
|
|
||||||
|
|
||||||
getEl('btn-next').onclick = () => {
|
|
||||||
this.playMode = 'paused';
|
|
||||||
let t = Math.floor(this.frame.currentTime + 1);
|
|
||||||
const maxT = this.getMaxTime();
|
|
||||||
if (t > maxT) t = this.isLooping ? 0 : maxT;
|
|
||||||
|
|
||||||
this.frame.seek(t);
|
|
||||||
this.syncUI();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => {
|
|
||||||
// Force integral tick seeking
|
|
||||||
const targetTime = Math.round(pct * this.getMaxTime());
|
|
||||||
this.frame.seek(targetTime);
|
|
||||||
this.syncUI();
|
|
||||||
});
|
|
||||||
|
|
||||||
// -- Secondary Playback --
|
|
||||||
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;
|
|
||||||
const targetLocalIdx = Math.round(pct * this.currentTickEventCount);
|
|
||||||
|
|
||||||
// LOCK TIME: Dragging to the end of the subtick track completes the tick.
|
|
||||||
// If we don't lock currentTime, it jumps to the next tick instantly, breaking the UI drag loop.
|
|
||||||
const lockTime = this.frame.currentTime;
|
|
||||||
this.frame.seekIndex(this.currentTickStartIndex + targetLocalIdx);
|
|
||||||
this.frame.currentTime = lockTime;
|
|
||||||
|
|
||||||
this.syncUI();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
syncUI() {
|
|
||||||
const getEl = id => this.container.querySelector('#' + id);
|
|
||||||
|
|
||||||
getEl('btn-play').textContent = this.playMode === 'realtime' ? '||' : 'R>';
|
|
||||||
getEl('btn-play-sub').textContent = this.playMode === 'subtick' ? '||' : 'S>';
|
|
||||||
|
|
||||||
getEl('btn-loop').style.background = this.isLooping ? '#4a4' : '#444';
|
|
||||||
getEl('btn-loop').style.borderColor = this.isLooping ? '#6c6' : '#666';
|
|
||||||
|
|
||||||
this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex';
|
|
||||||
|
|
||||||
// --- Sync Primary Slider ---
|
|
||||||
const maxTime = this.getMaxTime();
|
|
||||||
const displayTime = Math.max(0, this.frame.currentTime);
|
|
||||||
const tickPct = maxTime > 0 ? (displayTime / maxTime) * 100 : 0;
|
|
||||||
|
|
||||||
// Notice the exact CSS math mapping the width against the safe bounds
|
|
||||||
getEl('fill-tick').style.width = `calc(${tickPct}% + 10px)`;
|
|
||||||
getEl('handle-tick').style.left = `${tickPct}%`;
|
|
||||||
|
|
||||||
// --- Sync Secondary Slider ---
|
|
||||||
const events = this.frame.world.events;
|
|
||||||
let startIdx = 0, count = 0;
|
|
||||||
|
|
||||||
// Floor the current time so smooth interpolation doesn't lose the integer tick
|
|
||||||
const currentIntTick = Math.floor(this.frame.currentTime);
|
|
||||||
|
|
||||||
for (let i = 0; i < events.length; i++) {
|
|
||||||
if (events[i].time === currentIntTick) {
|
|
||||||
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 absIdx = startIdx + i;
|
|
||||||
const pct = (i / count) * 100;
|
|
||||||
|
|
||||||
const marker = document.createElement('div');
|
|
||||||
marker.className = 'marker';
|
|
||||||
marker.style.left = `${pct}%`;
|
|
||||||
|
|
||||||
if (labeledIndices.has(absIdx)) marker.classList.add('marker-label');
|
|
||||||
else marker.classList.add('marker-event');
|
|
||||||
|
|
||||||
markerContainer.appendChild(marker);
|
|
||||||
}
|
|
||||||
|
|
||||||
let localCursor = this.frame.currentIndex - startIdx;
|
|
||||||
localCursor = Math.max(0, Math.min(count, localCursor));
|
|
||||||
|
|
||||||
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) {
|
|
||||||
nextTime = nextTime % maxTime;
|
|
||||||
this.frame.seek(nextTime);
|
|
||||||
} else {
|
|
||||||
this.frame.seek(maxTime);
|
|
||||||
this.playMode = 'paused';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.frame.seek(nextTime);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.syncUI();
|
|
||||||
} else {
|
|
||||||
this.playbackAccumulator = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(this._loop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user