Merge branch 'dev'
This commit is contained in:
commit
cb2b8ac263
27
Readme.md
27
Readme.md
@ -12,19 +12,22 @@ Screenshots
|
|||||||
|
|
||||||

|

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

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