Compare commits
2 Commits
b41d6dd488
...
e3fe7bd4e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3fe7bd4e4 | ||
|
|
a1af83b392 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
*.sw[po]
|
*.sw[po]
|
||||||
|
node_modules/
|
||||||
|
|||||||
25
UNLICENSE
Normal file
25
UNLICENSE
Normal 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/>
|
||||||
|
|
||||||
33
config.js
33
config.js
@ -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;
|
||||||
|
|||||||
63
display.js
63
display.js
@ -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
7
eslint.config.mjs
Normal 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 } },
|
||||||
|
]);
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
172
objects.js
172
objects.js
@ -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)}°`,
|
||||||
];
|
];
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
1090
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
pointer.js
83
pointer.js
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
simulator.js
35
simulator.js
@ -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) {
|
||||||
|
|||||||
@ -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
87
tool/options.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
tool/zoom.js
44
tool/zoom.js
@ -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)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user