Compare commits

..

No commits in common. "cb2b8ac263c8ecb991e2ce98d27d50bd86f195cd" and "5e0f9783a3f1d167e261b4789aa3a3793c3b9513" have entirely different histories.

21 changed files with 172 additions and 637 deletions

View File

@ -12,22 +12,19 @@ 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
- [x] Feature: Object List - [ ] Feature: Object List
- [x] Feature: Object Detail - [ ] Feature: Object Detail
- [x] Feature: Zoom to Object - [ ] 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
- [x] Enhancement: Create Vector class and refactor to use - [ ] Enhancement: Create Vector class and refactor to use
- [x] Enhancement: Create Panning class and refactor to use - [ ] 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
- [x] Enhancement: Calculate Work as FxD as measure of energy flux - [ ] 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
@ -35,11 +32,9 @@ TODO
- [ ] Feature: Polar Coordinates - [ ] Feature: Polar Coordinates
- [ ] Feature: Cylindrical Coordinates - [ ] Feature: Cylindrical Coordinates
- [ ] Feature: Spherical Coordinates - [ ] Feature: Spherical Coordinates
- [x] Feature: Camera Velocity Display - [ ] Feature: Camera Velocity Display
- [x] Enhancement: World State Snapshots - [ ] Enhancement: World State Snapshots
- [x] Feature: List / Save / Load World States - [ ] 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)
@ -57,5 +52,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] Fix: Unpause panning when initiated while sim is paused - [x] Task: Verify stationary pointer leads to zero pointer velocity
- [ ] Enhancement: Refactor to use viewOrigin as center of display canvas - [ ] Fix: Unpause panning when initiated while sim is paused

View File

@ -19,9 +19,6 @@ 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;
@ -54,9 +51,6 @@ 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,18 +20,6 @@ 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();
@ -152,7 +140,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);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

View File

@ -1,51 +1,2 @@
// `items` is an array of which `item` is a member export function makeUtilityButton() {
// `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, copy, direction, div, magnitude, zero} from './vector.js'; import {add, components, 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};
timeCreated = undefined; created = undefined;
forces = []; // [{x, y}] forces = []; // [{x, y}]
history = []; history = [];
alive = true; alive = true;
@ -44,39 +44,17 @@ 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,5 +1,4 @@
import {PANNING_ZERO_TOUCH_THRESHOLD} from "./config.js"; import {add, copy, div, mult, sub, zero} from "./vector.js";
import {add, copy, div, mult, zero} from "./vector.js";
export class Panning { export class Panning {
sim = undefined; sim = undefined;
@ -12,6 +11,10 @@ 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,
@ -27,25 +30,6 @@ 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}) {
@ -58,46 +42,49 @@ 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')) {
// Convert pointer velocity to simulation scale this.updateVelocity();
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 < PANNING_ZERO_TOUCH_THRESHOLD) { if (this.touchLatest.dt === 0) {
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
display.viewOrigin = add(display.viewOrigin, mult(this.velocity, elapsedTime)); const delta = 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')) {
@ -109,6 +96,18 @@ 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,52 +3,63 @@ 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 {canvas} = this.sim.display; const el = window;
window.addEventListener('pointermove', e => { el.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});
}); });
canvas.addEventListener('pointerdown', e => { el.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});
}); });
window.addEventListener('pointerup', e => { el.addEventListener('pointerup', e => {
this.handlePointerUp({x: e.clientX, y: e.clientY}); this.handlePointerUp({x: e.clientX, y: e.clientY});
}); });
// window.addEventListener('pointerleave', e => { el.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
canvas.addEventListener('wheel', e => { el.addEventListener('wheel', e => {
const factor = e.deltaY < 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; const factor = e.deltaY < 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
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);
}); });
window.addEventListener('focus', () => { el.addEventListener('focus', () => {
console.log('window focus'); console.log('window focus');
}); });
window.addEventListener('blur', () => { el.addEventListener('blur', () => {
console.log('window blur'); console.log('window blur');
}); });
} }

View File

@ -1,5 +1,4 @@
import {EVENT_SELECT} from './config.js'; import {copy} from './vector.js';
import {add, copy, mult} from './vector.js';
export class Select { export class Select {
sim = undefined; sim = undefined;
@ -31,7 +30,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}) {
@ -52,24 +51,12 @@ 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;
@ -80,11 +67,8 @@ export class Select {
this.selectedSingle = this.selectedGroup[0] ?? undefined; this.selectedSingle = this.selectedGroup[0] ?? undefined;
} }
// cb: ({selectedGroup, selectedSingle}) => undefined frame() {
onSelect(cb) { if (!this.box.start) return;
this.sim.div.addEventListener(EVENT_SELECT, () => { this.sim.display.drawBox(this.box)
const {selectedGroup, selectedSingle} = this;
cb({selectedGroup, selectedSingle});
});
} }
} }

View File

