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

|
||||
|
||||

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