diff --git a/camera.js b/camera.js new file mode 100644 index 00000000..f9f3d613 --- /dev/null +++ b/camera.js @@ -0,0 +1,144 @@ +// 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/diorama.js b/diorama.js index f0d8f83e..ef961768 100644 --- a/diorama.js +++ b/diorama.js @@ -3,6 +3,8 @@ import {mat4Ortho, mat4LookAt, mat4Multiply} from './math.js'; export class Diorama { constructor(elementId, frame, requestRenderCallback) { this.element = document.getElementById(elementId); + this.element.style.position = 'relative'; // todo move to css + this.canvas = document.createElement('canvas') this.element.appendChild(this.canvas) this.ctx2d = this.canvas.getContext('2d'); @@ -18,40 +20,7 @@ export class Diorama { this.viewMatrix = new Float32Array(16); this.viewProjMatrix = new Float32Array(16); - this.isDragging = false; - this.dragButton = 0; - this.touchMode = ''; - this.lastMouse = {x: 0, y: 0}; - this.lastPinchDist = 0; - this.checkpoint = null; - - this.initUI(); - this.attachEvents(); - } - - initUI() { - this.element.style.position = "relative"; - - this.resetBtn = document.createElement('button'); - this.resetBtn.textContent = 'Reset View'; - this.resetBtn.style.cssText = ` - position: absolute; top: 1ch; right: 1ch; - padding: 0.5ch 1ch; background: rgba(0,0,0,0.7); - color: white; border: 1px solid gray; - cursor: pointer; display: none; z-index: 10; - `; - - this.resetBtn.addEventListener('mousedown', e => e.stopPropagation()); - this.resetBtn.addEventListener('touchstart', e => e.stopPropagation()); - this.resetBtn.addEventListener('click', () => { - this.loadState(); - this.resetBtn.style.display = 'none'; - }); - this.element.appendChild(this.resetBtn); - - this.touchTimer = null; - this.touchTimedOut = false; } saveState() { @@ -129,164 +98,4 @@ export class Diorama { return this.viewProjMatrix; } - - orbit(dx, dy) { - this.theta -= dx * 0.4; - this.phi += dy * 0.4; - this.phi = Math.max(-90, Math.min(90, this.phi)); - } - - pan(dx, dy) { - const t = this.theta * Math.PI / 180; - const p = this.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.radius / rect.height; - - this.target[0] += (-rightX * dx + upX * dy) * panSpeed; - this.target[1] += (upY * dy) * panSpeed; - this.target[2] += (-rightZ * dx + upZ * dy) * panSpeed; - } - - zoomRatio(ratio) { - this.radius *= ratio; - this.radius = Math.max(1, Math.min(50, this.radius)); - } - - zoomLinear(delta) { - this.radius += delta * 0.05; - this.radius = Math.max(1, Math.min(50, this.radius)); - } - - attachEvents() { - this.element.addEventListener('contextmenu', e => e.preventDefault()); - - const notifyChange = () => { - if (this.checkpoint) this.resetBtn.style.display = 'block'; - this.requestRender(); - }; - - this.element.addEventListener('mousedown', (e) => { - e.preventDefault(); - this.isDragging = true; - 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.element.style.cursor = 'move'; - else if (this.dragButton === 2) this.element.style.cursor = 'ns-resize'; - else this.element.style.cursor = 'grabbing'; - }); - - window.addEventListener('mouseup', () => { - this.isDragging = false; - this.element.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; - 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(); - }); - - this.element.addEventListener('touchstart', (e) => { - if (this.touchTimer) clearTimeout(this.touchTimer); - this.touchTimedOut = false; - - if (e.touches.length === 1) { - this.touchTimer = setTimeout(() => { - this.touchTimedOut = true; - this.isDragging = true; - this.touchMode = 'orbit'; - this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; - this.element.style.cursor = 'grabbing'; - }, 200); - } else if (e.touches.length >= 2) { - this.touchTimedOut = true; - this.isDragging = true; - 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}); - - this.element.addEventListener('touchmove', (e) => { - if (!this.touchTimedOut) { - if (this.touchTimer) { - clearTimeout(this.touchTimer); - this.touchTimer = null; - } - this.isDragging = false; - return; - } - - if (!this.isDragging) return; - if (e.cancelable) e.preventDefault(); - - if (this.touchMode === 'orbit' && e.touches.length === 1) { - const dx = e.touches[0].clientX - this.lastMouse.x; - const dy = e.touches[0].clientY - this.lastMouse.y; - this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; - - this.orbit(dx, dy); - notifyChange(); - } else if (this.touchMode === 'pan-zoom' && e.touches.length >= 2) { - const t1 = e.touches[0], t2 = e.touches[1]; - const cx = (t1.clientX + t2.clientX) / 2; - const cy = (t1.clientY + t2.clientY) / 2; - const dist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); - - const dx = cx - this.lastMouse.x; - const dy = cy - this.lastMouse.y; - const zRatio = this.lastPinchDist > 0 ? (this.lastPinchDist / dist) : 1; - - this.lastMouse = {x: cx, y: cy}; - this.lastPinchDist = dist; - - this.pan(dx, dy); - this.zoomRatio(zRatio); - notifyChange(); - } - }, {passive: false}); - - const onTouchEnd = (e) => { - if (this.touchTimer) { - clearTimeout(this.touchTimer); - this.touchTimer = null; - } - - if (e.touches.length === 0) { - this.isDragging = false; - this.touchMode = ''; - } else if (e.touches.length === 1) { - this.touchMode = 'orbit'; - this.lastMouse = {x: e.touches[0].clientX, y: e.touches[0].clientY}; - } - }; - - this.element.addEventListener('touchend', onTouchEnd); - this.element.addEventListener('touchcancel', onTouchEnd); - } } \ No newline at end of file diff --git a/index.html b/index.html index 78995d22..c24680cf 100644 --- a/index.html +++ b/index.html @@ -36,9 +36,7 @@
-
- -
+

