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]
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_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;

View File

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

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;
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 {

View File

@ -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;

View File

@ -2,32 +2,62 @@ import { MassObject } from './object.js';
import {
MASS_CREATION_RATE,
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 +67,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 +93,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 +103,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}) {
@ -97,7 +136,7 @@ export class Objects {
}
}
handlePointerUp({x, y}) {
handlePointerUp() {
this.doneCreatingObject();
this.deselect();
}
@ -106,6 +145,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 +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() {
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 +185,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 +206,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)}°`,
];
}
});
}
}
}

View File

@ -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';

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 {
POINTER_HISTORY_SIZE,
ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR,
DISPLAY_CURSOR_INFO,
DRAGGABLE_ELEMENT_CLASSNAME,
MODE_MASS_GENERATION,
MODE_PAN_VIEW,
PAN_VELOCITY_SCALE_FACTOR,
POINTER_HISTORY_SIZE,
ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR,
} from './config.js';
function dispatchEvent(target, eventType, data) {
const ev = new CustomEvent(eventType, {detail: data});
target.dispatchEvent(ev);
}
export class Pointer {
sim = undefined;
@ -43,7 +37,7 @@ export class Pointer {
this.handlePointerMove({x: e.clientX, y: e.clientY});
}
});
el.addEventListener('pointerdown', e => {
// If this is a child of a draggable element, handle dragging
let target = e.target;
@ -85,10 +79,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 +93,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 +122,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};
}
}
@ -145,14 +155,14 @@ export class Pointer {
if (this.panning?.gathering) {
this.panning.gathering = false;
}
}
}
}
// Handle cursor (mouse or touch) movement
// TODO: If e.touches.length > 1, user may be engaging pinch to zoom
handlePointerMove({x: clientX, y: clientY}) {
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 velocity = this.getPointerVelocity();
// Convert pointer velocity to sim internal scale
@ -162,9 +172,13 @@ 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();
this.updatePointer({x: clientX, y: clientY});
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 +191,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;
}
}
}

View File

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

View File

@ -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 = `<h3>${modeTitle}</h3>`;
button.classList.add('wide');
button.addEventListener('click', (e) => {
if (this.currentMode !== modeID) {
this.currentMode = modeID;
this.setModesOpacity();
}
});
button.addEventListener('click', () => 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();
}
});
}
}

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) => {
e.stopPropagation();
this.sim.pointer.panning = undefined;
if (this.playing) {
this.playing = false;
pauseButton.style.opacity = '50%';
@ -28,7 +29,7 @@ export class PlayPause extends Tool {
}
});
playButton.addEventListener('click', (e) => {
playButton.addEventListener('click', () => {
if (!this.playing) {
this.playing = true;
pauseButton.style.opacity = '100%';
@ -42,6 +43,6 @@ export class PlayPause extends Tool {
}
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(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 = '<h2>Zoom<br>In</h2>';
zoomAll.innerHTML = '<h2>Zoom to Fit</h2>';
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)
});
}
}

View File

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