diff --git a/Readme.md b/Readme.md
index 0f374ab..40ba8ab 100644
--- a/Readme.md
+++ b/Readme.md
@@ -12,19 +12,22 @@ Screenshots

+
+
+
TODO
----
- [x] Selection Box
-- [ ] Feature: Object List
-- [ ] Feature: Object Detail
-- [ ] Feature: Zoom to Object
+- [x] Feature: Object List
+- [x] Feature: Object Detail
+- [x] Feature: Zoom to Object
- [ ] Feature: Teleport Object
- [ ] Enhancement: Create Time class and refactor to use
-- [ ] Enhancement: Create Vector class and refactor to use
-- [ ] Enhancement: Create Panning class and refactor to use
+- [x] Enhancement: Create Vector class and refactor to use
+- [x] Enhancement: Create Panning class and refactor to use
- [ ] Enhancement: Handle pointerleave or other mechanism when window loses focus
-- [ ] Enhancement: Calculate Work as FxD as measure of energy flux
+- [x] Enhancement: Calculate Work as FxD as measure of energy flux
- [ ] Feature: Automatically slow time when energy flux is greater
- [ ] Enhancement: Add z-axis (Initially nothing changes, z = 0)
- [ ] Feature: Isometric 3d View
@@ -32,9 +35,11 @@ TODO
- [ ] Feature: Polar Coordinates
- [ ] Feature: Cylindrical Coordinates
- [ ] Feature: Spherical Coordinates
-- [ ] Feature: Camera Velocity Display
-- [ ] Enhancement: World State Snapshots
-- [ ] Feature: List / Save / Load World States
+- [x] Feature: Camera Velocity Display
+- [x] Enhancement: World State Snapshots
+- [x] Feature: List / Save / Load World States
+- [ ] Enhancement: Save / Load Snapshots from Local Storage
+- [x] Feature: Import / Export / Share Snapshots
- [ ] Feature: Left Button Panning
- [ ] Feature: Middle Button Pause
- [ ] Feature: Parameter Slider (Invisible, mouse/touch drag)
@@ -52,5 +57,5 @@ TODO
- [ ] Feature: Time Control: Reverse Time
- [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow)
- [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y)
-- [x] Task: Verify stationary pointer leads to zero pointer velocity
-- [ ] Fix: Unpause panning when initiated while sim is paused
+- [x] Fix: Unpause panning when initiated while sim is paused
+- [ ] Enhancement: Refactor to use viewOrigin as center of display canvas
diff --git a/config.js b/config.js
index 8ce6543..cb706d7 100644
--- a/config.js
+++ b/config.js
@@ -19,6 +19,9 @@ export const PATH_TRACES_OPACITY = 0.8;
export const PATH_TRACES_WIDTH = 1.5;
export const PATH_TRACES_DASHED_OPACITY = 1.0;
+// PANNING
+export const PANNING_ZERO_TOUCH_THRESHOLD = 200;
+
// SIZES
export const POINTER_HISTORY_SIZE = 20;
export const OBJECT_HISTORY_SIZE = 1e5;
@@ -51,6 +54,9 @@ export const EVENT_MODE_ENTER = 'lhg-mode-enter';
export const EVENT_ZOOM = 'lhg-zoom-event';
export const EVENT_OPTION_SET = 'lhg-option-set';
export const EVENT_PLAY_PAUSE = 'lhg-play-pause';
+export const EVENT_SELECT = 'lhg-select';
+export const EVENT_OBJECT_CREATE = 'lhg-object-create';
+export const EVENT_OBJECT_MERGE = 'lhg-object-merge';
// MODES
export const MODE_MASS_GENERATION = 'mass-gen';
diff --git a/display.js b/display.js
index 38ed700..db26bb3 100644
--- a/display.js
+++ b/display.js
@@ -20,6 +20,18 @@ export class Display {
}
}
+ toJSON() {
+ return {
+ scalePower: this.scalePower,
+ viewOrigin: this.viewOrigin,
+ };
+ }
+
+ fromJSON({scalePower, viewOrigin}) {
+ this.scalePower = scalePower;
+ this.viewOrigin = viewOrigin;
+ }
+
frame() {
// Clear canvas in preparation for other modules to render this frame
this.fillCanvas();
@@ -140,7 +152,7 @@ export class Display {
ctx.resetTransform();
}
- drawBox({start, end}) {
+ drawBox(start, end) {
const ctx = this.ctx;
ctx.strokeStyle = 'rgb(0, 255, 0)';
ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y);
diff --git a/gravity-simulator-6.png b/gravity-simulator-6.png
new file mode 100644
index 0000000..d0117d2
Binary files /dev/null and b/gravity-simulator-6.png differ
diff --git a/helper.js b/helper.js
index 33d0443..16a2a1f 100644
--- a/helper.js
+++ b/helper.js
@@ -1,2 +1,51 @@
-export function makeUtilityButton() {
+// `items` is an array of which `item` is a member
+// `item` must let us read/write property `hidden`
+// `parentEl` is the containing element for `itemEl`
+// `itemEl` is the
+//
+// The idea is that item remains a member of items, but
+// its elementmay be added and removed from the parent element.
+// We use the items array to determine the placement of itemEl
+export function show({items, item, parentEl, itemEl}) {
+ if (items.length < 2) {
+ parentEl.appendChild(itemEl);
+ return;
+ }
+ // To determine placement,
+ // Start with our index in the toolbar tools;
+ // iterate through toolbar tools before this one,
+ // and subtract hidden ones from the index.
+
+ let countHidden = 0;
+ let index = items.indexOf(item);
+ for (let i = 0; i < index; i++) {
+ const sibling = items[i];
+ if (sibling.hidden) countHidden += 1;
+ }
+ index -= countHidden;
+
+ // Now we need to find our place.
+ // Add to parent using insertBefore.
+ let idx = 0;
+ let nextEl = parentEl.firstChild;
+ while (idx < index) {
+ nextEl = nextEl.nextSibling;
+ idx += 1;
+ }
+ parentEl.insertBefore(itemEl, nextEl);
+ item.hidden = false;
+}
+
+export function hide({items, item, parentEl, itemEl}) {
+ if (items.indexOf(item) < 0) return;
+ parentEl.removeChild(itemEl);
+ item.hidden = true;
+}
+
+// Copied from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
+export async function hash(text) {
+ const msgUint8 = new TextEncoder().encode(text); // encode as (utf-8) Uint8Array
+ const hashBuffer = await window.crypto.subtle.digest("SHA-256", msgUint8); // hash the message
+ const hashHex = new Uint8Array(hashBuffer).toHex(); // Convert ArrayBuffer to hex string.
+ return hashHex;
}
diff --git a/object.js b/object.js
index a1336c5..ab403e9 100644
--- a/object.js
+++ b/object.js
@@ -16,7 +16,7 @@ import {
VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_WIDTH,
} from './config.js';
-import {add, components, direction, div, magnitude, zero} from './vector.js';
+import {add, components, copy, direction, div, magnitude, zero} from './vector.js';
export class MassObject {
sim = undefined;
@@ -27,7 +27,7 @@ export class MassObject {
velocity = zero;
acceleration = zero;
color = {r: undefined, g: undefined, b: undefined};
- created = undefined;
+ timeCreated = undefined;
forces = []; // [{x, y}]
history = [];
alive = true;
@@ -44,17 +44,39 @@ export class MassObject {
this.color.g = Math.random() * 256;
this.color.b = Math.random() * 256;
this.timeCreated = this.sim.time;
- this.rawTimeCreated = this.sim.rawTime;
+ }
+
+ toJSON() {
+ return {
+ id: this.id,
+ mass: this.mass,
+ density: this.density,
+ position: this.position,
+ velocity: this.velocity,
+ color: this.color,
+ timeCreated: this.timeCreated,
+ alive: this.alive,
+ // TODO: optional export history
+ };
+ }
+
+ fromJSON(obj) {
+ this.id = obj.id;
+ this.mass = obj.mass;
+ this.density = obj.density;
+ this.position = copy(obj.position);
+ this.velocity = copy(obj.velocity);
+ this.color = obj.color;
+ this.timeCreated = obj.timeCreated;
+ this.alive = obj.alive;
+ // TODO: optional import history
+ this.history = [];
}
get age() {
return this.sim.time - this.timeCreated;
}
- get rawAge() {
- return this.sim.rawTime - this.rawTimeCreated;
- }
-
get radius() {
// radius should be proportional to cube root of mass
return Math.pow(this.mass / this.density, 1 / 3);
diff --git a/panning.js b/panning.js
index fefd8ba..d53463b 100644
--- a/panning.js
+++ b/panning.js
@@ -1,4 +1,5 @@
-import {add, copy, div, mult, sub, zero} from "./vector.js";
+import {PANNING_ZERO_TOUCH_THRESHOLD} from "./config.js";
+import {add, copy, div, mult, zero} from "./vector.js";
export class Panning {
sim = undefined;
@@ -11,10 +12,6 @@ export class Panning {
this.sim = sim;
}
- handlePointerDown({x, y}) {
- this.initializeTouch({x, y});
- }
-
initializeTouch({x, y}) {
this.touchStart = {
x,
@@ -30,6 +27,25 @@ export class Panning {
};
}
+ toJSON() {
+ return {
+ velocity: this.velocity,
+ paused: this.paused,
+ };
+ }
+
+ fromJSON({velocity, paused}) {
+ this.velocity = copy(velocity);
+ this.paused = paused;
+ }
+
+ handlePointerDown({x, y}) {
+ this.initializeTouch({x, y});
+ if (this.paused) {
+ this.paused = false;
+ }
+ }
+
// With fast panning, panning velocity calculation happens every move;
// With normal panning, calculation only happens at pointer up.
handlePointerMove({x, y}) {
@@ -42,49 +58,46 @@ export class Panning {
dy: x - this.touchStart.y,
dt: this.sim.rawTime - this.touchStart.t,
};
- if (this.sim.getOption('compensate.fastPanning')) {
- this.updateVelocity();
+
+ // Convert pointer velocity to simulation scale
+ let velocity = div(this.sim.pointer.latestVelocity, this.sim.display.scale);
+
+ // Optional time scale compensation
+ if (this.sim.getOption('compensate.timeScale')) {
+ velocity = div(velocity, this.sim.timeScale);
}
+
+ // Additional scaling factor
+ velocity = mult(velocity, this.sim.getOption('display.panningSpeed'));
+
+ // TODO: Make it easier to slow down the camera
+
+ // Add pointer velocity to current panning velocity
+ this.velocity = add(this.velocity, velocity);
}
}
handlePointerUp() {
if (this.touchStart && this.touchLatest) {
- if (this.touchLatest.dt === 0) {
+ if (this.touchLatest.dt < PANNING_ZERO_TOUCH_THRESHOLD) {
this.velocity = zero;
}
this.touchStart = undefined;
if (this.sim.getOption('compensate.fastPanning')) {
this.velocity = zero;
- } else {
- this.updateVelocity();
}
}
}
frame(elapsedTime) {
- const {touchStart: start, touchLatest: latest} = this;
const {display} = this.sim;
- // Direct translate, unless using fast panning
- if (start && latest && !this.sim.getOption('compensate.fastPanning')) {
- // start and latest are in screen coordinates, need to convert to sim scale
- const delta = div(sub(latest, start), display.scale);
- display.viewOrigin = sub(start.viewOrigin, delta);
- }
-
// Apply update to viewOrigin based on panning
if (!this.paused) {
// elapsedTime is scaled by time scale, is that what we want?
// Yes because if panning.velocity == obj.velocity, object should stay in view
- const delta = mult(this.velocity, elapsedTime);
- display.viewOrigin = add(display.viewOrigin, delta);
- }
-
- // Update what's considered start
- if (start && latest) {
- this.initializeTouch(this.touchLatest);
+ display.viewOrigin = add(display.viewOrigin, mult(this.velocity, elapsedTime));
}
if (this.sim.getOption('debug.panningInfo')) {
@@ -96,18 +109,6 @@ export class Panning {
}
}
- updateVelocity() {
- // Convert pointer velocity to simulation scale, and multiply by -1
- // because the camera is panning opposite to the pointer velocity.
- let velocity = div(this.sim.pointer.latestVelocity, -this.sim.display.scale);
- if (this.sim.getOption('compensate.timeScale')) {
- velocity = div(velocity, this.sim.timeScale);
- }
- // Also add current panning
- velocity = add(velocity, this.velocity);
- this.velocity = velocity;
- }
-
setVelocity(velocity) {
this.velocity = velocity;
if (!this.sim.playing) {
diff --git a/pointer.js b/pointer.js
index 0977cb4..2aaeca1 100644
--- a/pointer.js
+++ b/pointer.js
@@ -3,63 +3,52 @@ import {
MODE_OBJECT_SELECT,
MODE_PAN_VIEW,
POINTER_HISTORY_SIZE,
- TOOLBAR_CLASSNAME,
ZOOM_IN_FACTOR,
- ZOOM_OUT_FACTOR,
+ ZOOM_OUT_FACTOR
} from './config.js';
export class Pointer {
sim = undefined;
pointerHistory = [];
- touchStart = undefined; // {x: undefined, y: undefined, t: undefined};
- touchLatest = undefined; // {x: undefined, y: undefined, t: undefined};
suppressClick = false;
constructor(sim) {
this.sim = sim;
// Monitor mouse movements
- const el = window;
+ const {canvas} = this.sim.display;
- el.addEventListener('pointermove', e => {
+ window.addEventListener('pointermove', e => {
if (this.sim.getOption('debug.cursorInfo')) {
this.sim.info['pointermove'] = [`${e.clientX.toPrecision(6)}, `, `${e.clientY.toPrecision(6)}`];
}
this.handlePointerMove({x: e.clientX, y: e.clientY});
});
- el.addEventListener('pointerdown', e => {
- let target = e.target;
- while (target && !target.classList?.contains(TOOLBAR_CLASSNAME)) {
- target = target.parentNode;
- }
- if (target) {
- return;
- }
-
+ canvas.addEventListener('pointerdown', e => {
this.handlePointerDown({x: e.clientX, y: e.clientY});
});
- el.addEventListener('pointerup', e => {
+ window.addEventListener('pointerup', e => {
this.handlePointerUp({x: e.clientX, y: e.clientY});
});
- el.addEventListener('pointerleave', e => {
- this.handlePointerUp({x: e.clientX, y: e.clientY});
- });
+ // window.addEventListener('pointerleave', e => {
+ // this.handlePointerUp({x: e.clientX, y: e.clientY});
+ // });
// Monitor wheel events
- el.addEventListener('wheel', e => {
+ canvas.addEventListener('wheel', e => {
const factor = e.deltaY < 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
const {x, y} = this.sim.screenToSim(e.clientX, e.clientY);
this.sim.scheduleZoom({x, y}, factor);
});
- el.addEventListener('focus', () => {
+ window.addEventListener('focus', () => {
console.log('window focus');
});
- el.addEventListener('blur', () => {
+ window.addEventListener('blur', () => {
console.log('window blur');
});
}
diff --git a/select.js b/select.js
index 67144c4..fd0e064 100644
--- a/select.js
+++ b/select.js
@@ -1,4 +1,5 @@
-import {copy} from './vector.js';
+import {EVENT_SELECT} from './config.js';
+import {add, copy, mult} from './vector.js';
export class Select {
sim = undefined;
@@ -30,7 +31,7 @@ export class Select {
handlePointerDown({x: clientX, y: clientY}) {
this.box.start = this.sim.screenToSim(clientX, clientY);
this.box.end = this.box.start;
- this.getSelectedObjects();
+ // this.getSelectedObjects();
}
handlePointerMove({x: clientX, y: clientY}) {
@@ -51,12 +52,24 @@ export class Select {
y: Math.max(start.y, end.y),
};
this.getSelectedObjects();
+ this.sim.div.dispatchEvent(new CustomEvent(EVENT_SELECT));
this.box = {
start: undefined,
end: undefined,
};
}
+ frame(elapsedTime) {
+ if (!this.box.start) return;
+ // If panning, let's update the position of our box so it doesn't drift away
+ const {velocity} = this.sim.panning;
+ const delta = mult(velocity, elapsedTime);
+ this.box.start = add(this.box.start, delta);
+ this.box.end = add(this.box.end, delta);
+ // Display the box
+ this.sim.display.drawBox(this.box.start, this.box.end);
+ }
+
getSelectedObjects() {
const {start, end} = this.box;
if (!start) return;
@@ -67,8 +80,11 @@ export class Select {
this.selectedSingle = this.selectedGroup[0] ?? undefined;
}
- frame() {
- if (!this.box.start) return;
- this.sim.display.drawBox(this.box)
+ // cb: ({selectedGroup, selectedSingle}) => undefined
+ onSelect(cb) {
+ this.sim.div.addEventListener(EVENT_SELECT, () => {
+ const {selectedGroup, selectedSingle} = this;
+ cb({selectedGroup, selectedSingle});
+ });
}
}
diff --git a/sim-options.js b/sim-options.js
index 598f2be..c79a310 100644
--- a/sim-options.js
+++ b/sim-options.js
@@ -8,10 +8,11 @@ export const simOptions = {
velocity: ['Velocity Vectors', 'boolean', true],
acceleration: ['Accel Vectors', 'boolean', true],
traces: ['Path Traces', 'boolean', true],
- dashedTraces: ['Dashed', 'boolean', false, {tall: true, hideUnless: 'display.traces'}],
- velocityScale: ['Velocity
Vec Scale', 'number', 80, {hideUnless: 'display.velocity'}],
- accelerationScale: ['Accel
Vec Scale', 'number', 800, {hideUnless: 'display.acceleration'}],
- zoomVectors: ['Zoom Vectors', 'boolean', true]
+ dashedTraces: ['Dashed', 'boolean', false, {tall: true, showIf: 'display.traces'}],
+ velocityScale: ['Velocity
Vec Scale', 'number', 80, {showIf: 'display.velocity'}],
+ accelerationScale: ['Accel
Vec Scale', 'number', 800, {showIf: 'display.acceleration'}],
+ zoomVectors: ['Zoom Vectors', 'boolean', true],
+ panningSpeed: ['Pan
Speed', 'number', 0.1],
},
compensate: {
timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}],
diff --git a/sim-tools.js b/sim-tools.js
index 0abf9ab..81da2e6 100644
--- a/sim-tools.js
+++ b/sim-tools.js
@@ -1,8 +1,10 @@
+import {CameraTool} from './tool/camera.js';
import {ModeSwitch} from './tool/modes.js';
import {ObjectTool} from './tool/object.js';
import {ObjectsTool} from './tool/objects.js';
import {OptionsTool} from './tool/options.js';
import {PlayPause} from './tool/play-pause.js';
+import {StateTool} from './tool/state.js';
import {UtilityTool} from './tool/utility.js';
import {Zoom} from './tool/zoom.js';
import {ToolbarGroup} from './toolbar-group.js';
@@ -11,6 +13,12 @@ import {Toolbar} from './toolbar.js';
export function initializeTools(sim) {
sim.toolbarGroups = {
left: new ToolbarGroup(sim)
+ .addToolbar(new Toolbar(sim, 'Camera')
+ .addTool(new CameraTool())
+ )
+ .addToolbar(new Toolbar(sim, 'State')
+ .addTool(new StateTool())
+ )
.addToolbar(new Toolbar(sim, 'Tools')
.addTool(new Zoom())
.addTool(new PlayPause())
diff --git a/simulator.js b/simulator.js
index 4d02bd8..d63fea7 100644
--- a/simulator.js
+++ b/simulator.js
@@ -4,15 +4,15 @@ import {
FRAMERATE_SAMPLE_DURATION,
} from './config.js';
import {Display} from './display.js';
-import {System} from './system.js';
-import {Overlay} from './overlay.js';
-import {Pointer} from './pointer.js';
import {Options} from './options.js';
-import {Zoom} from './zoom.js';
+import {Overlay} from './overlay.js';
import {Panning} from './panning.js';
+import {Pointer} from './pointer.js';
import {Select} from './select.js';
import {simOptions} from './sim-options.js';
import {initializeTools} from './sim-tools.js';
+import {System} from './system.js';
+import {Zoom} from './zoom.js';
export class Sim {
info = {};
@@ -88,13 +88,19 @@ export class Sim {
this.zoom.frame(elapsedTime);
this.panning.frame(elapsedTime);
this.pointer.frame(elapsedTime);
+ // The display.frame() wipes out the canvas, so all
+ // main canvas drawing routines must come after the next line.
this.display.frame(elapsedTime);
this.select.frame(elapsedTime);
+ // The system.frame() renders objects
this.system.frame(elapsedTime);
this.overlay.frame(elapsedTime);
for (const group in this.toolbarGroups) {
this.toolbarGroups[group].frame(elapsedTime);
}
+ // Schedule our next iteration
+ // TODO: Consider waiting until the next frame is likely to execute,
+ // in order to aim closer to the target frame rate
requestAnimationFrame(t => this.frame(t));
}
@@ -122,6 +128,27 @@ export class Sim {
return false;
}
+ toJSON() {
+ return {
+ dateSaved: new Date().toISOString(),
+ system: this.system.toJSON(),
+ panning: this.panning.toJSON(),
+ display: this.display.toJSON(),
+ time: this.time,
+ timeScale: this.timeScale,
+ currentMode: this.getCurrentMode(),
+ };
+ }
+
+ fromJSON(state) {
+ this.system.fromJSON(state.system);
+ this.panning.fromJSON(state.panning);
+ this.display.fromJSON(state.display);
+ this.time = state.time;
+ this.timeScale = state.timeScale;
+ this.setCurrentMode(state.currentMode);
+ }
+
// velocity should be in Sim coordinate scale
scheduleZoom({x, y}, factor, velocity) {
this.zoom.scheduleZoom({x, y}, factor, velocity);
diff --git a/style.css b/style.css
index f969938..943ecc3 100644
--- a/style.css
+++ b/style.css
@@ -33,6 +33,10 @@ div[id=simulator] > canvas {
left: 0;
}
+div.lhg-toolbar-group button {
+ /* opacity: 0.8; */
+}
+
/* normal toolbar group */
div.lhg-toolbar-group div.lhg-tool {
width: 12em;
@@ -102,7 +106,12 @@ div.lhg-tool button, div.lhg-tool input {
box-sizing: border-box;
}
-div.lhg-tool button:hover {
+div.lhg-tool button a {
+ color: #5f5;
+ text-decoration: none;
+}
+
+div.lhg-tool button:hover, div.lhg-tool input:hover {
background-color: #444;
}
diff --git a/system.js b/system.js
index 637fd34..90f7317 100644
--- a/system.js
+++ b/system.js
@@ -1,9 +1,8 @@
-import {OBJECT_HISTORY_SIZE} from './config.js';
+import {EVENT_OBJECT_CREATE, EVENT_OBJECT_MERGE, OBJECT_HISTORY_SIZE} from './config.js';
import {MassObject} from './object.js';
import {
- add, copy, cross, degrees,
- direction, div, magnitude, mult,
- square, sub, weightedAvg, zero
+ add, copy, cross, degrees, direction, div,
+ dot, magnitude, mult, square, sub, weightedAvg, zero
} from './vector.js';
export class System {
@@ -11,13 +10,30 @@ export class System {
creatingObject = undefined;
selectedObject = undefined;
selectObjectStart = undefined;
- panVelocityPaused = undefined;
paused = false;
constructor(sim) {
this.sim = sim;
}
+ toJSON() {
+ return {
+ objects: this.objects.map(obj => obj.toJSON()),
+ }
+ }
+
+ fromJSON({objects} = {}) {
+ objects = objects ?? [];
+ // Replace current state with the provided one.
+ // Assumes a backup has already been saved if desired.
+ this.objects = [];
+ for (const objectJSON of objects) {
+ const obj = new MassObject(this.sim, 0, 0);
+ obj.fromJSON(objectJSON);
+ this.objects.push(obj);
+ }
+ }
+
handlePointerDown({x, y}) {
// If pointer is touching an object, select the object
const touchingObject = this.objectAtLocation(x, y);
@@ -76,12 +92,11 @@ export class System {
if (this.sim.playing) {
// Predict positions (Velocity verlet method)
this.forEachObject(obj => {
- obj.currentAcceleration = {...obj.acceleration};
+ obj.currentAcceleration = copy(obj.acceleration);
- // If this object is being created/selected, clamp its position
- if (obj.id === this.getSelectedOrCreating()?.id) {
- return;
- }
+ // If this object is being created/selected, we're not going to let it move...
+ // but we can calculate the work being done by holding it in place.
+ obj.currentPosition = copy(obj.position);
obj.position = add(obj.position, mult(
elapsedTime,
@@ -134,6 +149,8 @@ export class System {
};
T.alive = false;
T.forces = [];
+ const e = new CustomEvent(EVENT_OBJECT_MERGE, {detail: {surviving: S, merged: T}});
+ this.sim.div.dispatchEvent(e);
}
}, {alive: true, startWith: i + 1});
});
@@ -143,10 +160,21 @@ export class System {
// Predict velocities
this.forEachObject(obj => {
- const acceleration = {...obj.acceleration};
+ const acceleration = copy(obj.acceleration);
obj.acceleration = div(add(obj.currentAcceleration, acceleration), 2);
obj.velocity = add(obj.velocity, mult(obj.acceleration, elapsedTime));
+
+ // If the user is positioning this object, we'll leave its position unchanged;
+ // but let's compute how much work we're doing to accomplish it!
+ if (obj.id === this.getSelectedOrCreating()?.id) {
+ const delta = sub(obj.currentPosition, obj.position);
+ const netForce = mult(obj.acceleration, obj.mass);
+ const work = dot(netForce, delta);
+ console.log('work', work);
+ obj.position = obj.currentPosition;
+ }
+
// Append to object history
obj.history.push({position: {...obj.position}});
@@ -189,6 +217,8 @@ export class System {
this.drawObjects();
}
+ // Pause and resume to enable automatic pause on object create/select
+ // in this mode (mass generation)
pause() {
this.sim.pause();
this.paused = true;
@@ -212,8 +242,9 @@ export class System {
if (this.sim.getOption('pauseDuring.creation')) {
this.pause();
}
-
obj.velocity = copy(this.sim.panning.velocity);
+ const e = new CustomEvent(EVENT_OBJECT_CREATE, {detail: {obj}});
+ this.sim.div.dispatchEvent(e);
}
doneCreatingObject() {
@@ -223,6 +254,20 @@ export class System {
}
}
+ // cb: (obj) => undefined
+ onCreate(cb) {
+ this.sim.div.addEventListener(EVENT_OBJECT_CREATE, ({detail: {obj}}) => {
+ cb(obj);
+ });
+ }
+
+ // cb: ({surviving, merged}) => undefined
+ onMerge(cb) {
+ this.sim.div.addEventListener(EVENT_OBJECT_MERGE, ({detail: {surviving, merged}}) => {
+ cb({surviving, merged});
+ });
+ }
+
object(i) {
return this.objects[i];
}
@@ -253,21 +298,20 @@ export class System {
return this.objects.length;
}
- get boundingBox() {
+ getBoundingBox(objects = []) {
const box = this.reduce(({start, end}, obj) => {
+ if (objects.length && !objects.includes(obj)) return {start, end};
const lx = obj.position.x - obj.radius;
const gx = obj.position.x + obj.radius;
const ly = obj.position.y - obj.radius;
const gy = obj.position.y + obj.radius;
- let ret;
if (start.x === undefined) {
- ret = {
+ return {
start: {x: lx, y: ly},
end: {x: gx, y: gy},
};
- return ret;
}
- ret = {
+ return {
start: {
x: Math.min(start.x, lx),
y: Math.min(start.y, ly),
@@ -277,7 +321,6 @@ export class System {
y: Math.max(end.y, gy),
}
};
- return ret;
}, {
start: {x: undefined, y: undefined},
end: {x: undefined, y: undefined},
@@ -373,15 +416,18 @@ export class System {
});
}
- computeSystemCenter() {
+ computeSystemCenter(objects = []) {
// Determine center of mass
const {totalMass, count, totalMassLocation} =
- this.reduce((acc, obj) => ({
- count: acc.count + 1,
- totalMass: acc.totalMass + obj.mass,
- totalMassLocation: add(acc.totalMassLocation,
- mult(obj.position, obj.mass)),
- }), {
+ this.reduce((acc, obj) => {
+ if (objects.length && !objects.includes(obj)) return acc;
+ return {
+ count: acc.count + 1,
+ totalMass: acc.totalMass + obj.mass,
+ totalMassLocation: add(acc.totalMassLocation,
+ mult(obj.position, obj.mass)),
+ };
+ }, {
totalMassLocation: {x: 0, y: 0},
totalMass: 0,
count: 0,
@@ -390,8 +436,10 @@ export class System {
const centerOfMass = count ? div(totalMassLocation, totalMass) : zero;
// Determine average momentum
- const netMomentum = this.reduce((acc, obj) =>
- add(acc, mult(obj.velocity, obj.mass)), zero);
+ const netMomentum = this.reduce((acc, obj) => {
+ if (objects.length && !objects.includes(obj)) return acc;
+ return add(acc, mult(obj.velocity, obj.mass));
+ }, zero);
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
}
@@ -411,5 +459,4 @@ export class System {
return acc + obj.mass * s / d;
}, 0);
}
-
}
diff --git a/tool.js b/tool.js
index 0513e7b..91a7b2d 100644
--- a/tool.js
+++ b/tool.js
@@ -9,6 +9,7 @@ import {
export class Tool {
container = undefined;
sim = undefined;
+ hidden = false;
constructor() {
const div = document.createElement('div');
diff --git a/tool/camera.js b/tool/camera.js
new file mode 100644
index 0000000..a645a58
--- /dev/null
+++ b/tool/camera.js
@@ -0,0 +1,42 @@
+import {VELOCITY_VECTOR_COLOR} from '../config.js';
+import {Tool} from '../tool.js';
+import {add, components, direction, div, magnitude} from '../vector.js';
+
+export class CameraTool extends Tool {
+ setContainer(container) {
+ super.setContainer(container);
+
+ // Use the main sim display, but create a placeholder and draw inside it.
+ // That way we aren't blocking the main display more than necessary
+ }
+
+ constructor() {
+ super();
+
+ this.div.style.width = '150px';
+ this.div.style.height = '150px';
+ }
+
+ frame() {
+ if (!this.container.expanded) return;
+
+ const {display, panning} = this.sim;
+ const {left, top, width, height} = this.div.getBoundingClientRect();
+ const vecScale = this.sim.getOption('display.velocityScale');
+
+ // Draw a vector for the camera velocity
+ const offset = add(display.viewOrigin, div({x: left, y: top}, display.scale));
+ const start = add(offset, div({x: width, y: height}, 2 * display.scale));
+ let speed = magnitude(panning.velocity);
+ let arrowLength = Math.log10(speed + 1) * vecScale;
+ const arrowDirection = direction(panning.velocity);
+ if (!this.sim.getOption('display.zoomVectors')) {
+ arrowLength /= display.scale;
+ }
+ const end = add(start, components(arrowLength, arrowDirection));
+ display.drawArrow(start.x, start.y, end.x, end.y, {
+ style: VELOCITY_VECTOR_COLOR,
+ ifShort: 'head',
+ });
+ }
+}
diff --git a/tool/objects.js b/tool/objects.js
index a7d61d7..b9c4585 100644
--- a/tool/objects.js
+++ b/tool/objects.js
@@ -1,11 +1,82 @@
+import {hide, show} from '../helper.js';
import {Tool} from '../tool.js';
+import {add, magnitude, sub} from '../vector.js';
export class ObjectsTool extends Tool {
+ objects = [];
+
setContainer(container) {
super.setContainer(container);
+
+ // Display a list of the currently selected objects,
+ // or all objects if none are currently selected.
+ if (this.sim.select.selectedGroup.length) {
+ this.objects = this.sim.select.selectedGroup;
+ } else {
+ this.objects = this.sim.system.filter(obj => obj.alive);
+ }
+
+ this.populate();
+
+ this.sim.select.onSelect(({selectedGroup}) => {
+ this.objects = selectedGroup;
+ this.depopulate();
+ this.populate();
+ });
+
+ this.sim.system.onCreate(obj => {
+ if (!this.sim.select.selectedGroup.length) {
+ this.objects.push(obj);
+ this.populate();
+ }
+ });
+
+ this.sim.system.onMerge(({merged}) => {
+ if (!merged.objectsToolEl) return;
+ hide({
+ items: this.objects,
+ item: merged,
+ parentEl: this.div,
+ itemEl: merged.objectsToolEl,
+ });
+ });
}
- constructor() {
- super();
+ frame() {
+ this.populate();
+ }
+
+ depopulate() {
+ while (this.div.firstChild) {
+ this.div.removeChild(this.div.firstChild);
+ }
+ }
+
+ populate() {
+ for (const obj of this.objects) {
+ const objectEl = obj.objectsToolEl ?? document.createElement('div');
+ obj.objectsToolEl = objectEl;
+ const {r, g, b} = obj.color;
+ // Distance from center of screen
+ const distance = magnitude(sub(obj.position, add(this.sim.display.viewOrigin, {
+ x: this.sim.display.width / 2,
+ y: this.sim.display.height / 2,
+ })));
+ objectEl.innerHTML = `
+ ` +
+ ' ' +
+ `${obj.mass.toPrecision(3)} ` +
+ `${distance.toPrecision(3)}`;
+ // `${magnitude(obj.velocity).toExponential(0)} ` +
+ // `${-degrees(direction(obj.velocity)).toFixed(0)}°`;
+ if (!obj.hidden) {
+ show({
+ items: this.objects,
+ item: obj,
+ parentEl: this.div,
+ itemEl: objectEl,
+ });
+ }
+ }
}
}
diff --git a/tool/options.js b/tool/options.js
index fe04a1e..83ac97f 100644
--- a/tool/options.js
+++ b/tool/options.js
@@ -1,45 +1,92 @@
// Options picker
import {
- TOOL_INFO_CLASSNAME,
OPTION_GROUP_CLASSNAME,
- WIDE_CLASSNAME,
TALL_CLASSNAME,
+ TOOL_INFO_CLASSNAME,
+ WIDE_CLASSNAME,
} from '../config.js';
-import { Tool } from '../tool.js';
+import {Tool} from '../tool.js';
+import {show, hide} from '../helper.js';
export class OptionsTool extends Tool {
- sections = undefined;
+ sectionNames = undefined;
+ groups = {};
- constructor(sections) {
+ constructor(sectionNames) {
super();
- this.sections = sections;
+ this.sectionNames = sectionNames;
}
setContainer(container) {
super.setContainer(container);
- for (const sectionName of this.sections) {
- const option = this.sim.options.getSection(sectionName);
- const item = this.visitItem(option);
+ // Initialize
+ for (const sectionName of this.sectionNames) {
+ const group = this.sim.options.getSection(sectionName);
+ const item = this.visitItem(group);
this.div.appendChild(item);
}
}
+ // For now, `showIf` must be the name of a boolean property, with optional negation
+ shouldShow(option) {
+ if (option.showIf === undefined) return true;
+ const {name, value} = this.deconstructOption(option.showIf);
+ return this.sim.getOption(name) === value;
+ }
+
+ deconstructOption(showIf) {
+ let name = showIf;
+ let value = true;
+ if (name.startsWith('!')) {
+ value = false;
+ name = name.slice(1);
+ }
+ return {name, value};
+ }
+
visitItem(item, path) {
path = [path, item.name].filter(x => !!x).join('.');
switch (item.type) {
case 'group': {
- const group = document.createElement('div');
- group.classList.add(OPTION_GROUP_CLASSNAME);
+ const groupEl = document.createElement('div');
+ groupEl.classList.add(OPTION_GROUP_CLASSNAME);
+ const group = {groupEl, items: []};
+ this.groups[path] = group;
if (item.title) {
const heading = document.createElement('h3');
heading.innerHTML = item.title;
- group.appendChild(heading);
+ groupEl.appendChild(heading);
+ groupEl.items.push({itemEl: heading});
}
for (const next of item.items) {
- const child = this.visitItem(next, path);
- group.appendChild(child);
+ const optionEl = this.visitItem(next, path);
+ group.items.push(next);
+ if (this.shouldShow(next)) {
+ groupEl.appendChild(optionEl);
+ }
+ if (next.showIf) {
+ const {name} = this.deconstructOption(next.showIf);
+ this.sim.onOptionSet(name, () => {
+ if (this.shouldShow(next)) {
+ show({
+ items: group.items,
+ item: next,
+ parentEl: groupEl,
+ itemEl: optionEl,
+ });
+ } else {
+ hide({
+ items: group.items,
+ item: next,
+ parentEl: groupEl,
+ itemEl: optionEl,
+ });
+ }
+ });
+ }
+
}
- return group;
+ return groupEl;
}
case 'boolean': {
const button = document.createElement('button');
diff --git a/tool/state.js b/tool/state.js
new file mode 100644
index 0000000..e7ab3ba
--- /dev/null
+++ b/tool/state.js
@@ -0,0 +1,108 @@
+import {TOOL_INFO_CLASSNAME, WIDE_CLASSNAME} from '../config.js';
+import {hash} from '../helper.js';
+import {Tool} from '../tool.js';
+
+export class StateTool extends Tool {
+ stored = [];
+
+ async setContainer(container) {
+ super.setContainer(container);
+
+ const buttons = document.createElement('div');
+ const save = document.createElement('button');
+ const list = document.createElement('div');
+
+ save.innerHTML = 'Save';
+
+ save.classList.add(WIDE_CLASSNAME);
+ buttons.style.display = 'flex';
+ buttons.style.flexDirection = 'row';
+ buttons.appendChild(save);
+ list.style.display = 'flex';
+ list.style.flexDirection = 'column';
+ this.div.appendChild(buttons);
+ this.div.appendChild(list);
+
+ save.addEventListener('click', async () => {
+ const state = this.sim.toJSON();
+ this.stored.push(state);
+ const item = await this.createItem(state);
+ list.appendChild(item);
+ });
+
+ // Check url query parameter, and load specified state if found
+ await this.fromUrl();
+ }
+
+ async toUrl(state) {
+ const stateText = JSON.stringify(state);
+ const digest = await hash(stateText);
+ const rawUrl = `./?state=${stateText}&digest=${digest}`;
+ const url = encodeURI(rawUrl);
+ return {url, digest};
+ }
+
+ async fromUrl() {
+ const paramsString = window.location.search;
+ const searchParams = new URLSearchParams(paramsString);
+ const stateEnc = searchParams.get("state");
+ const rxDigest = searchParams.get("digest");
+ if (stateEnc) {
+ const stateText = decodeURI(stateEnc);
+ console.log('decoded state text', stateText);
+ const state = JSON.parse(stateText);
+ const digest = await hash(stateText);
+ if (digest !== rxDigest) {
+ throw new Error('state query parameter does not match digest query parameter');
+ }
+ // Tools in this system can be very powerful
+ this.sim.pause();
+ this.sim.fromJSON(state);
+ }
+ }
+
+ getStateDescription(state) {
+ const date = new Date(state.dateSaved);
+ const Y = date.getFullYear().toString();
+ const M = (date.getMonth() + 1).toString().padStart(2, '0');
+ const D = date.getDate().toString().padStart(2, '0');
+ const h = date.getHours().toString().padStart(2, '0');
+ const m = date.getMinutes().toString().padStart(2, '0');
+ const s = date.getSeconds().toString().padStart(2, '0');
+ return `${Y}-${M}-${D} ${h}:${m}:${s}`;
+ }
+
+ async createItem(state) {
+ const item = document.createElement('div');
+ item.style.display = 'flex';
+ item.style.flexDirection = 'row';
+ item.style.flexWrap = 'wrap';
+
+ const description = document.createElement('button');
+ description.style.flex = '2';
+ description.classList.add(TOOL_INFO_CLASSNAME);
+ description.innerHTML = this.getStateDescription(state);
+
+ const load = document.createElement('button');
+ load.style.flex = '1';
+
+ const link = document.createElement('a');
+ const {url, digest} = await this.toUrl(state);
+ link.href = url;
+ link.innerHTML = digest.slice(0, 5);
+
+ load.appendChild(link);
+
+ item.appendChild(description);
+ item.appendChild(load);
+
+ load.addEventListener('click', (e) => {
+ e.preventDefault();
+ // Tools in this system can wield great power
+ this.sim.pause();
+ this.sim.fromJSON(state);
+ });
+
+ return item;
+ }
+}
diff --git a/tool/utility.js b/tool/utility.js
index 20d8c27..5a3cfac 100644
--- a/tool/utility.js
+++ b/tool/utility.js
@@ -7,42 +7,13 @@ import {
export class UtilityTool extends Tool {
currentTimeEl = undefined;
- get timeText() {
- let time = this.sim.time;
- // Time in milliseconds
- const ms = Math.floor(time % 1000);
- time = (time - ms) / 1000;
- const s = Math.floor(time % 60);
- time = (time - s) / 60;
- const m = Math.floor(time % 60);
- time = (time - m) / 60;
- const h = Math.floor(time % 24);
- time = (time - h) / 24;
- const d = Math.floor(time);
- return [
- d || undefined,
- h.toString().padStart(2, '0'),
- m.toString().padStart(2, '0'),
- [
- s.toString().padStart(2, '0'),
- ms.toString().padStart(3, '0'),
- ].join('.')
- ].filter(x => x !== undefined).join(':');
- }
-
- frame() {
- if (this.currentTimeEl) {
- this.currentTimeEl.innerHTML = this.timeText;
- }
- }
-
setContainer(container) {
super.setContainer(container);
this.currentTimeEl.innerHTML = this.timeText;
}
- constructor(container) {
- super(container);
+ constructor() {
+ super();
const clearTraces = document.createElement('button');
const currentTime = document.createElement('button');
@@ -73,4 +44,34 @@ export class UtilityTool extends Tool {
this.sim.info = {};
});
}
+
+ frame() {
+ if (this.currentTimeEl) {
+ this.currentTimeEl.innerHTML = this.timeText;
+ }
+ }
+
+ get timeText() {
+ let time = this.sim.time;
+ // Time in milliseconds
+ const ms = Math.floor(time % 1000);
+ time = (time - ms) / 1000;
+ const s = Math.floor(time % 60);
+ time = (time - s) / 60;
+ const m = Math.floor(time % 60);
+ time = (time - m) / 60;
+ const h = Math.floor(time % 24);
+ time = (time - h) / 24;
+ const d = Math.floor(time);
+ return [
+ d || undefined,
+ h.toString().padStart(2, '0'),
+ m.toString().padStart(2, '0'),
+ [
+ s.toString().padStart(2, '0'),
+ ms.toString().padStart(3, '0'),
+ ].join('.')
+ ].filter(x => x !== undefined).join(':');
+ }
+
}
diff --git a/tool/zoom.js b/tool/zoom.js
index f01e8ba..545b9fd 100644
--- a/tool/zoom.js
+++ b/tool/zoom.js
@@ -65,7 +65,8 @@ export class Zoom extends Tool {
zoomAll.addEventListener('click', () => {
// Determine bounding box
- const box = this.sim.system.boundingBox;
+ const objects = this.sim.select.selectedGroup;
+ const box = this.sim.system.getBoundingBox(objects);
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;
@@ -74,7 +75,7 @@ export class Zoom extends Tool {
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 {netMomentum, totalMass} = this.sim.system.computeSystemCenter(objects);
const netVelocity = {
x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass,
@@ -84,7 +85,9 @@ export class Zoom extends Tool {
zeroVelocity.addEventListener('click', () => {
// Determine center of mass and average momentum
- const {totalMass, netMomentum} = this.sim.system.computeSystemCenter();
+ const objects = this.sim.select.selectedGroup;
+ const {netMomentum} = this.sim.system.computeSystemCenter(objects);
+ const {totalMass} = this.sim.system.computeSystemCenter();
const netVelocity = {
x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass,