latest developments. works dang decent

This commit is contained in:
Ladd 2025-12-27 16:36:55 -06:00
parent b41d6dd488
commit a1af83b392
13 changed files with 438 additions and 127 deletions

View File

@ -1,3 +1,4 @@
// DISPLAY
export const DISPLAY_OBJECTS_INFO = false; export const DISPLAY_OBJECTS_INFO = false;
export const DISPLAY_CURSOR_INFO = false; export const DISPLAY_CURSOR_INFO = false;
export const DISPLAY_CANVAS_SIZE = 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_CURRENT_MODE = false;
export const DISPLAY_VELOCITY_VECTORS = true; export const DISPLAY_VELOCITY_VECTORS = true;
export const DISPLAY_ACCELERATION_VECTORS = true; export const DISPLAY_ACCELERATION_VECTORS = true;
export const DISPLAY_PATH_TRACES = false;
export const MASS_CREATION_RATE = 1E1; // VELOCITY
export const POINTER_HISTORY_SIZE = 15;
export const VELOCITY_VECTOR_SCALE = 5E0; export const VELOCITY_VECTOR_SCALE = 5E0;
export const VELOCITY_VECTOR_COLOR = 'rgb(150, 150, 150)'; // optionally set to 'object color' 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_WIDTH = 1.5;
export const VELOCITY_VECTOR_ARROWHEAD = true; export const VELOCITY_VECTOR_ARROWHEAD = true;
// ACCELERATION
export const ACCELERATION_VECTOR_SCALE = 5E0; 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 = 'rgb(0, 128, 0)'; // optionally set to 'object color'
// export const ACCELERATION_VECTOR_COLOR = 'object color';
export const ACCELERATION_VECTOR_WIDTH = 1.5; export const ACCELERATION_VECTOR_WIDTH = 1.5;
export const ACCELERATION_VECTOR_ARROWHEAD = true; export const ACCELERATION_VECTOR_ARROWHEAD = true;
export const MOTION_TIME_SCALE = 1E-4; // PATH TRACES
export const PAN_VELOCITY_SCALE_FACTOR = 1E-3; // 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_LENGTH = 7;
export const ARROWHEAD_WIDTH = 5; export const ARROWHEAD_WIDTH = 5;
export const OFFSCREEN_OBJECT_LINE_SCALE = 7; export const OFFSCREEN_OBJECT_LINE_SCALE = 7;
export const OFFSCREEN_OBJECT_LINE_WIDTH = 2; export const OFFSCREEN_OBJECT_LINE_WIDTH = 2;
export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15; export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15;
export const ZOOM_TO_FIT_PADDING = 50;
export const ZOOM_IN_FACTOR = 1; export const ZOOM_IN_FACTOR = 1;
export const ZOOM_OUT_FACTOR = -1; export const ZOOM_OUT_FACTOR = -1;
export const SCALE_POWER_MAX = 8; export const SCALE_POWER_MAX = 8;
export const SCALE_POWER_MIN = -8; export const SCALE_POWER_MIN = -8;
export const GRAVITATIONAL_CONSTANT = 1E5; export const GRAVITATIONAL_CONSTANT = 1E5;
// CSS CLASS NAMES
export const DRAGGABLE_ELEMENT_CLASSNAME = 'lhg-draggable-element'; 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_MASS_GENERATION = 'mass-gen';
export const MODE_PAN_VIEW = 'pan-view'; 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;

View File