@ -8,11 +8,10 @@ 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, showIf: 'display.traces'}], dashedTraces: ['Dashed', 'boolean', false, {tall: true, hideUnless: 'display.traces'}],
velocityScale: ['Velocity<br>Vec Scale', 'number', 80, {showIf: 'display.velocity'}], velocityScale: ['Velocity<br>Vec Scale', 'number', 80, {hideUnless: 'display.velocity'}],
accelerationScale: ['Accel<br>Vec Scale', 'number', 800, {showIf: 'display.acceleration'}], accelerationScale: ['Accel<br>Vec Scale', 'number', 800, {hideUnless: '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,10 +1,8 @@
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';
@ -13,12 +11,6 @@ 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 {Options} from './options.js'; import {System} from './system.js';
import {Overlay} from './overlay.js'; import {Overlay} from './overlay.js';
import {Panning} from './panning.js';
import {Pointer} from './pointer.js'; import {Pointer} from './pointer.js';
import {Options} from './options.js';
import {Zoom} from './zoom.js';
import {Panning} from './panning.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,19 +88,13 @@ 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));
} }
@ -128,27 +122,6 @@ 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,10 +33,6 @@ 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;
@ -106,12 +102,7 @@ div.lhg-tool button, div.lhg-tool input {
box-sizing: border-box; box-sizing: border-box;
} }
div.lhg-tool button a { div.lhg-tool button:hover {
color: #5f5;
text-decoration: none;
}
div.lhg-tool button:hover, div.lhg-tool input:hover {
background-color: #444; background-color: #444;
} }

View File

@ -1,8 +1,9 @@
import {EVENT_OBJECT_CREATE, EVENT_OBJECT_MERGE, OBJECT_HISTORY_SIZE} from './config.js'; import {OBJECT_HISTORY_SIZE} from './config.js';
import {MassObject} from './object.js'; import {MassObject} from './object.js';
import { import {
add, copy, cross, degrees, direction, div, add, copy, cross, degrees,
dot, magnitude, mult, square, sub, weightedAvg, zero direction, div, magnitude, mult,
square, sub, weightedAvg, zero
} from './vector.js'; } from './vector.js';
export class System { export class System {
@ -10,30 +11,13 @@ 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);
@ -92,11 +76,12 @@ 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 = copy(obj.acceleration); obj.currentAcceleration = {...obj.acceleration};
// If this object is being created/selected, we're not going to let it move... // If this object is being created/selected, clamp its position
// but we can calculate the work being done by holding it in place. if (obj.id === this.getSelectedOrCreating()?.id) {
obj.currentPosition = copy(obj.position); return;
}
obj.position = add(obj.position, mult( obj.position = add(obj.position, mult(
elapsedTime, elapsedTime,
@ -149,8 +134,6 @@ 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});
}); });
@ -160,21 +143,10 @@ export class System {
// Predict velocities // Predict velocities
this.forEachObject(obj => { this.forEachObject(obj => {
const acceleration = copy(obj.acceleration); const acceleration = {...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}});
@ -217,8 +189,6 @@ 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;
@ -242,9 +212,8 @@ 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() {
@ -254,20 +223,6 @@ 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];
} }
@ -298,20 +253,21 @@ export class System {
return this.objects.length; return this.objects.length;
} }
getBoundingBox(objects = []) { get boundingBox() {
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) {
return { ret = {
start: {x: lx, y: ly}, start: {x: lx, y: ly},
end: {x: gx, y: gy}, end: {x: gx, y: gy},
}; };
return ret;
} }
return { ret = {
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),
@ -321,6 +277,7 @@ 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},
@ -416,18 +373,15 @@ export class System {
}); });
} }
computeSystemCenter(objects = []) { computeSystemCenter() {
// Determine center of mass // Determine center of mass
const {totalMass, count, totalMassLocation} = const {totalMass, count, totalMassLocation} =
this.reduce((acc, obj) => { this.reduce((acc, obj) => ({
if (objects.length && !objects.includes(obj)) return acc;
return {
count: acc.count + 1, count: acc.count + 1,
totalMass: acc.totalMass + obj.mass, totalMass: acc.totalMass + obj.mass,
totalMassLocation: add(acc.totalMassLocation, totalMassLocation: add(acc.totalMassLocation,
mult(obj.position, obj.mass)), mult(obj.position, obj.mass)),
}; }), {
}, {
totalMassLocation: {x: 0, y: 0}, totalMassLocation: {x: 0, y: 0},
totalMass: 0, totalMass: 0,
count: 0, count: 0,
@ -436,10 +390,8 @@ 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) =>
if (objects.length && !objects.includes(obj)) return acc; add(acc, mult(obj.velocity, obj.mass)), zero);
return add(acc, mult(obj.velocity, obj.mass));
}, zero);
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum}; return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
} }
@ -459,4 +411,5 @@ export class System {
return acc + obj.mass * s / d; return acc + obj.mass * s / d;
}, 0); }, 0);
} }
} }

View File

@ -9,7 +9,6 @@ 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');

View File

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

View File

@ -1,108 +0,0 @@
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,13 +7,42 @@ 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() { constructor(container) {
super(); super(container);
const clearTraces = document.createElement('button'); const clearTraces = document.createElement('button');
const currentTime = document.createElement('button'); const currentTime = document.createElement('button');
@ -44,34 +73,4 @@ 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,8 +65,7 @@ export class Zoom extends Tool {
zoomAll.addEventListener('click', () => { zoomAll.addEventListener('click', () => {
// Determine bounding box // Determine bounding box
const objects = this.sim.select.selectedGroup; const box = this.sim.system.boundingBox;
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;
@ -75,7 +74,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(objects); const {netMomentum, 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,
@ -85,9 +84,7 @@ 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 objects = this.sim.select.selectedGroup; const {totalMass, netMomentum} = this.sim.system.computeSystemCenter();
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,