diff --git a/camera.js b/camera.js deleted file mode 100644 index f9f3d613..00000000 --- a/camera.js +++ /dev/null @@ -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! - }); - } -} \ No newline at end of file diff --git a/controller.js b/controller.js new file mode 100644 index 00000000..fb72030f --- /dev/null +++ b/controller.js @@ -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 = ` +
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(); + } + + // 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); + } +} \ No newline at end of file diff --git a/index.html b/index.html index 8b85d450..b69a29d3 100644 --- a/index.html +++ b/index.html @@ -51,8 +51,7 @@ import {Engine} from './engine.js'; import {Diorama} from './diorama.js'; import {World, WorldFrame} from './world.js'; - import {SubtickScrubber} from "./scrubber.js"; - import {CameraController} from "./camera.js"; + import {SpacetimeController} from "./controller.js"; async function initDocument() { try { @@ -79,7 +78,12 @@ d_static_iso.centerView(); d_static_iso.saveState(); 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(` p 0 0 0 observer facing=south powered=false @@ -98,6 +102,8 @@ t 6 p 1 0 1 powered=false p 0 0 1 powered=true + t 8 + p 0 0 0 `); const f_pulses = new WorldFrame(w_pulses); const d_pulses = new Diorama('demo-pulses', f_pulses, () => engine.requestRender()); @@ -105,6 +111,12 @@ d_pulses.centerView() d_pulses.saveState() engine.addDiorama(d_pulses); + const c_pulses = new SpacetimeController(d_pulses, { + timeline: false, + subticks: false, + loop: true, + autoplay: true, + }) const w_piston = new World(` 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.saveState(); engine.addDiorama(d_piston_t); - - const s_piston_t = new SubtickScrubber(d_piston_t) - const c_piston = new CameraController(d_piston_t) + const c_piston = new SpacetimeController(d_piston_t, { + loop: true, + }) // Fetch and build everything concurrently! await engine.updateAll(); diff --git a/scrubber.js b/scrubber.js deleted file mode 100644 index 854b9137..00000000 --- a/scrubber.js +++ /dev/null @@ -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 = ` -
S>
-
<
-
-
-
-
-
-
-
-
-
>
- `; - - this.tickRow = document.createElement('div'); - this.tickRow.className = 'scrubber-row'; - this.tickRow.innerHTML = ` -
R>
-
Loop
-
<<
-
-
-
-
-
-
-
-
-
>>
- `; - - 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); - } -} \ No newline at end of file