Merge branch 'dev'

This commit is contained in:
Ladd 2026-01-05 00:46:35 -06:00
commit cb2b8ac263
21 changed files with 637 additions and 172 deletions

View File

@ -12,19 +12,22 @@ Screenshots
![A greater mass attracts others which had been on escape trajectories](./gravity-simulator-5.png "Gravity Simulator Screenshot 5") ![A greater mass attracts others which had been on escape trajectories](./gravity-simulator-5.png "Gravity Simulator Screenshot 5")
![A small object orbits a more massive binary pair](./gravity-simulator-6.png "Gravity Simulator Screenshot 6")
TODO TODO
---- ----
- [x] Selection Box - [x] Selection Box
- [ ] Feature: Object List - [x] Feature: Object List
- [ ] Feature: Object Detail - [x] Feature: Object Detail
- [ ] Feature: Zoom to Object - [x] Feature: Zoom to Object
- [ ] Feature: Teleport Object - [ ] Feature: Teleport Object
- [ ] Enhancement: Create Time class and refactor to use - [ ] Enhancement: Create Time class and refactor to use
- [ ] Enhancement: Create Vector class and refactor to use - [x] Enhancement: Create Vector class and refactor to use
- [ ] Enhancement: Create Panning 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: 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 - [ ] Feature: Automatically slow time when energy flux is greater
- [ ] Enhancement: Add z-axis (Initially nothing changes, z = 0) - [ ] Enhancement: Add z-axis (Initially nothing changes, z = 0)
- [ ] Feature: Isometric 3d View - [ ] Feature: Isometric 3d View
@ -32,9 +35,11 @@ TODO
- [ ] Feature: Polar Coordinates - [ ] Feature: Polar Coordinates
- [ ] Feature: Cylindrical Coordinates - [ ] Feature: Cylindrical Coordinates
- [ ] Feature: Spherical Coordinates - [ ] Feature: Spherical Coordinates
- [ ] Feature: Camera Velocity Display - [x] Feature: Camera Velocity Display
- [ ] Enhancement: World State Snapshots - [x] Enhancement: World State Snapshots
- [ ] Feature: List / Save / Load World States - [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: Left Button Panning
- [ ] Feature: Middle Button Pause - [ ] Feature: Middle Button Pause
- [ ] Feature: Parameter Slider (Invisible, mouse/touch drag) - [ ] Feature: Parameter Slider (Invisible, mouse/touch drag)
@ -52,5 +57,5 @@ TODO
- [ ] Feature: Time Control: Reverse Time - [ ] Feature: Time Control: Reverse Time
- [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow) - [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow)
- [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y) - [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y)
- [x] Task: Verify stationary pointer leads to zero pointer velocity - [x] Fix: Unpause panning when initiated while sim is paused
- [ ] Fix: Unpause panning when initiated while sim is paused - [ ] Enhancement: Refactor to use viewOrigin as center of display canvas

View File

@ -19,6 +19,9 @@ export const PATH_TRACES_OPACITY = 0.8;
export const PATH_TRACES_WIDTH = 1.5; export const PATH_TRACES_WIDTH = 1.5;
export const PATH_TRACES_DASHED_OPACITY = 1.0; export const PATH_TRACES_DASHED_OPACITY = 1.0;
// PANNING
export const PANNING_ZERO_TOUCH_THRESHOLD = 200;
// SIZES // SIZES
export const POINTER_HISTORY_SIZE = 20; export const POINTER_HISTORY_SIZE = 20;
export const OBJECT_HISTORY_SIZE = 1e5; 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_ZOOM = 'lhg-zoom-event';
export const EVENT_OPTION_SET = 'lhg-option-set'; export const EVENT_OPTION_SET = 'lhg-option-set';
export const EVENT_PLAY_PAUSE = 'lhg-play-pause'; 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 // MODES
export const MODE_MASS_GENERATION = 'mass-gen'; export const MODE_MASS_GENERATION = 'mass-gen';

View File

@ -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() { frame() {
// Clear canvas in preparation for other modules to render this frame // Clear canvas in preparation for other modules to render this frame
this.fillCanvas(); this.fillCanvas();
@ -140,7 +152,7 @@ export class Display {
ctx.resetTransform(); ctx.resetTransform();
} }
drawBox({start, end}) { drawBox(start, end) {
const ctx = this.ctx; const ctx = this.ctx;
ctx.strokeStyle = 'rgb(0, 255, 0)'; ctx.strokeStyle = 'rgb(0, 255, 0)';
ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y); ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y);

BIN
gravity-simulator-6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

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

View File

@ -16,7 +16,7 @@ import {
VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_WIDTH, VELOCITY_VECTOR_WIDTH,
} from './config.js'; } 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 { export class MassObject {
sim = undefined; sim = undefined;
@ -27,7 +27,7 @@ export class MassObject {
velocity = zero; velocity = zero;
acceleration = zero; acceleration = zero;
color = {r: undefined, g: undefined, b: undefined}; color = {r: undefined, g: undefined, b: undefined};
created = undefined; timeCreated = undefined;
forces = []; // [{x, y}] forces = []; // [{x, y}]
history = []; history = [];
alive = true; alive = true;
@ -44,17 +44,39 @@ export class MassObject {
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.timeCreated = this.sim.time; 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() { get age() {
return this.sim.time - this.timeCreated; return this.sim.time - this.timeCreated;
} }
get rawAge() {
return this.sim.rawTime - this.rawTimeCreated;
}
get radius() { get radius() {
// radius should be proportional to cube root of mass // radius should be proportional to cube root of mass
return Math.pow(this.mass / this.density, 1 / 3); return Math.pow(this.mass / this.density, 1 / 3);

View File

@ -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 { export class Panning {
sim = undefined; sim = undefined;
@ -11,10 +12,6 @@ export class Panning {
this.sim = sim; this.sim = sim;
} }
handlePointerDown({x, y}) {
this.initializeTouch({x, y});
}
initializeTouch({x, y}) { initializeTouch({x, y}) {
this.touchStart = { this.touchStart = {
x, 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 fast panning, panning velocity calculation happens every move;
// With normal panning, calculation only happens at pointer up. // With normal panning, calculation only happens at pointer up.
handlePointerMove({x, y}) { handlePointerMove({x, y}) {
@ -42,49 +58,46 @@ export class Panning {
dy: x - this.touchStart.y, dy: x - this.touchStart.y,
dt: this.sim.rawTime - this.touchStart.t, 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() { handlePointerUp() {
if (this.touchStart && this.touchLatest) { if (this.touchStart && this.touchLatest) {
if (this.touchLatest.dt === 0) { if (this.touchLatest.dt < PANNING_ZERO_TOUCH_THRESHOLD) {
this.velocity = zero; this.velocity = zero;
} }
this.touchStart = undefined; this.touchStart = undefined;
if (this.sim.getOption('compensate.fastPanning')) { if (this.sim.getOption('compensate.fastPanning')) {
this.velocity = zero; this.velocity = zero;
} else {
this.updateVelocity();
} }
} }
} }
frame(elapsedTime) { frame(elapsedTime) {
const {touchStart: start, touchLatest: latest} = this;
const {display} = this.sim; 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 // Apply update to viewOrigin based on panning
if (!this.paused) { if (!this.paused) {
// elapsedTime is scaled by time scale, is that what we want? // elapsedTime is scaled by time scale, is that what we want?
// Yes because if panning.velocity == obj.velocity, object should stay in view // Yes because if panning.velocity == obj.velocity, object should stay in view
const delta = mult(this.velocity, elapsedTime); display.viewOrigin = add(display.viewOrigin, mult(this.velocity, elapsedTime));
display.viewOrigin = add(display.viewOrigin, delta);
}
// Update what's considered start
if (start && latest) {
this.initializeTouch(this.touchLatest);
} }
if (this.sim.getOption('debug.panningInfo')) { 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) { setVelocity(velocity) {
this.velocity = velocity; this.velocity = velocity;
if (!this.sim.playing) { if (!this.sim.playing) {

View File

@ -3,63 +3,52 @@ import {
MODE_OBJECT_SELECT, MODE_OBJECT_SELECT,
MODE_PAN_VIEW, MODE_PAN_VIEW,
POINTER_HISTORY_SIZE, POINTER_HISTORY_SIZE,
TOOLBAR_CLASSNAME,
ZOOM_IN_FACTOR, ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR, ZOOM_OUT_FACTOR
} from './config.js'; } from './config.js';
export class Pointer { export class Pointer {
sim = undefined; sim = undefined;
pointerHistory = []; pointerHistory = [];
touchStart = undefined; // {x: undefined, y: undefined, t: undefined};
touchLatest = undefined; // {x: undefined, y: undefined, t: undefined};
suppressClick = false; suppressClick = false;
constructor(sim) { constructor(sim) {
this.sim = sim; this.sim = sim;
// Monitor mouse movements // 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')) { if (this.sim.getOption('debug.cursorInfo')) {
this.sim.info['pointermove'] = [`${e.clientX.toPrecision(6)}, `, `${e.clientY.toPrecision(6)}`]; this.sim.info['pointermove'] = [`${e.clientX.toPrecision(6)}, `, `${e.clientY.toPrecision(6)}`];
} }
this.handlePointerMove({x: e.clientX, y: e.clientY}); this.handlePointerMove({x: e.clientX, y: e.clientY});
}); });
el.addEventListener('pointerdown', e => { canvas.addEventListener('pointerdown', e => {
let target = e.target;
while (target && !target.classList?.contains(TOOLBAR_CLASSNAME)) {
target = target.parentNode;
}
if (target) {
return;
}
this.handlePointerDown({x: e.clientX, y: e.clientY}); this.handlePointerDown({x: e.clientX, y: e.clientY});
}); });
el.addEventListener('pointerup', e => { window.addEventListener('pointerup', e => {
this.handlePointerUp({x: e.clientX, y: e.clientY}); this.handlePointerUp({x: e.clientX, y: e.clientY});
}); });
el.addEventListener('pointerleave', e => { // window.addEventListener('pointerleave', e => {
this.handlePointerUp({x: e.clientX, y: e.clientY}); // this.handlePointerUp({x: e.clientX, y: e.clientY});
}); // });
// Monitor wheel events // Monitor wheel events
el.addEventListener('wheel', e => { canvas.addEventListener('wheel', e => {
const factor = e.deltaY < 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; const factor = e.deltaY < 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
const {x, y} = this.sim.screenToSim(e.clientX, e.clientY); const {x, y} = this.sim.screenToSim(e.clientX, e.clientY);
this.sim.scheduleZoom({x, y}, factor); this.sim.scheduleZoom({x, y}, factor);
}); });
el.addEventListener('focus', () => { window.addEventListener('focus', () => {
console.log('window focus'); console.log('window focus');
}); });
el.addEventListener('blur', () => { window.addEventListener('blur', () => {
console.log('window blur'); console.log('window blur');
}); });
} }

View File

@ -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 { export class Select {
sim = undefined; sim = undefined;
@ -30,7 +31,7 @@ export class Select {
handlePointerDown({x: clientX, y: clientY}) { handlePointerDown({x: clientX, y: clientY}) {
this.box.start = this.sim.screenToSim(clientX, clientY); this.box.start = this.sim.screenToSim(clientX, clientY);
this.box.end = this.box.start; this.box.end = this.box.start;
this.getSelectedObjects(); // this.getSelectedObjects();
} }
handlePointerMove({x: clientX, y: clientY}) { handlePointerMove({x: clientX, y: clientY}) {
@ -51,12 +52,24 @@ export class Select {
y: Math.max(start.y, end.y), y: Math.max(start.y, end.y),
}; };
this.getSelectedObjects(); this.getSelectedObjects();
this.sim.div.dispatchEvent(new CustomEvent(EVENT_SELECT));
this.box = { this.box = {
start: undefined, start: undefined,
end: 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() { getSelectedObjects() {
const {start, end} = this.box; const {start, end} = this.box;
if (!start) return; if (!start) return;
@ -67,8 +80,11 @@ export class Select {
this.selectedSingle = this.selectedGroup[0] ?? undefined; this.selectedSingle = this.selectedGroup[0] ?? undefined;
} }
frame() { // cb: ({selectedGroup, selectedSingle}) => undefined
if (!this.box.start) return; onSelect(cb) {
this.sim.display.drawBox(this.box) this.sim.div.addEventListener(EVENT_SELECT, () => {
const {selectedGroup, selectedSingle} = this;
cb({selectedGroup, selectedSingle});
});
} }
} }

View File

@ -8,10 +8,11 @@ export const simOptions = {
velocity: ['Velocity Vectors', 'boolean', true], velocity: ['Velocity Vectors', 'boolean', true],
acceleration: ['Accel Vectors', 'boolean', true], acceleration: ['Accel Vectors', 'boolean', true],
traces: ['Path Traces', 'boolean', true], traces: ['Path Traces', 'boolean', true],
dashedTraces: ['Dashed', 'boolean', false, {tall: true, hideUnless: 'display.traces'}], dashedTraces: ['Dashed', 'boolean', false, {tall: true, showIf: 'display.traces'}],
velocityScale: ['Velocity<br>Vec Scale', 'number', 80, {hideUnless: 'display.velocity'}], velocityScale: ['Velocity<br>Vec Scale', 'number', 80, {showIf: 'display.velocity'}],
accelerationScale: ['Accel<br>Vec Scale', 'number', 800, {hideUnless: 'display.acceleration'}], accelerationScale: ['Accel<br>Vec Scale', 'number', 800, {showIf: 'display.acceleration'}],
zoomVectors: ['Zoom Vectors', 'boolean', true] zoomVectors: ['Zoom Vectors', 'boolean', true],
panningSpeed: ['Pan<br>Speed', 'number', 0.1],
}, },
compensate: { compensate: {
timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}], timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}],

View File

@ -1,8 +1,10 @@
import {CameraTool} from './tool/camera.js';
import {ModeSwitch} from './tool/modes.js'; import {ModeSwitch} from './tool/modes.js';
import {ObjectTool} from './tool/object.js'; import {ObjectTool} from './tool/object.js';
import {ObjectsTool} from './tool/objects.js'; import {ObjectsTool} from './tool/objects.js';
import {OptionsTool} from './tool/options.js'; import {OptionsTool} from './tool/options.js';
import {PlayPause} from './tool/play-pause.js'; import {PlayPause} from './tool/play-pause.js';
import {StateTool} from './tool/state.js';
import {UtilityTool} from './tool/utility.js'; import {UtilityTool} from './tool/utility.js';
import {Zoom} from './tool/zoom.js'; import {Zoom} from './tool/zoom.js';
import {ToolbarGroup} from './toolbar-group.js'; import {ToolbarGroup} from './toolbar-group.js';
@ -11,6 +13,12 @@ import {Toolbar} from './toolbar.js';
export function initializeTools(sim) { export function initializeTools(sim) {
sim.toolbarGroups = { sim.toolbarGroups = {
left: new ToolbarGroup(sim) 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') .addToolbar(new Toolbar(sim, 'Tools')
.addTool(new Zoom()) .addTool(new Zoom())
.addTool(new PlayPause()) .addTool(new PlayPause())

View File

@ -4,15 +4,15 @@ import {
FRAMERATE_SAMPLE_DURATION, FRAMERATE_SAMPLE_DURATION,
} from './config.js'; } from './config.js';
import {Display} from './display.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 {Options} from './options.js';
import {Zoom} from './zoom.js'; import {Overlay} from './overlay.js';
import {Panning} from './panning.js'; import {Panning} from './panning.js';
import {Pointer} from './pointer.js';
import {Select} from './select.js'; import {Select} from './select.js';
import {simOptions} from './sim-options.js'; import {simOptions} from './sim-options.js';
import {initializeTools} from './sim-tools.js'; import {initializeTools} from './sim-tools.js';
import {System} from './system.js';
import {Zoom} from './zoom.js';
export class Sim { export class Sim {
info = {}; info = {};
@ -88,13 +88,19 @@ export class Sim {
this.zoom.frame(elapsedTime); this.zoom.frame(elapsedTime);
this.panning.frame(elapsedTime); this.panning.frame(elapsedTime);
this.pointer.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.display.frame(elapsedTime);
this.select.frame(elapsedTime); this.select.frame(elapsedTime);
// The system.frame() renders objects
this.system.frame(elapsedTime); this.system.frame(elapsedTime);
this.overlay.frame(elapsedTime); this.overlay.frame(elapsedTime);
for (const group in this.toolbarGroups) { for (const group in this.toolbarGroups) {
this.toolbarGroups[group].frame(elapsedTime); 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)); requestAnimationFrame(t => this.frame(t));
} }
@ -122,6 +128,27 @@ export class Sim {
return false; 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 // velocity should be in Sim coordinate scale
scheduleZoom({x, y}, factor, velocity) { scheduleZoom({x, y}, factor, velocity) {
this.zoom.scheduleZoom({x, y}, factor, velocity); this.zoom.scheduleZoom({x, y}, factor, velocity);

View File

@ -33,6 +33,10 @@ div[id=simulator] > canvas {
left: 0; left: 0;
} }
div.lhg-toolbar-group button {
/* opacity: 0.8; */
}
/* normal toolbar group */ /* normal toolbar group */
div.lhg-toolbar-group div.lhg-tool { div.lhg-toolbar-group div.lhg-tool {
width: 12em; width: 12em;
@ -102,7 +106,12 @@ div.lhg-tool button, div.lhg-tool input {
box-sizing: border-box; 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; background-color: #444;
} }

103
system.js
View File

@ -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 {MassObject} from './object.js';
import { import {
add, copy, cross, degrees, add, copy, cross, degrees, direction, div,
direction, div, magnitude, mult, dot, magnitude, mult, square, sub, weightedAvg, zero
square, sub, weightedAvg, zero
} from './vector.js'; } from './vector.js';
export class System { export class System {
@ -11,13 +10,30 @@ export class System {
creatingObject = undefined; creatingObject = undefined;
selectedObject = undefined; selectedObject = undefined;
selectObjectStart = undefined; selectObjectStart = undefined;
panVelocityPaused = undefined;
paused = false; paused = false;
constructor(sim) { constructor(sim) {
this.sim = 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}) { handlePointerDown({x, y}) {
// If pointer is touching an object, select the object // If pointer is touching an object, select the object
const touchingObject = this.objectAtLocation(x, y); const touchingObject = this.objectAtLocation(x, y);
@ -76,12 +92,11 @@ export class System {
if (this.sim.playing) { if (this.sim.playing) {
// Predict positions (Velocity verlet method) // Predict positions (Velocity verlet method)
this.forEachObject(obj => { this.forEachObject(obj => {
obj.currentAcceleration = {...obj.acceleration}; obj.currentAcceleration = copy(obj.acceleration);
// If this object is being created/selected, clamp its position // If this object is being created/selected, we're not going to let it move...
if (obj.id === this.getSelectedOrCreating()?.id) { // but we can calculate the work being done by holding it in place.
return; obj.currentPosition = copy(obj.position);
}
obj.position = add(obj.position, mult( obj.position = add(obj.position, mult(
elapsedTime, elapsedTime,
@ -134,6 +149,8 @@ export class System {
}; };
T.alive = false; T.alive = false;
T.forces = []; T.forces = [];
const e = new CustomEvent(EVENT_OBJECT_MERGE, {detail: {surviving: S, merged: T}});
this.sim.div.dispatchEvent(e);
} }
}, {alive: true, startWith: i + 1}); }, {alive: true, startWith: i + 1});
}); });
@ -143,10 +160,21 @@ export class System {
// Predict velocities // Predict velocities
this.forEachObject(obj => { this.forEachObject(obj => {
const acceleration = {...obj.acceleration}; const acceleration = copy(obj.acceleration);
obj.acceleration = div(add(obj.currentAcceleration, acceleration), 2); obj.acceleration = div(add(obj.currentAcceleration, acceleration), 2);
obj.velocity = add(obj.velocity, mult(obj.acceleration, elapsedTime)); 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 // Append to object history
obj.history.push({position: {...obj.position}}); obj.history.push({position: {...obj.position}});
@ -189,6 +217,8 @@ export class System {
this.drawObjects(); this.drawObjects();
} }
// Pause and resume to enable automatic pause on object create/select
// in this mode (mass generation)
pause() { pause() {
this.sim.pause(); this.sim.pause();
this.paused = true; this.paused = true;
@ -212,8 +242,9 @@ export class System {
if (this.sim.getOption('pauseDuring.creation')) { if (this.sim.getOption('pauseDuring.creation')) {
this.pause(); this.pause();
} }
obj.velocity = copy(this.sim.panning.velocity); obj.velocity = copy(this.sim.panning.velocity);
const e = new CustomEvent(EVENT_OBJECT_CREATE, {detail: {obj}});
this.sim.div.dispatchEvent(e);
} }
doneCreatingObject() { 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) { object(i) {
return this.objects[i]; return this.objects[i];
} }
@ -253,21 +298,20 @@ export class System {
return this.objects.length; return this.objects.length;
} }
get boundingBox() { getBoundingBox(objects = []) {
const box = this.reduce(({start, end}, obj) => { const box = this.reduce(({start, end}, obj) => {
if (objects.length && !objects.includes(obj)) return {start, end};
const lx = obj.position.x - obj.radius; const lx = obj.position.x - obj.radius;
const gx = obj.position.x + obj.radius; const gx = obj.position.x + obj.radius;
const ly = obj.position.y - obj.radius; const ly = obj.position.y - obj.radius;
const gy = obj.position.y + obj.radius; const gy = obj.position.y + obj.radius;
let ret;
if (start.x === undefined) { if (start.x === undefined) {
ret = { return {
start: {x: lx, y: ly}, start: {x: lx, y: ly},
end: {x: gx, y: gy}, end: {x: gx, y: gy},
}; };
return ret;
} }
ret = { return {
start: { start: {
x: Math.min(start.x, lx), x: Math.min(start.x, lx),
y: Math.min(start.y, ly), y: Math.min(start.y, ly),
@ -277,7 +321,6 @@ export class System {
y: Math.max(end.y, gy), 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},
@ -373,15 +416,18 @@ export class System {
}); });
} }
computeSystemCenter() { computeSystemCenter(objects = []) {
// Determine center of mass // Determine center of mass
const {totalMass, count, totalMassLocation} = const {totalMass, count, totalMassLocation} =
this.reduce((acc, obj) => ({ this.reduce((acc, obj) => {
count: acc.count + 1, if (objects.length && !objects.includes(obj)) return acc;
totalMass: acc.totalMass + obj.mass, return {
totalMassLocation: add(acc.totalMassLocation, count: acc.count + 1,
mult(obj.position, obj.mass)), totalMass: acc.totalMass + obj.mass,
}), { totalMassLocation: add(acc.totalMassLocation,
mult(obj.position, obj.mass)),
};
}, {
totalMassLocation: {x: 0, y: 0}, totalMassLocation: {x: 0, y: 0},
totalMass: 0, totalMass: 0,
count: 0, count: 0,
@ -390,8 +436,10 @@ export class System {
const centerOfMass = count ? div(totalMassLocation, totalMass) : zero; const centerOfMass = count ? div(totalMassLocation, totalMass) : zero;
// Determine average momentum // Determine average momentum
const netMomentum = this.reduce((acc, obj) => const netMomentum = this.reduce((acc, obj) => {
add(acc, mult(obj.velocity, obj.mass)), zero); if (objects.length && !objects.includes(obj)) return acc;
return add(acc, mult(obj.velocity, obj.mass));
}, zero);
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum}; return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
} }
@ -411,5 +459,4 @@ export class System {
return acc + obj.mass * s / d; return acc + obj.mass * s / d;
}, 0); }, 0);
} }
} }

View File

@ -9,6 +9,7 @@ import {
export class Tool { export class Tool {
container = undefined; container = undefined;
sim = undefined; sim = undefined;
hidden = false;
constructor() { constructor() {
const div = document.createElement('div'); const div = document.createElement('div');

42
tool/camera.js Normal file
View File

@ -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',
});
}
}

View File

@ -1,11 +1,82 @@
import {hide, show} from '../helper.js';
import {Tool} from '../tool.js'; import {Tool} from '../tool.js';
import {add, magnitude, sub} from '../vector.js';
export class ObjectsTool extends Tool { export class ObjectsTool extends Tool {
objects = [];
setContainer(container) { setContainer(container) {
super.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() { frame() {
super(); 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 = `
<span style="background-color: rgb(${r},${g},${b});">` +
'&nbsp;&nbsp;</span>' +
`${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,
});
}
}
} }
} }

View File

@ -1,45 +1,92 @@
// Options picker // Options picker
import { import {
TOOL_INFO_CLASSNAME,
OPTION_GROUP_CLASSNAME, OPTION_GROUP_CLASSNAME,
WIDE_CLASSNAME,
TALL_CLASSNAME, TALL_CLASSNAME,
TOOL_INFO_CLASSNAME,
WIDE_CLASSNAME,
} from '../config.js'; } from '../config.js';
import { Tool } from '../tool.js'; import {Tool} from '../tool.js';
import {show, hide} from '../helper.js';
export class OptionsTool extends Tool { export class OptionsTool extends Tool {
sections = undefined; sectionNames = undefined;
groups = {};
constructor(sections) { constructor(sectionNames) {
super(); super();
this.sections = sections; this.sectionNames = sectionNames;
} }
setContainer(container) { setContainer(container) {
super.setContainer(container); super.setContainer(container);
for (const sectionName of this.sections) { // Initialize
const option = this.sim.options.getSection(sectionName); for (const sectionName of this.sectionNames) {
const item = this.visitItem(option); const group = this.sim.options.getSection(sectionName);
const item = this.visitItem(group);
this.div.appendChild(item); 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) { 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 groupEl = document.createElement('div');
group.classList.add(OPTION_GROUP_CLASSNAME); groupEl.classList.add(OPTION_GROUP_CLASSNAME);
const group = {groupEl, items: []};
this.groups[path] = group;
if (item.title) { if (item.title) {
const heading = document.createElement('h3'); const heading = document.createElement('h3');
heading.innerHTML = item.title; heading.innerHTML = item.title;
group.appendChild(heading); groupEl.appendChild(heading);
groupEl.items.push({itemEl: heading});
} }
for (const next of item.items) { for (const next of item.items) {
const child = this.visitItem(next, path); const optionEl = this.visitItem(next, path);
group.appendChild(child); 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': { case 'boolean': {
const button = document.createElement('button'); const button = document.createElement('button');

108
tool/state.js Normal file
View File

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

View File

@ -7,42 +7,13 @@ import {
export class UtilityTool extends Tool { export class UtilityTool extends Tool {
currentTimeEl = undefined; 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) { setContainer(container) {
super.setContainer(container); super.setContainer(container);
this.currentTimeEl.innerHTML = this.timeText; this.currentTimeEl.innerHTML = this.timeText;
} }
constructor(container) { constructor() {
super(container); super();
const clearTraces = document.createElement('button'); const clearTraces = document.createElement('button');
const currentTime = document.createElement('button'); const currentTime = document.createElement('button');
@ -73,4 +44,34 @@ export class UtilityTool extends Tool {
this.sim.info = {}; 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(':');
}
} }

View File

@ -65,7 +65,8 @@ export class Zoom extends Tool {
zoomAll.addEventListener('click', () => { zoomAll.addEventListener('click', () => {
// Determine bounding box // 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 x = (box.start.x + box.end.x) / 2;
const y = (box.start.y + box.end.y) / 2; const y = (box.start.y + box.end.y) / 2;
const widthRatio = Math.abs(box.start.x - box.end.x) / this.sim.display.width; const widthRatio = Math.abs(box.start.x - box.end.x) / this.sim.display.width;
@ -74,7 +75,7 @@ export class Zoom extends Tool {
const factor = Math.ceil(Math.log2(1 / ratio)); const factor = Math.ceil(Math.log2(1 / ratio));
// Determine average momentum and set panning velocity to match // 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 = { const netVelocity = {
x: netMomentum.x / totalMass, x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass, y: netMomentum.y / totalMass,
@ -84,7 +85,9 @@ export class Zoom extends Tool {
zeroVelocity.addEventListener('click', () => { zeroVelocity.addEventListener('click', () => {
// Determine center of mass and average momentum // 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 = { const netVelocity = {
x: netMomentum.x / totalMass, x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass, y: netMomentum.y / totalMass,