Static Sub-Figures

@@ -53,6 +51,8 @@ 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"; async function initDocument() { try { @@ -79,6 +79,7 @@ d_static_iso.centerView(); d_static_iso.saveState(); engine.addDiorama(d_static_iso); + const c_static_iso = new CameraController(d_static_iso); const w_pulses = new World(` p 0 0 0 observer facing=south powered=false @@ -158,48 +159,45 @@ p 2 -1 0 extended=false `); const f_piston_t = new WorldFrame(w_piston); - const d_piston_t = new Diorama('demo-time', f_piston_t, () => engine.requestRender()); + const d_piston_t = new Diorama('demo-piston', f_piston_t, () => engine.requestRender()); d_piston_t.radius = 4; d_piston_t.centerView(); d_piston_t.saveState(); engine.addDiorama(d_piston_t); - const f_piston_s = new WorldFrame(w_piston); - const d_piston_s = new Diorama('demo-state', f_piston_s, () => engine.requestRender()); - d_piston_s.theta = 180 - d_piston_s.phi = 0 - d_piston_s.radius = 4; - d_piston_s.centerView(); - d_piston_s.saveState(); - engine.addDiorama(d_piston_s); + + const s_piston_t = new SubtickScrubber('demo-piston', f_piston_t, d_piston_t) + const c_piston = new CameraController(d_piston_t) // Fetch and build everything concurrently! await engine.updateAll(); + engine.requestRender(); - const factor = 2.5; - const update_time = () => { - for (let i = 0; i < 20; i++) { - setTimeout(() => f_piston_t.seek(i), i * factor * 50) - } - }; - setInterval(update_time, factor * 1000) - update_time(); - let i = 0; - let n = w_piston.eventCount; - const update_state = () => { - f_piston_s.seekIndex(i); - i = (i + 1) % n; - }; - setInterval(update_state, 250); - update_state() - - const update_pulses = () => { - for (let i = 0; i < 8; i++) { - setTimeout(() => f_pulses.seek(i), i * factor * 50) - } - }; - setInterval(update_pulses, factor * 8 * 50) - update_pulses(); + // const factor = 2.5; + // const update_time = () => { + // for (let i = 0; i < 20; i++) { + // setTimeout(() => f_piston_t.seek(i), i * factor * 50) + // } + // }; + // setInterval(update_time, factor * 1000) + // update_time(); + // + // let i = 0; + // let n = w_piston.eventCount; + // const update_state = () => { + // f_piston_s.seekIndex(i); + // i = (i + 1) % n; + // }; + // setInterval(update_state, 250); + // update_state() + // + // const update_pulses = () => { + // for (let i = 0; i < 8; i++) { + // setTimeout(() => f_pulses.seek(i), i * factor * 50) + // } + // }; + // setInterval(update_pulses, factor * 8 * 50) + // update_pulses(); } catch (err) { console.error("Renderer Initialization Failed:", err); } diff --git a/scrubber.js b/scrubber.js new file mode 100644 index 00000000..c0e66899 --- /dev/null +++ b/scrubber.js @@ -0,0 +1,200 @@ +// scrubber.js + +export class SubtickScrubber { + constructor(elementId, frame, diorama) { + this.frame = frame; + this.diorama = diorama; + this.container = diorama.element; + + this.playMode = 'paused'; // 'paused' | 'realtime' | 'subtick' + this.speed = 1.0; // Ticks per second scaling + + this.lastFrameTime = performance.now(); + this.playbackAccumulator = 0; + + // Hook into the Diorama tap event we just created + this.diorama.onTap = () => { + if (this.playMode !== 'paused') this.playMode = 'paused'; + else this.playMode = 'realtime'; + this.syncUI(); + }; + + this.buildUI(); + this.frame.subscribe(() => this.syncUI()); + + // Start playback loop + this._loop = this.loop.bind(this); + requestAnimationFrame(this._loop); + } + + buildUI() { + this.container.style.cssText = ` + font-family: monospace; background: #333; padding: 10px; + border-radius: 4px; display: flex; flex-direction: column; gap: 8px; + `; + + // Secondary Row (Subticks) + this.subRow = document.createElement('div'); + this.subRow.style.cssText = `display: flex; gap: 10px; align-items: center;`; + this.subRow.innerHTML = ` + + +
+ +
+ + `; + + // Primary Row (Ticks) + this.tickRow = document.createElement('div'); + this.tickRow.style.cssText = `display: flex; gap: 10px; align-items: center;`; + + // Determine absolute timeline boundaries + const maxTime = this.frame.world.events.length > 0 ? + this.frame.world.events[this.frame.world.events.length - 1].time : 0; + + this.tickRow.innerHTML = ` + + +
+ +
+ + `; + + this.container.appendChild(this.subRow); + this.container.appendChild(this.tickRow); + + this.bindEvents(); + } + + bindEvents() { + const getEl = id => this.container.querySelector('#' + id); + + // --- Primary Actions --- + getEl('btn-play').onclick = () => { + this.playMode = this.playMode === 'realtime' ? 'paused' : 'realtime'; + this.syncUI(); + }; + + getEl('btn-prev').onclick = () => { + this.playMode = 'paused'; + this.frame.seek(Math.floor(this.frame.currentTime - 1)); + }; + + getEl('btn-next').onclick = () => { + this.playMode = 'paused'; + this.frame.seek(Math.floor(this.frame.currentTime + 1)); + }; + + const tickSlider = getEl('slider-tick'); + tickSlider.oninput = () => { + this.playMode = 'paused'; + this.frame.seek(parseFloat(tickSlider.value)); + }; + + // --- Secondary Actions --- + getEl('btn-play-sub').onclick = () => { + this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick'; + this.syncUI(); + }; + + getEl('btn-prev-sub').onclick = () => { + this.playMode = 'paused'; + this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex - 1); + }; + + getEl('btn-next-sub').onclick = () => { + this.playMode = 'paused'; + this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex + 1); + }; + + const subSlider = getEl('slider-sub'); + subSlider.oninput = () => { + this.playMode = 'paused'; + // Translate the localized sub-slider value back into an absolute global index + const localValue = parseInt(subSlider.value); + this.frame.seekIndex((this.currentTickStartIndex - this.frame.world.initialIndex) + localValue); + }; + } + + syncUI() { + const getEl = id => this.container.querySelector('#' + id); + + getEl('btn-play').textContent = this.playMode === 'realtime' ? '||' : 'Play'; + getEl('btn-play-sub').textContent = this.playMode === 'subtick' ? '||' : 'S-Play'; + + // Hide secondary scrubber in realtime mode + this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex'; + + // Sync Primary Slider + getEl('slider-tick').value = Math.max(0, this.frame.currentTime); + + // --- Calculate Localized Subtick Slider Bounds --- + const events = this.frame.world.events; + let tickStartIdx = 0; + let tickEventCount = 0; + + // Find the absolute bounds of the current tick in the event array + for (let i = 0; i < events.length; i++) { + if (events[i].time === this.frame.currentTime) { + if (tickEventCount === 0) tickStartIdx = i; + tickEventCount++; + } + } + + this.currentTickStartIndex = tickStartIdx; + const subSlider = getEl('slider-sub'); + subSlider.max = tickEventCount; + + // Calculate where the cursor is relative to the start of this specific tick + let localCursor = this.frame.currentIndex - tickStartIdx; + if (localCursor < 0) localCursor = 0; + if (localCursor > tickEventCount) localCursor = tickEventCount; + + subSlider.value = localCursor; + } + + loop(time) { + const dt = (time - this.lastFrameTime) / 1000; + this.lastFrameTime = time; + + const maxTime = this.frame.world.events.length > 0 ? + this.frame.world.events[this.frame.world.events.length - 1].time : 0; + + if (this.playMode === 'realtime') { + // 20 ticks per second * speed multiplier + this.playbackAccumulator += dt * 20 * this.speed; + + if (this.playbackAccumulator >= 1.0) { + const ticksToAdvance = Math.floor(this.playbackAccumulator); + this.playbackAccumulator -= ticksToAdvance; + + const nextTime = this.frame.currentTime + ticksToAdvance; + if (nextTime > maxTime) { + this.frame.seek(maxTime); + this.playMode = 'paused'; // Auto-pause at end + } else { + this.frame.seek(nextTime); + } + } + } else if (this.playMode === 'subtick') { + // Slower playback for sequence animation (e.g., 4 events per second) + this.playbackAccumulator += dt * 4 * this.speed; + + if (this.playbackAccumulator >= 1.0) { + this.playbackAccumulator = 0; + + if (this.frame.currentIndex >= this.frame.world.events.length) { + this.playMode = 'paused'; // Auto-pause at end + } else { + this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex + 1); + } + } + } else { + this.playbackAccumulator = 0; // Reset accumulator when paused + } + + requestAnimationFrame(this._loop); + } +} \ No newline at end of file diff --git a/world.js b/world.js index bb4f01a3..c0877594 100644 --- a/world.js +++ b/world.js @@ -67,7 +67,7 @@ export class World { } if (cmd === 'l') { - this.labels.set(tokens[1], currentTime); + this.labels.set(tokens[1], this.events.length); continue; } @@ -168,8 +168,10 @@ export class WorldFrame { let targetTime = target; if (typeof target === 'string') { - targetTime = this.world.labels.get(target); - if (targetTime === undefined) throw new Error(`Label not found: ${target}`); + const absoluteIdx = this.world.labels.get(target); + if (absoluteIdx === undefined) throw new Error(`Label not found: ${target}`); + this.seekIndex(absoluteIdx - this.world.initialIndex); + return; } let changed = false;