Compare commits
No commits in common. "cb2b8ac263c8ecb991e2ce98d27d50bd86f195cd" and "5e0f9783a3f1d167e261b4789aa3a3793c3b9513" have entirely different histories.
cb2b8ac263
...
5e0f9783a3
27
Readme.md
27
Readme.md
@ -12,22 +12,19 @@ Screenshots
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
14
display.js
14
display.js
@ -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 |
51
helper.js
51
helper.js
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
36
object.js
36
object.js
@ -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);
|
||||||
|
|||||||
75
panning.js
75
panning.js
@ -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) {
|
||||||
|
|||||||
33
pointer.js
33
pointer.js
@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
26
select.js
26
select.js
@ -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});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}],
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
35
simulator.js
35
simulator.js
@ -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);
|
||||||
|
|||||||
11
style.css
11
style.css
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
system.js
95
system.js
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
1
tool.js
1
tool.js
@ -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');
|
||||||
|
|||||||
@ -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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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});">` +
|
|
||||||
' </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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
108
tool/state.js
108
tool/state.js
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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(':');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user