From a1af83b39264d31783b56e9174b7cbffdce6092f Mon Sep 17 00:00:00 2001 From: Ladd Date: Sat, 27 Dec 2025 16:36:55 -0600 Subject: [PATCH] latest developments. works dang decent --- config.js | 33 +++++++-- display.js | 63 ++++++++++++++--- index.html | 6 +- object.js | 7 +- objects.js | 168 +++++++++++++++++++++++++++++++++++---------- overlay.js | 1 + pointer.js | 68 ++++++++++-------- simulator.js | 35 +++++++--- tool/modes.js | 50 ++++++++++---- tool/options.js | 87 +++++++++++++++++++++++ tool/play-pause.js | 1 + tool/zoom.js | 44 +++++++----- toolbar.js | 2 +- 13 files changed, 438 insertions(+), 127 deletions(-) create mode 100644 tool/options.js diff --git a/config.js b/config.js index 78e39d0..9c7ab36 100644 --- a/config.js +++ b/config.js @@ -1,3 +1,4 @@ +// DISPLAY export const DISPLAY_OBJECTS_INFO = false; export const DISPLAY_CURSOR_INFO = false; export const DISPLAY_CANVAS_SIZE = false; @@ -5,36 +6,54 @@ export const DISPLAY_CURRENT_SCALE = true; export const DISPLAY_CURRENT_MODE = false; export const DISPLAY_VELOCITY_VECTORS = true; export const DISPLAY_ACCELERATION_VECTORS = true; +export const DISPLAY_PATH_TRACES = false; -export const MASS_CREATION_RATE = 1E1; -export const POINTER_HISTORY_SIZE = 15; - +// VELOCITY export const VELOCITY_VECTOR_SCALE = 5E0; export const VELOCITY_VECTOR_COLOR = 'rgb(150, 150, 150)'; // optionally set to 'object color' export const VELOCITY_VECTOR_WIDTH = 1.5; export const VELOCITY_VECTOR_ARROWHEAD = true; +// ACCELERATION export const ACCELERATION_VECTOR_SCALE = 5E0; -export const ACCELERATION_VECTOR_COLOR = 'rgb(0, 255, 0)'; // optionally set to 'object color' -// export const ACCELERATION_VECTOR_COLOR = 'object color'; +export const ACCELERATION_VECTOR_COLOR = 'rgb(0, 128, 0)'; // optionally set to 'object color' export const ACCELERATION_VECTOR_WIDTH = 1.5; export const ACCELERATION_VECTOR_ARROWHEAD = true; -export const MOTION_TIME_SCALE = 1E-4; -export const PAN_VELOCITY_SCALE_FACTOR = 1E-3; +// PATH TRACES +// export const PATH_TRACES_COLOR = 'rgb(128, 128, 0)'; // optionally set to 'object color' +export const PATH_TRACES_COLOR = 'object color'; +export const PATH_TRACES_WIDTH = 1.5; +// SCALING FACTORS +export const MASS_CREATION_RATE = 1E1; +export const POINTER_HISTORY_SIZE = 15; +export const MOTION_TIME_SCALE = 1E-4; +export const PAN_ACCELERATION = 1E1; export const ARROWHEAD_LENGTH = 7; export const ARROWHEAD_WIDTH = 5; export const OFFSCREEN_OBJECT_LINE_SCALE = 7; export const OFFSCREEN_OBJECT_LINE_WIDTH = 2; export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15; +export const ZOOM_TO_FIT_PADDING = 50; export const ZOOM_IN_FACTOR = 1; export const ZOOM_OUT_FACTOR = -1; export const SCALE_POWER_MAX = 8; export const SCALE_POWER_MIN = -8; export const GRAVITATIONAL_CONSTANT = 1E5; +// CSS CLASS NAMES export const DRAGGABLE_ELEMENT_CLASSNAME = 'lhg-draggable-element'; +// EVENT NAMES +export const EVENT_MODE_LEAVE = 'lhg-mode-leave'; +export const EVENT_MODE_ENTER = 'lhg-mode-enter'; + +// MODES export const MODE_MASS_GENERATION = 'mass-gen'; export const MODE_PAN_VIEW = 'pan-view'; + +// OPTIONS +export const PAUSE_DURING_CREATION = true; +export const PAUSE_DURING_SELECTION = true; +export const MERGE_ON_COLLIDE = true; diff --git a/display.js b/display.js index c43d707..590698a 100644 --- a/display.js +++ b/display.js @@ -13,8 +13,8 @@ import { OFFSCREEN_OBJECT_LINE_WIDTH, OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, DISPLAY_CANVAS_SIZE, - DISPLAY_VELOCITY_VECTORS, - DISPLAY_ACCELERATION_VECTORS, + PATH_TRACES_COLOR, + PATH_TRACES_WIDTH, } from './config.js'; export class Display { @@ -63,6 +63,13 @@ export class Display { }; } + simToScreen(x, y) { + return { + x: (x - this.viewOrigin.x) * this.scale, + y: (y - this.viewOrigin.y) * this.scale, + }; + } + fullscreen() { this.canvas.width = document.documentElement.clientWidth; this.canvas.height = document.documentElement.clientHeight; @@ -77,8 +84,7 @@ export class Display { ctx.fillRect(this.viewOrigin.x, this.viewOrigin.y, this.width, this.height); } - drawObject(idx) { - const obj = this.sim.objects.object(idx); + drawObject(obj) { const ctx = this.ctx; const {r, g, b} = obj.color; const {x, y} = obj.position; @@ -90,6 +96,47 @@ export class Display { const oy = this.viewOrigin.y; const cx = ox + W / 2; const cy = oy + H / 2; + + // Draw path traces + if (this.sim.getOption('display.traces') && obj.history?.length) { + ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ? + `rgb(${r}, ${g}, ${b})` : PATH_TRACES_COLOR; + ctx.lineWidth = PATH_TRACES_WIDTH / this.scale; + ctx.beginPath(); + let dash = false; + const skip = 1; + let skipped = 0; + { + const [{position}] = obj.history; + const x = position.x; + const y = position.y; + + if (Math.abs(x - cx) <= W / 2 && + Math.abs(y - cy) <= H / 2) { + ctx.moveTo(x, y); + dash = true; + } + } + for (let i = 1; i < obj.history.length; i++) { + if (++skipped < skip) continue; + skipped = 0; + const {position} = obj.history[i]; + const x = position.x; + const y = position.y; + if (dash) { + ctx.lineTo(x, y); + dash = false; + } else if (Math.abs(x - cx) <= W / 2 && + Math.abs(y - cy) <= H / 2) { + ctx.moveTo(x, y); + dash = true; + } + } + ctx.stroke(); + } + + if (!obj.alive) return; + // If the object is outside the display area, draw an arrow at the edge of the display if (Math.abs(x - cx) - radius >= W / 2 || Math.abs(y - cy) - radius >= H / 2) { @@ -146,7 +193,7 @@ export class Display { ctx.fill(); // Draw arrow for the velocity - if (DISPLAY_VELOCITY_VECTORS) { + if (this.sim.getOption('display.velocity')) { const speed = Math.sqrt(vx ** 2 + vy ** 2); const endVx = x + VELOCITY_VECTOR_SCALE * vx / speed * Math.log(speed); const endVy = y + VELOCITY_VECTOR_SCALE * vy / speed * Math.log(speed); @@ -162,7 +209,7 @@ export class Display { } // Draw arrow for acceleration - if (DISPLAY_ACCELERATION_VECTORS) { + if (this.sim.getOption('display.acceleration')) { const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2); const endAx = x + ACCELERATION_VECTOR_SCALE * acceleration.x / accelerationMagnitude * Math.log(accelerationMagnitude); @@ -181,9 +228,7 @@ export class Display { } drawObjects() { - for (let i = 0; i < this.sim.objects.length; i++) { - this.drawObject(i); - } + this.sim.objects.forEachObject(obj => this.drawObject(obj), null); } drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) { diff --git a/index.html b/index.html index a5c355a..8ce8cfb 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ body { background-color: #000; color: #8f8; font-family: monospace; - font-size: 16pt; + font-size: 12pt; overflow: hidden; user-select: none; -webkit-user-select: none; @@ -24,7 +24,9 @@ div[id=simulator] { button { width: 8em; padding-left: 0.5em; - padding-left: 0.5em; + padding-right: 0.5em; + padding-top: 0; + padding-bottom: 0; } button.wide { diff --git a/object.js b/object.js index 3b4f22b..d05daab 100644 --- a/object.js +++ b/object.js @@ -1,5 +1,4 @@ export class MassObject { - index = undefined; mass = 0; density = 1; position = {x: undefined, y: undefined}; @@ -8,10 +7,10 @@ export class MassObject { color = {r: undefined, g: undefined, b: undefined}; created = undefined; forces = []; // [{x, y}] - active = false; + history = []; + alive = true; - constructor(x, y, index) { - this.index = index; + constructor(x, y) { this.position.x = x; this.position.y = y; this.color.r = Math.random() * 256; diff --git a/objects.js b/objects.js index f2efac5..620a96f 100644 --- a/objects.js +++ b/objects.js @@ -4,30 +4,62 @@ import { DISPLAY_OBJECTS_INFO, MOTION_TIME_SCALE, GRAVITATIONAL_CONSTANT, + ZOOM_TO_FIT_PADDING, } from './config.js'; export class Objects { objects = []; creatingObject = undefined; selectedObject = undefined; + paused = false; + panVelocityPaused = undefined; constructor(sim) { this.sim = sim; } + pause() { + if (this.sim.playing) { + this.sim.playing = false; + this.paused = true; + if (this.sim.pointer.panning?.velocity) { + this.panVelocityPaused = {...this.sim.pointer.panning.velocity}; + this.sim.pointer.panning = undefined; + } + } + } + + resume() { + if (this.paused) { + this.sim.playing = true; + this.paused = false; + if (this.panVelocityPaused) { + this.sim.pointer.panning = { + velocity: {...this.panVelocityPaused}, + }; + this.panVelocityPaused = undefined; + } + } + } + // Create an object with mass that grows as pointer is held down createObject(x, y) { const idx = this.objects.length; const obj = new MassObject(x, y, idx); this.creatingObject = idx; this.objects.push(obj); + // Pause the simulation during mass creation; this avoids some complex local dynamics + // TODO: Make this optional (toolbar item to enable) + if (this.sim.getOption('pauseDuring.creation')) { + this.pause(); + } } doneCreatingObject() { if (this.creatingObject !== undefined) { const obj = this.objects[this.creatingObject]; - obj.active = true; this.creatingObject = undefined; + this.resume(); } } @@ -37,10 +69,14 @@ export class Objects { selectObject(i) { this.selectedObject = i; + if (this.sim.getOption('pauseDuring.selection')) { + this.pause(); + } } deselect() { this.selectedObject = undefined; + this.resume(); } getSelectedOrCreating() { @@ -59,8 +95,7 @@ export class Objects { start: {x: undefined, y: undefined}, end: {x: undefined, y: undefined}, }; - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; + this.forEachObject(obj => { if (box.start.x === undefined) { box.start = {...obj.position}; box.end = {...obj.position}; @@ -70,19 +105,25 @@ export class Objects { if (obj.position.y < box.start.y) box.start.y = obj.position.y; if (obj.position.y > box.end.y) box.end.y = obj.position.y; } - } + }); + box.start.x -= ZOOM_TO_FIT_PADDING; + box.start.y -= ZOOM_TO_FIT_PADDING; + box.end.x += ZOOM_TO_FIT_PADDING; + box.end.y += ZOOM_TO_FIT_PADDING; return box; } objectAtLocation(x, y) { - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; + let idx = undefined; + this.forEachObject((obj, i) => { // If distance to object is less than object's radius, we are touching the object const dist = Math.pow((obj.position.x - x)**2 + (obj.position.y - y)**2, 1/2); if (dist <= obj.radius) { - return i; + idx = i; + return null; } - } + }); + return idx; } handlePointerDown({x, y}) { @@ -106,6 +147,10 @@ export class Objects { // If the cursor moves while creating an object, or while an object is selected, // update the position and velocity of the object const obj = this.getSelectedOrCreating(); + if (this.sim.pointer.panning?.velocity) { + vx += this.sim.pointer.panning.velocity.x; + vy += this.sim.pointer.panning.velocity.y; + } if (obj !== undefined) { obj.position.x = x; obj.position.y = y; @@ -114,16 +159,25 @@ export class Objects { } } + // cb: (obj, idx) => {} + // TODO: Reducer + forEachObject(cb, alive = true, startWith = 0) { + for (let i = startWith; i < this.objects.length; i++) { + const obj = this.objects[i]; + if (alive === null || alive == obj.alive) { + const ret = cb(obj, i); + if (ret === null) break; + } + } + } + computeForces() { if (this.objects.length < 2) return; - for (let i = 0; i < this.objects.length; i++) { - this.objects[i].forces = []; - } - for (let i = 0; i < this.objects.length; i++) { - const A = this.objects[i]; - for (let j = i + 1; j < this.objects.length; j++) { - const B = this.objects[j]; - if (!A.active || !B.active) continue; + this.forEachObject(obj => { + obj.forces = []; + }); + this.forEachObject((A, i) => { + this.forEachObject(B => { const dx = (B.position.x - A.position.x); const dy = (B.position.y - A.position.y); const dSquared = dx ** 2 + dy ** 2; @@ -133,12 +187,15 @@ export class Objects { const Fy = F * dy / d; A.forces.push({ x: Fx, y: Fy }); B.forces.push({ x: -Fx, y: -Fy }); - } - } + }, true, i + 1); + }); + // Also compute acceleration + this.forEachObject(obj => { + obj.acceleration = obj.getAcceleration(); + }); } computeFrame(elapsedTime) { - elapsedTime = elapsedTime * MOTION_TIME_SCALE; // If we're creating an object, increment its mass // with the mass creation rate accelerating over time if (this.creatingObject !== undefined) { @@ -151,51 +208,86 @@ export class Objects { this.computeForces(); if (this.sim.playing) { - // Generate predicted positions (Velocity verlet method) - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; - obj.currentAcceleration = obj.getAcceleration(); + // Predict positions (Velocity verlet method) + this.forEachObject(obj => { + obj.currentAcceleration = {...obj.acceleration}; + obj.position.x += elapsedTime * (obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime); obj.position.y += elapsedTime * (obj.velocity.y + 1/2 * obj.currentAcceleration.y * elapsedTime); + }); + + // Collisions + if (this.sim.getOption('collision.merge')) { + this.forEachObject((A, i) => { + this.forEachObject((B, j) => { + const dx = (B.position.x - A.position.x); + const dy = (B.position.y - A.position.y); + const dSquared = dx ** 2 + dy ** 2; + const d = Math.sqrt(dSquared); + if (d < A.radius + B.radius) { + // Merge B into A: + // Set position = center of mass + // Set velocity = total momentum / total mass + // Combine forces + // Add masses + // Average color + // If B was selected or being created, select A instead + // Remove B using splice + A.position.x = (A.position.x * A.mass + B.position.x * B.mass) / (A.mass + B.mass); + A.position.y = (A.position.y * A.mass + B.position.y * B.mass) / (A.mass + B.mass); + A.velocity.x = (A.velocity.x * A.mass + B.velocity.x * B.mass) / (A.mass + B.mass); + A.velocity.y = (A.velocity.y * A.mass + B.velocity.y * B.mass) / (A.mass + B.mass); + A.forces.push(...B.forces); + A.mass += B.mass; + A.color = { + r: (A.mass * A.color.r + B.mass * B.color.r) / (A.mass + B.mass), + g: (A.mass * A.color.g + B.mass * B.color.g) / (A.mass + B.mass), + b: (A.mass * A.color.b + B.mass * B.color.b) / (A.mass + B.mass), + }; + if (this.creatingObject === j) this.creatingObject = i; + if (this.selectedObject === j) this.selectedObject = i; + B.alive = false; + B.forces = []; + } + }, true, i + 1); + }); } // Recompute forces this.computeForces(); - // Generated predicted velocities - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; - const acceleration = obj.getAcceleration(); + // Predict velocities + this.forEachObject(obj => { + const acceleration = {...obj.acceleration}; obj.acceleration = { x: (obj.currentAcceleration.x + acceleration.x) / 2, y: (obj.currentAcceleration.y + acceleration.y) / 2, }; obj.velocity.x += obj.acceleration.x * elapsedTime; obj.velocity.y += obj.acceleration.y * elapsedTime; - } - } else { - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; - obj.acceleration = obj.getAcceleration(); - } + + // Append to object history + // TODO: enforce object history length + // TODO: store object color changes in history + obj.history.push({position: {...obj.position}}); + }); } // Display objects info if (DISPLAY_OBJECTS_INFO) { - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; + this.forEachObject((obj, i) => { const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1/2); // Invert y so that the angle is counterclockwise from x-axis const direction = Math.atan2(-obj.velocity.y, obj.velocity.x) * 180 / Math.PI; this.sim.info[`Object ${i}`] = [ - `${obj.position.x}, `, - `${obj.position.y}, `, + `${obj.position.x.toPrecision(6)}, `, + `${obj.position.y.toPrecision(6)}, `, `${obj.mass.toPrecision(6)} kg, `, `${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`, ]; - } + }); } } } diff --git a/overlay.js b/overlay.js index cbb46b9..52ca549 100644 --- a/overlay.js +++ b/overlay.js @@ -10,6 +10,7 @@ export class Overlay { this.sim.div.appendChild(infoBox); this.infoBox = infoBox; infoBox.style.position = 'relative'; + infoBox.style.display = 'inline-block'; infoBox.style.top = 0; infoBox.style.left = 0; infoBox.width = 'fit-content'; diff --git a/pointer.js b/pointer.js index 5eb9341..8a2c78f 100644 --- a/pointer.js +++ b/pointer.js @@ -6,7 +6,6 @@ import { DRAGGABLE_ELEMENT_CLASSNAME, MODE_MASS_GENERATION, MODE_PAN_VIEW, - PAN_VELOCITY_SCALE_FACTOR, } from './config.js'; function dispatchEvent(target, eventType, data) { @@ -85,10 +84,10 @@ export class Pointer { // Monitor wheel events el.addEventListener('wheel', e => { - const factor = e.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; - this.sim.scheduleZoom({x: e.clientX, y: e.clientY}, factor); + const factor = e.deltaY < 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; + const {x, y} = this.sim.screenToSim(e.clientX, e.clientY); + this.sim.scheduleZoom({x, y}, factor); }); - } getPointerVelocity() { @@ -99,9 +98,25 @@ export class Pointer { const start = this.pointerHistory[0]; const end = this.pointerHistory[this.pointerHistory.length - 1]; const dt = (end.t - start.t) / 1000; + // Bonus scale factor for pointer power return { - x: (end.x - start.x) / dt, - y: (end.y - start.y) / dt, + x: (end.x - start.x) / dt * this.sim.display.scale, + y: (end.y - start.y) / dt * this.sim.display.scale, + dt + }; + } + + getPointerAcceleration() { + // Average over pointer history + if (this.pointerHistory.length < 2) { + return {x: 0, y: 0, dt: 1}; + } + const start = this.pointerHistory[0]; + const end = this.pointerHistory[this.pointerHistory.length - 1]; + const dt = (end.t - start.t) / 1000; + return { + x: (end.v.x - start.v.x) / dt, + y: (end.v.y - start.v.y) / dt, dt }; } @@ -112,25 +127,25 @@ export class Pointer { updatePointer({x, y}) { const t = document.timeline.currentTime; - this.pointerHistory.push({x, y, t}); - if (this.pointerHistory.length > POINTER_HISTORY_SIZE) { + while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) { this.pointerHistory.shift(); } + const v = this.getPointerVelocity(); + const a = this.getPointerAcceleration(); + this.pointerHistory.push({t, x, y, v, a}); } handlePointerDown({x: clientX, y: clientY}) { + this.updatePointer({x: clientX, y: clientY}); if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) { + // this.panning = undefined; const {x, y} = this.sim.screenToSim(clientX, clientY) this.sim.objects.handlePointerDown({x, y}); } else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { - this.panning = { - gathering: true, - viewOriginStart: this.sim.display.viewOrigin, - pointerStart: {x: clientX, y: clientY}, - pointerCurrent: {x: clientX, y: clientY}, - velocity: this.getPointerVelocity(), - }; + this.panning = this.panning || {}; + this.panning.gathering = true; + this.panning.velocity = {x: 0, y: 0}; } } @@ -163,8 +178,12 @@ export class Pointer { } else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { if (this.panning?.gathering) { this.updatePointer({ x: clientX, y: clientY }); - this.panning.pointerCurrent = {x: clientX, y: clientY}; - this.panning.velocity = this.getPointerVelocity(); + const velocity = this.getPointerVelocity(); + const acceleration = this.getPointerAcceleration(); + // Convet to sim coordinates + // Let's try incorporating pointer acceleration + this.panning.velocity.x = velocity.x + acceleration.x * velocity.dt; + this.panning.velocity.y = velocity.y + acceleration.y * velocity.dt; } } } @@ -177,20 +196,11 @@ export class Pointer { } // Apply update to viewOrigin based on panning - if (!this.sim.isCurrentMode(MODE_PAN_VIEW)) { - this.panning = undefined; - return; - } if (this.panning) { - const {pointerStart, pointerCurrent, viewOriginStart, velocity} = this.sim.pointer.panning; + const {velocity} = this.panning; // Convert pointer velocity to sim internal scale - const vx = velocity.x / this.sim.display.scale; - const vy = velocity.y / this.sim.display.scale; - this.panning.viewOriginStart = this.sim.display.viewOrigin; - this.panning.pointerStart = pointerCurrent; - // const speed = Math.sqrt(velocity.x ** 2 + velocity.y ** 2); - this.sim.display.viewOrigin.x -= vx * elapsedTime * PAN_VELOCITY_SCALE_FACTOR; - this.sim.display.viewOrigin.y -= vy * elapsedTime * PAN_VELOCITY_SCALE_FACTOR; + this.sim.display.viewOrigin.x -= velocity.x * elapsedTime; + this.sim.display.viewOrigin.y -= velocity.y * elapsedTime; } } } diff --git a/simulator.js b/simulator.js index c500e81..fb50ad0 100644 --- a/simulator.js +++ b/simulator.js @@ -6,11 +6,14 @@ import { Toolbar } from './toolbar.js'; import { PlayPause } from './tool/play-pause.js'; import { Zoom } from './tool/zoom.js'; import { ModeSwitch } from './tool/modes.js'; +import { Options } from './tool/options.js'; import { SCALE_POWER_MAX, SCALE_POWER_MIN, DISPLAY_CURRENT_SCALE, DISPLAY_CURRENT_MODE, + MOTION_TIME_SCALE, + MODE_PAN_VIEW, } from './config.js'; export class Sim { @@ -25,6 +28,7 @@ export class Sim { objects = undefined; isCurrentMode = () => false; + getOption = () => undefined; init(divId) { this.divId = divId; @@ -34,13 +38,15 @@ export class Sim { this.display = new Display(this); this.overlay = new Overlay(this); this.objects = new Objects(this); - this.pointer = new Pointer(this); this.toolbar = new Toolbar(this); // Set up toolbar this.toolbar.addTool(new Zoom(this.toolbar)); this.toolbar.addTool(new PlayPause(this.toolbar)); this.toolbar.addTool(new ModeSwitch(this.toolbar)); + this.toolbar.addTool(new Options(this.toolbar)); + + this.pointer = new Pointer(this); // Initiate main loop this.time = document.timeline.currentTime; @@ -49,16 +55,19 @@ export class Sim { // It's better not to change the scale in the middle of possible frame calculations, // so use this to schedule it and let the event loop pick it up. - scheduleZoom({x, y}, factor) { - this.nextZoom = {x, y, factor}; + // velocity should be in Sim coordinate scale + scheduleZoom({x, y}, factor, velocity) { + this.nextZoom = {x, y, factor, velocity}; } - zoom({x: screenX, y: screenY, factor}) { - const {x, y} = this.screenToSim(screenX, screenY); + // x, y should be in Sim coordinates + // velocity should be in Sim coordinate scale + zoom({x, y, factor, velocity}) { // x, y are the mouse coordinates, which should be the center of the new view frame // the new view origin should be x, y minus half the new view width and height // compute new scale this.display.scalePower += factor; + // TODO: Lossy rescaling to expand zoom range if (this.display.scalePower > SCALE_POWER_MAX) this.display.scalePower = SCALE_POWER_MAX; if (this.display.scalePower < SCALE_POWER_MIN) this.display.scalePower = SCALE_POWER_MIN; // compute coordinates of new view frame @@ -66,9 +75,17 @@ export class Sim { this.display.viewOrigin.y = y - this.display.height / 2; this.pointer.clearPointerHistory(); - if (this.pointer.panning) { - this.pointer.panning = undefined; - // TODO: Maybe rescale velocity + + if (this.playing && velocity) { + // Switch to pan mode + this.setCurrentMode(MODE_PAN_VIEW); + + this.pointer.panning = { + velocity: { + x: -velocity.x, + y: -velocity.y, + } + }; } } @@ -87,7 +104,7 @@ export class Sim { // Main loop loop(currentTime) { - const elapsedTime = currentTime - this.time; + const elapsedTime = (currentTime - this.time) * MOTION_TIME_SCALE; this.time = currentTime; if (DISPLAY_CURRENT_MODE) { diff --git a/tool/modes.js b/tool/modes.js index d4ec7de..4e9a6b7 100644 --- a/tool/modes.js +++ b/tool/modes.js @@ -3,6 +3,8 @@ import { Tool } from '../tool.js'; import { MODE_MASS_GENERATION, MODE_PAN_VIEW, + EVENT_MODE_LEAVE, + EVENT_MODE_ENTER, } from '../config.js'; export class ModeSwitch extends Tool { @@ -16,9 +18,6 @@ export class ModeSwitch extends Tool { constructor(toolbar) { super(toolbar); - const [[currentModeID, _]] = this.modes; - this.currentMode = currentModeID; - const modesDiv = document.createElement('div'); const titleDiv = document.createElement('div'); @@ -41,18 +40,19 @@ export class ModeSwitch extends Tool { button.innerHTML = `

