Compare commits

..

2 Commits

Author SHA1 Message Date
Ladd
e3fe7bd4e4 eslint 2025-12-27 17:10:34 -06:00
Ladd
a1af83b392 latest developments. works dang decent 2025-12-27 16:36:55 -06:00
18 changed files with 1593 additions and 145 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
*.sw[po] *.sw[po]
node_modules/

25
UNLICENSE Normal file
View File

@ -0,0 +1,25 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>

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}) {

7
eslint.config.mjs Normal file
View File

@ -0,0 +1,7 @@
import js from "@eslint/js";
import globals from "globals";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
]);

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

@ -2,32 +2,62 @@ import { MassObject } from './object.js';
import { import {
MASS_CREATION_RATE, MASS_CREATION_RATE,
DISPLAY_OBJECTS_INFO, DISPLAY_OBJECTS_INFO,
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];
obj.active = true;
this.creatingObject = undefined; this.creatingObject = undefined;
this.resume();
} }
} }
@ -37,10 +67,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 +93,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 +103,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}) {
@ -97,7 +136,7 @@ export class Objects {
} }
} }
handlePointerUp({x, y}) { handlePointerUp() {
this.doneCreatingObject(); this.doneCreatingObject();
this.deselect(); this.deselect();
} }
@ -106,6 +145,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 +157,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 +185,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 +206,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';

1090
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "gravity",
"version": "0.2.0",
"description": "Gravity Simulator",
"main": "simulator.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "npx eslint"
},
"repository": {
"type": "git",
"url": "git@git.laddhoffman.com:lentil/gravity.git"
},
"author": "Ladd Hoffman <lentil@laddhoffman.com>",
"license": "Unlicense",
"devDependencies": {
"@eslint/js": "^9.39.2",
"eslint": "^9.39.2",
"globals": "^16.5.0"
}
}

View File

@ -1,19 +1,13 @@
import { import {
POINTER_HISTORY_SIZE,
ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR,
DISPLAY_CURSOR_INFO, DISPLAY_CURSOR_INFO,
DRAGGABLE_ELEMENT_CLASSNAME, DRAGGABLE_ELEMENT_CLASSNAME,
MODE_MASS_GENERATION, MODE_MASS_GENERATION,
MODE_PAN_VIEW, MODE_PAN_VIEW,
PAN_VELOCITY_SCALE_FACTOR, POINTER_HISTORY_SIZE,
ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR,
} from './config.js'; } from './config.js';
function dispatchEvent(target, eventType, data) {
const ev = new CustomEvent(eventType, {detail: data});
target.dispatchEvent(ev);
}
export class Pointer { export class Pointer {
sim = undefined; sim = undefined;
@ -85,10 +79,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 +93,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 +122,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(),
};
} }
} }
@ -152,7 +162,7 @@ export class Pointer {
// TODO: If e.touches.length > 1, user may be engaging pinch to zoom // TODO: If e.touches.length > 1, user may be engaging pinch to zoom
handlePointerMove({x: clientX, y: clientY}) { handlePointerMove({x: clientX, y: clientY}) {
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) { if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
this.updatePointer({ x: clientX, y: clientY }); this.updatePointer({x: clientX, y: clientY});
const {x, y} = this.sim.screenToSim(clientX, clientY); const {x, y} = this.sim.screenToSim(clientX, clientY);
const velocity = this.getPointerVelocity(); const velocity = this.getPointerVelocity();
// Convert pointer velocity to sim internal scale // Convert pointer velocity to sim internal scale
@ -162,9 +172,13 @@ 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 +191,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', () => 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%';
@ -28,7 +29,7 @@ export class PlayPause extends Tool {
} }
}); });
playButton.addEventListener('click', (e) => { playButton.addEventListener('click', () => {
if (!this.playing) { if (!this.playing) {
this.playing = true; this.playing = true;
pauseButton.style.opacity = '100%'; pauseButton.style.opacity = '100%';
@ -42,6 +43,6 @@ export class PlayPause extends Tool {
} }
set playing(playing) { set playing(playing) {
return this.sim.playing = playing; this.sim.playing = playing;
} }
} }

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;
@ -25,7 +25,7 @@ export class Toolbar {
frame() { frame() {
for (let tool in this.tools) { for (let tool in this.tools) {
// TODO: tool.frame() // TODO: tool.frame()
tool.frame();
} }
} }
} }