@ -13,8 +13,8 @@ import {
OFFSCREEN_OBJECT_LINE_WIDTH, OFFSCREEN_OBJECT_LINE_WIDTH,
OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
DISPLAY_CANVAS_SIZE, DISPLAY_CANVAS_SIZE,
DISPLAY_VELOCITY_VECTORS, PATH_TRACES_COLOR,
DISPLAY_ACCELERATION_VECTORS, PATH_TRACES_WIDTH,
} from './config.js'; } from './config.js';
export class Display { 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() { fullscreen() {
this.canvas.width = document.documentElement.clientWidth; this.canvas.width = document.documentElement.clientWidth;
this.canvas.height = document.documentElement.clientHeight; 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); ctx.fillRect(this.viewOrigin.x, this.viewOrigin.y, this.width, this.height);
} }
drawObject(idx) { drawObject(obj) {
const obj = this.sim.objects.object(idx);
const ctx = this.ctx; const ctx = this.ctx;
const {r, g, b} = obj.color; const {r, g, b} = obj.color;
const {x, y} = obj.position; const {x, y} = obj.position;
@ -90,6 +96,47 @@ export class Display {
const oy = this.viewOrigin.y; const oy = this.viewOrigin.y;
const cx = ox + W / 2; const cx = ox + W / 2;
const cy = oy + H / 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 the object is outside the display area, draw an arrow at the edge of the display
if (Math.abs(x - cx) - radius >= W / 2 || if (Math.abs(x - cx) - radius >= W / 2 ||
Math.abs(y - cy) - radius >= H / 2) { Math.abs(y - cy) - radius >= H / 2) {
@ -146,7 +193,7 @@ export class Display {
ctx.fill(); ctx.fill();
// Draw arrow for the velocity // Draw arrow for the velocity
if (DISPLAY_VELOCITY_VECTORS) { if (this.sim.getOption('display.velocity')) {
const speed = Math.sqrt(vx ** 2 + vy ** 2); const speed = Math.sqrt(vx ** 2 + vy ** 2);
const endVx = x + VELOCITY_VECTOR_SCALE * vx / speed * Math.log(speed); const endVx = x + VELOCITY_VECTOR_SCALE * vx / speed * Math.log(speed);
const endVy = y + VELOCITY_VECTOR_SCALE * vy / 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 // 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 accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2);
const endAx = x + ACCELERATION_VECTOR_SCALE * acceleration.x / const endAx = x + ACCELERATION_VECTOR_SCALE * acceleration.x /
accelerationMagnitude * Math.log(accelerationMagnitude); accelerationMagnitude * Math.log(accelerationMagnitude);
@ -181,9 +228,7 @@ export class Display {
} }
drawObjects() { drawObjects() {
for (let i = 0; i < this.sim.objects.length; i++) { this.sim.objects.forEachObject(obj => this.drawObject(obj), null);
this.drawObject(i);
}
} }
drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) { drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) {

View File

@ -8,7 +8,7 @@ body {
background-color: #000; background-color: #000;
color: #8f8; color: #8f8;
font-family: monospace; font-family: monospace;
font-size: 16pt; font-size: 12pt;
overflow: hidden; overflow: hidden;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
@ -24,7 +24,9 @@ div[id=simulator] {
button { button {
width: 8em; width: 8em;
padding-left: 0.5em; padding-left: 0.5em;
padding-left: 0.5em; padding-right: 0.5em;
padding-top: 0;
padding-bottom: 0;
} }
button.wide { button.wide {

View File

@ -1,5 +1,4 @@
export class MassObject { export class MassObject {
index = undefined;
mass = 0; mass = 0;
density = 1; density = 1;
position = {x: undefined, y: undefined}; position = {x: undefined, y: undefined};
@ -8,10 +7,10 @@ export class MassObject {
color = {r: undefined, g: undefined, b: undefined}; color = {r: undefined, g: undefined, b: undefined};
created = undefined; created = undefined;
forces = []; // [{x, y}] forces = []; // [{x, y}]
active = false; history = [];
alive = true;
constructor(x, y, index) { constructor(x, y) {
this.index = index;
this.position.x = x; this.position.x = x;
this.position.y = y; this.position.y = y;
this.color.r = Math.random() * 256; this.color.r = Math.random() * 256;

View File

@ -4,30 +4,62 @@ import {
DISPLAY_OBJECTS_INFO, DISPLAY_OBJECTS_INFO,
MOTION_TIME_SCALE, MOTION_TIME_SCALE,
GRAVITATIONAL_CONSTANT, GRAVITATIONAL_CONSTANT,
ZOOM_TO_FIT_PADDING,
} from './config.js'; } from './config.js';
export class Objects { export class Objects {
objects = []; objects = [];
creatingObject = undefined; creatingObject = undefined;
selectedObject = undefined; selectedObject = undefined;
paused = false;
panVelocityPaused = undefined;
constructor(sim) { constructor(sim) {
this.sim = 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 // Create an object with mass that grows as pointer is held down
createObject(x, y) { createObject(x, y) {
const idx = this.objects.length; const idx = this.objects.length;
const obj = new MassObject(x, y, idx); const obj = new MassObject(x, y, idx);
this.creatingObject = idx; this.creatingObject = idx;
this.objects.push(obj); 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() { doneCreatingObject() {
if (this.creatingObject !== undefined) { if (this.creatingObject !== undefined) {
const obj = this.objects[this.creatingObject]; const obj = this.objects[this.creatingObject];
obj.active = true;
this.creatingObject = undefined; this.creatingObject = undefined;
this.resume();
} }
} }
@ -37,10 +69,14 @@ export class Objects {
selectObject(i) { selectObject(i) {
this.selectedObject = i; this.selectedObject = i;
if (this.sim.getOption('pauseDuring.selection')) {
this.pause();
}
} }
deselect() { deselect() {
this.selectedObject = undefined; this.selectedObject = undefined;
this.resume();
} }
getSelectedOrCreating() { getSelectedOrCreating() {
@ -59,8 +95,7 @@ export class Objects {
start: {x: undefined, y: undefined}, start: {x: undefined, y: undefined},
end: {x: undefined, y: undefined}, end: {x: undefined, y: undefined},
}; };
for (let i = 0; i < this.objects.length; i++) { this.forEachObject(obj => {
const obj = this.objects[i];
if (box.start.x === undefined) { if (box.start.x === undefined) {
box.start = {...obj.position}; box.start = {...obj.position};
box.end = {...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.start.y) box.start.y = obj.position.y;
if (obj.position.y > box.end.y) box.end.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; return box;
} }
objectAtLocation(x, y) { objectAtLocation(x, y) {
for (let i = 0; i < this.objects.length; i++) { let idx = undefined;
const obj = this.objects[i]; this.forEachObject((obj, i) => {
// If distance to object is less than object's radius, we are touching the object // 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); const dist = Math.pow((obj.position.x - x)**2 + (obj.position.y - y)**2, 1/2);
if (dist <= obj.radius) { if (dist <= obj.radius) {
return i; idx = i;
return null;
} }
} });
return idx;
} }
handlePointerDown({x, y}) { handlePointerDown({x, y}) {
@ -106,6 +147,10 @@ export class Objects {
// If the cursor moves while creating an object, or while an object is selected, // If the cursor moves while creating an object, or while an object is selected,
// update the position and velocity of the object // update the position and velocity of the object
const obj = this.getSelectedOrCreating(); 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) { if (obj !== undefined) {
obj.position.x = x; obj.position.x = x;
obj.position.y = y; 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() { computeForces() {
if (this.objects.length < 2) return; if (this.objects.length < 2) return;
for (let i = 0; i < this.objects.length; i++) { this.forEachObject(obj => {
this.objects[i].forces = []; obj.forces = [];
} });
for (let i = 0; i < this.objects.length; i++) { this.forEachObject((A, i) => {
const A = this.objects[i]; this.forEachObject(B => {
for (let j = i + 1; j < this.objects.length; j++) {
const B = this.objects[j];
if (!A.active || !B.active) continue;
const dx = (B.position.x - A.position.x); const dx = (B.position.x - A.position.x);
const dy = (B.position.y - A.position.y); const dy = (B.position.y - A.position.y);
const dSquared = dx ** 2 + dy ** 2; const dSquared = dx ** 2 + dy ** 2;
@ -133,12 +187,15 @@ export class Objects {
const Fy = F * dy / d; const Fy = F * dy / d;
A.forces.push({ x: Fx, y: Fy }); A.forces.push({ x: Fx, y: Fy });
B.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) { computeFrame(elapsedTime) {
elapsedTime = elapsedTime * MOTION_TIME_SCALE;
// If we're creating an object, increment its mass // If we're creating an object, increment its mass
// with the mass creation rate accelerating over time // with the mass creation rate accelerating over time
if (this.creatingObject !== undefined) { if (this.creatingObject !== undefined) {
@ -151,51 +208,86 @@ export class Objects {
this.computeForces(); this.computeForces();
if (this.sim.playing) { if (this.sim.playing) {
// Generate predicted positions (Velocity verlet method) // Predict positions (Velocity verlet method)
for (let i = 0; i < this.objects.length; i++) { this.forEachObject(obj => {
const obj = this.objects[i]; obj.currentAcceleration = {...obj.acceleration};
obj.currentAcceleration = obj.getAcceleration();
obj.position.x += elapsedTime * obj.position.x += elapsedTime *
(obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime); (obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime);
obj.position.y += elapsedTime * obj.position.y += elapsedTime *
(obj.velocity.y + 1/2 * obj.currentAcceleration.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 // Recompute forces
this.computeForces(); this.computeForces();
// Generated predicted velocities // Predict velocities
for (let i = 0; i < this.objects.length; i++) { this.forEachObject(obj => {
const obj = this.objects[i]; const acceleration = {...obj.acceleration};
const acceleration = obj.getAcceleration();
obj.acceleration = { obj.acceleration = {
x: (obj.currentAcceleration.x + acceleration.x) / 2, x: (obj.currentAcceleration.x + acceleration.x) / 2,
y: (obj.currentAcceleration.y + acceleration.y) / 2, y: (obj.currentAcceleration.y + acceleration.y) / 2,
}; };
obj.velocity.x += obj.acceleration.x * elapsedTime; obj.velocity.x += obj.acceleration.x * elapsedTime;
obj.velocity.y += obj.acceleration.y * elapsedTime; obj.velocity.y += obj.acceleration.y * elapsedTime;
}
} else { // Append to object history
for (let i = 0; i < this.objects.length; i++) { // TODO: enforce object history length
const obj = this.objects[i]; // TODO: store object color changes in history
obj.acceleration = obj.getAcceleration(); obj.history.push({position: {...obj.position}});
} });
} }
// Display objects info // Display objects info
if (DISPLAY_OBJECTS_INFO) { if (DISPLAY_OBJECTS_INFO) {
for (let i = 0; i < this.objects.length; i++) { this.forEachObject((obj, i) => {
const obj = this.objects[i];
const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1/2); 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 // Invert y so that the angle is counterclockwise from x-axis
const direction = Math.atan2(-obj.velocity.y, obj.velocity.x) * 180 / Math.PI; const direction = Math.atan2(-obj.velocity.y, obj.velocity.x) * 180 / Math.PI;
this.sim.info[`Object ${i}`] = [ this.sim.info[`Object ${i}`] = [
`${obj.position.x}, `, `${obj.position.x.toPrecision(6)}, `,
`${obj.position.y}, `, `${obj.position.y.toPrecision(6)}, `,
`${obj.mass.toPrecision(6)} kg, `, `${obj.mass.toPrecision(6)} kg, `,
`${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`, `${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`,
]; ];
} });
} }
} }
} }

View File

@ -10,6 +10,7 @@ export class Overlay {
this.sim.div.appendChild(infoBox); this.sim.div.appendChild(infoBox);
this.infoBox = infoBox; this.infoBox = infoBox;
infoBox.style.position = 'relative'; infoBox.style.position = 'relative';
infoBox.style.display = 'inline-block';
infoBox.style.top = 0; infoBox.style.top = 0;
infoBox.style.left = 0; infoBox.style.left = 0;
infoBox.width = 'fit-content'; infoBox.width = 'fit-content';

View File

@ -6,7 +6,6 @@ import {
DRAGGABLE_ELEMENT_CLASSNAME, DRAGGABLE_ELEMENT_CLASSNAME,
MODE_MASS_GENERATION, MODE_MASS_GENERATION,
MODE_PAN_VIEW, MODE_PAN_VIEW,
PAN_VELOCITY_SCALE_FACTOR,
} from './config.js'; } from './config.js';
function dispatchEvent(target, eventType, data) { function dispatchEvent(target, eventType, data) {
@ -85,10 +84,10 @@ export class Pointer {
// Monitor wheel events // Monitor wheel events
el.addEventListener('wheel', e => { el.addEventListener('wheel', e => {
const factor = e.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; const factor = e.deltaY < 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
this.sim.scheduleZoom({x: e.clientX, y: e.clientY}, factor); const {x, y} = this.sim.screenToSim(e.clientX, e.clientY);
this.sim.scheduleZoom({x, y}, factor);
}); });
} }
getPointerVelocity() { getPointerVelocity() {
@ -99,9 +98,25 @@ export class Pointer {
const start = this.pointerHistory[0]; const start = this.pointerHistory[0];
const end = this.pointerHistory[this.pointerHistory.length - 1]; const end = this.pointerHistory[this.pointerHistory.length - 1];
const dt = (end.t - start.t) / 1000; const dt = (end.t - start.t) / 1000;
// Bonus scale factor for pointer power
return { return {
x: (end.x - start.x) / dt, x: (end.x - start.x) / dt * this.sim.display.scale,
y: (end.y - start.y) / dt, 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 dt
}; };
} }
@ -112,25 +127,25 @@ export class Pointer {
updatePointer({x, y}) { updatePointer({x, y}) {
const t = document.timeline.currentTime; const t = document.timeline.currentTime;
this.pointerHistory.push({x, y, t}); while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) {
if (this.pointerHistory.length > POINTER_HISTORY_SIZE) {
this.pointerHistory.shift(); this.pointerHistory.shift();
} }
const v = this.getPointerVelocity();
const a = this.getPointerAcceleration();
this.pointerHistory.push({t, x, y, v, a});
} }
handlePointerDown({x: clientX, y: clientY}) { handlePointerDown({x: clientX, y: clientY}) {
this.updatePointer({x: clientX, y: clientY});
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) { if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
// this.panning = undefined;
const {x, y} = this.sim.screenToSim(clientX, clientY) const {x, y} = this.sim.screenToSim(clientX, clientY)
this.sim.objects.handlePointerDown({x, y}); this.sim.objects.handlePointerDown({x, y});
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { } else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
this.panning = { this.panning = this.panning || {};
gathering: true, this.panning.gathering = true;
viewOriginStart: this.sim.display.viewOrigin, this.panning.velocity = {x: 0, y: 0};
pointerStart: {x: clientX, y: clientY},
pointerCurrent: {x: clientX, y: clientY},
velocity: this.getPointerVelocity(),
};
} }
} }
@ -163,8 +178,12 @@ export class Pointer {
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { } else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
if (this.panning?.gathering) { if (this.panning?.gathering) {
this.updatePointer({ x: clientX, y: clientY }); this.updatePointer({ x: clientX, y: clientY });
this.panning.pointerCurrent = {x: clientX, y: clientY}; const velocity = this.getPointerVelocity();
this.panning.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 // Apply update to viewOrigin based on panning
if (!this.sim.isCurrentMode(MODE_PAN_VIEW)) {
this.panning = undefined;
return;
}
if (this.panning) { if (this.panning) {
const {pointerStart, pointerCurrent, viewOriginStart, velocity} = this.sim.pointer.panning; const {velocity} = this.panning;
// Convert pointer velocity to sim internal scale // Convert pointer velocity to sim internal scale
const vx = velocity.x / this.sim.display.scale; this.sim.display.viewOrigin.x -= velocity.x * elapsedTime;
const vy = velocity.y / this.sim.display.scale; this.sim.display.viewOrigin.y -= velocity.y * elapsedTime;
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;
} }
} }
} }

View File

@ -6,11 +6,14 @@ import { Toolbar } from './toolbar.js';
import { PlayPause } from './tool/play-pause.js'; import { PlayPause } from './tool/play-pause.js';
import { Zoom } from './tool/zoom.js'; import { Zoom } from './tool/zoom.js';
import { ModeSwitch } from './tool/modes.js'; import { ModeSwitch } from './tool/modes.js';
import { Options } from './tool/options.js';
import { import {
SCALE_POWER_MAX, SCALE_POWER_MAX,
SCALE_POWER_MIN, SCALE_POWER_MIN,
DISPLAY_CURRENT_SCALE, DISPLAY_CURRENT_SCALE,
DISPLAY_CURRENT_MODE, DISPLAY_CURRENT_MODE,
MOTION_TIME_SCALE,
MODE_PAN_VIEW,
} from './config.js'; } from './config.js';
export class Sim { export class Sim {
@ -25,6 +28,7 @@ export class Sim {
objects = undefined; objects = undefined;
isCurrentMode = () => false; isCurrentMode = () => false;
getOption = () => undefined;
init(divId) { init(divId) {
this.divId = divId; this.divId = divId;
@ -34,13 +38,15 @@ export class Sim {
this.display = new Display(this); this.display = new Display(this);
this.overlay = new Overlay(this); this.overlay = new Overlay(this);
this.objects = new Objects(this); this.objects = new Objects(this);
this.pointer = new Pointer(this);
this.toolbar = new Toolbar(this); this.toolbar = new Toolbar(this);
// Set up toolbar // Set up toolbar
this.toolbar.addTool(new Zoom(this.toolbar)); this.toolbar.addTool(new Zoom(this.toolbar));
this.toolbar.addTool(new PlayPause(this.toolbar)); this.toolbar.addTool(new PlayPause(this.toolbar));
this.toolbar.addTool(new ModeSwitch(this.toolbar)); this.toolbar.addTool(new ModeSwitch(this.toolbar));
this.toolbar.addTool(new Options(this.toolbar));
this.pointer = new Pointer(this);
// Initiate main loop // Initiate main loop
this.time = document.timeline.currentTime; 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, // 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. // so use this to schedule it and let the event loop pick it up.
scheduleZoom({x, y}, factor) { // velocity should be in Sim coordinate scale
this.nextZoom = {x, y, factor}; scheduleZoom({x, y}, factor, velocity) {
this.nextZoom = {x, y, factor, velocity};
} }
zoom({x: screenX, y: screenY, factor}) { // x, y should be in Sim coordinates
const {x, y} = this.screenToSim(screenX, screenY); // 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 // 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 // the new view origin should be x, y minus half the new view width and height
// compute new scale // compute new scale
this.display.scalePower += factor; 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_MAX) this.display.scalePower = SCALE_POWER_MAX;
if (this.display.scalePower < SCALE_POWER_MIN) this.display.scalePower = SCALE_POWER_MIN; if (this.display.scalePower < SCALE_POWER_MIN) this.display.scalePower = SCALE_POWER_MIN;
// compute coordinates of new view frame // compute coordinates of new view frame
@ -66,9 +75,17 @@ export class Sim {
this.display.viewOrigin.y = y - this.display.height / 2; this.display.viewOrigin.y = y - this.display.height / 2;
this.pointer.clearPointerHistory(); this.pointer.clearPointerHistory();
if (this.pointer.panning) {
this.pointer.panning = undefined; if (this.playing && velocity) {
// TODO: Maybe rescale 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 // Main loop
loop(currentTime) { loop(currentTime) {
const elapsedTime = currentTime - this.time; const elapsedTime = (currentTime - this.time) * MOTION_TIME_SCALE;
this.time = currentTime; this.time = currentTime;
if (DISPLAY_CURRENT_MODE) { if (DISPLAY_CURRENT_MODE) {

View File

@ -3,6 +3,8 @@ import { Tool } from '../tool.js';
import { import {
MODE_MASS_GENERATION, MODE_MASS_GENERATION,
MODE_PAN_VIEW, MODE_PAN_VIEW,
EVENT_MODE_LEAVE,
EVENT_MODE_ENTER,
} from '../config.js'; } from '../config.js';
export class ModeSwitch extends Tool { export class ModeSwitch extends Tool {
@ -16,9 +18,6 @@ export class ModeSwitch extends Tool {
constructor(toolbar) { constructor(toolbar) {
super(toolbar); super(toolbar);
const [[currentModeID, _]] = this.modes;
this.currentMode = currentModeID;
const modesDiv = document.createElement('div'); const modesDiv = document.createElement('div');
const titleDiv = document.createElement('div'); const titleDiv = document.createElement('div');
@ -41,18 +40,19 @@ export class ModeSwitch extends Tool {
button.innerHTML = `<h3>${modeTitle}</h3>`; button.innerHTML = `<h3>${modeTitle}</h3>`;
button.classList.add('wide'); button.classList.add('wide');
button.addEventListener('click', (e) => { button.addEventListener('click', (e) => this.setCurrentMode(modeID));
if (this.currentMode !== modeID) {
this.currentMode = modeID;
this.setModesOpacity();
}
});
} }
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.getCurrentMode = () => this.currentMode;
this.sim.isCurrentMode = (modeID) => modeID === 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() { setModesOpacity() {
@ -61,6 +61,32 @@ export class ModeSwitch extends Tool {
} }
} }
// TODO: on enter / on leave mode / some sort of callbacks on mode transitions 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);
}
// 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();
}
});
}
} }

87
tool/options.js Normal file
View File

@ -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 = `<h4>${item.title}</h4>`;
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
}
}

View File

@ -21,6 +21,7 @@ export class PlayPause extends Tool {
pauseButton.addEventListener('click', (e) => { pauseButton.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.sim.pointer.panning = undefined;
if (this.playing) { if (this.playing) {
this.playing = false; this.playing = false;
pauseButton.style.opacity = '50%'; pauseButton.style.opacity = '50%';

View File

@ -16,6 +16,7 @@ export class Zoom extends Tool {
this.div.appendChild(zoomIn); this.div.appendChild(zoomIn);
this.div.appendChild(document.createElement('br')); this.div.appendChild(document.createElement('br'));
this.div.appendChild(zoomAll); this.div.appendChild(zoomAll);
this.div.appendChild(document.createElement('br'));
zoomAll.classList.add('wide'); zoomAll.classList.add('wide');
@ -23,37 +24,48 @@ export class Zoom extends Tool {
zoomIn.innerHTML = '<h2>Zoom<br>In</h2>'; zoomIn.innerHTML = '<h2>Zoom<br>In</h2>';
zoomAll.innerHTML = '<h2>Zoom to Fit</h2>'; zoomAll.innerHTML = '<h2>Zoom to Fit</h2>';
zoomOut.addEventListener('click', (e) => { zoomOut.addEventListener('click', () => {
// Aim at center of view // Aim at center of view
const x = this.sim.display.width * this.sim.display.scale / 2; const x = this.sim.display.width * this.sim.display.scale / 2;
const y = this.sim.display.height * 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 // Aim at center of view
const x = this.sim.display.width * this.sim.display.scale / 2; const x = this.sim.display.width * this.sim.display.scale / 2;
const y = this.sim.display.height * 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 // Determine bounding box
const box = this.sim.objects.boundingBox; const box = this.sim.objects.boundingBox;
const x = (box.start.x + box.end.x) / 2; const x = (box.start.x + box.end.x) / 2;
const y = (box.start.y + box.end.y) / 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 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 heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height; const biggerRatio = Math.max(widthRatio, heightRatio);
const biggerRatio = Math.max(widthRatio, heightRatio); const base2factor = Math.log(1/biggerRatio) / Math.log(2) - 0.5;
if (biggerRatio <= 1) { const factor = Math.floor(base2factor);
const base2Ratio = Math.log(1/biggerRatio) / Math.log(2); // Determine average momentum and set panning velocity to match
this.sim.scheduleZoom({x, y}, Math.floor(base2Ratio)); const netMomentum = {x: 0, y: 0};
} else { let totalMass = 0;
const base2Ratio = Math.log(1/biggerRatio) / Math.log(2); let count = 0;
this.sim.scheduleZoom({x, y}, Math.ceil(base2Ratio)); 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)
}); });
} }
} }

View File

@ -10,7 +10,7 @@ export class Toolbar {
this.div = div; this.div = div;
this.sim.div.appendChild(div); this.sim.div.appendChild(div);
div.style.position = 'relative'; div.style.position = 'relative';
div.style.width = 'fit-content'; div.style.width = '20EM';
div.style.top = 0; div.style.top = 0;
div.style.left = 0; div.style.left = 0;
div.style.zIndex = 2; div.style.zIndex = 2;