${modeTitle}

`; button.classList.add('wide'); - button.addEventListener('click', (e) => { - if (this.currentMode !== modeID) { - this.currentMode = modeID; - this.setModesOpacity(); - } - }); + button.addEventListener('click', (e) => this.setCurrentMode(modeID)); } - this.setModesOpacity(); - // Add global method to get current mode / check mode + // First listed mode is the default + const [[currentModeID, _]] = this.modes; + this.setCurrentMode(currentModeID); + + // Add global method to set/get current mode + this.sim.setCurrentMode = (modeID) => this.setCurrentMode(modeID); this.sim.getCurrentMode = () => this.currentMode; this.sim.isCurrentMode = (modeID) => modeID === this.currentMode; + this.sim.onModeLeave = (modeID, cb) => this.onModeLeave(modeID, cb); + this.sim.onModeEnter = (modeID, cb) => this.onModeEnter(modeID, cb); } setModesOpacity() { @@ -60,7 +60,33 @@ export class ModeSwitch extends Tool { button.style.opacity = button.modeID === this.currentMode ? '50%' : '100%'; } } + + setCurrentMode(modeID) { + if (modeID === this.currentMode) return; + const leave = new CustomEvent(EVENT_MODE_LEAVE, {detail: {modeID: this.currentMode}}); + const enter = new CustomEvent(EVENT_MODE_LEAVE, {detail: {modeID}}); + this.currentMode = modeID; + this.setModesOpacity(); + this.div.dispatchEvent(leave); + this.div.dispatchEvent(enter); + } - // TODO: on enter / on leave mode / some sort of callbacks on mode transitions + // cb: () => {} + onModeLeave(modeID, cb) { + this.div.addEventListener(EVENT_MODE_LEAVE, (e) => { + if (e.detail?.modeID === modeID) { + cb(); + } + }); + } + + // cb: () => {} + onModeEnter(modeID, cb) { + this.div.addEventListener(EVENT_MODE_ENTER, (e) => { + if (e.detail?.modeID === modeID) { + cb(); + } + }); + } } diff --git a/tool/options.js b/tool/options.js new file mode 100644 index 0000000..9822602 --- /dev/null +++ b/tool/options.js @@ -0,0 +1,87 @@ +// Options picker +import { Tool } from '../tool.js'; +import { + DISPLAY_ACCELERATION_VECTORS, + DISPLAY_VELOCITY_VECTORS, + PAUSE_DURING_CREATION, + PAUSE_DURING_SELECTION, + MERGE_ON_COLLIDE, +} from '../config.js'; + +export class Options extends Tool { + options = [{ + type: 'group', name: 'pauseDuring', title: 'Pause During', + items: [ + { type: 'boolean', name: 'creation', title: 'Creation', default: PAUSE_DURING_CREATION }, + { type: 'boolean', name: 'selection', title: 'Selection', default: PAUSE_DURING_SELECTION }, + ]}, { + type: 'group', name: 'display', title: 'Display', + items: [ + { type: 'boolean', name: 'velocity', title: 'Velocity', default: DISPLAY_VELOCITY_VECTORS }, + { type: 'boolean', name: 'acceleration', title: 'Acceleration', default: DISPLAY_ACCELERATION_VECTORS }, + { type: 'boolean', name: 'traces', title: 'Trace Path', default: DISPLAY_ACCELERATION_VECTORS }, + ]}, { + type: 'group', name: 'collision', title: 'Collisions', + items: [ + { type: 'boolean', name: 'merge', title: 'Merge', default: MERGE_ON_COLLIDE }, + ]}, + ]; + + values = {}; + + visitItem(item, path) { + path = [path, item.name].filter(x => !!x).join('.'); + switch (item.type) { + case 'group': { + const group = document.createElement('div'); + const heading = document.createElement('h3'); + heading.innerHTML = item.title; + group.appendChild(heading); + for (const next of item.items) { + const child = this.visitItem(next, path); + group.appendChild(child); + } + return group; + } + case 'boolean': { + const button = document.createElement('button'); + button.innerHTML = `

