Compare commits
10 Commits
d6c3db8e45
...
fe7e9f43ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7e9f43ad | ||
|
|
debf31e9b0 | ||
|
|
d010d19495 | ||
|
|
bfaa2b2437 | ||
|
|
f53a82f594 | ||
|
|
106707d6b2 | ||
|
|
a50c7c2a58 | ||
|
|
f5512c1be3 | ||
|
|
fe6a263164 | ||
|
|
49feb0c106 |
23
Layout.md
Normal file
23
Layout.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
Overlay > Info Box
|
||||||
|
|
||||||
|
Each toolbar gives itself a header tool which expands/collapses the toolbar.
|
||||||
|
|
||||||
|
- Toolbar Group: Left Side
|
||||||
|
- Toolbar: Tools
|
||||||
|
- Tool: Zoom
|
||||||
|
- Toolbar: Modes
|
||||||
|
- Tool: Mode Switch
|
||||||
|
- Toolbar: Utils
|
||||||
|
- Tool: Utility Tool
|
||||||
|
- Toolbar Group: Right Side
|
||||||
|
- Toolbar: Options
|
||||||
|
- Tool: Options Tool
|
||||||
|
- Section: Pause During
|
||||||
|
- Section: Display
|
||||||
|
- Section: Collision
|
||||||
|
- Toolbar: Params
|
||||||
|
- Tool: Options Tool
|
||||||
|
- Section: Param
|
||||||
|
- Toolbar: Debug
|
||||||
|
- Tool: Options Tool
|
||||||
|
- Section: Debug
|
||||||
13
Readme.md
13
Readme.md
@ -8,21 +8,23 @@ Uses `npm` for `eslint`.
|
|||||||
Screenshots
|
Screenshots
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||

|

|
||||||

|
|
||||||
|

