very wip scrubber and camera
This commit is contained in:
144
camera.js
Normal file
144
camera.js
Normal file
@@ -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!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
195
diorama.js
195
diorama.js
@@ -3,6 +3,8 @@ import {mat4Ortho, mat4LookAt, mat4Multiply} from './math.js';
|
|||||||
export class Diorama {
|
export class Diorama {
|
||||||
constructor(elementId, frame, requestRenderCallback) {
|
constructor(elementId, frame, requestRenderCallback) {
|
||||||
this.element = document.getElementById(elementId);
|
this.element = document.getElementById(elementId);
|
||||||
|
this.element.style.position = 'relative'; // todo move to css
|
||||||
|
|
||||||
this.canvas = document.createElement('canvas')
|
this.canvas = document.createElement('canvas')
|
||||||
this.element.appendChild(this.canvas)
|
this.element.appendChild(this.canvas)
|
||||||
this.ctx2d = this.canvas.getContext('2d');
|
this.ctx2d = this.canvas.getContext('2d');
|
||||||
@@ -18,40 +20,7 @@ export class Diorama {
|
|||||||
this.viewMatrix = new Float32Array(16);
|
this.viewMatrix = new Float32Array(16);
|
||||||
this.viewProjMatrix = 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.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() {
|
saveState() {
|
||||||
@@ -129,164 +98,4 @@ export class Diorama {
|
|||||||
|
|
||||||
return this.viewProjMatrix;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
70
index.html
70
index.html
@@ -36,9 +36,7 @@
|
|||||||
|
|
||||||
<div class="figure" id="demo-pulses"></div>
|
<div class="figure" id="demo-pulses"></div>
|
||||||
|
|
||||||
<div class="figure" id="demo-time"></div>
|
<div class="figure" id="demo-piston"></div>
|
||||||
|
|
||||||
<div class="figure" id="demo-state"></div>
|
|
||||||
|
|
||||||
<h2>Static Sub-Figures</h2>
|
<h2>Static Sub-Figures</h2>
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr;">
|
||||||
@@ -53,6 +51,8 @@
|
|||||||
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 {CameraController} from "./camera.js";
|
||||||
|
|
||||||
async function initDocument() {
|
async function initDocument() {
|
||||||
try {
|
try {
|
||||||
@@ -79,6 +79,7 @@
|
|||||||
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 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
|
||||||
@@ -158,48 +159,45 @@
|
|||||||
p 2 -1 0 extended=false
|
p 2 -1 0 extended=false
|
||||||
`);
|
`);
|
||||||
const f_piston_t = new WorldFrame(w_piston);
|
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.radius = 4;
|
||||||
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 f_piston_s = new WorldFrame(w_piston);
|
|
||||||
const d_piston_s = new Diorama('demo-state', f_piston_s, () => engine.requestRender());
|
const s_piston_t = new SubtickScrubber('demo-piston', f_piston_t, d_piston_t)
|
||||||
d_piston_s.theta = 180
|
const c_piston = new CameraController(d_piston_t)
|
||||||
d_piston_s.phi = 0
|
|
||||||
d_piston_s.radius = 4;
|
|
||||||
d_piston_s.centerView();
|
|
||||||
d_piston_s.saveState();
|
|
||||||
engine.addDiorama(d_piston_s);
|
|
||||||
|
|
||||||
// Fetch and build everything concurrently!
|
// Fetch and build everything concurrently!
|
||||||
await engine.updateAll();
|
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;
|
// const factor = 2.5;
|
||||||
let n = w_piston.eventCount;
|
// const update_time = () => {
|
||||||
const update_state = () => {
|
// for (let i = 0; i < 20; i++) {
|
||||||
f_piston_s.seekIndex(i);
|
// setTimeout(() => f_piston_t.seek(i), i * factor * 50)
|
||||||
i = (i + 1) % n;
|
// }
|
||||||
};
|
// };
|
||||||
setInterval(update_state, 250);
|
// setInterval(update_time, factor * 1000)
|
||||||
update_state()
|
// update_time();
|
||||||
|
//
|
||||||
const update_pulses = () => {
|
// let i = 0;
|
||||||
for (let i = 0; i < 8; i++) {
|
// let n = w_piston.eventCount;
|
||||||
setTimeout(() => f_pulses.seek(i), i * factor * 50)
|
// const update_state = () => {
|
||||||
}
|
// f_piston_s.seekIndex(i);
|
||||||
};
|
// i = (i + 1) % n;
|
||||||
setInterval(update_pulses, factor * 8 * 50)
|
// };
|
||||||
update_pulses();
|
// 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) {
|
} catch (err) {
|
||||||
console.error("Renderer Initialization Failed:", err);
|
console.error("Renderer Initialization Failed:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
200
scrubber.js
Normal file
200
scrubber.js
Normal file
@@ -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 = `
|
||||||
|
<button id="btn-play-sub" style="width: 40px;">S-Play</button>
|
||||||
|
<button id="btn-prev-sub"><</button>
|
||||||
|
<div style="flex-grow: 1; position: relative;">
|
||||||
|
<input type="range" id="slider-sub" min="0" max="1" step="1" style="width: 100%;">
|
||||||
|
</div>
|
||||||
|
<button id="btn-next-sub">></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<button id="btn-play" style="width: 40px;">Play</button>
|
||||||
|
<button id="btn-prev"><</button>
|
||||||
|
<div style="flex-grow: 1; position: relative;">
|
||||||
|
<input type="range" id="slider-tick" min="0" max="${Math.max(1, maxTime)}" step="0.1" style="width: 100%;">
|
||||||
|
</div>
|
||||||
|
<button id="btn-next">></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
world.js
8
world.js
@@ -67,7 +67,7 @@ export class World {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cmd === 'l') {
|
if (cmd === 'l') {
|
||||||
this.labels.set(tokens[1], currentTime);
|
this.labels.set(tokens[1], this.events.length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +168,10 @@ export class WorldFrame {
|
|||||||
let targetTime = target;
|
let targetTime = target;
|
||||||
|
|
||||||
if (typeof target === 'string') {
|
if (typeof target === 'string') {
|
||||||
targetTime = this.world.labels.get(target);
|
const absoluteIdx = this.world.labels.get(target);
|
||||||
if (targetTime === undefined) throw new Error(`Label not found: ${target}`);
|
if (absoluteIdx === undefined) throw new Error(`Label not found: ${target}`);
|
||||||
|
this.seekIndex(absoluteIdx - this.world.initialIndex);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user