${item.title}

`; + this.setOption(path, item.default); + button.style.opacity = this.values[path] ? '100%' : '50%'; + button.addEventListener('click', () => { + this.setOption(path, !this.getOption(path)); + button.style.opacity = this.values[path] ? '100%' : '50%'; + }); + return button; + } + default: + throw new Error('unknown option type'); + } + } + + constructor(toolbar) { + super(toolbar); + const heading = document.createElement('h2'); + heading.innerHTML = 'Options'; + this.div.appendChild(heading); + this.div.style.paddingTop = '0px'; + for (const item of this.options) { + const child = this.visitItem(item); + this.div.appendChild(child); + } + + // Global methods to get/set current option values + this.sim.getOption = (path) => this.getOption(path); + this.sim.setOption = (path, value) => this.setOption(path, value); + } + + getOption(path) { + const val = this.values[path]; + return val; + } + + setOption(path, value) { + this.values[path] = value; + // TODO Update button opacity in case this is called from elsewhere + } +} diff --git a/tool/play-pause.js b/tool/play-pause.js index 6acc896..ac88565 100644 --- a/tool/play-pause.js +++ b/tool/play-pause.js @@ -21,6 +21,7 @@ export class PlayPause extends Tool { pauseButton.addEventListener('click', (e) => { e.stopPropagation(); + this.sim.pointer.panning = undefined; if (this.playing) { this.playing = false; pauseButton.style.opacity = '50%'; diff --git a/tool/zoom.js b/tool/zoom.js index cb671ea..a1008dc 100644 --- a/tool/zoom.js +++ b/tool/zoom.js @@ -16,6 +16,7 @@ export class Zoom extends Tool { this.div.appendChild(zoomIn); this.div.appendChild(document.createElement('br')); this.div.appendChild(zoomAll); + this.div.appendChild(document.createElement('br')); zoomAll.classList.add('wide'); @@ -23,37 +24,48 @@ export class Zoom extends Tool { zoomIn.innerHTML = '

Zoom
In

'; zoomAll.innerHTML = '

Zoom to Fit

'; - zoomOut.addEventListener('click', (e) => { + zoomOut.addEventListener('click', () => { // Aim at center of view const x = this.sim.display.width * this.sim.display.scale / 2; const y = this.sim.display.height * this.sim.display.scale / 2; - this.sim.scheduleZoom({x, y}, ZOOM_OUT_FACTOR); + this.sim.scheduleZoom(this.sim.screenToSim(x, y), ZOOM_OUT_FACTOR); }); - zoomIn.addEventListener('click', (e) => { + zoomIn.addEventListener('click', () => { // Aim at center of view const x = this.sim.display.width * this.sim.display.scale / 2; const y = this.sim.display.height * this.sim.display.scale / 2; - this.sim.scheduleZoom({x, y}, ZOOM_IN_FACTOR); + this.sim.scheduleZoom(this.sim.screenToSim(x, y), ZOOM_IN_FACTOR); }); - zoomAll.addEventListener('click', (e) => { + zoomAll.addEventListener('click', () => { // Determine bounding box const box = this.sim.objects.boundingBox; const x = (box.start.x + box.end.x) / 2; const y = (box.start.y + box.end.y) / 2; - if (box.start.x !== box.end.x && box.start.y !== box.end.y) { - const widthRatio = Math.abs(box.start.x - box.end.x) / this.sim.display.width; - const heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height; - const biggerRatio = Math.max(widthRatio, heightRatio); - if (biggerRatio <= 1) { - const base2Ratio = Math.log(1/biggerRatio) / Math.log(2); - this.sim.scheduleZoom({x, y}, Math.floor(base2Ratio)); - } else { - const base2Ratio = Math.log(1/biggerRatio) / Math.log(2); - this.sim.scheduleZoom({x, y}, Math.ceil(base2Ratio)); - } + const widthRatio = Math.abs(box.start.x - box.end.x) / this.sim.display.width; + const heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height; + const biggerRatio = Math.max(widthRatio, heightRatio); + const base2factor = Math.log(1/biggerRatio) / Math.log(2) - 0.5; + const factor = Math.floor(base2factor); + // Determine average momentum and set panning velocity to match + const netMomentum = {x: 0, y: 0}; + let totalMass = 0; + let count = 0; + this.sim.objects.forEachObject(obj => { + count++; + netMomentum.x += obj.mass * obj.velocity.x; + netMomentum.y += obj.mass * obj.velocity.y; + totalMass += obj.mass; + }); + if (!count) { + return; } + const netVelocity = { + x: netMomentum.x / totalMass, + y: netMomentum.y / totalMass, + }; + this.sim.scheduleZoom({x, y}, factor, netVelocity) }); } } diff --git a/toolbar.js b/toolbar.js index 24e6188..7b37ff6 100644 --- a/toolbar.js +++ b/toolbar.js @@ -10,7 +10,7 @@ export class Toolbar { this.div = div; this.sim.div.appendChild(div); div.style.position = 'relative'; - div.style.width = 'fit-content'; + div.style.width = '20EM'; div.style.top = 0; div.style.left = 0; div.style.zIndex = 2;