|
||||||
|
|
||||||
TODO
|
TODO
|
||||||
----
|
----
|
||||||
|
|
||||||
- [ ] Parameter Slider (Invisible, mouse/touch drag)
|
|
||||||
- [ ] Selection Box
|
- [ ] Selection Box
|
||||||
- [ ] Object List
|
- [ ] Object List
|
||||||
- [ ] Object Detail
|
- [ ] Object Detail
|
||||||
- [ ] Left Button Panning
|
- [ ] Left Button Panning
|
||||||
- [ ] Middle Button Pause
|
- [ ] Middle Button Pause
|
||||||
|
- [ ] Parameter Slider (Invisible, mouse/touch drag)
|
||||||
- [ ] Grid
|
- [ ] Grid
|
||||||
- [ ] Zoom Easing
|
- [ ] Zoom Easing
|
||||||
- [ ] 2-touch Pan & Zoom
|
- [ ] 2-touch Pan & Zoom
|
||||||
|
- Either continuous zoom, or discrete with animation of bounding box proposed changes
|
||||||
- [ ] Multi-touch Mass Create
|
- [ ] Multi-touch Mass Create
|
||||||
- [ ] Camera Rotation
|
- [ ] Camera Rotation
|
||||||
- [ ] 2-touch Rotation
|
- [ ] 2-touch Rotation
|
||||||
@ -31,10 +33,9 @@ TODO
|
|||||||
- [ ] Undo "Clear Traces" Action
|
- [ ] Undo "Clear Traces" Action
|
||||||
- [ ] Undo "Reset
|
- [ ] Undo "Reset
|
||||||
- [ ] Time Control: Reverse Time
|
- [ ] Time Control: Reverse Time
|
||||||
- [x] Save to LocalStorage
|
|
||||||
- [ ] Lossy Rescaling To Widen Zoom (Handling overflow/underflow)
|
- [ ] Lossy Rescaling To Widen Zoom (Handling overflow/underflow)
|
||||||
- [ ] Track farthest reaches, min/max in each dimension (x, y)
|
- [ ] Track farthest reaches, min/max in each dimension (x, y)
|
||||||
- [x] Compute Net Angular Momentum
|
|
||||||
- [ ] Display Net Angular Momentum
|
|
||||||
- [ ] Calculate Work as FxD as measure of energy flux
|
- [ ] Calculate Work as FxD as measure of energy flux
|
||||||
- [ ] Option to automatically slow time when energy flux is greater
|
- [ ] Option to automatically slow time when energy flux is greater
|
||||||
|
- [ ] Handle pointerleave or other mechanism when window loses focus
|
||||||
|
- [ ] Verify stationary pointer leads to zero pointer velocity
|
||||||
|
|||||||
11
config.js
11
config.js
@ -1,11 +1,9 @@
|
|||||||
// VELOCITY
|
// VELOCITY
|
||||||
export const VELOCITY_VECTOR_SCALE = 8E0;
|
|
||||||
export const VELOCITY_VECTOR_COLOR = 'rgba(150, 150, 150, 0.8)'; // optionally set to 'object color'
|
export const VELOCITY_VECTOR_COLOR = 'rgba(150, 150, 150, 0.8)'; // 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
|
// ACCELERATION
|
||||||
export const ACCELERATION_VECTOR_SCALE = 8E0;
|
|
||||||
export const ACCELERATION_VECTOR_COLOR = 'rgba(0, 128, 0, 0.8)'; // optionally set to 'object color'
|
export const ACCELERATION_VECTOR_COLOR = 'rgba(0, 128, 0, 0.8)'; // optionally set to '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;
|
||||||
@ -18,6 +16,7 @@ export const PATH_TRACES_DASHED_OPACITY = 1.0;
|
|||||||
|
|
||||||
// SIZES
|
// SIZES
|
||||||
export const POINTER_HISTORY_SIZE = 20;
|
export const POINTER_HISTORY_SIZE = 20;
|
||||||
|
export const OBJECT_HISTORY_SIZE = 1e5;
|
||||||
export const FRAMERATE_SAMPLE_DURATION = 2.0; // Seconds
|
export const FRAMERATE_SAMPLE_DURATION = 2.0; // Seconds
|
||||||
export const POINTER_DOWN_HISTORY_SIZE = 5;
|
export const POINTER_DOWN_HISTORY_SIZE = 5;
|
||||||
export const ARROWHEAD_LENGTH = 7;
|
export const ARROWHEAD_LENGTH = 7;
|
||||||
@ -25,7 +24,6 @@ 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;
|
||||||
@ -39,6 +37,8 @@ export const TOOLBAR_HEADER_CLASSNAME = 'lhg-toolbar-header';
|
|||||||
export const WIDE_CLASSNAME = 'lhg-wide';
|
export const WIDE_CLASSNAME = 'lhg-wide';
|
||||||
export const TALL_CLASSNAME = 'lhg-tall';
|
export const TALL_CLASSNAME = 'lhg-tall';
|
||||||
export const OVERLAY_INFO_BOX_CLASSNAME = 'lhg-overlay-info-box';
|
export const OVERLAY_INFO_BOX_CLASSNAME = 'lhg-overlay-info-box';
|
||||||
|
export const OPTION_GROUP_CLASSNAME = 'lhg-option-group';
|
||||||
|
export const TOOLBAR_GROUP_CLASSNAME = 'lhg-toolbar-group';
|
||||||
|
|
||||||
// EVENT NAMES
|
// EVENT NAMES
|
||||||
export const EVENT_MODE_LEAVE = 'lhg-mode-leave';
|
export const EVENT_MODE_LEAVE = 'lhg-mode-leave';
|
||||||
@ -51,5 +51,8 @@ export const MODE_MASS_GENERATION = 'mass-gen';
|
|||||||
export const MODE_PAN_VIEW = 'pan-view';
|
export const MODE_PAN_VIEW = 'pan-view';
|
||||||
export const MODE_OBJECT_SELECT = 'select';
|
export const MODE_OBJECT_SELECT = 'select';
|
||||||
|
|
||||||
// LOCAL STORAGE PREFIXES/SUFFIXES
|
// LOCAL STORAGE NAMESPACES
|
||||||
export const TOOLBAR_EXPANDED_SUFFIX = 'lhg-toolbar-expanded';
|
export const TOOLBAR_EXPANDED_SUFFIX = 'lhg-toolbar-expanded';
|
||||||
|
export const OBJECT_MAGIC_PROP_PREFIX = '_lhg_';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
55
display.js
55
display.js
@ -67,10 +67,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawObjects() {
|
|
||||||
this.sim.objects.forEachObject(obj => obj.drawObject(this.sim), {alive: null});
|
|
||||||
}
|
|
||||||
|
|
||||||
drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) {
|
drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) {
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
ctx.strokeStyle = style;
|
ctx.strokeStyle = style;
|
||||||
@ -139,28 +135,47 @@ export class Display {
|
|||||||
ctx.resetTransform();
|
ctx.resetTransform();
|
||||||
}
|
}
|
||||||
|
|
||||||
computePanning(elapsedTime) {
|
frame(elapsedTime) {
|
||||||
// Add another entry for the current pointer position
|
|
||||||
const {
|
const {
|
||||||
pointerHistory,
|
touchStart: start,
|
||||||
panTouchStart: start,
|
touchLatest: latest,
|
||||||
panTouchLatest: latest,
|
|
||||||
} = this.sim.pointer ?? {};
|
} = this.sim.pointer ?? {};
|
||||||
if (pointerHistory?.length) {
|
|
||||||
const currentPointer = pointerHistory[pointerHistory.length - 1];
|
|
||||||
this.sim.pointer.updatePointer(currentPointer);
|
|
||||||
}
|
|
||||||
if (start && latest) {
|
if (start && latest) {
|
||||||
// Direct translate
|
// Direct translate
|
||||||
this.viewOrigin.x = start.viewOrigin.x - (latest.x - start.x) / this.scale;
|
this.viewOrigin.x = start.viewOrigin.x - (latest.x - start.x) / this.scale;
|
||||||
this.viewOrigin.y = start.viewOrigin.y - (latest.y - start.y) / this.scale;
|
this.viewOrigin.y = start.viewOrigin.y - (latest.y - start.y) / this.scale;
|
||||||
} else if (this.sim.panning && !this.sim.panning.paused) {
|
|
||||||
// Apply update to viewOrigin based on panning
|
|
||||||
const { velocity } = this.sim.panning;
|
|
||||||
// TODO: something with time scale? Panning is too fast.
|
|
||||||
this.viewOrigin.x -= velocity.x * elapsedTime / 1000; // millisecond conversion?
|
|
||||||
this.viewOrigin.y -= velocity.y * elapsedTime / 1000;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
let pdx = 0;
|
||||||
|
let pdy = 0;
|
||||||
|
|
||||||
|
if (this.sim.panning && !this.sim.panning.paused) {
|
||||||
|
// Apply update to viewOrigin based on panning
|
||||||
|
// TODO: elapsedTime is scaled by time scale, is that what we want?
|
||||||
|
pdx = this.sim.panning.velocity.x * elapsedTime;
|
||||||
|
pdy = this.sim.panning.velocity.y * elapsedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewOrigin.x += pdx;
|
||||||
|
this.viewOrigin.y += pdy;
|
||||||
|
|
||||||
|
if (start && latest) {
|
||||||
|
// Update what's considered start
|
||||||
|
start.viewOrigin = {...this.viewOrigin};
|
||||||
|
start.x = latest.x;
|
||||||
|
start.y = latest.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sim.getOption('debug.panningInfo')) {
|
||||||
|
const {x, y} = this.sim.panning?.velocity ?? {};
|
||||||
|
this.sim.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)];
|
||||||
|
const { centerOfMass } = this.sim.system.computeSystemCenter();
|
||||||
|
this.sim.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)];
|
||||||
|
this.sim.info['Net Angular Momentum'] = this.sim.system.computeSystemAngularMomentum().toPrecision(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear canvas in preparation for other modules to render this frame
|
||||||
|
this.fillCanvas();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
gravity-simulator-4.png
Normal file
BIN
gravity-simulator-4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
BIN
gravity-simulator-5.png
Normal file
BIN
gravity-simulator-5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
63
object.js
63
object.js
@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ACCELERATION_VECTOR_ARROWHEAD,
|
ACCELERATION_VECTOR_ARROWHEAD,
|
||||||
ACCELERATION_VECTOR_COLOR,
|
ACCELERATION_VECTOR_COLOR,
|
||||||
ACCELERATION_VECTOR_SCALE,
|
|
||||||
ACCELERATION_VECTOR_WIDTH,
|
ACCELERATION_VECTOR_WIDTH,
|
||||||
OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
|
OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
|
||||||
OFFSCREEN_OBJECT_LINE_SCALE,
|
OFFSCREEN_OBJECT_LINE_SCALE,
|
||||||
@ -12,11 +11,12 @@ import {
|
|||||||
PATH_TRACES_WIDTH,
|
PATH_TRACES_WIDTH,
|
||||||
VELOCITY_VECTOR_ARROWHEAD,
|
VELOCITY_VECTOR_ARROWHEAD,
|
||||||
VELOCITY_VECTOR_COLOR,
|
VELOCITY_VECTOR_COLOR,
|
||||||
VELOCITY_VECTOR_SCALE,
|
|
||||||
VELOCITY_VECTOR_WIDTH,
|
VELOCITY_VECTOR_WIDTH,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
|
|
||||||
export class MassObject {
|
export class MassObject {
|
||||||
|
sim = undefined;
|
||||||
|
id = undefined;
|
||||||
mass = 0;
|
mass = 0;
|
||||||
density = 1;
|
density = 1;
|
||||||
position = {x: undefined, y: undefined};
|
position = {x: undefined, y: undefined};
|
||||||
@ -28,17 +28,24 @@ export class MassObject {
|
|||||||
history = [];
|
history = [];
|
||||||
alive = true;
|
alive = true;
|
||||||
|
|
||||||
constructor(x, y) {
|
constructor(sim, x, y) {
|
||||||
|
this.sim = sim;
|
||||||
|
this.id = crypto.randomUUID();
|
||||||
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;
|
||||||
this.color.g = Math.random() * 256;
|
this.color.g = Math.random() * 256;
|
||||||
this.color.b = Math.random() * 256;
|
this.color.b = Math.random() * 256;
|
||||||
this.created = document.timeline.currentTime;
|
this.timeCreated = this.sim.time;
|
||||||
|
this.rawTimeCreated = this.sim.rawTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
get age() {
|
get age() {
|
||||||
return document.timeline.currentTime - this.created;
|
return this.sim.time - this.timeCreated;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rawAge() {
|
||||||
|
return this.sim.rawTime - this.rawTimeCreated;
|
||||||
}
|
}
|
||||||
|
|
||||||
get radius() {
|
get radius() {
|
||||||
@ -86,7 +93,7 @@ export class MassObject {
|
|||||||
const opacity = dashedTraces ? PATH_TRACES_DASHED_OPACITY : PATH_TRACES_OPACITY;
|
const opacity = dashedTraces ? PATH_TRACES_DASHED_OPACITY : PATH_TRACES_OPACITY;
|
||||||
ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ?
|
ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ?
|
||||||
`rgba(${r}, ${g}, ${b}, ${opacity})` : PATH_TRACES_COLOR;
|
`rgba(${r}, ${g}, ${b}, ${opacity})` : PATH_TRACES_COLOR;
|
||||||
ctx.lineWidth = PATH_TRACES_WIDTH / this.scale;
|
ctx.lineWidth = PATH_TRACES_WIDTH / this.sim.display.scale;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
let dash = false;
|
let dash = false;
|
||||||
for (let i = 0; i < this.history.length; i++) {
|
for (let i = 0; i < this.history.length; i++) {
|
||||||
@ -142,8 +149,8 @@ export class MassObject {
|
|||||||
|
|
||||||
const arrowDirection = Math.atan2(py - cy, px - cx);
|
const arrowDirection = Math.atan2(py - cy, px - cx);
|
||||||
// Length of arrow based on distance (logarithmic scale)
|
// Length of arrow based on distance (logarithmic scale)
|
||||||
const distance = Math.sqrt((x - px) ** 2, (y - py) ** 2) * this.scale;
|
const distance = Math.sqrt((x - px) ** 2, (y - py) ** 2);
|
||||||
const arrowLength = Math.log(distance) * OFFSCREEN_OBJECT_LINE_SCALE / this.scale;
|
const arrowLength = Math.log(distance + 1) * OFFSCREEN_OBJECT_LINE_SCALE / this.sim.display.scale;
|
||||||
const startAx = px - arrowLength * Math.cos(arrowDirection);
|
const startAx = px - arrowLength * Math.cos(arrowDirection);
|
||||||
const startAy = py - arrowLength * Math.sin(arrowDirection);
|
const startAy = py - arrowLength * Math.sin(arrowDirection);
|
||||||
sim.display.drawArrow(startAx, startAy, px, py, {
|
sim.display.drawArrow(startAx, startAy, px, py, {
|
||||||
@ -165,9 +172,29 @@ export class MassObject {
|
|||||||
|
|
||||||
// Draw arrow for the velocity
|
// Draw arrow for the velocity
|
||||||
if (sim.getOption('display.velocity')) {
|
if (sim.getOption('display.velocity')) {
|
||||||
const speed = Math.sqrt(vx ** 2 + vy ** 2);
|
// If this object is being dragged by the user,
|
||||||
const endVx = x + VELOCITY_VECTOR_SCALE * vx / speed * Math.log(speed);
|
// show the pointer velocity instead of object velocity
|
||||||
const endVy = y + VELOCITY_VECTOR_SCALE * vy / speed * Math.log(speed);
|
const vecScale = this.sim.getOption('display.velocityScale');
|
||||||
|
const selected = this.sim.system.getSelectedOrCreating();
|
||||||
|
const isSelected = selected?.id === this.id;
|
||||||
|
let velocity = {x: vx, y: vy};
|
||||||
|
if (isSelected) {
|
||||||
|
const pointerV = this.sim.pointer.latestVelocity;
|
||||||
|
// const panning = this.sim.panning?.velocity ?? {x: 0, y: 0};
|
||||||
|
// velocity.x = vx + (pointerV.x + panning.x) * scale;
|
||||||
|
// velocity.y = vy + (pointerV.y + panning.y) * scale;
|
||||||
|
if (this.sim.getOption('compensate.timeScale')) {
|
||||||
|
velocity.x = vx + pointerV.x / this.sim.timeScale;
|
||||||
|
velocity.y = vy + pointerV.y / this.sim.timeScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const speed = Math.sqrt(velocity.x ** 2, velocity.y ** 2) / this.sim.display.scale;
|
||||||
|
const arrowDirection = Math.atan2(velocity.y, velocity.x);
|
||||||
|
// Prevent negative numbers by adding 1
|
||||||
|
// TODO: Make logarithmic vector length scale optional
|
||||||
|
const arrowLength = Math.log10(speed + 1) * vecScale + radius;
|
||||||
|
const endVx = x + arrowLength * Math.cos(arrowDirection);
|
||||||
|
const endVy = y + arrowLength * Math.sin(arrowDirection);
|
||||||
const style = VELOCITY_VECTOR_COLOR === 'object color' ?
|
const style = VELOCITY_VECTOR_COLOR === 'object color' ?
|
||||||
`rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR;
|
`rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR;
|
||||||
sim.display.drawArrow(x, y, endVx, endVy, {
|
sim.display.drawArrow(x, y, endVx, endVy, {
|
||||||
@ -181,11 +208,15 @@ export class MassObject {
|
|||||||
|
|
||||||
// Draw arrow for acceleration
|
// Draw arrow for acceleration
|
||||||
if (sim.getOption('display.acceleration')) {
|
if (sim.getOption('display.acceleration')) {
|
||||||
const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2);
|
const vecScale = this.sim.getOption('display.accelerationScale');
|
||||||
const endAx = x + ACCELERATION_VECTOR_SCALE * acceleration.x /
|
const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2) /
|
||||||
accelerationMagnitude * Math.log(accelerationMagnitude);
|
this.sim.display.scale;
|
||||||
const endAy = y + ACCELERATION_VECTOR_SCALE * acceleration.y /
|
const arrowDirection = Math.atan2(acceleration.y, acceleration.x);
|
||||||
accelerationMagnitude * Math.log(accelerationMagnitude);
|
// Prevent negative numbers by adding 1
|
||||||
|
const arrowLength = Math.log10(accelerationMagnitude + 1) * vecScale + radius;
|
||||||
|
//const arrowLength = accelerationMagnitude * vecScale;
|
||||||
|
const endAx = x + arrowLength * Math.cos(arrowDirection);
|
||||||
|
const endAy = y + arrowLength * Math.sin(arrowDirection);
|
||||||
const style = ACCELERATION_VECTOR_COLOR === 'object color' ?
|
const style = ACCELERATION_VECTOR_COLOR === 'object color' ?
|
||||||
`rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR;
|
`rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR;
|
||||||
sim.display.drawArrow(x, y, endAx, endAy, {
|
sim.display.drawArrow(x, y, endAx, endAy, {
|
||||||
|
|||||||
54
options.js
54
options.js
@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
EVENT_OPTION_SET,
|
EVENT_OPTION_SET,
|
||||||
|
OBJECT_MAGIC_PROP_PREFIX,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
|
|
||||||
export class Options {
|
export class Options {
|
||||||
sim = undefined;
|
sim = undefined;
|
||||||
options = undefined;
|
options = undefined;
|
||||||
values = {};
|
values = {};
|
||||||
|
undefinedObj = { [OBJECT_MAGIC_PROP_PREFIX + 'undefined']: true};
|
||||||
|
nullObj = { [OBJECT_MAGIC_PROP_PREFIX + 'null']: true};
|
||||||
|
|
||||||
getStorageKey(path) {
|
getStorageKey(path) {
|
||||||
return `${path}:options`;
|
return `${path}:options`;
|
||||||
@ -24,29 +27,62 @@ export class Options {
|
|||||||
for (const groupName of Object.keys(options)) {
|
for (const groupName of Object.keys(options)) {
|
||||||
for (const [name, [, , defaultValue]] of Object.entries(this.options[groupName])) {
|
for (const [name, [, , defaultValue]] of Object.entries(this.options[groupName])) {
|
||||||
const path = [groupName, name].join('.');
|
const path = [groupName, name].join('.');
|
||||||
let value = this.getOption(path)
|
let value = this.getFromLocalStorage(path);
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
value = defaultValue;
|
value = defaultValue;
|
||||||
this.setOption(path, value);
|
|
||||||
}
|
}
|
||||||
|
this.values[path] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getOption(path) {
|
toStored(value) {
|
||||||
let value = this.values[path];
|
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
value = localStorage.getItem(this.getStorageKey(path));
|
// Do we want to interpret this as removing from storage?
|
||||||
if (value === 'false') value = false;
|
// Let's just treat it as a value for now;
|
||||||
else if (value === 'true') value = true;
|
// Semantically it works because when retrieved, it will return undefined,
|
||||||
this.values[path] = value;
|
// which is the same result you get if the key is not set
|
||||||
|
return JSON.stringify(this.undefinedObj);
|
||||||
|
} else if (value === null) {
|
||||||
|
return JSON.stringify(this.nullObj);
|
||||||
}
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// value: string
|
||||||
|
fromStored(value) {
|
||||||
|
if (value === null) {
|
||||||
|
return undefined;
|
||||||
|
} else if (value === JSON.stringify(this.undefinedObj)) {
|
||||||
|
return undefined;
|
||||||
|
} else if (value === JSON.stringify(this.nullObj)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFromLocalStorage(path) {
|
||||||
|
const storageKey = this.getStorageKey(path);
|
||||||
|
const value = this.fromStored(window.localStorage.getItem(storageKey));
|
||||||
|
this.values[path] = value;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOption(path) {
|
||||||
|
const [group, name] = path.split('.');
|
||||||
|
const {type} = this.options[group][name];
|
||||||
|
const value = this.values[path];
|
||||||
|
switch (type) {
|
||||||
|
case 'number': return Number(value);
|
||||||
|
case 'boolean': return value === true || value === 'true';
|
||||||
|
default: return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setOption(path, value) {
|
setOption(path, value) {
|
||||||
this.values[path] = value;
|
this.values[path] = value;
|
||||||
window.localStorage.setItem(this.getStorageKey(path), value);
|
const storageKey = this.getStorageKey(path);
|
||||||
|
window.localStorage.setItem(storageKey, this.toStored(value));
|
||||||
const e = new CustomEvent(EVENT_OPTION_SET, {detail: {path, value}});
|
const e = new CustomEvent(EVENT_OPTION_SET, {detail: {path, value}});
|
||||||
this.sim.div.dispatchEvent(e);
|
this.sim.div.dispatchEvent(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export class Overlay {
|
|||||||
infoBox.classList.add(OVERLAY_INFO_BOX_CLASSNAME);
|
infoBox.classList.add(OVERLAY_INFO_BOX_CLASSNAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderInfo() {
|
frame() {
|
||||||
this.infoBox.innerHTML = '';
|
this.infoBox.innerHTML = '';
|
||||||
const table = document.createElement('table');
|
const table = document.createElement('table');
|
||||||
for (let [k, v] of Object.entries(this.sim.info)) {
|
for (let [k, v] of Object.entries(this.sim.info)) {
|
||||||
|
|||||||
179
pointer.js
179
pointer.js
@ -3,7 +3,6 @@ import {
|
|||||||
MODE_OBJECT_SELECT,
|
MODE_OBJECT_SELECT,
|
||||||
MODE_PAN_VIEW,
|
MODE_PAN_VIEW,
|
||||||
POINTER_HISTORY_SIZE,
|
POINTER_HISTORY_SIZE,
|
||||||
POINTER_DOWN_HISTORY_SIZE,
|
|
||||||
TOOLBAR_CLASSNAME,
|
TOOLBAR_CLASSNAME,
|
||||||
ZOOM_IN_FACTOR,
|
ZOOM_IN_FACTOR,
|
||||||
ZOOM_OUT_FACTOR,
|
ZOOM_OUT_FACTOR,
|
||||||
@ -13,8 +12,8 @@ export class Pointer {
|
|||||||
sim = undefined;
|
sim = undefined;
|
||||||
|
|
||||||
pointerHistory = [];
|
pointerHistory = [];
|
||||||
panTouchStart = undefined; // {x: undefined, y: undefined, t: undefined};
|
touchStart = undefined; // {x: undefined, y: undefined, t: undefined};
|
||||||
panTouchLatest = undefined; // {x: undefined, y: undefined, t: undefined};
|
touchLatest = undefined; // {x: undefined, y: undefined, t: undefined};
|
||||||
suppressClick = false;
|
suppressClick = false;
|
||||||
|
|
||||||
constructor(sim) {
|
constructor(sim) {
|
||||||
@ -61,7 +60,7 @@ export class Pointer {
|
|||||||
getPointerVelocity(points = POINTER_HISTORY_SIZE) {
|
getPointerVelocity(points = POINTER_HISTORY_SIZE) {
|
||||||
// Average over pointer history
|
// Average over pointer history
|
||||||
if (this.pointerHistory.length < 2) {
|
if (this.pointerHistory.length < 2) {
|
||||||
return this.latestPointerVelocity ?? {x: 0, y: 0, dt: 1};
|
return this.latestVelocity;
|
||||||
}
|
}
|
||||||
points = Math.min(points, POINTER_HISTORY_SIZE, this.pointerHistory.length);
|
points = Math.min(points, POINTER_HISTORY_SIZE, this.pointerHistory.length);
|
||||||
const start = this.pointerHistory[this.pointerHistory.length - points];
|
const start = this.pointerHistory[this.pointerHistory.length - points];
|
||||||
@ -74,12 +73,13 @@ export class Pointer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the specified number of entries at the end of the array (most recent)
|
||||||
clearPointerHistory(keep = 0) {
|
clearPointerHistory(keep = 0) {
|
||||||
this.pointerHistory.splice(0, this.pointerHistory.length - keep)
|
this.pointerHistory.splice(keep, this.pointerHistory.length - keep)
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePointer({x, y}) {
|
updatePointer({x, y}) {
|
||||||
const t = document.timeline.currentTime;
|
const t = this.sim.rawTime;
|
||||||
while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) {
|
while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) {
|
||||||
this.pointerHistory.shift();
|
this.pointerHistory.shift();
|
||||||
}
|
}
|
||||||
@ -87,83 +87,128 @@ export class Pointer {
|
|||||||
this.pointerHistory.push({t, x, y, v});
|
this.pointerHistory.push({t, x, y, v});
|
||||||
}
|
}
|
||||||
|
|
||||||
get latestPointerVelocity() {
|
get latestVelocity() {
|
||||||
const latestPointer = this.pointerHistory[this.pointerHistory.length - 1];
|
const latestPointer = this.pointerHistory[this.pointerHistory.length - 1];
|
||||||
return latestPointer?.v;
|
return {
|
||||||
}
|
x: 0,
|
||||||
|
y: 0,
|
||||||
handlePointerDown({x: clientX, y: clientY}) {
|
...latestPointer?.v
|
||||||
this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE);
|
|
||||||
this.updatePointer({x: clientX, y: clientY});
|
|
||||||
|
|
||||||
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
|
|
||||||
const {x, y} = this.sim.screenToSim(clientX, clientY)
|
|
||||||
this.sim.objects.handlePointerDown({x, y});
|
|
||||||
|
|
||||||
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
|
|
||||||
this.panTouchStart = {
|
|
||||||
x: clientX,
|
|
||||||
y: clientY,
|
|
||||||
t: document.timeline.currentTime,
|
|
||||||
viewOrigin: {...this.sim.display.viewOrigin},
|
|
||||||
};
|
|
||||||
this.panTouchLatest = {...this.panTouchStart};
|
|
||||||
|
|
||||||
} else if (this.sim.isCurrentMode(MODE_OBJECT_SELECT)) {
|
|
||||||
// TODO: Start a selection box
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePointerUp({x: clientX, y: clientY}) {
|
handlePointerDown({x: clientX, y: clientY}) {
|
||||||
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
|
// this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE);
|
||||||
const {x, y} = this.sim.screenToSim(clientX, clientY);
|
this.updatePointer({x: clientX, y: clientY});
|
||||||
this.sim.objects.handlePointerUp({x, y});
|
|
||||||
|
|
||||||
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
|
switch (this.sim.getCurrentMode()) {
|
||||||
// Set panning velocity
|
case MODE_MASS_GENERATION: {
|
||||||
if (this.panTouchStart && this.panTouchLatest) {
|
const {x, y} = this.sim.screenToSim(clientX, clientY)
|
||||||
const dt = (this.panTouchLatest.t - this.panTouchStart.t) / 1000;
|
this.sim.system.handlePointerDown({x, y});
|
||||||
if (!dt) {
|
break;
|
||||||
this.sim.panning = undefined;
|
}
|
||||||
} else {
|
case MODE_PAN_VIEW: {
|
||||||
const v = {...this.latestPointerVelocity};
|
this.touchStart = {
|
||||||
// Convert pointer velocity to simulation scale
|
x: clientX,
|
||||||
v.x /= this.sim.display.scale;
|
y: clientY,
|
||||||
v.y /= this.sim.display.scale;
|
t: this.sim.rawTime,
|
||||||
|
viewOrigin: {...this.sim.display.viewOrigin},
|
||||||
this.sim.panning = {
|
};
|
||||||
velocity: v
|
// Since we've processed this increment, reset
|
||||||
};
|
this.touchLatest = {
|
||||||
}
|
...this.touchStart,
|
||||||
|
dx: 0,
|
||||||
this.panTouchStart = undefined;
|
dy: 0,
|
||||||
|
dt: 0,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MODE_OBJECT_SELECT: {
|
||||||
|
// TODO: Start a selection box
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle cursor (mouse or touch) movement
|
// Handle cursor (mouse or touch) movement
|
||||||
// TODO: If e.touches.length > 1, user may be engaging pinch to zoom
|
|
||||||
handlePointerMove({x: clientX, y: clientY}) {
|
handlePointerMove({x: clientX, y: clientY}) {
|
||||||
|
// TODO: If e.touches.length > 1, user may be engaging pinch to zoom
|
||||||
this.updatePointer({x: clientX, y: clientY});
|
this.updatePointer({x: clientX, y: clientY});
|
||||||
|
|
||||||
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
|
switch (this.sim.getCurrentMode()) {
|
||||||
// Convert pointer velocity to simulation scale
|
case MODE_MASS_GENERATION: {
|
||||||
const vx = this.latestPointerVelocity.x / this.sim.display.scale;
|
const {x, y} = this.sim.screenToSim(clientX, clientY);
|
||||||
const vy = this.latestPointerVelocity.y / this.sim.display.scale;
|
this.sim.system.handlePointerMove({x, y});
|
||||||
const {x, y} = this.sim.screenToSim(clientX, clientY);
|
break;
|
||||||
this.sim.objects.handlePointerMove({x, y, vx, vy});
|
}
|
||||||
|
case MODE_PAN_VIEW: {
|
||||||
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
|
if (this.touchStart) {
|
||||||
if (this.panTouchStart) {
|
// Event loop should be able to read
|
||||||
// Event loop should be able to read
|
this.touchLatest = {
|
||||||
this.panTouchLatest = {
|
x: clientX,
|
||||||
x: clientX,
|
y: clientY,
|
||||||
y: clientY,
|
t: this.sim.rawTime,
|
||||||
t: this.sim.rawTime,
|
dx: clientX - this.touchStart.x,
|
||||||
};
|
dy: clientY - this.touchStart.y,
|
||||||
|
dt: this.sim.rawTime - this.touchStart.t,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MODE_OBJECT_SELECT: {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePointerUp({x: clientX, y: clientY}) {
|
||||||
|
switch (this.sim.getCurrentMode()) {
|
||||||
|
case MODE_MASS_GENERATION: {
|
||||||
|
const {x, y} = this.sim.screenToSim(clientX, clientY);
|
||||||
|
this.sim.system.handlePointerUp({x, y});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MODE_PAN_VIEW: {
|
||||||
|
// Set panning velocity
|
||||||
|
if (this.touchStart && this.touchLatest) {
|
||||||
|
if (!this.touchLatest.dt) {
|
||||||
|
this.sim.panning = undefined;
|
||||||
|
} else {
|
||||||
|
const panning = {...this.latestVelocity};
|
||||||
|
// Convert pointer velocity to simulation scale.
|
||||||
|
// Also multiply by -1 because the camera is
|
||||||
|
// panning opposite to the pointer velocity.
|
||||||
|
panning.x /= -this.sim.display.scale;
|
||||||
|
panning.y /= -this.sim.display.scale;
|
||||||
|
|
||||||
|
if (this.sim.getOption('compensate.timeScale')) {
|
||||||
|
panning.x /= this.sim.timeScale;
|
||||||
|
panning.y /= this.sim.timeScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also add current panning
|
||||||
|
panning.x += this.sim.panning?.velocity.x ?? 0;
|
||||||
|
panning.y += this.sim.panning?.velocity.y ?? 0;
|
||||||
|
|
||||||
|
this.sim.panning = {
|
||||||
|
velocity: panning
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.touchStart = undefined;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MODE_OBJECT_SELECT: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frame() {
|
||||||
|
// Add another entry for the current pointer position
|
||||||
|
const { pointerHistory } = this;
|
||||||
|
if (pointerHistory.length) {
|
||||||
|
const currentPointer = pointerHistory[pointerHistory.length - 1];
|
||||||
|
this.updatePointer(currentPointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
sim-options.js
Normal file
35
sim-options.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export const simOptions = {
|
||||||
|
pauseDuring: {
|
||||||
|
creation: ['Pause While Creating', 'boolean', true],
|
||||||
|
selection: ['Pause While Selecting', 'boolean', true],
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
traces: ['Path Traces', 'boolean', true],
|
||||||
|
dashedTraces: ['Dashed', 'boolean', false, {tall: true}],
|
||||||
|
velocity: ['Velocity Vectors', 'boolean', true],
|
||||||
|
acceleration: ['Accel Vectors', 'boolean', true],
|
||||||
|
velocityScale: ['Velocity<br>Vec Scale', 'number', 80, {hideUnless: 'display.velocity'}],
|
||||||
|
accelerationScale: ['Accel<br>Vec Scale', 'number', 800, {hideUnless: 'display.acceleration'}],
|
||||||
|
targetFrameRate: ['Frame Rate', 'number', 60],
|
||||||
|
},
|
||||||
|
collision: {
|
||||||
|
merge: ['Merge Masses on Collision', 'boolean', true, {wide: true}],
|
||||||
|
},
|
||||||
|
compensate: {
|
||||||
|
timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}],
|
||||||
|
},
|
||||||
|
param: {
|
||||||
|
gravity: ['Gravity', 'number', 1],
|
||||||
|
timeScale: ['Time Scale', 'number', 0.1],
|
||||||
|
massCreationRate: ['Mass Creation Rate', 'number', 1],
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
objectsInfo: ['Objects Info', 'boolean', false],
|
||||||
|
aliveObjects: ['Alive Only', 'boolean', false],
|
||||||
|
cursorInfo: ['Cursor Info', 'boolean', false],
|
||||||
|
frameRate: ['Frame Rate', 'boolean', false],
|
||||||
|
currentMode: ['Current Mode', 'boolean', false],
|
||||||
|
panningInfo: ['Panning Info', 'boolean', false],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
54
sim-tools.js
Normal file
54
sim-tools.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import {Overlay} from './overlay.js';
|
||||||
|
import {Pointer} from './pointer.js';
|
||||||
|
import {ModeSwitch} from './tool/modes.js';
|
||||||
|
import {OptionsTool} from './tool/options.js';
|
||||||
|
import {PlayPause} from './tool/play-pause.js';
|
||||||
|
import {UtilityTool} from './tool/utility.js';
|
||||||
|
import {Zoom} from './tool/zoom.js';
|
||||||
|
import {ToolbarGroup} from './toolbar-group.js';
|
||||||
|
import {Toolbar} from './toolbar.js';
|
||||||
|
|
||||||
|
export function initializeTools(sim) {
|
||||||
|
sim.toolbars = {
|
||||||
|
tools: new Toolbar(sim, 'Tools')
|
||||||
|
.addTool(new Zoom())
|
||||||
|
.addTool(new PlayPause()),
|
||||||
|
modes: new Toolbar(sim, 'Modes')
|
||||||
|
.addTool(new ModeSwitch()),
|
||||||
|
utils: new Toolbar(sim, 'Utils', {expanded: false})
|
||||||
|
.addTool(new UtilityTool()),
|
||||||
|
options: new Toolbar(sim, 'Options')
|
||||||
|
.addTool(new OptionsTool([
|
||||||
|
'pauseDuring',
|
||||||
|
'display',
|
||||||
|
'collision',
|
||||||
|
'compensate',
|
||||||
|
])),
|
||||||
|
params: new Toolbar(sim, 'Params')
|
||||||
|
.addTool(new OptionsTool([
|
||||||
|
'param'
|
||||||
|
])),
|
||||||
|
debug: new Toolbar(sim, 'Debug', {expanded: false})
|
||||||
|
.addTool(new OptionsTool([
|
||||||
|
'debug'
|
||||||
|
])),
|
||||||
|
};
|
||||||
|
const {tools, modes, options, params, debug, utils} = sim.toolbars;
|
||||||
|
sim.toolbarGroups = {
|
||||||
|
left: new ToolbarGroup(sim)
|
||||||
|
.addToolbar(tools)
|
||||||
|
.addToolbar(modes)
|
||||||
|
.addToolbar(utils),
|
||||||
|
right: new ToolbarGroup(sim).topRight()
|
||||||
|
.addToolbar(options)
|
||||||
|
.addToolbar(params)
|
||||||
|
.addToolbar(debug),
|
||||||
|
};
|
||||||
|
sim.overlay = new Overlay(sim);
|
||||||
|
sim.pointer = new Pointer(sim);
|
||||||
|
|
||||||
|
for (const id in sim.toolbars) {
|
||||||
|
const toolbar = sim.toolbars[id];
|
||||||
|
toolbar.applyExpanded();
|
||||||
|
}
|
||||||
|
}
|
||||||
254
simulator.js
254
simulator.js
@ -1,64 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
EVENT_ZOOM,
|
EVENT_ZOOM,
|
||||||
FRAMERATE_SAMPLE_DURATION,
|
FRAMERATE_SAMPLE_DURATION,
|
||||||
SCALE_POWER_MAX,
|
|
||||||
SCALE_POWER_MIN,
|
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { Display } from './display.js';
|
import { Display } from './display.js';
|
||||||
import { Objects } from './objects.js';
|
import { System } from './system.js';
|
||||||
import { Overlay } from './overlay.js';
|
|
||||||
import { Pointer } from './pointer.js';
|
|
||||||
import { Options } from './options.js';
|
import { Options } from './options.js';
|
||||||
import { ModeSwitch } from './tool/modes.js';
|
import { Zoom } from './zoom.js';
|
||||||
import { OptionsTool } from './tool/options.js';
|
import { simOptions } from './sim-options.js';
|
||||||
import { PlayPause } from './tool/play-pause.js';
|
import { initializeTools } from './sim-tools.js';
|
||||||
import { Zoom } from './tool/zoom.js';
|
|
||||||
import { UtilityTool } from './tool/utility.js';
|
|
||||||
import { Toolbar } from './toolbar.js';
|
|
||||||
import { ToolbarGroup } from './toolbar-group.js';
|
|
||||||
|
|
||||||
const simOptions = {
|
|
||||||
pauseDuring: {
|
|
||||||
creation: ['Pause While Creating', 'boolean', true],
|
|
||||||
selection: ['Pause While Selecting', 'boolean', true],
|
|
||||||
},
|
|
||||||
display: {
|
|
||||||
velocity: ['Velocity Vectors', 'boolean', true],
|
|
||||||
acceleration: ['Accel. Vectors', 'boolean', true],
|
|
||||||
traces: ['Path Traces', 'boolean', true],
|
|
||||||
dashedTraces: ['Dashed Traces', 'boolean', false],
|
|
||||||
},
|
|
||||||
collision: {
|
|
||||||
merge: ['Merge Masses<br>on Collision', 'boolean', true, {wide: true}],
|
|
||||||
},
|
|
||||||
param: {
|
|
||||||
gravity: ['Gravity', 'number', 4E4],
|
|
||||||
timeScale: ['Time Scale', 'number', 0.2],
|
|
||||||
massCreationRate: ['Mass Creation Rate', 'number', 10],
|
|
||||||
},
|
|
||||||
debug: {
|
|
||||||
objectsInfo: ['Objects Info', 'boolean', false],
|
|
||||||
cursorInfo: ['Cursor Info', 'boolean', false],
|
|
||||||
frameRate: ['Frame Rate', 'boolean', false, {wide: true}],
|
|
||||||
currentMode: ['Current Mode', 'boolean', false],
|
|
||||||
panningInfo: ['Panning Info', 'boolean', false],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Sim {
|
export class Sim {
|
||||||
info = {};
|
info = {};
|
||||||
rawTime = undefined;
|
rawTime = undefined;
|
||||||
time = undefined;
|
time = undefined;
|
||||||
|
timeScale = undefined;
|
||||||
nextZoom = undefined;
|
nextZoom = undefined;
|
||||||
playing = true;
|
playing = true;
|
||||||
recentFrames = [];
|
recentFrames = [];
|
||||||
|
panning = undefined;
|
||||||
frameRate = 0;
|
frameRate = 0;
|
||||||
|
|
||||||
objects = undefined;
|
system = undefined;
|
||||||
display = undefined;
|
display = undefined;
|
||||||
overlay = undefined;
|
overlay = undefined;
|
||||||
pointer = undefined;
|
pointer = undefined;
|
||||||
panning = undefined;
|
zoom = undefined;
|
||||||
|
|
||||||
toolbarGroups = {};
|
toolbarGroups = {};
|
||||||
toolbars = {};
|
toolbars = {};
|
||||||
|
|
||||||
@ -66,23 +33,10 @@ export class Sim {
|
|||||||
getCurrentMode = () => undefined;
|
getCurrentMode = () => undefined;
|
||||||
setCurrentMode = () => undefined;
|
setCurrentMode = () => undefined;
|
||||||
getOption = () => undefined;
|
getOption = () => undefined;
|
||||||
|
setOption = () => undefined;
|
||||||
onModeEnter = () => undefined;
|
onModeEnter = () => undefined;
|
||||||
onModeLeave = () => undefined;
|
onModeLeave = () => undefined;
|
||||||
|
|
||||||
markFrame(t) {
|
|
||||||
const { recentFrames: rfs } = this;
|
|
||||||
rfs.push(t);
|
|
||||||
if (rfs.length < 2) return;
|
|
||||||
const oldest = rfs[0];
|
|
||||||
const newest = rfs[rfs.length - 1];
|
|
||||||
const count = rfs.length;
|
|
||||||
const duration = (newest - oldest) / 1000; // ms to s
|
|
||||||
this.frameRate = count / duration;
|
|
||||||
if (duration >= FRAMERATE_SAMPLE_DURATION) {
|
|
||||||
rfs.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(divId) {
|
constructor(divId) {
|
||||||
this.divId = divId;
|
this.divId = divId;
|
||||||
const div = document.getElementById(this.divId);
|
const div = document.getElementById(this.divId);
|
||||||
@ -90,95 +44,75 @@ export class Sim {
|
|||||||
|
|
||||||
this.options = new Options(this, simOptions);
|
this.options = new Options(this, simOptions);
|
||||||
this.display = new Display(this);
|
this.display = new Display(this);
|
||||||
this.objects = new Objects(this);
|
this.system = new System(this);
|
||||||
this.toolbars = {
|
this.zoom = new Zoom(this);
|
||||||
tools: new Toolbar(this, 'Tools'),
|
|
||||||
modes: new Toolbar(this, 'Modes'),
|
|
||||||
utils: new Toolbar(this, 'Utility', { expanded: false }),
|
|
||||||
options: new Toolbar(this, 'Options'),
|
|
||||||
params: new Toolbar(this, 'Parameters'),
|
|
||||||
debug: new Toolbar(this, 'Debug', { expanded: false }),
|
|
||||||
};
|
|
||||||
const { tools, modes, options, params, debug, utils } = this.toolbars;
|
|
||||||
this.toolbarGroups = {
|
|
||||||
left: new ToolbarGroup(this)
|
|
||||||
.addToolbar(tools)
|
|
||||||
.addToolbar(modes)
|
|
||||||
.addToolbar(utils),
|
|
||||||
right: new ToolbarGroup(this).topRight()
|
|
||||||
.addToolbar(options)
|
|
||||||
.addToolbar(params)
|
|
||||||
.addToolbar(debug),
|
|
||||||
};
|
|
||||||
this.overlay = new Overlay(this);
|
|
||||||
this.pointer = new Pointer(this);
|
|
||||||
|
|
||||||
// Configure toolbars
|
initializeTools(this);
|
||||||
|
|
||||||
// Primary
|
|
||||||
tools.addTool(new Zoom(tools));
|
|
||||||
tools.addTool(new PlayPause(tools));
|
|
||||||
|
|
||||||
// Secondary
|
|
||||||
modes.addTool(new ModeSwitch(modes));
|
|
||||||
|
|
||||||
// Utility
|
|
||||||
utils.addTool(new UtilityTool(utils));
|
|
||||||
|
|
||||||
// Options
|
|
||||||
options.addTool(new OptionsTool(options, ['pauseDuring', 'display', 'collision']));
|
|
||||||
|
|
||||||
// Parameters
|
|
||||||
params.addTool(new OptionsTool(params, ['param']));
|
|
||||||
|
|
||||||
// Debug
|
|
||||||
debug.addTool(new OptionsTool(debug, ['debug']));
|
|
||||||
|
|
||||||
for (const id in this.toolbars) {
|
|
||||||
const toolbar = this.toolbars[id];
|
|
||||||
toolbar.applyExpanded();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiate main loop
|
// Initiate main loop
|
||||||
this.rawTime = document.timeline.currentTime;
|
this.rawTime = document.timeline.currentTime;
|
||||||
this.time = 0;
|
this.time = 0;
|
||||||
requestAnimationFrame(t => this.loop(t));
|
requestAnimationFrame(t => this.frame(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main loop
|
||||||
|
frame(currentTime) {
|
||||||
|
const early = this.markFrame(currentTime);
|
||||||
|
if (early) {
|
||||||
|
// Slow down :)
|
||||||
|
requestAnimationFrame(t => this.frame(t));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.timeScale = this.getOption('param.timeScale');
|
||||||
|
const elapsedTime = (currentTime - this.rawTime) * this.timeScale;
|
||||||
|
this.rawTime = currentTime;
|
||||||
|
if (this.playing) {
|
||||||
|
this.time += elapsedTime;
|
||||||
|
}
|
||||||
|
if (this.getOption('debug.currentMode')) {
|
||||||
|
this.info['Mode'] = this.getCurrentMode();
|
||||||
|
}
|
||||||
|
if (this.getOption('debug.frameRate')) {
|
||||||
|
this.info['Frame Rate'] = this.frameRate.toPrecision(3);
|
||||||
|
}
|
||||||
|
this.zoom.frame();
|
||||||
|
this.pointer.frame();
|
||||||
|
this.display.frame(elapsedTime);
|
||||||
|
this.system.frame(elapsedTime);
|
||||||
|
this.overlay.frame();
|
||||||
|
for (const group in this.toolbarGroups) {
|
||||||
|
this.toolbarGroups[group].frame();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(t => this.frame(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
markFrame(t) {
|
||||||
|
const { recentFrames: rfs } = this;
|
||||||
|
if (!rfs.length) {
|
||||||
|
rfs.push(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let oldest = rfs[0];
|
||||||
|
let duration = t - oldest;
|
||||||
|
const count = rfs.length + 1;
|
||||||
|
const frameRate = 1000 * count / duration;
|
||||||
|
const targetFrameRate = parseInt(this.getOption('display.targetFrameRate'));
|
||||||
|
if (frameRate > targetFrameRate + 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.frameRate = frameRate;
|
||||||
|
rfs.push(t);
|
||||||
|
while (duration >= FRAMERATE_SAMPLE_DURATION) {
|
||||||
|
rfs.shift();
|
||||||
|
oldest = rfs[0];
|
||||||
|
duration = t - oldest;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
|
||||||
// velocity should be in Sim coordinate scale
|
// velocity should be in Sim coordinate scale
|
||||||
scheduleZoom({x, y}, factor, velocity) {
|
scheduleZoom({x, y}, factor, velocity) {
|
||||||
this.nextZoom = {x, y, factor, velocity};
|
this.zoom.scheduleZoom({x, y}, factor, velocity);
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
this.display.viewOrigin.x = x - this.display.width / 2;
|
|
||||||
this.display.viewOrigin.y = y - this.display.height / 2;
|
|
||||||
|
|
||||||
this.pointer.clearPointerHistory();
|
|
||||||
|
|
||||||
if (this.playing && velocity) {
|
|
||||||
this.panning = {
|
|
||||||
velocity: {
|
|
||||||
x: -velocity.x,
|
|
||||||
y: -velocity.y,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const e = new CustomEvent(EVENT_ZOOM);
|
|
||||||
this.div.dispatchEvent(e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform display coordinates to simulator coordinates using scale and viewOrigin
|
// Transform display coordinates to simulator coordinates using scale and viewOrigin
|
||||||
@ -196,7 +130,8 @@ export class Sim {
|
|||||||
|
|
||||||
getScaleDisplay() {
|
getScaleDisplay() {
|
||||||
const scale = 2 ** Math.abs(this.display.scalePower);
|
const scale = 2 ** Math.abs(this.display.scalePower);
|
||||||
return this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;
|
const scaleText = this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;
|
||||||
|
return `${scaleText} (${this.display.scalePower})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// cb: () => undefined
|
// cb: () => undefined
|
||||||
@ -206,49 +141,4 @@ export class Sim {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main loop
|
|
||||||
loop(currentTime) {
|
|
||||||
this.markFrame(currentTime);
|
|
||||||
const timeScale = this.getOption('param.timeScale');
|
|
||||||
|
|
||||||
// elapsedTime in milliseconds
|
|
||||||
// rawTime in milliseconds
|
|
||||||
const elapsedTime = (currentTime - this.rawTime) / timeScale;
|
|
||||||
this.rawTime = currentTime;
|
|
||||||
|
|
||||||
if (this.playing) {
|
|
||||||
this.time += elapsedTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.getOption('debug.currentMode')) {
|
|
||||||
this.info['Mode'] = this.getCurrentMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.getOption('debug.frameRate')) {
|
|
||||||
this.info['Frame Rate'] = this.frameRate?.toPrecision(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.nextZoom) {
|
|
||||||
this.zoom(this.nextZoom);
|
|
||||||
this.nextZoom = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.getOption('debug.panningInfo')) {
|
|
||||||
const {x, y} = this.panning?.velocity ?? {};
|
|
||||||
this.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)];
|
|
||||||
const { centerOfMass } = this.objects.computeSystemCenter();
|
|
||||||
this.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)];
|
|
||||||
this.info['Net Angular Momentum'] = this.objects.computeSystemAngularMomentum().toPrecision(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.objects.computeFrame(elapsedTime);
|
|
||||||
this.overlay.renderInfo();
|
|
||||||
// this.display.computePanning(elapsedTime);
|
|
||||||
this.display.fillCanvas();
|
|
||||||
this.display.drawObjects();
|
|
||||||
for (const group in this.toolbarGroups) {
|
|
||||||
this.toolbarGroups[group].frame();
|
|
||||||
}
|
|
||||||
requestAnimationFrame(t => this.loop(t));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
50
style.css
50
style.css
@ -27,6 +27,31 @@ div[id=simulator] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* normal toolbar group */
|
||||||
|
div.lhg-toolbar-group div.lhg-tool {
|
||||||
|
width: 12em;
|
||||||
|
}
|
||||||
|
div.lhg-toolbar-group div.lhg-tool button, div.lhg-toolbar-group div.lhg-tool input {
|
||||||
|
width: 6em;
|
||||||
|
}
|
||||||
|
div.lhg-toolbar-group div.lhg-tool .lhg-wide {
|
||||||
|
width: 12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* wide toolbar group */
|
||||||
|
div.lhg-toolbar-group.lhg-wide div.lhg-tool {
|
||||||
|
width: 16em;
|
||||||
|
}
|
||||||
|
div.lhg-toolbar-group.lhg-wide div.lhg-tool button, div.lhg-toolbar-group.lhg-wide div.lhg-tool input {
|
||||||
|
width: 8em;
|
||||||
|
}
|
||||||
|
div.lhg-toolbar-group.lhg-wide div.lhg-tool .lhg-wide {
|
||||||
|
width: 16em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* end of toolbar group section */
|
||||||
|
|
||||||
div.lhg-toolbar {
|
div.lhg-toolbar {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
@ -42,7 +67,6 @@ div.lhg-tool {
|
|||||||
position: relative;
|
position: relative;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 12em;
|
|
||||||
/* padding: 0.5em; */
|
/* padding: 0.5em; */
|
||||||
/* margin: 0.5em; */
|
/* margin: 0.5em; */
|
||||||
text-align: middle;
|
text-align: middle;
|
||||||
@ -56,7 +80,6 @@ div.lhg-tool div.lhg-wide {
|
|||||||
div.lhg-tool button, div.lhg-tool input {
|
div.lhg-tool button, div.lhg-tool input {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
width: 6em;
|
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
color: #5f5;
|
color: #5f5;
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5em;
|
||||||
@ -73,10 +96,6 @@ div.lhg-tool button, div.lhg-tool input {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.lhg-tool input {
|
|
||||||
width: 6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.lhg-tool button:hover {
|
div.lhg-tool button:hover {
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
}
|
}
|
||||||
@ -103,13 +122,8 @@ div.lhg-tool .lhg-tool-info {
|
|||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.lhg-tool .lhg-wide {
|
|
||||||
width: 12em;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.lhg-tool .lhg-tall {
|
div.lhg-tool .lhg-tall {
|
||||||
padding-top: 1em;
|
height: 3.666em;
|
||||||
padding-bottom: 1em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.lhg-overlay-info-box {
|
div.lhg-overlay-info-box {
|
||||||
@ -119,3 +133,15 @@ div.lhg-overlay-info-box {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.lhg-option-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
div.lhg-option-group > * {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { MassObject } from './object.js';
|
import {OBJECT_HISTORY_SIZE} from './config.js';
|
||||||
import { ZOOM_TO_FIT_PADDING } from './config.js';
|
import {MassObject} from './object.js';
|
||||||
|
|
||||||
export class Objects {
|
export class System {
|
||||||
objects = [];
|
objects = [];
|
||||||
creatingObject = undefined;
|
creatingObject = undefined;
|
||||||
selectedObject = undefined;
|
selectedObject = undefined;
|
||||||
|
selectObjectStart = undefined;
|
||||||
paused = false;
|
paused = false;
|
||||||
panVelocityPaused = undefined;
|
panVelocityPaused = undefined;
|
||||||
|
|
||||||
@ -35,8 +36,9 @@ export class Objects {
|
|||||||
// 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(this.sim, x, y);
|
||||||
this.creatingObject = idx;
|
this.creatingObject = idx;
|
||||||
|
this.selectedObjectStart = {x, y, pointer: {x, y}};
|
||||||
this.objects.push(obj);
|
this.objects.push(obj);
|
||||||
// Pause the simulation during mass creation; this avoids some complex local dynamics
|
// Pause the simulation during mass creation; this avoids some complex local dynamics
|
||||||
if (this.sim.getOption('pauseDuring.creation')) {
|
if (this.sim.getOption('pauseDuring.creation')) {
|
||||||
@ -59,8 +61,10 @@ export class Objects {
|
|||||||
return this.objects[i];
|
return this.objects[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
selectObject(i) {
|
selectObject(i, pointer) {
|
||||||
this.selectedObject = i;
|
this.selectedObject = i;
|
||||||
|
const {x, y} = this.object(i).position;
|
||||||
|
this.selectedObjectStart = {x, y, pointer};
|
||||||
if (this.sim.getOption('pauseDuring.selection')) {
|
if (this.sim.getOption('pauseDuring.selection')) {
|
||||||
this.pause();
|
this.pause();
|
||||||
}
|
}
|
||||||
@ -68,6 +72,7 @@ export class Objects {
|
|||||||
|
|
||||||
deselect() {
|
deselect() {
|
||||||
this.selectedObject = undefined;
|
this.selectedObject = undefined;
|
||||||
|
this.selectedObjectStart = undefined;
|
||||||
this.resume();
|
this.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,33 +88,48 @@ export class Objects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get boundingBox() {
|
get boundingBox() {
|
||||||
const box = this.reduce((acc, obj) => {
|
const box = this.reduce(({start, end}, obj) => {
|
||||||
if (acc.start.x === undefined) {
|
const lx = obj.position.x - obj.radius;
|
||||||
acc.start = {...obj.position};
|
const gx = obj.position.x + obj.radius;
|
||||||
acc.end = {...obj.position};
|
const ly = obj.position.y - obj.radius;
|
||||||
} else {
|
const gy = obj.position.y + obj.radius;
|
||||||
if (obj.position.x < acc.start.x) acc.start.x = obj.position.x;
|
let ret;
|
||||||
if (obj.position.x > acc.end.x) acc.end.x = obj.position.x;
|
if (start.x === undefined) {
|
||||||
if (obj.position.y < acc.start.y) acc.start.y = obj.position.y;
|
ret = {
|
||||||
if (obj.position.y > acc.end.y) acc.end.y = obj.position.y;
|
start: {x: lx, y: ly},
|
||||||
|
end: {x: gx, y: gy},
|
||||||
|
};
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
|
ret = {
|
||||||
|
start: {
|
||||||
|
x: Math.min(start.x, lx),
|
||||||
|
y: Math.min(start.y, ly),
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
x: Math.max(end.x, gx),
|
||||||
|
y: Math.max(end.y, gy),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return ret;
|
||||||
}, {
|
}, {
|
||||||
start: {x: undefined, y: undefined},
|
start: {x: undefined, y: undefined},
|
||||||
end: {x: undefined, y: undefined},
|
end: {x: undefined, y: undefined},
|
||||||
});
|
});
|
||||||
|
|
||||||
box.start.x = (box.start.x ?? 0) - ZOOM_TO_FIT_PADDING;
|
box.start.x = (box.start.x ?? 0);
|
||||||
box.start.y = (box.start.y ?? 0) - ZOOM_TO_FIT_PADDING;
|
box.start.y = (box.start.y ?? 0);
|
||||||
box.end.x = (box.end.x ?? 0) + ZOOM_TO_FIT_PADDING;
|
box.end.x = (box.end.x ?? 0);
|
||||||
box.end.y = (box.end.y ?? 0) + ZOOM_TO_FIT_PADDING;
|
box.end.y = (box.end.y ?? 0);
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
objectAtLocation(x, y) {
|
objectAtLocation(x, y) {
|
||||||
let idx = undefined;
|
let idx = undefined;
|
||||||
|
this.selectedObjectStart = undefined;
|
||||||
this.forEachObject((obj, 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) {
|
||||||
idx = i;
|
idx = i;
|
||||||
return null;
|
return null;
|
||||||
@ -123,7 +143,7 @@ export class Objects {
|
|||||||
const touchingObject = this.objectAtLocation(x, y);
|
const touchingObject = this.objectAtLocation(x, y);
|
||||||
|
|
||||||
if (touchingObject !== undefined) {
|
if (touchingObject !== undefined) {
|
||||||
this.selectObject(touchingObject);
|
this.selectObject(touchingObject, {x, y});
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, create a new object
|
// Otherwise, create a new object
|
||||||
this.createObject(x, y);
|
this.createObject(x, y);
|
||||||
@ -131,24 +151,37 @@ export class Objects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handlePointerUp() {
|
handlePointerUp() {
|
||||||
|
const obj = this.getSelectedOrCreating();
|
||||||
|
if (obj === undefined) return;
|
||||||
this.doneCreatingObject();
|
this.doneCreatingObject();
|
||||||
this.deselect();
|
this.deselect();
|
||||||
|
// Convert pointer velocity to simulation scale
|
||||||
|
const pointer = {...this.sim.pointer.latestVelocity};
|
||||||
|
obj.velocity.x = pointer.x / this.sim.display.scale;
|
||||||
|
obj.velocity.y = pointer.y / this.sim.display.scale;
|
||||||
|
|
||||||
|
// Including time scale - if time is slow, our motion is relatively faster
|
||||||
|
if (this.sim.getOption('compensate.timeScale')) {
|
||||||
|
obj.velocity.x /= this.sim.timeScale;
|
||||||
|
obj.velocity.y /= this.sim.timeScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.sim.panning?.velocity) {
|
||||||
|
obj.velocity.x += this.sim.panning.velocity.x;
|
||||||
|
obj.velocity.y += this.sim.panning.velocity.y;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePointerMove({x, y, vx, vy}) {
|
handlePointerMove({x, y}) {
|
||||||
// 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 using the pointer motion but the velocity using the pointer velocity
|
// update the position using the pointer motion but the velocity using the pointer velocity
|
||||||
const obj = this.getSelectedOrCreating();
|
const obj = this.getSelectedOrCreating();
|
||||||
if (obj === undefined) return;
|
if (obj === undefined) return;
|
||||||
if (this.sim.panning?.velocity) {
|
const start = this.selectedObjectStart;
|
||||||
vx += this.sim.panning.velocity.x;
|
obj.position.x = start.x + (x - start.pointer.x);
|
||||||
vy += this.sim.panning.velocity.y;
|
obj.position.y = start.y + (y - start.pointer.y);
|
||||||
}
|
obj.velocity.x = 0;
|
||||||
obj.position.x = x;
|
obj.velocity.y = 0;
|
||||||
obj.position.y = y;
|
|
||||||
obj.velocity.x = vx;
|
|
||||||
obj.velocity.y = vy;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cb: (obj, idx) => {}
|
// cb: (obj, idx) => {}
|
||||||
@ -163,6 +196,10 @@ export class Objects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drawObjects() {
|
||||||
|
this.forEachObject(obj => obj.drawObject(this.sim), {alive: null});
|
||||||
|
}
|
||||||
|
|
||||||
// cb: (acc, obj, idx) => {}
|
// cb: (acc, obj, idx) => {}
|
||||||
reduce(cb, initial, opts) {
|
reduce(cb, initial, opts) {
|
||||||
let acc = initial;
|
let acc = initial;
|
||||||
@ -190,8 +227,8 @@ export class Objects {
|
|||||||
const F = gravity * A.mass * B.mass / dSquared;
|
const F = gravity * A.mass * B.mass / dSquared;
|
||||||
const Fx = F * dx / d;
|
const Fx = F * dx / d;
|
||||||
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});
|
||||||
}, {alive: true, startWith: i + 1});
|
}, {alive: true, startWith: i + 1});
|
||||||
});
|
});
|
||||||
// Also compute acceleration
|
// Also compute acceleration
|
||||||
@ -201,37 +238,35 @@ export class Objects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// elapsedTime is given in milliseconds
|
// elapsedTime is given in milliseconds
|
||||||
computeFrame(elapsedTime) {
|
frame(elapsedTime) {
|
||||||
// convert elapsed time to seconds
|
|
||||||
elapsedTime /= 1000;
|
|
||||||
// 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
|
||||||
|
|
||||||
// Scaling this parameter because of millisecond conversion
|
// Scaling this parameter because of millisecond conversion
|
||||||
const massCreationRate = this.sim.getOption('param.massCreationRate') / 1000;
|
|
||||||
|
|
||||||
if (this.creatingObject !== undefined) {
|
if (this.creatingObject !== undefined) {
|
||||||
const obj = this.objects[this.creatingObject];
|
const obj = this.objects[this.creatingObject];
|
||||||
const rate = massCreationRate * obj.age;
|
let massCreationRate = this.sim.getOption('param.massCreationRate');
|
||||||
console.log('obj.age', obj.age, 'mass creation rate', rate, 'elapsedTime', elapsedTime);
|
massCreationRate /= this.sim.display.scale;
|
||||||
// TODO: After objects merge during creation, mass creation rate can accelerate
|
// Keep consistent time scale
|
||||||
obj.mass += rate * elapsedTime;
|
if (this.sim.getOption('compensate.timeScale')) {
|
||||||
|
massCreationRate /= this.sim.timeScale;
|
||||||
|
}
|
||||||
|
obj.mass += massCreationRate * elapsedTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate forces due to gravity.
|
// Calculate forces due to gravity.
|
||||||
this.computeForces();
|
this.computeForces();
|
||||||
|
|
||||||
if (this.sim.playing) {
|
if (this.sim.playing) {
|
||||||
// TODO: If creating/selected object, clamp its position to the cursor
|
|
||||||
|
|
||||||
// Predict positions (Velocity verlet method)
|
// Predict positions (Velocity verlet method)
|
||||||
this.forEachObject(obj => {
|
this.forEachObject(obj => {
|
||||||
obj.currentAcceleration = {...obj.acceleration};
|
obj.currentAcceleration = {...obj.acceleration};
|
||||||
|
|
||||||
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
|
// Collisions
|
||||||
@ -298,33 +333,51 @@ export class Objects {
|
|||||||
obj.velocity.y += obj.acceleration.y * elapsedTime;
|
obj.velocity.y += obj.acceleration.y * elapsedTime;
|
||||||
|
|
||||||
// Append to object history
|
// Append to object history
|
||||||
// TODO: enforce object history length
|
|
||||||
// TODO: store object color changes in history
|
|
||||||
obj.history.push({position: {...obj.position}});
|
obj.history.push({position: {...obj.position}});
|
||||||
|
|
||||||
|
// TODO: store object color changes in history
|
||||||
|
|
||||||
|
// Enforce object history length
|
||||||
|
while (obj.history.length > OBJECT_HISTORY_SIZE) {
|
||||||
|
obj.history.shift();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display objects info
|
// Display objects info
|
||||||
|
// First clear info from previous frame
|
||||||
|
this.forEachObject((_obj, i) => {
|
||||||
|
delete this.sim.info[`Object ${i}`];
|
||||||
|
}, {alive: null});
|
||||||
if (this.sim.getOption('debug.objectsInfo')) {
|
if (this.sim.getOption('debug.objectsInfo')) {
|
||||||
|
const aliveOnly = this.sim.getOption('debug.aliveObjects');
|
||||||
this.forEachObject((obj, i) => {
|
this.forEachObject((obj, 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);
|
||||||
|
const accel = Math.pow(obj.acceleration.x ** 2 + obj.acceleration.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;
|
||||||
|
const accelDir = Math.atan2(-obj.acceleration.y, obj.acceleration.x) * 180 / Math.PI;
|
||||||
|
const {r, g, b} = obj.color;
|
||||||
this.sim.info[`Object ${i}`] = [
|
this.sim.info[`Object ${i}`] = [
|
||||||
`${obj.position.x.toPrecision(6)}, `,
|
`<span style="background-color: rgb(${r},${g},${b});"> </span>`,
|
||||||
`${obj.position.y.toPrecision(6)}, `,
|
`${obj.position.x.toPrecision(4)}, `,
|
||||||
`${obj.mass.toPrecision(6)} kg, `,
|
`${obj.position.y.toPrecision(4)}, `,
|
||||||
|
`${obj.mass.toPrecision(4)} kg, `,
|
||||||
`${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`,
|
`${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`,
|
||||||
|
`${accel.toPrecision(2)} m/s<sup>2</sup>, ${accelDir.toPrecision(2)}°`,
|
||||||
`Alive: ${obj.alive}`,
|
`Alive: ${obj.alive}`,
|
||||||
];
|
];
|
||||||
}, { alive: null });
|
}, {alive: aliveOnly || null});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render the objects
|
||||||
|
this.drawObjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
computeSystemCenter() {
|
computeSystemCenter() {
|
||||||
// Determine center of mass
|
// Determine center of mass
|
||||||
const { totalMass, count, totalMassLocation } =
|
const {totalMass, count, totalMassLocation} =
|
||||||
this.sim.objects.reduce((acc, obj) => ({
|
this.reduce((acc, obj) => ({
|
||||||
count: acc.count + 1,
|
count: acc.count + 1,
|
||||||
totalMass: acc.totalMass + obj.mass,
|
totalMass: acc.totalMass + obj.mass,
|
||||||
totalMassLocation: {
|
totalMassLocation: {
|
||||||
@ -343,12 +396,12 @@ export class Objects {
|
|||||||
} : {x: 0, y: 0};
|
} : {x: 0, y: 0};
|
||||||
|
|
||||||
// Determine average momentum
|
// Determine average momentum
|
||||||
const netMomentum = this.sim.objects.reduce((acc, obj) => ({
|
const netMomentum = this.reduce((acc, obj) => ({
|
||||||
x: acc.x + obj.mass * obj.velocity.x,
|
x: acc.x + obj.mass * obj.velocity.x,
|
||||||
y: acc.y + obj.mass * obj.velocity.y,
|
y: acc.y + obj.mass * obj.velocity.y,
|
||||||
}), { x: 0, y: 0 });
|
}), {x: 0, y: 0});
|
||||||
|
|
||||||
return { totalMass, count, totalMassLocation, centerOfMass, netMomentum };
|
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
|
||||||
}
|
}
|
||||||
|
|
||||||
computeSystemAngularMomentum(centerOfMass) {
|
computeSystemAngularMomentum(centerOfMass) {
|
||||||
9
tool.js
9
tool.js
@ -10,13 +10,16 @@ export class Tool {
|
|||||||
container = undefined;
|
container = undefined;
|
||||||
sim = undefined;
|
sim = undefined;
|
||||||
|
|
||||||
constructor(container) {
|
constructor() {
|
||||||
this.container = container;
|
|
||||||
this.sim = this.container.sim;
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
this.div = div;
|
this.div = div;
|
||||||
div.classList.add(TOOL_CLASSNAME)
|
div.classList.add(TOOL_CLASSNAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setContainer(container) {
|
||||||
|
this.container = container;
|
||||||
|
this.sim = this.container.sim;
|
||||||
|
}
|
||||||
|
|
||||||
frame() {}
|
frame() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import {TOOLBAR_HEADER_CLASSNAME} from '../config.js';
|
|||||||
import { Tool } from '../tool.js';
|
import { Tool } from '../tool.js';
|
||||||
|
|
||||||
export class Header extends Tool {
|
export class Header extends Tool {
|
||||||
|
|
||||||
constructor(container, title = 'Tools') {
|
constructor(container, title = 'Tools') {
|
||||||
super(container);
|
super(container);
|
||||||
this.title = document.createElement('h1');
|
this.title = document.createElement('h1');
|
||||||
@ -10,7 +9,6 @@ export class Header extends Tool {
|
|||||||
|
|
||||||
this.toggleButton = document.createElement('button');
|
this.toggleButton = document.createElement('button');
|
||||||
this.div.addEventListener('click', () => this.toggle());
|
this.div.addEventListener('click', () => this.toggle());
|
||||||
this.updateButton();
|
|
||||||
|
|
||||||
this.div.appendChild(this.title);
|
this.div.appendChild(this.title);
|
||||||
this.div.appendChild(this.toggleButton);
|
this.div.appendChild(this.toggleButton);
|
||||||
@ -22,6 +20,11 @@ export class Header extends Tool {
|
|||||||
this.div.classList.add(TOOLBAR_HEADER_CLASSNAME);
|
this.div.classList.add(TOOLBAR_HEADER_CLASSNAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setContainer(container) {
|
||||||
|
super.setContainer(container);
|
||||||
|
this.updateButton();
|
||||||
|
}
|
||||||
|
|
||||||
updateButton() {
|
updateButton() {
|
||||||
this.toggleButton.innerHTML = this.container.expanded ? '˅' : '˄';
|
this.toggleButton.innerHTML = this.container.expanded ? '˅' : '˄';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,21 @@ export class ModeSwitch extends Tool {
|
|||||||
];
|
];
|
||||||
buttons = [];
|
buttons = [];
|
||||||
|
|
||||||
|
setContainer(container) {
|
||||||
|
super.setContainer(container);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(container) {
|
constructor(container) {
|
||||||
super(container);
|
super(container);
|
||||||
|
|
||||||
@ -42,17 +57,6 @@ export class ModeSwitch extends Tool {
|
|||||||
|
|
||||||
button.addEventListener('click', () => this.setCurrentMode(modeID));
|
button.addEventListener('click', () => this.setCurrentMode(modeID));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
setModesOpacity() {
|
||||||
|
|||||||
@ -1,16 +1,35 @@
|
|||||||
// Options picker
|
// Options picker
|
||||||
import {
|
import {
|
||||||
TOOL_INFO_CLASSNAME,
|
TOOL_INFO_CLASSNAME,
|
||||||
|
OPTION_GROUP_CLASSNAME,
|
||||||
WIDE_CLASSNAME,
|
WIDE_CLASSNAME,
|
||||||
|
TALL_CLASSNAME,
|
||||||
} from '../config.js';
|
} from '../config.js';
|
||||||
import { Tool } from '../tool.js';
|
import { Tool } from '../tool.js';
|
||||||
|
|
||||||
export class OptionsTool extends Tool {
|
export class OptionsTool extends Tool {
|
||||||
|
sections = undefined;
|
||||||
|
|
||||||
|
constructor(sections) {
|
||||||
|
super();
|
||||||
|
this.sections = sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContainer(container) {
|
||||||
|
super.setContainer(container);
|
||||||
|
for (const sectionName of this.sections) {
|
||||||
|
const option = this.sim.options.getSection(sectionName);
|
||||||
|
const child = this.visitItem(option);
|
||||||
|
this.div.appendChild(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
visitItem(item, path) {
|
visitItem(item, path) {
|
||||||
path = [path, item.name].filter(x => !!x).join('.');
|
path = [path, item.name].filter(x => !!x).join('.');
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'group': {
|
case 'group': {
|
||||||
const group = document.createElement('div');
|
const group = document.createElement('div');
|
||||||
|
group.classList.add(OPTION_GROUP_CLASSNAME);
|
||||||
if (item.title) {
|
if (item.title) {
|
||||||
const heading = document.createElement('h3');
|
const heading = document.createElement('h3');
|
||||||
heading.innerHTML = item.title;
|
heading.innerHTML = item.title;
|
||||||
@ -25,22 +44,15 @@ export class OptionsTool extends Tool {
|
|||||||
case 'boolean': {
|
case 'boolean': {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.innerHTML = item.title;
|
button.innerHTML = item.title;
|
||||||
if (item.wide === true) {
|
if (item.wide === true) button.classList.add(WIDE_CLASSNAME);
|
||||||
button.classList.add(WIDE_CLASSNAME);
|
if (item.tall === true) button.classList.add(TALL_CLASSNAME);
|
||||||
}
|
|
||||||
const value = this.sim.getOption(path);
|
const value = this.sim.getOption(path);
|
||||||
if (value === undefined) {
|
|
||||||
this.sim.setOption(path, item.default);
|
|
||||||
}
|
|
||||||
button.style.opacity = value ? '100%' : '50%';
|
button.style.opacity = value ? '100%' : '50%';
|
||||||
this.sim.onOptionSet(path, value => {
|
this.sim.onOptionSet(path, value => {
|
||||||
console.log('option set cb', path, value);
|
|
||||||
button.style.opacity = value ? '100%' : '50%';
|
button.style.opacity = value ? '100%' : '50%';
|
||||||
console.log('button opacity', button.style.opacity);
|
|
||||||
});
|
});
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const value = this.sim.options.getOption(path, true);
|
const value = this.sim.getOption(path);
|
||||||
console.log('click, option value', value);
|
|
||||||
this.sim.setOption(path, !value);
|
this.sim.setOption(path, !value);
|
||||||
});
|
});
|
||||||
return button;
|
return button;
|
||||||
@ -59,8 +71,7 @@ export class OptionsTool extends Tool {
|
|||||||
input.classList.add(WIDE_CLASSNAME);
|
input.classList.add(WIDE_CLASSNAME);
|
||||||
}
|
}
|
||||||
title.innerHTML = item.title;
|
title.innerHTML = item.title;
|
||||||
input.value = item.default;
|
input.value = this.sim.getOption(path);
|
||||||
this.sim.setOption(path, item.default);
|
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
input.addEventListener('input', () => {
|
||||||
input.value = input.value.slice(0, maxLength);
|
input.value = input.value.slice(0, maxLength);
|
||||||
@ -77,18 +88,7 @@ export class OptionsTool extends Tool {
|
|||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.error('Unknown option type', item);
|
|
||||||
throw new Error('Unknown option type');
|
throw new Error('Unknown option type');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(container, sections) {
|
|
||||||
super(container);
|
|
||||||
|
|
||||||
for (const sectionName of sections) {
|
|
||||||
const option = this.sim.options.getSection(sectionName);
|
|
||||||
const child = this.visitItem(option);
|
|
||||||
this.div.appendChild(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,11 @@ export class PlayPause extends Tool {
|
|||||||
this.playButton.style.opacity = this.sim.playing ? '50%' : '100%';
|
this.playButton.style.opacity = this.sim.playing ? '50%' : '100%';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setContainer(container) {
|
||||||
|
super.setContainer(container);
|
||||||
|
this.updateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
constructor(container) {
|
constructor(container) {
|
||||||
super(container);
|
super(container);
|
||||||
|
|
||||||
@ -32,8 +37,6 @@ export class PlayPause extends Tool {
|
|||||||
playButton.classList.add(TALL_CLASSNAME);
|
playButton.classList.add(TALL_CLASSNAME);
|
||||||
pauseButton.classList.add(TALL_CLASSNAME);
|
pauseButton.classList.add(TALL_CLASSNAME);
|
||||||
|
|
||||||
this.updateButtons();
|
|
||||||
|
|
||||||
pauseButton.addEventListener('click', () => {
|
pauseButton.addEventListener('click', () => {
|
||||||
this.sim.panning = undefined;
|
this.sim.panning = undefined;
|
||||||
if (this.sim.playing) {
|
if (this.sim.playing) {
|
||||||
|
|||||||
@ -7,10 +7,6 @@ import {
|
|||||||
export class UtilityTool extends Tool {
|
export class UtilityTool extends Tool {
|
||||||
currentTimeEl = undefined;
|
currentTimeEl = undefined;
|
||||||
|
|
||||||
get displayScaleText() {
|
|
||||||
return `Scale: ${this.sim.getScaleDisplay()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get timeText() {
|
get timeText() {
|
||||||
let time = this.sim.time;
|
let time = this.sim.time;
|
||||||
// Time in milliseconds
|
// Time in milliseconds
|
||||||
@ -40,100 +36,39 @@ export class UtilityTool extends Tool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setContainer(container) {
|
||||||
|
super.setContainer(container);
|
||||||
|
this.currentTimeEl.innerHTML = this.timeText;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(container) {
|
constructor(container) {
|
||||||
super(container);
|
super(container);
|
||||||
|
|
||||||
const zeroVelocity = document.createElement('button');
|
|
||||||
const clearTraces = document.createElement('button');
|
const clearTraces = document.createElement('button');
|
||||||
const zoomAll = document.createElement('button');
|
|
||||||
const currentScale = document.createElement('button')
|
|
||||||
const currentTime = document.createElement('button');
|
const currentTime = document.createElement('button');
|
||||||
const clearDebug = document.createElement('button');
|
const clearDebug = document.createElement('button');
|
||||||
|
|
||||||
this.currentTimeEl = currentTime;
|
this.currentTimeEl = currentTime;
|
||||||
|
|
||||||
this.div.appendChild(currentTime);
|
this.div.appendChild(currentTime);
|
||||||
this.div.appendChild(currentScale);
|
|
||||||
this.div.appendChild(zoomAll);
|
|
||||||
this.div.appendChild(zeroVelocity);
|
|
||||||
this.div.appendChild(clearTraces);
|
this.div.appendChild(clearTraces);
|
||||||
this.div.appendChild(clearDebug);
|
this.div.appendChild(clearDebug);
|
||||||
|
|
||||||
zeroVelocity.classList.add(WIDE_CLASSNAME);
|
|
||||||
clearTraces.classList.add(WIDE_CLASSNAME);
|
clearTraces.classList.add(WIDE_CLASSNAME);
|
||||||
zoomAll.classList.add(WIDE_CLASSNAME);
|
|
||||||
currentScale.classList.add(WIDE_CLASSNAME);
|
|
||||||
currentScale.classList.add(TOOL_INFO_CLASSNAME);
|
|
||||||
currentTime.classList.add(TOOL_INFO_CLASSNAME);
|
currentTime.classList.add(TOOL_INFO_CLASSNAME);
|
||||||
currentTime.classList.add(WIDE_CLASSNAME);
|
currentTime.classList.add(WIDE_CLASSNAME);
|
||||||
clearDebug.classList.add(WIDE_CLASSNAME);
|
clearDebug.classList.add(WIDE_CLASSNAME);
|
||||||
|
|
||||||
zeroVelocity.innerHTML = 'Zero Momentum';
|
|
||||||
clearTraces.innerHTML = 'Clear Traces';
|
clearTraces.innerHTML = 'Clear Traces';
|
||||||
zoomAll.innerHTML = 'Zoom to Fit';
|
|
||||||
currentScale.innerHTML = this.displayScaleText;
|
|
||||||
currentTime.innerHTML = this.timeText;
|
|
||||||
clearDebug.innerHTML = 'Clear Debug';
|
clearDebug.innerHTML = 'Clear Debug';
|
||||||
|
|
||||||
this.sim.onZoom(() => {
|
|
||||||
currentScale.innerHTML = this.displayScaleText;
|
|
||||||
});
|
|
||||||
|
|
||||||
zeroVelocity.addEventListener('click', () => {
|
|
||||||
// Determine center of mass and average momentum
|
|
||||||
const { totalMass, netMomentum } = this.sim.objects.computeSystemCenter();
|
|
||||||
const netVelocity = {
|
|
||||||
x: netMomentum.x / totalMass,
|
|
||||||
y: netMomentum.y / totalMass,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply offset to all object velocities
|
|
||||||
this.sim.objects.forEachObject(obj => {
|
|
||||||
obj.velocity.x -= netVelocity.x;
|
|
||||||
obj.velocity.y -= netVelocity.y;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cancel panning
|
|
||||||
this.sim.panning = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTraces.addEventListener('click', () => {
|
clearTraces.addEventListener('click', () => {
|
||||||
// Obliterate object histories
|
// Obliterate object histories
|
||||||
this.sim.objects.forEachObject(obj => {
|
this.sim.system.forEachObject(obj => {
|
||||||
obj.history = [];
|
obj.history = [];
|
||||||
}, {alive: null});
|
}, {alive: null});
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
|
||||||
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)
|
|
||||||
});
|
|
||||||
|
|
||||||
clearDebug.addEventListener('click', () => {
|
clearDebug.addEventListener('click', () => {
|
||||||
this.sim.info = {};
|
this.sim.info = {};
|
||||||
});
|
});
|
||||||
|
|||||||
69
tool/zoom.js
69
tool/zoom.js
@ -2,20 +2,51 @@ import { Tool } from '../tool.js';
|
|||||||
import {
|
import {
|
||||||
ZOOM_IN_FACTOR,
|
ZOOM_IN_FACTOR,
|
||||||
ZOOM_OUT_FACTOR,
|
ZOOM_OUT_FACTOR,
|
||||||
|
WIDE_CLASSNAME,
|
||||||
|
TOOL_INFO_CLASSNAME,
|
||||||
} from '../config.js';
|
} from '../config.js';
|
||||||
|
|
||||||
export class Zoom extends Tool {
|
export class Zoom extends Tool {
|
||||||
|
currentScaleEl = undefined;
|
||||||
|
|
||||||
|
get displayScaleText() {
|
||||||
|
return `Scale: ${this.sim.getScaleDisplay()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContainer(container) {
|
||||||
|
super.setContainer(container);
|
||||||
|
|
||||||
|
this.currentScaleEl.innerHTML = this.displayScaleText;
|
||||||
|
|
||||||
|
this.sim.onZoom(() => {
|
||||||
|
this.currentScaleEl.innerHTML = this.displayScaleText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
constructor(container) {
|
constructor(container) {
|
||||||
super(container);
|
super(container);
|
||||||
|
|
||||||
|
const currentScale = document.createElement('button')
|
||||||
const zoomOut = document.createElement('button');
|
const zoomOut = document.createElement('button');
|
||||||
const zoomIn = document.createElement('button');
|
const zoomIn = document.createElement('button');
|
||||||
|
const zoomAll = document.createElement('button');
|
||||||
|
const zeroVelocity = document.createElement('button');
|
||||||
|
|
||||||
|
this.currentScaleEl = currentScale;
|
||||||
|
|
||||||
|
this.div.appendChild(currentScale);
|
||||||
this.div.appendChild(zoomOut);
|
this.div.appendChild(zoomOut);
|
||||||
this.div.appendChild(zoomIn);
|
this.div.appendChild(zoomIn);
|
||||||
|
this.div.appendChild(zoomAll);
|
||||||
|
this.div.appendChild(zeroVelocity);
|
||||||
|
|
||||||
|
currentScale.classList.add(WIDE_CLASSNAME);
|
||||||
|
currentScale.classList.add(TOOL_INFO_CLASSNAME);
|
||||||
|
|
||||||
zoomOut.innerHTML = 'Zoom<br>Out';
|
zoomOut.innerHTML = 'Zoom<br>Out';
|
||||||
zoomIn.innerHTML = 'Zoom<br>In';
|
zoomIn.innerHTML = 'Zoom<br>In';
|
||||||
|
zoomAll.innerHTML = 'Zoom to Fit';
|
||||||
|
zeroVelocity.innerHTML = 'Zero Momentum';
|
||||||
|
|
||||||
zoomOut.addEventListener('click', () => {
|
zoomOut.addEventListener('click', () => {
|
||||||
// Aim at center of view
|
// Aim at center of view
|
||||||
@ -30,5 +61,43 @@ export class Zoom extends Tool {
|
|||||||
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(this.sim.screenToSim(x, y), ZOOM_IN_FACTOR);
|
this.sim.scheduleZoom(this.sim.screenToSim(x, y), ZOOM_IN_FACTOR);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
zoomAll.addEventListener('click', () => {
|
||||||
|
// Determine bounding box
|
||||||
|
const box = this.sim.system.boundingBox;
|
||||||
|
const x = (box.start.x + box.end.x) / 2;
|
||||||
|
const y = (box.start.y + box.end.y) / 2;
|
||||||
|
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 ratio = Math.max(widthRatio, heightRatio) * 4;
|
||||||
|
const factor = Math.ceil(Math.log2(1 / ratio));
|
||||||
|
|
||||||
|
// Determine average momentum and set panning velocity to match
|
||||||
|
const { netMomentum, totalMass } = this.sim.system.computeSystemCenter();
|
||||||
|
const netVelocity = {
|
||||||
|
x: netMomentum.x / totalMass,
|
||||||
|
y: netMomentum.y / totalMass,
|
||||||
|
};
|
||||||
|
this.sim.scheduleZoom({x, y}, factor, netVelocity)
|
||||||
|
});
|
||||||
|
|
||||||
|
zeroVelocity.addEventListener('click', () => {
|
||||||
|
// Determine center of mass and average momentum
|
||||||
|
const { totalMass, netMomentum } = this.sim.system.computeSystemCenter();
|
||||||
|
const netVelocity = {
|
||||||
|
x: netMomentum.x / totalMass,
|
||||||
|
y: netMomentum.y / totalMass,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply offset to all object velocities
|
||||||
|
this.sim.system.forEachObject(obj => {
|
||||||
|
obj.velocity.x -= netVelocity.x;
|
||||||
|
obj.velocity.y -= netVelocity.y;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel panning
|
||||||
|
this.sim.panning = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
|
import {TOOLBAR_GROUP_CLASSNAME, WIDE_CLASSNAME} from './config.js';
|
||||||
export class ToolbarGroup {
|
export class ToolbarGroup {
|
||||||
sim = undefined;
|
sim = undefined;
|
||||||
toolbars = [];
|
toolbars = [];
|
||||||
|
|
||||||
constructor(sim) {
|
constructor(sim) {
|
||||||
this.sim = sim;
|
this.sim = sim;
|
||||||
const div = document.createElement('div');
|
this.div = document.createElement('div');
|
||||||
this.div = div;
|
this.sim.div.appendChild(this.div);
|
||||||
this.sim.div.appendChild(div);
|
this.div.classList.add(TOOLBAR_GROUP_CLASSNAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
topRight() {
|
topRight() {
|
||||||
@ -16,6 +17,11 @@ export class ToolbarGroup {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wide() {
|
||||||
|
this.div.classList.add(WIDE_CLASSNAME);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
addToolbar(toolbar) {
|
addToolbar(toolbar) {
|
||||||
this.div.appendChild(toolbar.div);
|
this.div.appendChild(toolbar.div);
|
||||||
this.toolbars.push(toolbar);
|
this.toolbars.push(toolbar);
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export class Toolbar {
|
|||||||
// tool: instance of Tool
|
// tool: instance of Tool
|
||||||
addTool(tool) {
|
addTool(tool) {
|
||||||
this.div.appendChild(tool.div);
|
this.div.appendChild(tool.div);
|
||||||
|
tool.setContainer(this);
|
||||||
this.tools.push(tool);
|
this.tools.push(tool);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
59
zoom.js
Normal file
59
zoom.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
EVENT_ZOOM,
|
||||||
|
SCALE_POWER_MAX,
|
||||||
|
SCALE_POWER_MIN,
|
||||||
|
} from './config.js';
|
||||||
|
|
||||||
|
export class Zoom {
|
||||||
|
sim = undefined;
|
||||||
|
nextZoom = undefined;
|
||||||
|
|
||||||
|
constructor(sim) {
|
||||||
|
this.sim = sim;
|
||||||
|
}
|
||||||
|
|
||||||
|
// velocity should be in Sim coordinate scale
|
||||||
|
scheduleZoom({x, y}, factor, velocity) {
|
||||||
|
this.nextZoom = {x, y, factor, velocity};
|
||||||
|
}
|
||||||
|
|
||||||
|
frame() {
|
||||||
|
if (this.nextZoom) {
|
||||||
|
this.zoom(this.nextZoom);
|
||||||
|
this.nextZoom = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// x, y should be in Sim coordinates
|
||||||
|
// velocity should be in Sim coordinate scale
|
||||||
|
zoom({x, y, factor, velocity}) {
|
||||||
|
const { display } = this.sim;
|
||||||
|
// 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
|
||||||
|
// TODO: Lossy rescaling to expand zoom range
|
||||||
|
let scalePower = display.scalePower + factor;
|
||||||
|
scalePower = Math.max(scalePower, SCALE_POWER_MIN);
|
||||||
|
scalePower = Math.min(scalePower, SCALE_POWER_MAX);
|
||||||
|
this.sim.display.scalePower = scalePower;
|
||||||
|
|
||||||
|
// compute coordinates of new view frame
|
||||||
|
display.viewOrigin.x = x - display.width / 2;
|
||||||
|
display.viewOrigin.y = y - display.height / 2;
|
||||||
|
|
||||||
|
// Pointer history is stored in client coordinates, so we shouldn't need to clear it?
|
||||||
|
// this.pointer.clearPointerHistory();
|
||||||
|
|
||||||
|
// TODO: If paused, set panning velocity on resume
|
||||||
|
// if (this.sim.playing && velocity) {
|
||||||
|
if (velocity) {
|
||||||
|
this.sim.panning = {
|
||||||
|
velocity: { ...velocity }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const e = new CustomEvent(EVENT_ZOOM);
|
||||||
|
this.sim.div.dispatchEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user