Compare commits
No commits in common. "4eec03dd1f231e2cfcfc66a068b8be19f186e350" and "6922011ca7b863c3d9ee7167d792a374feaa65a9" have entirely different histories.
4eec03dd1f
...
6922011ca7
38
Readme.md
38
Readme.md
@ -16,41 +16,65 @@ TODO
|
||||
----
|
||||
|
||||
- [x] Selection Box
|
||||
- [ ] Feature: Object List
|
||||
- [ ] Feature: Object Detail
|
||||
- [ ] 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
|
||||
|
||||
- [ ] Feature: Object List
|
||||
|
||||
- [ ] Feature: Object Detail
|
||||
|
||||
- [ ] Enhancement: Handle pointerleave or other mechanism when window loses focus
|
||||
|
||||
- [ ] 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
|
||||
|
||||
- [ ] Feature: Grid
|
||||
|
||||
- [ ] Feature: Polar Coordinates
|
||||
|
||||
- [ ] Feature: Cylindrical Coordinates
|
||||
|
||||
- [ ] Feature: Spherical Coordinates
|
||||
|
||||
- [ ] Feature: Camera Velocity Display
|
||||
|
||||
- [ ] Enhancement: World State Snapshots
|
||||
|
||||
- [ ] Feature: List / Save / Load World States
|
||||
|
||||
- [ ] Feature: Left Button Panning
|
||||
|
||||
- [ ] Feature: Middle Button Pause
|
||||
|
||||
- [ ] Feature: Parameter Slider (Invisible, mouse/touch drag)
|
||||
|
||||
- [ ] Enhancement: Zoom Easing
|
||||
|
||||
- [ ] Feature: 2-touch Pan & Zoom
|
||||
Either continuous zoom, or discrete with animation of bounding box proposed changes
|
||||
|
||||
- [ ] Feature: Multi-touch Mass Create
|
||||
|
||||
- [ ] Feature: Camera Rotation
|
||||
|
||||
- [ ] 2-touch Rotation
|
||||
- [ ] Spinning Frame
|
||||
|
||||
- [ ] Feature: Undo
|
||||
|
||||
- [ ] Undo "Clear Traces" Action
|
||||
- [ ] In general have a user action history
|
||||
- [ ] Point-in-time restoral of world state (Marked points, i.e. Snapshots)
|
||||
|
||||
- [ ] 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
|
||||
|
||||
@ -8,11 +8,6 @@ export const ACCELERATION_VECTOR_COLOR = 'rgba(0, 128, 0, 0.8)'; // optionally s
|
||||
export const ACCELERATION_VECTOR_WIDTH = 1.5;
|
||||
export const ACCELERATION_VECTOR_ARROWHEAD = true;
|
||||
|
||||
// SELECTION
|
||||
export const SELECTED_GROUP_COLOR = 'rgba(0, 255, 0, 0.50)';
|
||||
export const SELECTED_SINGLE_COLOR = 'rgba(0, 255, 0, 1.0)';
|
||||
export const SELECTED_MARGIN = 5;
|
||||
|
||||
// PATH TRACES
|
||||
export const PATH_TRACES_COLOR = 'object color';
|
||||
export const PATH_TRACES_OPACITY = 0.8;
|
||||
@ -50,7 +45,6 @@ export const EVENT_MODE_LEAVE = 'lhg-mode-leave';
|
||||
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';
|
||||
|
||||
// MODES
|
||||
export const MODE_MASS_GENERATION = 'mass-gen';
|
||||
|
||||
54
display.js
54
display.js
@ -4,23 +4,59 @@ import {
|
||||
} from './config.js';
|
||||
|
||||
export class Display {
|
||||
sim = undefined;
|
||||
scalePower = 0;
|
||||
viewOrigin = {x: 0, y: 0};
|
||||
|
||||
constructor(sim) {
|
||||
this.sim = sim;
|
||||
// Create canvas that fills the window
|
||||
// If the window resizes, also resize the canvas
|
||||
const canvas = document.createElement('canvas')
|
||||
this.canvas = canvas;
|
||||
|
||||
if (sim) {
|
||||
// Create canvas that fills the window
|
||||
// If the window resizes, also resize the canvas
|
||||
sim.div.appendChild(canvas);
|
||||
this.fullscreen();
|
||||
window.addEventListener('resize', () => this.fullscreen());
|
||||
}
|
||||
this.sim.div.appendChild(canvas);
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = 0;
|
||||
canvas.style.left = 0;
|
||||
this.fullscreen();
|
||||
window.addEventListener('resize', () => this.fullscreen());
|
||||
}
|
||||
|
||||
frame() {
|
||||
frame(elapsedTime) {
|
||||
const {
|
||||
touchStart: start,
|
||||
touchLatest: latest,
|
||||
} = this.sim.pointer ?? {};
|
||||
|
||||
// Direct translate, unless using fast panning
|
||||
if (start && latest && !this.sim.getOption('compensate.fastPanning')) {
|
||||
this.viewOrigin.x = start.viewOrigin.x - (latest.x - start.x) / this.scale;
|
||||
this.viewOrigin.y = start.viewOrigin.y - (latest.y - start.y) / this.scale;
|
||||
}
|
||||
|
||||
// Apply update to viewOrigin based on panning
|
||||
if (this.sim.panning && !this.sim.panning.paused) {
|
||||
// elapsedTime is scaled by time scale, is that what we want?
|
||||
// Yes because if panning.velocity == obj.velocity, object should stay in view
|
||||
this.viewOrigin.x += this.sim.panning.velocity.x * elapsedTime;
|
||||
this.viewOrigin.y += this.sim.panning.velocity.y * elapsedTime;
|
||||
}
|
||||
|
||||
// Update what's considered start
|
||||
if (start && latest) {
|
||||
start.viewOrigin = {...this.viewOrigin};
|
||||
start.x = latest.x;
|
||||
start.y = latest.y;
|
||||
}
|
||||
|
||||
if (this.sim.getOption('debug.panningInfo')) {
|
||||
const {x, y} = this.sim.panning?.velocity ?? {};
|
||||
this.sim.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)];
|
||||
const {centerOfMass} = this.sim.system.computeSystemCenter();
|
||||
this.sim.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)];
|
||||
this.sim.info['Net Angular Momentum'] = this.sim.system.computeSystemAngularMomentum().toPrecision(6);
|
||||
}
|
||||
|
||||
// Clear canvas in preparation for other modules to render this frame
|
||||
this.fillCanvas();
|
||||
}
|
||||
|
||||
247
object.js
247
object.js
@ -9,14 +9,10 @@ import {
|
||||
PATH_TRACES_DASHED_OPACITY,
|
||||
PATH_TRACES_OPACITY,
|
||||
PATH_TRACES_WIDTH,
|
||||
SELECTED_GROUP_COLOR,
|
||||
SELECTED_MARGIN,
|
||||
SELECTED_SINGLE_COLOR,
|
||||
VELOCITY_VECTOR_ARROWHEAD,
|
||||
VELOCITY_VECTOR_COLOR,
|
||||
VELOCITY_VECTOR_WIDTH,
|
||||
} from './config.js';
|
||||
import {add, components, direction, div, magnitude, zero} from './vector.js';
|
||||
|
||||
export class MassObject {
|
||||
sim = undefined;
|
||||
@ -24,8 +20,8 @@ export class MassObject {
|
||||
mass = 0;
|
||||
density = 1;
|
||||
position = {x: undefined, y: undefined};
|
||||
velocity = zero;
|
||||
acceleration = zero;
|
||||
velocity = {x: 0, y: 0};
|
||||
acceleration = {x: 0, y: 0};
|
||||
color = {r: undefined, g: undefined, b: undefined};
|
||||
created = undefined;
|
||||
forces = []; // [{x, y}]
|
||||
@ -61,21 +57,46 @@ export class MassObject {
|
||||
}
|
||||
|
||||
getAcceleration() {
|
||||
const netForce = this.forces.reduce((t, f) => add(t, f), zero);
|
||||
return div(netForce, this.mass);
|
||||
let ax = 0;
|
||||
let ay = 0;
|
||||
for (let {x, y} of this.forces) {
|
||||
ax += x;
|
||||
ay += y;
|
||||
}
|
||||
return {
|
||||
x: ax / this.mass,
|
||||
y: ay / this.mass,
|
||||
};
|
||||
}
|
||||
|
||||
drawPath(display) {
|
||||
const {ctx} = display;
|
||||
const {color: {r, g, b}} = this;
|
||||
drawObject(sim) {
|
||||
const {
|
||||
color: {r, g, b},
|
||||
position: {x, y},
|
||||
velocity: {x: vx, y: vy},
|
||||
acceleration,
|
||||
radius,
|
||||
} = this;
|
||||
|
||||
const {
|
||||
display: {
|
||||
ctx,
|
||||
height: H,
|
||||
width: W,
|
||||
viewOrigin: {x: ox, y: oy},
|
||||
}
|
||||
} = sim;
|
||||
|
||||
const cx = ox + W / 2;
|
||||
const cy = oy + H / 2;
|
||||
|
||||
// Draw path traces
|
||||
if (this.sim.getOption('display.traces') && this.history?.length) {
|
||||
const dashedTraces = this.sim.getOption('display.dashedTraces');
|
||||
if (sim.getOption('display.traces') && this.history?.length) {
|
||||
const dashedTraces = sim.getOption('display.dashedTraces');
|
||||
const opacity = dashedTraces ? PATH_TRACES_DASHED_OPACITY : PATH_TRACES_OPACITY;
|
||||
ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ?
|
||||
`rgba(${r}, ${g}, ${b}, ${opacity})` : PATH_TRACES_COLOR;
|
||||
ctx.lineWidth = PATH_TRACES_WIDTH / display.scale;
|
||||
ctx.lineWidth = PATH_TRACES_WIDTH / this.sim.display.scale;
|
||||
ctx.beginPath();
|
||||
let dash = false;
|
||||
for (let i = 0; i < this.history.length; i++) {
|
||||
@ -94,114 +115,6 @@ export class MassObject {
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
drawArrows(display) {
|
||||
const {
|
||||
color: {r, g, b},
|
||||
position: {x, y},
|
||||
velocity: {x: vx, y: vy},
|
||||
acceleration,
|
||||
radius,
|
||||
} = this;
|
||||
|
||||
const {
|
||||
height: H,
|
||||
width: W,
|
||||
viewOrigin: {x: ox, y: oy},
|
||||
} = display;
|
||||
|
||||
const cx = ox + W / 2;
|
||||
const cy = oy + H / 2;
|
||||
|
||||
if (!this.alive) return;
|
||||
|
||||
// If the object is outside the display area, skip drawing arrows
|
||||
if (Math.abs(x - cx) - radius >= W / 2 ||
|
||||
Math.abs(y - cy) - radius >= H / 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw arrow for the velocity
|
||||
if (this.sim.getOption('display.velocity')) {
|
||||
const vecScale = this.sim.getOption('display.velocityScale');
|
||||
const selected = this.sim.system.getSelectedOrCreating();
|
||||
const isSelected = selected?.id === this.id;
|
||||
let velocity = {x: vx, y: vy};
|
||||
if (isSelected) {
|
||||
// If this object is being dragged by the user,
|
||||
// show the pointer velocity instead of object velocity
|
||||
let pointerV = this.sim.pointer.latestVelocity;
|
||||
if (this.sim.getOption('compensate.timeScale')) {
|
||||
pointerV = div(pointerV, this.sim.timeScale);
|
||||
}
|
||||
velocity = add(velocity, pointerV);
|
||||
}
|
||||
const speed = magnitude(velocity);
|
||||
const arrowDirection = direction(velocity);
|
||||
// Prevent negative numbers by adding 1
|
||||
// Make arrow lengths appear consistent regardless of display scale
|
||||
let arrowLength = Math.log10(speed + 1) * vecScale;
|
||||
if (!this.sim.getOption('display.zoomVectors')) {
|
||||
arrowLength /= display.scale;
|
||||
}
|
||||
// const offset = mult({x: Math.cos(
|
||||
const offset = components(radius, arrowDirection)
|
||||
const start = add({x, y}, offset);
|
||||
const end = add(start, components(arrowLength, arrowDirection));
|
||||
const style = VELOCITY_VECTOR_COLOR === 'object color' ?
|
||||
`rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR;
|
||||
display.drawArrow(start.x, start.y, end.x, end.y, {
|
||||
style,
|
||||
width: VELOCITY_VECTOR_WIDTH,
|
||||
arrowhead: VELOCITY_VECTOR_ARROWHEAD,
|
||||
fill: false,
|
||||
ifShort: 'head'
|
||||
});
|
||||
}
|
||||
|
||||
// Draw arrow for acceleration
|
||||
if (this.sim.getOption('display.acceleration')) {
|
||||
const vecScale = this.sim.getOption('display.accelerationScale');
|
||||
const accel = magnitude(acceleration);
|
||||
const arrowDirection = direction(acceleration);
|
||||
// Prevent negative numbers by adding 1
|
||||
// Make arrow lengths appear consistent regardless of display scale
|
||||
let arrowLength = Math.log10(accel + 1) * vecScale;
|
||||
if (!this.sim.getOption('display.zoomVectors')) {
|
||||
arrowLength /= this.sim.display.scale;
|
||||
}
|
||||
const offset = components(radius, arrowDirection)
|
||||
const start = add({x, y}, offset);
|
||||
const end = add(start, components(arrowLength, arrowDirection));
|
||||
const style = ACCELERATION_VECTOR_COLOR === 'object color' ?
|
||||
`rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR;
|
||||
display.drawArrow(start.x, start.y, end.x, end.y, {
|
||||
style,
|
||||
width: ACCELERATION_VECTOR_WIDTH,
|
||||
arrowhead: ACCELERATION_VECTOR_ARROWHEAD,
|
||||
fill: false,
|
||||
ifShort: 'tail'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawObject(display) {
|
||||
const {
|
||||
color: {r, g, b},
|
||||
position: {x, y},
|
||||
radius,
|
||||
} = this;
|
||||
|
||||
const {
|
||||
ctx,
|
||||
height: H,
|
||||
width: W,
|
||||
viewOrigin: {x: ox, y: oy},
|
||||
} = display;
|
||||
|
||||
const cx = ox + W / 2;
|
||||
const cy = oy + H / 2;
|
||||
|
||||
if (!this.alive) return;
|
||||
|
||||
@ -239,11 +152,11 @@ export class MassObject {
|
||||
|
||||
const arrowDirection = Math.atan2(py - cy, px - cx);
|
||||
// Length of arrow based on distance (logarithmic scale)
|
||||
const d = Math.sqrt((x - px) ** 2, (y - py) ** 2);
|
||||
const arrowLength = Math.log(d + 1) * OFFSCREEN_OBJECT_LINE_SCALE / display.scale;
|
||||
const distance = Math.sqrt((x - px) ** 2, (y - py) ** 2);
|
||||
const arrowLength = Math.log(distance + 1) * OFFSCREEN_OBJECT_LINE_SCALE / this.sim.display.scale;
|
||||
const startAx = px - arrowLength * Math.cos(arrowDirection);
|
||||
const startAy = py - arrowLength * Math.sin(arrowDirection);
|
||||
display.drawArrow(startAx, startAy, px, py, {
|
||||
sim.display.drawArrow(startAx, startAy, px, py, {
|
||||
style: `rgb(${r}, ${g}, ${b})`,
|
||||
width: OFFSCREEN_OBJECT_LINE_WIDTH,
|
||||
arrowheadLength: OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
|
||||
@ -259,26 +172,74 @@ export class MassObject {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
drawSelection(display) {
|
||||
const {
|
||||
position: {x, y},
|
||||
radius,
|
||||
} = this;
|
||||
const {ctx} = display;
|
||||
|
||||
if (!this.alive) return;
|
||||
|
||||
// If object is currently selected, draw a circle around it
|
||||
const isInSelectedGroup = this.sim.select.selectedGroup?.some(({id}) => id === this.id);
|
||||
const isSelectedSingle = this.sim.select.selectedSingle?.id === this.id;
|
||||
if (isSelectedSingle || isInSelectedGroup) {
|
||||
ctx.strokeStyle = isSelectedSingle ? SELECTED_SINGLE_COLOR : SELECTED_GROUP_COLOR;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius + SELECTED_MARGIN / display.scale, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
// Draw arrow for the velocity
|
||||
if (sim.getOption('display.velocity')) {
|
||||
// If this object is being dragged by the user,
|
||||
// show the pointer velocity instead of object velocity
|
||||
const vecScale = this.sim.getOption('display.velocityScale');
|
||||
const selected = this.sim.system.getSelectedOrCreating();
|
||||
const isSelected = selected?.id === this.id;
|
||||
let velocity = {x: vx, y: vy};
|
||||
if (isSelected) {
|
||||
const pointerV = this.sim.pointer.latestVelocity;
|
||||
// const panning = this.sim.panning?.velocity ?? {x: 0, y: 0};
|
||||
// velocity.x = vx + (pointerV.x + panning.x) * scale;
|
||||
// velocity.y = vy + (pointerV.y + panning.y) * scale;
|
||||
if (this.sim.getOption('compensate.timeScale')) {
|
||||
velocity.x = vx + pointerV.x / this.sim.timeScale;
|
||||
velocity.y = vy + pointerV.y / this.sim.timeScale;
|
||||
}
|
||||
}
|
||||
const speed = Math.sqrt(velocity.x ** 2, velocity.y ** 2) / this.sim.display.scale;
|
||||
const arrowDirection = Math.atan2(velocity.y, velocity.x);
|
||||
// Prevent negative numbers by adding 1
|
||||
// TODO: Make logarithmic vector length scale optional
|
||||
const arrowLength = Math.log10(speed + 1) * vecScale;
|
||||
const offsetX = radius * Math.cos(arrowDirection);
|
||||
const offsetY = radius * Math.sin(arrowDirection);
|
||||
const startVx = x + offsetX;
|
||||
const startVy = y + offsetY;
|
||||
const endVx = startVx + arrowLength * Math.cos(arrowDirection);
|
||||
const endVy = startVy + arrowLength * Math.sin(arrowDirection);
|
||||
const style = VELOCITY_VECTOR_COLOR === 'object color' ?
|
||||
`rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR;
|
||||
sim.display.drawArrow(startVx, startVy, endVx, endVy, {
|
||||
style,
|
||||
width: VELOCITY_VECTOR_WIDTH,
|
||||
arrowhead: VELOCITY_VECTOR_ARROWHEAD,
|
||||
fill: false,
|
||||
ifShort: 'head'
|
||||
});
|
||||
}
|
||||
|
||||
// Draw arrow for acceleration
|
||||
if (sim.getOption('display.acceleration')) {
|
||||
const vecScale = this.sim.getOption('display.accelerationScale');
|
||||
const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2) /
|
||||
this.sim.display.scale;
|
||||
const arrowDirection = Math.atan2(acceleration.y, acceleration.x);
|
||||
// Prevent negative numbers by adding 1
|
||||
const arrowLength = Math.log10(accelerationMagnitude + 1) * vecScale;
|
||||
//const arrowLength = accelerationMagnitude * vecScale;
|
||||
const offsetX = radius * Math.cos(arrowDirection);
|
||||
const offsetY = radius * Math.sin(arrowDirection);
|
||||
const startAx = x + offsetX;
|
||||
const startAy = y + offsetY;
|
||||
const endAx = startAx + arrowLength * Math.cos(arrowDirection);
|
||||
const endAy = startAy + arrowLength * Math.sin(arrowDirection);
|
||||
const style = ACCELERATION_VECTOR_COLOR === 'object color' ?
|
||||
`rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR;
|
||||
sim.display.drawArrow(startAx, startAy, endAx, endAy, {
|
||||
style,
|
||||
width: ACCELERATION_VECTOR_WIDTH,
|
||||
arrowhead: ACCELERATION_VECTOR_ARROWHEAD,
|
||||
fill: false,
|
||||
ifShort: 'tail'
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
129
options-1.js
Normal file
129
options-1.js
Normal file
@ -0,0 +1,129 @@
|
||||
import {
|
||||
EVENT_OPTION_SET,
|
||||
OBJECT_MAGIC_PROP_PREFIX,
|
||||
} from './config.js';
|
||||
|
||||
export class Options {
|
||||
sim = undefined;
|
||||
options = undefined;
|
||||
values = {};
|
||||
undefinedObj = {[OBJECT_MAGIC_PROP_PREFIX + 'undefined']: true};
|
||||
nullObj = {[OBJECT_MAGIC_PROP_PREFIX + 'null']: true};
|
||||
|
||||
getStorageKey(path) {
|
||||
return `${path}:options`;
|
||||
}
|
||||
|
||||
constructor(sim, options) {
|
||||
this.sim = sim;
|
||||
this.options = options;
|
||||
|
||||
// Global methods to get/set current option values
|
||||
this.sim.getOption = (path) => this.getOption(path);
|
||||
this.sim.setOption = (path, value) => this.setOption(path, value);
|
||||
this.sim.onOptionSet = (path, cb) => this.onOptionSet(path, cb);
|
||||
|
||||
// Initialize values from localStorage
|
||||
for (const groupName of Object.keys(options)) {
|
||||
for (const [name, [, , defaultValue]] of Object.entries(this.options[groupName])) {
|
||||
const path = [groupName, name].join('.');
|
||||
let value = this.getFromLocalStorage(path);
|
||||
if (value === undefined) {
|
||||
value = defaultValue;
|
||||
}
|
||||
this.values[path] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toStored(value) {
|
||||
if (value === undefined) {
|
||||
// Do we want to interpret this as removing from storage?
|
||||
// Let's just treat it as a value for now;
|
||||
// Semantically it works because when retrieved, it will return undefined,
|
||||
// which is the same result you get if the key is not set
|
||||
return JSON.stringify(this.undefinedObj);
|
||||
} else if (value === null) {
|
||||
return JSON.stringify(this.nullObj);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// value: string
|
||||
fromStored(value) {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
} else if (value === JSON.stringify(this.undefinedObj)) {
|
||||
return undefined;
|
||||
} else if (value === JSON.stringify(this.nullObj)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
getFromLocalStorage(path) {
|
||||
const storageKey = this.getStorageKey(path);
|
||||
const value = this.fromStored(window.localStorage.getItem(storageKey));
|
||||
this.values[path] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
getOption(path) {
|
||||
const [groupName, name] = path.split('.');
|
||||
const group = this.options[groupName];
|
||||
const item = group[name];
|
||||
const {type} = item;
|
||||
const value = this.values[path];
|
||||
switch (type) {
|
||||
case 'number': return Number(value);
|
||||
case 'boolean': return value === true || value === 'true';
|
||||
default: {
|
||||
console.log({
|
||||
path, groupName, name,
|
||||
group, item,
|
||||
type, value
|
||||
});
|
||||
throw new Error('unknown option type');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOption(path, value) {
|
||||
this.values[path] = value;
|
||||
const storageKey = this.getStorageKey(path);
|
||||
window.localStorage.setItem(storageKey, this.toStored(value));
|
||||
const e = new CustomEvent(EVENT_OPTION_SET, {detail: {path, value}});
|
||||
this.sim.div.dispatchEvent(e);
|
||||
}
|
||||
|
||||
// cb: (value) => undefined
|
||||
onOptionSet(path, cb) {
|
||||
this.sim.div.addEventListener(EVENT_OPTION_SET, (e) => {
|
||||
if (path === e.detail.path) {
|
||||
cb(e.detail.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSection(sectionName) {
|
||||
const section = this.options[sectionName];
|
||||
const group = {
|
||||
type: 'group',
|
||||
name: sectionName,
|
||||
title: section._title,
|
||||
items: [],
|
||||
};
|
||||
for (const name in section) {
|
||||
if (name.startsWith('_')) continue;
|
||||
const [title, type, defaultValue, opts] = section[name];
|
||||
group.items.push({
|
||||
name,
|
||||
type,
|
||||
title,
|
||||
default: defaultValue,
|
||||
...opts
|
||||
})
|
||||
}
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@ -96,11 +96,11 @@ export class Options {
|
||||
this.sim.div.dispatchEvent(e);
|
||||
}
|
||||
|
||||
// cb: ({path, value}) => undefined
|
||||
// cb: (value) => undefined
|
||||
onOptionSet(path, cb) {
|
||||
this.sim.div.addEventListener(EVENT_OPTION_SET, (e) => {
|
||||
if (!path || path === e.detail.path) {
|
||||
cb({ path, value: e.detail.value });
|
||||
if (path === e.detail.path) {
|
||||
cb(e.detail.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
117
panning.js
117
panning.js
@ -1,117 +0,0 @@
|
||||
import {add, copy, div, mult, sub, zero} from "./vector.js";
|
||||
|
||||
export class Panning {
|
||||
sim = undefined;
|
||||
touchStart = undefined;
|
||||
touchLatest = undefined;
|
||||
paused = false;
|
||||
velocity = zero;
|
||||
|
||||
constructor(sim) {
|
||||
this.sim = sim;
|
||||
}
|
||||
|
||||
handlePointerDown({x, y}) {
|
||||
this.initializeTouch({x, y});
|
||||
}
|
||||
|
||||
initializeTouch({x, y}) {
|
||||
this.touchStart = {
|
||||
x,
|
||||
y,
|
||||
t: this.sim.rawTime,
|
||||
viewOrigin: copy(this.sim.display.viewOrigin),
|
||||
};
|
||||
this.touchLatest = {
|
||||
...this.touchStart,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
dt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// With fast panning, panning velocity calculation happens every move;
|
||||
// With normal panning, calculation only happens at pointer up.
|
||||
handlePointerMove({x, y}) {
|
||||
if (this.touchStart) {
|
||||
this.touchLatest = {
|
||||
x,
|
||||
y,
|
||||
t: this.sim.rawTime,
|
||||
dx: x - this.touchStart.x,
|
||||
dy: x - this.touchStart.y,
|
||||
dt: this.sim.rawTime - this.touchStart.t,
|
||||
};
|
||||
if (this.sim.getOption('compensate.fastPanning')) {
|
||||
this.updateVelocity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePointerUp() {
|
||||
if (this.touchStart && this.touchLatest) {
|
||||
if (this.touchLatest.dt === 0) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (this.sim.getOption('debug.panningInfo')) {
|
||||
const {x, y} = this.sim.panning?.velocity ?? {};
|
||||
this.sim.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)];
|
||||
const {centerOfMass} = this.sim.system.computeSystemCenter();
|
||||
this.sim.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)];
|
||||
this.sim.info['Net Angular Momentum'] = this.sim.system.computeSystemAngularMomentum().toPrecision(6);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
this.paused = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
95
pointer.js
95
pointer.js
@ -55,16 +55,10 @@ export class Pointer {
|
||||
const {x, y} = this.sim.screenToSim(e.clientX, e.clientY);
|
||||
this.sim.scheduleZoom({x, y}, factor);
|
||||
});
|
||||
|
||||
el.addEventListener('focus', () => {
|
||||
console.log('window focus');
|
||||
});
|
||||
el.addEventListener('blur', () => {
|
||||
console.log('window blur');
|
||||
});
|
||||
}
|
||||
|
||||
handlePointerDown({x: clientX, y: clientY}) {
|
||||
// this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE);
|
||||
this.updatePointer({x: clientX, y: clientY});
|
||||
|
||||
switch (this.sim.getCurrentMode()) {
|
||||
@ -74,7 +68,19 @@ export class Pointer {
|
||||
break;
|
||||
}
|
||||
case MODE_PAN_VIEW: {
|
||||
this.sim.panning.handlePointerDown({x: clientX, y: clientY});
|
||||
this.touchStart = {
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
t: this.sim.rawTime,
|
||||
viewOrigin: {...this.sim.display.viewOrigin},
|
||||
};
|
||||
// Since we've processed this increment, reset
|
||||
this.touchLatest = {
|
||||
...this.touchStart,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
dt: 0,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case MODE_OBJECT_SELECT: {
|
||||
@ -96,7 +102,41 @@ export class Pointer {
|
||||
break;
|
||||
}
|
||||
case MODE_PAN_VIEW: {
|
||||
this.sim.panning.handlePointerMove({x: clientX, y: clientY});
|
||||
if (this.touchStart) {
|
||||
this.touchLatest = {
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
t: this.sim.rawTime,
|
||||
dx: clientX - this.touchStart.x,
|
||||
dy: clientY - this.touchStart.y,
|
||||
dt: this.sim.rawTime - this.touchStart.t,
|
||||
};
|
||||
|
||||
// With fast panning, this calculation happens every move
|
||||
// With normal panning, this calculation only happens at pointer up
|
||||
if (this.sim.getOption('compensate.fastPanning')) {
|
||||
const panning = {...this.latestVelocity};
|
||||
// Convert pointer velocity to simulation scale.
|
||||
// Also multiply by -1 because the camera is
|
||||
// panning opposite to the pointer velocity.
|
||||
panning.x /= -this.sim.display.scale;
|
||||
panning.y /= -this.sim.display.scale;
|
||||
|
||||
if (this.sim.getOption('compensate.timeScale')) {
|
||||
panning.x /= this.sim.timeScale;
|
||||
panning.y /= this.sim.timeScale;
|
||||
}
|
||||
|
||||
// Also add current panning
|
||||
panning.x += this.sim.panning?.velocity.x ?? 0;
|
||||
panning.y += this.sim.panning?.velocity.y ?? 0;
|
||||
|
||||
this.sim.panning = {
|
||||
velocity: panning
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case MODE_OBJECT_SELECT: {
|
||||
@ -114,7 +154,37 @@ export class Pointer {
|
||||
break;
|
||||
}
|
||||
case MODE_PAN_VIEW: {
|
||||
this.sim.panning.handlePointerUp({x: clientX, y: clientY});
|
||||
// Set panning velocity
|
||||
if (this.touchStart && this.touchLatest) {
|
||||
if (this.touchLatest.dt === 0) {
|
||||
this.sim.panning = undefined;
|
||||
}
|
||||
this.touchStart = undefined;
|
||||
|
||||
if (this.sim.getOption('compensate.fastPanning')) {
|
||||
this.sim.panning = undefined;
|
||||
} else {
|
||||
const panning = {...this.latestVelocity};
|
||||
// Convert pointer velocity to simulation scale.
|
||||
// Also multiply by -1 because the camera is
|
||||
// panning opposite to the pointer velocity.
|
||||
panning.x /= -this.sim.display.scale;
|
||||
panning.y /= -this.sim.display.scale;
|
||||
|
||||
if (this.sim.getOption('compensate.timeScale')) {
|
||||
panning.x /= this.sim.timeScale;
|
||||
panning.y /= this.sim.timeScale;
|
||||
}
|
||||
|
||||
// Also add current panning
|
||||
panning.x += this.sim.panning?.velocity.x ?? 0;
|
||||
panning.y += this.sim.panning?.velocity.y ?? 0;
|
||||
|
||||
this.sim.panning = {
|
||||
velocity: panning
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MODE_OBJECT_SELECT: {
|
||||
@ -149,6 +219,11 @@ export class Pointer {
|
||||
};
|
||||
}
|
||||
|
||||
// Keep the specified number of entries at the end of the array (most recent)
|
||||
clearPointerHistory(keep = 0) {
|
||||
this.pointerHistory.splice(keep, this.pointerHistory.length - keep)
|
||||
}
|
||||
|
||||
updatePointer({x, y}) {
|
||||
const t = this.sim.rawTime;
|
||||
while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) {
|
||||
|
||||
45
select.js
45
select.js
@ -1,74 +1,33 @@
|
||||
import {copy} from './vector.js';
|
||||
|
||||
export class Select {
|
||||
sim = undefined;
|
||||
box = {
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
};
|
||||
selectedSingle = undefined;
|
||||
selectedGroup = [];
|
||||
|
||||
constructor(sim) {
|
||||
this.sim = sim;
|
||||
|
||||
// TODO: Move this to a new Keyboard class singleton
|
||||
window.addEventListener('keydown', (e) => {
|
||||
switch (e.key) {
|
||||
case 'Tab': {
|
||||
e.preventDefault();
|
||||
if (!this.selectedGroup.length) return;
|
||||
const currentIdx = this.selectedGroup.indexOf(this.selectedSingle);
|
||||
const newIdx = (currentIdx + 1) % this.selectedGroup.length;
|
||||
this.selectedSingle = this.selectedGroup[newIdx];
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handlePointerDown({x: clientX, y: clientY}) {
|
||||
this.box.start = this.sim.screenToSim(clientX, clientY);
|
||||
this.box.end = this.box.start;
|
||||
this.getSelectedObjects();
|
||||
}
|
||||
|
||||
handlePointerMove({x: clientX, y: clientY}) {
|
||||
if (!this.box.start) return;
|
||||
this.box.end = this.sim.screenToSim(clientX, clientY);
|
||||
}
|
||||
|
||||
handlePointerUp() {
|
||||
if (!this.box.start) return;
|
||||
const start = copy(this.box.start);
|
||||
const end = copy(this.box.end);
|
||||
this.box.start = {
|
||||
x: Math.min(start.x, end.x),
|
||||
y: Math.min(start.y, end.y),
|
||||
};
|
||||
this.box.end = {
|
||||
x: Math.max(start.x, end.x),
|
||||
y: Math.max(start.y, end.y),
|
||||
};
|
||||
this.getSelectedObjects();
|
||||
console.log('selection box', {...this.box});
|
||||
this.box = {
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getSelectedObjects() {
|
||||
const {start, end} = this.box;
|
||||
if (!start) return;
|
||||
this.selectedGroup = this.sim.system.filter(({position: {x, y}}) => {
|
||||
return x >= start.x && x <= end.x && y >= start.y && y <= end.y;
|
||||
});
|
||||
// For now, first object in group is selected single
|
||||
this.selectedSingle = this.selectedGroup[0] ?? undefined;
|
||||
}
|
||||
|
||||
frame() {
|
||||
if (!this.box.start) return;
|
||||
|
||||
this.sim.display.drawBox(this.box)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,14 +4,13 @@ export const simOptions = {
|
||||
selection: ['Pause While Selecting', 'boolean', true],
|
||||
},
|
||||
display: {
|
||||
targetFrameRate: ['Frame Rate', 'number', 60],
|
||||
traces: ['Path Traces', 'boolean', true],
|
||||
dashedTraces: ['Dashed', 'boolean', false, {tall: true}],
|
||||
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]
|
||||
targetFrameRate: ['Frame Rate', 'number', 60],
|
||||
},
|
||||
compensate: {
|
||||
timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}],
|
||||
|
||||
65
sim-tools.js
65
sim-tools.js
@ -1,6 +1,4 @@
|
||||
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 {UtilityTool} from './tool/utility.js';
|
||||
@ -9,40 +7,43 @@ import {ToolbarGroup} from './toolbar-group.js';
|
||||
import {Toolbar} from './toolbar.js';
|
||||
|
||||
export function initializeTools(sim) {
|
||||
sim.toolbars = {
|
||||
tools: new Toolbar(sim, 'Tools')
|
||||
.addTool(new Zoom())
|
||||
.addTool(new PlayPause()),
|
||||
modes: new Toolbar(sim, 'Modes')
|
||||
.addTool(new ModeSwitch()),
|
||||
utils: new Toolbar(sim, 'Utils', {expanded: false})
|
||||
.addTool(new UtilityTool()),
|
||||
options: new Toolbar(sim, 'Options')
|
||||
.addTool(new OptionsTool([
|
||||
'pauseDuring',
|
||||
'display',
|
||||
'compensate',
|
||||
])),
|
||||
params: new Toolbar(sim, 'Params')
|
||||
.addTool(new OptionsTool([
|
||||
'param'
|
||||
])),
|
||||
debug: new Toolbar(sim, 'Debug', {expanded: false})
|
||||
.addTool(new OptionsTool([
|
||||
'debug'
|
||||
])),
|
||||
};
|
||||
const {tools, modes, options, params, debug, utils} = sim.toolbars;
|
||||
sim.toolbarGroups = {
|
||||
left: new ToolbarGroup(sim)
|
||||
.addToolbar(new Toolbar(sim, 'Tools')
|
||||
.addTool(new Zoom())
|
||||
.addTool(new PlayPause())
|
||||
)
|
||||
.addToolbar(new Toolbar(sim, 'Modes')
|
||||
.addTool(new ModeSwitch())
|
||||
)
|
||||
.addToolbar(new Toolbar(sim, 'Utils', {expanded: false})
|
||||
.addTool(new UtilityTool())
|
||||
)
|
||||
.addToolbar(new Toolbar(sim, 'Objects')
|
||||
.addTool(new ObjectsTool()),
|
||||
),
|
||||
.addToolbar(tools)
|
||||
.addToolbar(modes)
|
||||
.addToolbar(utils),
|
||||
right: new ToolbarGroup(sim).topRight()
|
||||
.addToolbar(new Toolbar(sim, 'Options')
|
||||
.addTool(new OptionsTool(['pauseDuring', 'display', 'compensate']))
|
||||
)
|
||||
.addToolbar(new Toolbar(sim, 'Params')
|
||||
.addTool(new OptionsTool([
|
||||
'param'
|
||||
])),
|
||||
)
|
||||
.addToolbar(new Toolbar(sim, 'Debug', {expanded: false})
|
||||
.addTool(new OptionsTool(['debug'])),
|
||||
)
|
||||
.addToolbar(new Toolbar(sim, 'Object')
|
||||
.addTool(new ObjectTool()),
|
||||
),
|
||||
.addToolbar(options)
|
||||
.addToolbar(params)
|
||||
.addToolbar(debug),
|
||||
};
|
||||
|
||||
for (const groupName in sim.toolbarGroups) {
|
||||
const group = sim.toolbarGroups[groupName];
|
||||
group.applyExpanded();
|
||||
for (const id in sim.toolbars) {
|
||||
const toolbar = sim.toolbars[id];
|
||||
toolbar.applyExpanded();
|
||||
}
|
||||
}
|
||||
|
||||
24
simulator.js
24
simulator.js
@ -1,5 +1,4 @@
|
||||
import {
|
||||
EVENT_PLAY_PAUSE,
|
||||
EVENT_ZOOM,
|
||||
FRAMERATE_SAMPLE_DURATION,
|
||||
} from './config.js';
|
||||
@ -9,7 +8,6 @@ import {Overlay} from './overlay.js';
|
||||
import {Pointer} from './pointer.js';
|
||||
import {Options} from './options.js';
|
||||
import {Zoom} from './zoom.js';
|
||||
import {Panning} from './panning.js';
|
||||
import {Select} from './select.js';
|
||||
import {simOptions} from './sim-options.js';
|
||||
import {initializeTools} from './sim-tools.js';
|
||||
@ -22,6 +20,7 @@ export class Sim {
|
||||
nextZoom = undefined;
|
||||
playing = true;
|
||||
recentFrames = [];
|
||||
panning = undefined;
|
||||
frameRate = 0;
|
||||
|
||||
system = undefined;
|
||||
@ -29,7 +28,6 @@ export class Sim {
|
||||
overlay = undefined;
|
||||
pointer = undefined;
|
||||
zoom = undefined;
|
||||
panning = undefined;
|
||||
select = undefined;
|
||||
|
||||
toolbarGroups = {};
|
||||
@ -54,7 +52,6 @@ export class Sim {
|
||||
this.overlay = new Overlay(this);
|
||||
this.pointer = new Pointer(this);
|
||||
this.zoom = new Zoom(this);
|
||||
this.panning = new Panning(this);
|
||||
this.select = new Select(this);
|
||||
|
||||
initializeTools(this);
|
||||
@ -85,15 +82,14 @@ export class Sim {
|
||||
if (this.getOption('debug.frameRate')) {
|
||||
this.info['Frame Rate'] = this.frameRate.toPrecision(3);
|
||||
}
|
||||
this.zoom.frame(elapsedTime);
|
||||
this.panning.frame(elapsedTime);
|
||||
this.pointer.frame(elapsedTime);
|
||||
this.zoom.frame();
|
||||
this.pointer.frame();
|
||||
this.display.frame(elapsedTime);
|
||||
this.select.frame(elapsedTime);
|
||||
this.select.frame();
|
||||
this.system.frame(elapsedTime);
|
||||
this.overlay.frame(elapsedTime);
|
||||
this.overlay.frame();
|
||||
for (const group in this.toolbarGroups) {
|
||||
this.toolbarGroups[group].frame(elapsedTime);
|
||||
this.toolbarGroups[group].frame();
|
||||
}
|
||||
requestAnimationFrame(t => this.frame(t));
|
||||
}
|
||||
@ -135,12 +131,9 @@ export class Sim {
|
||||
play() {
|
||||
this.playing = true;
|
||||
|
||||
if (this.panning.paused) {
|
||||
if (this.panning?.paused) {
|
||||
this.panning.paused = false;
|
||||
}
|
||||
|
||||
const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}});
|
||||
this.div.dispatchEvent(e);
|
||||
}
|
||||
|
||||
pause() {
|
||||
@ -149,9 +142,6 @@ export class Sim {
|
||||
if (this.panning?.velocity) {
|
||||
this.panning.paused = true;
|
||||
}
|
||||
|
||||
const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}});
|
||||
this.div.dispatchEvent(e);
|
||||
}
|
||||
|
||||
getScaleDisplay() {
|
||||
|
||||
13
style.css
13
style.css
@ -27,12 +27,6 @@ div[id=simulator] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div[id=simulator] > canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* normal toolbar group */
|
||||
div.lhg-toolbar-group div.lhg-tool {
|
||||
width: 12em;
|
||||
@ -144,3 +138,10 @@ div.lhg-option-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/*
|
||||
div.lhg-option-group > * {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
*/
|
||||
|
||||
129
system.js
129
system.js
@ -1,10 +1,5 @@
|
||||
import {OBJECT_HISTORY_SIZE} from './config.js';
|
||||
import {MassObject} from './object.js';
|
||||
import {
|
||||
add, copy, cross, degrees,
|
||||
direction, div, magnitude, mult,
|
||||
square, sub, weightedAvg, zero
|
||||
} from './vector.js';
|
||||
|
||||
export class System {
|
||||
objects = [];
|
||||
@ -36,29 +31,41 @@ export class System {
|
||||
this.doneCreatingObject();
|
||||
this.deselect();
|
||||
// Convert pointer velocity to simulation scale
|
||||
obj.velocity = div(this.sim.pointer.latestVelocity, this.sim.display.scale);
|
||||
const pointer = {...this.sim.pointer.latestVelocity};
|
||||
obj.velocity.x = pointer.x / this.sim.display.scale;
|
||||
obj.velocity.y = pointer.y / this.sim.display.scale;
|
||||
|
||||
// Including time scale - if time is slow, our motion is relatively faster
|
||||
if (this.sim.getOption('compensate.timeScale')) {
|
||||
obj.velocity = div(obj.velocity, this.sim.timeScale);
|
||||
obj.velocity.x /= this.sim.timeScale;
|
||||
obj.velocity.y /= this.sim.timeScale;
|
||||
}
|
||||
|
||||
obj.velocity = add(obj.velocity, this.sim.panning.velocity);
|
||||
if (this.sim.panning?.velocity) {
|
||||
obj.velocity.x += this.sim.panning.velocity.x;
|
||||
obj.velocity.y += this.sim.panning.velocity.y;
|
||||
}
|
||||
}
|
||||
|
||||
handlePointerMove(r) {
|
||||
handlePointerMove({x, y}) {
|
||||
// If the cursor moves while creating an object, or while an object is selected,
|
||||
// update the position using the pointer motion but the velocity using the pointer velocity
|
||||
const obj = this.getSelectedOrCreating();
|
||||
if (obj === undefined) return;
|
||||
const start = this.selectedObjectStart;
|
||||
obj.position = add(start, sub(r, start.pointer));
|
||||
obj.velocity = zero;
|
||||
obj.position.x = start.x + (x - start.pointer.x);
|
||||
obj.position.y = start.y + (y - start.pointer.y);
|
||||
obj.velocity.x = 0;
|
||||
obj.velocity.y = 0;
|
||||
}
|
||||
|
||||
// elapsedTime is given in milliseconds
|
||||
frame(elapsedTime) {
|
||||
// If we're creating an object, increment its mass
|
||||
// with the mass creation rate accelerating over time
|
||||
|
||||
// Scaling this parameter because of millisecond conversion
|
||||
|
||||
if (this.creatingObject !== undefined) {
|
||||
const obj = this.objects[this.creatingObject];
|
||||
let massCreationRate = this.sim.getOption('param.massCreationRate');
|
||||
@ -83,13 +90,10 @@ export class System {
|
||||
return;
|
||||
}
|
||||
|
||||
obj.position = add(obj.position, mult(
|
||||
elapsedTime,
|
||||
add(
|
||||
obj.velocity,
|
||||
mult(obj.currentAcceleration, elapsedTime / 2)
|
||||
),
|
||||
));
|
||||
obj.position.x += elapsedTime *
|
||||
(obj.velocity.x + 1 / 2 * obj.currentAcceleration.x * elapsedTime);
|
||||
obj.position.y += elapsedTime *
|
||||
(obj.velocity.y + 1 / 2 * obj.currentAcceleration.y * elapsedTime);
|
||||
});
|
||||
|
||||
// Collisions
|
||||
@ -121,10 +125,12 @@ export class System {
|
||||
// Set position = center of mass
|
||||
// Set velocity = total momentum / total mass
|
||||
// Combine forces
|
||||
// Add masses
|
||||
// Average color
|
||||
S.position = weightedAvg([[S.position, S.mass], [T.position, T.mass]]);
|
||||
S.velocity = weightedAvg([[S.velocity, S.mass], [T.velocity, T.mass]]);
|
||||
// Sdd masses
|
||||
// Sverage color
|
||||
S.position.x = (S.position.x * S.mass + T.position.x * T.mass) / (S.mass + T.mass);
|
||||
S.position.y = (S.position.y * S.mass + T.position.y * T.mass) / (S.mass + T.mass);
|
||||
S.velocity.x = (S.velocity.x * S.mass + T.velocity.x * T.mass) / (S.mass + T.mass);
|
||||
S.velocity.y = (S.velocity.y * S.mass + T.velocity.y * T.mass) / (S.mass + T.mass);
|
||||
S.forces.push(...T.forces);
|
||||
S.mass += T.mass;
|
||||
S.color = {
|
||||
@ -144,8 +150,12 @@ export class System {
|
||||
// Predict velocities
|
||||
this.forEachObject(obj => {
|
||||
const acceleration = {...obj.acceleration};
|
||||
obj.acceleration = div(add(obj.currentAcceleration, acceleration), 2);
|
||||
obj.velocity = add(obj.velocity, mult(obj.acceleration, elapsedTime));
|
||||
obj.acceleration = {
|
||||
x: (obj.currentAcceleration.x + acceleration.x) / 2,
|
||||
y: (obj.currentAcceleration.y + acceleration.y) / 2,
|
||||
};
|
||||
obj.velocity.x += obj.acceleration.x * elapsedTime;
|
||||
obj.velocity.y += obj.acceleration.y * elapsedTime;
|
||||
|
||||
// Append to object history
|
||||
obj.history.push({position: {...obj.position}});
|
||||
@ -167,18 +177,18 @@ export class System {
|
||||
if (this.sim.getOption('debug.objectsInfo')) {
|
||||
const aliveOnly = this.sim.getOption('debug.aliveObjects');
|
||||
this.forEachObject((obj, i) => {
|
||||
const speed = magnitude(obj.velocity);
|
||||
const accel = magnitude(obj.acceleration);
|
||||
// Give angle counterclockwise from horizontal
|
||||
const velocityDir = -1 * degrees(direction(obj.velocity));
|
||||
const accelDir = -1 * degrees(direction(obj.acceleration));
|
||||
const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1 / 2);
|
||||
const accel = Math.pow(obj.acceleration.x ** 2 + obj.acceleration.y ** 2, 1 / 2);
|
||||
// Invert y so that the angle is counterclockwise from x-axis
|
||||
const direction = Math.atan2(-obj.velocity.y, obj.velocity.x) * 180 / Math.PI;
|
||||
const accelDir = Math.atan2(-obj.acceleration.y, obj.acceleration.x) * 180 / Math.PI;
|
||||
const {r, g, b} = obj.color;
|
||||
this.sim.info[`Object ${i}`] = [
|
||||
`<span style="background-color: rgb(${r},${g},${b});"> </span>`,
|
||||
`${obj.position.x.toPrecision(4)}, `,
|
||||
`${obj.position.y.toPrecision(4)}, `,
|
||||
`${obj.mass.toPrecision(4)} kg, `,
|
||||
`${speed.toPrecision(2)} m/s, ${velocityDir.toPrecision(2)}°`,
|
||||
`${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`,
|
||||
`${accel.toPrecision(2)} m/s<sup>2</sup>, ${accelDir.toPrecision(2)}°`,
|
||||
`Alive: ${obj.alive}`,
|
||||
];
|
||||
@ -213,7 +223,9 @@ export class System {
|
||||
this.pause();
|
||||
}
|
||||
|
||||
obj.velocity = copy(this.sim.panning.velocity);
|
||||
if (this.sim.panning?.velocity) {
|
||||
obj.velocity = {...this.sim.panning.velocity};
|
||||
}
|
||||
}
|
||||
|
||||
doneCreatingObject() {
|
||||
@ -295,7 +307,7 @@ export class System {
|
||||
this.selectedObjectStart = undefined;
|
||||
this.forEachObject((obj, i) => {
|
||||
// If distance to object is less than object's radius, we are touching the object
|
||||
const dist = magnitude(sub(obj.position, {x, y}));
|
||||
const dist = Math.pow((obj.position.x - x) ** 2 + (obj.position.y - y) ** 2, 1 / 2);
|
||||
if (dist <= obj.radius) {
|
||||
idx = i;
|
||||
return null;
|
||||
@ -317,11 +329,7 @@ export class System {
|
||||
}
|
||||
|
||||
drawObjects() {
|
||||
// Draw all paths, all objects, and then all arrows
|
||||
this.forEachObject(obj => obj.drawPath(this.sim.display), {alive: null});
|
||||
this.forEachObject(obj => obj.drawObject(this.sim.display), {alive: true});
|
||||
this.forEachObject(obj => obj.drawSelection(this.sim.display), {alive: true});
|
||||
this.forEachObject(obj => obj.drawArrows(this.sim.display), {alive: true});
|
||||
this.forEachObject(obj => obj.drawObject(this.sim), {alive: null});
|
||||
}
|
||||
|
||||
// cb: (acc, obj, idx) => {}
|
||||
@ -336,18 +344,6 @@ export class System {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// cb: (obj, idx) => boolean
|
||||
filter(cb, opts) {
|
||||
let objects = [];
|
||||
this.forEachObject((obj, idx) => {
|
||||
const ret = cb(obj, idx);
|
||||
if (ret) {
|
||||
objects.push(obj);
|
||||
}
|
||||
}, opts);
|
||||
return objects;
|
||||
}
|
||||
|
||||
computeForces() {
|
||||
const gravity = this.sim.getOption('param.gravity');
|
||||
if (this.objects.length < 2) return;
|
||||
@ -356,13 +352,13 @@ export class System {
|
||||
});
|
||||
this.forEachObject((A, i) => {
|
||||
this.forEachObject(B => {
|
||||
const r = sub(B.position, A.position);
|
||||
const dSquared = square(r);
|
||||
const dx = (B.position.x - A.position.x);
|
||||
const dy = (B.position.y - A.position.y);
|
||||
const dSquared = dx ** 2 + dy ** 2;
|
||||
const d = Math.sqrt(dSquared);
|
||||
const F = gravity * A.mass * B.mass / dSquared;
|
||||
const Fx = F * r.x / d;
|
||||
const Fy = F * r.y / d;
|
||||
// Equal and opposite forces
|
||||
const Fx = F * dx / d;
|
||||
const Fy = F * dy / d;
|
||||
A.forces.push({x: Fx, y: Fy});
|
||||
B.forces.push({x: -Fx, y: -Fy});
|
||||
}, {alive: true, startWith: i + 1});
|
||||
@ -379,19 +375,26 @@ export class System {
|
||||
this.reduce((acc, obj) => ({
|
||||
count: acc.count + 1,
|
||||
totalMass: acc.totalMass + obj.mass,
|
||||
totalMassLocation: add(acc.totalMassLocation,
|
||||
mult(obj.position, obj.mass)),
|
||||
totalMassLocation: {
|
||||
x: acc.totalMassLocation.x + obj.position.x * obj.mass,
|
||||
y: acc.totalMassLocation.y + obj.position.y * obj.mass,
|
||||
},
|
||||
}), {
|
||||
totalMassLocation: {x: 0, y: 0},
|
||||
totalMass: 0,
|
||||
count: 0,
|
||||
});
|
||||
|
||||
const centerOfMass = count ? div(totalMassLocation, totalMass) : zero;
|
||||
const centerOfMass = count ? {
|
||||
x: totalMassLocation.x / totalMass,
|
||||
y: totalMassLocation.y / totalMass,
|
||||
} : {x: 0, y: 0};
|
||||
|
||||
// Determine average momentum
|
||||
const netMomentum = this.reduce((acc, obj) =>
|
||||
add(acc, mult(obj.velocity, obj.mass)), zero);
|
||||
const netMomentum = this.reduce((acc, obj) => ({
|
||||
x: acc.x + obj.mass * obj.velocity.x,
|
||||
y: acc.y + obj.mass * obj.velocity.y,
|
||||
}), {x: 0, y: 0});
|
||||
|
||||
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
|
||||
}
|
||||
@ -405,8 +408,12 @@ export class System {
|
||||
// Angular momentum for each object is m * s / d
|
||||
// where d is the distance of the object from the global center of mass
|
||||
// and s is the magnitude of the cross product of v and r
|
||||
const r = sub(obj.position, centerOfMass);
|
||||
const s = cross(obj.velocity, r);
|
||||
const r = {
|
||||
x: obj.position.x - centerOfMass.x,
|
||||
y: obj.position.y - centerOfMass.y,
|
||||
};
|
||||
const v = obj.velocity;
|
||||
const s = v.x * r.y - v.y * r.x;
|
||||
const d = Math.sqrt(r.x ** 2 + r.y ** 2);
|
||||
return acc + obj.mass * s / d;
|
||||
}, 0);
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
import {Display} from '../display.js';
|
||||
import {Tool} from '../tool.js';
|
||||
import {div, sub} from '../vector.js';
|
||||
|
||||
export class ObjectTool extends Tool {
|
||||
selected = undefined;
|
||||
display = undefined;
|
||||
textEl = undefined;
|
||||
|
||||
setContainer(container) {
|
||||
super.setContainer(container);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.display = new Display();
|
||||
this.display.canvas.width = 150;
|
||||
this.display.canvas.height = 150;
|
||||
|
||||
this.textEl = document.createElement('div');
|
||||
|
||||
this.div.appendChild(this.textEl);
|
||||
this.div.appendChild(this.display.canvas);
|
||||
}
|
||||
|
||||
frame() {
|
||||
this.display.frame();
|
||||
const obj = this.sim.select.selectedSingle;
|
||||
this.textEl.innerHTML = `obj id: ${obj?.id}`;
|
||||
if (!obj) return;
|
||||
|
||||
// Show object details
|
||||
// Distance from center of screen
|
||||
// Render the object
|
||||
|
||||
// Set up this.display.viewOrigin
|
||||
|
||||
const {radius, position} = obj;
|
||||
const widthRatio = Math.abs(2 * radius) / this.display.canvas.width;
|
||||
const heightRatio = Math.abs(2 * radius) / this.display.canvas.height;
|
||||
const ratio = Math.max(widthRatio, heightRatio) * 2;
|
||||
this.display.scalePower = Math.log2(1 / ratio);
|
||||
this.display.viewOrigin = sub(position,
|
||||
div({x: this.display.width, y: this.display.height}, 2));
|
||||
obj.drawObject(this.display);
|
||||
obj.drawArrows(this.display);
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import {Tool} from '../tool.js';
|
||||
|
||||
export class ObjectsTool extends Tool {
|
||||
setContainer(container) {
|
||||
super.setContainer(container);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@ -48,7 +48,7 @@ export class OptionsTool extends Tool {
|
||||
if (item.tall === true) button.classList.add(TALL_CLASSNAME);
|
||||
const value = this.sim.getOption(path);
|
||||
button.style.opacity = value ? '100%' : '50%';
|
||||
this.sim.onOptionSet(path, ({value}) => {
|
||||
this.sim.onOptionSet(path, value => {
|
||||
button.style.opacity = value ? '100%' : '50%';
|
||||
});
|
||||
button.addEventListener('click', () => {
|
||||
@ -81,7 +81,7 @@ export class OptionsTool extends Tool {
|
||||
this.sim.setOption(path, input.value);
|
||||
});
|
||||
|
||||
this.sim.onOptionSet(path, ({value}) => {
|
||||
this.sim.onOptionSet(path, value => {
|
||||
input.value = value;
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import {
|
||||
EVENT_PLAY_PAUSE,
|
||||
TALL_CLASSNAME
|
||||
} from '../config.js';
|
||||
import {Tool} from '../tool.js';
|
||||
@ -18,10 +17,6 @@ export class PlayPause extends Tool {
|
||||
setContainer(container) {
|
||||
super.setContainer(container);
|
||||
this.updateButtons();
|
||||
|
||||
this.sim.div.addEventListener(EVENT_PLAY_PAUSE, () => {
|
||||
this.updateButtons();
|
||||
});
|
||||
}
|
||||
|
||||
constructor(container) {
|
||||
@ -43,13 +38,18 @@ export class PlayPause extends Tool {
|
||||
pauseButton.classList.add(TALL_CLASSNAME);
|
||||
|
||||
pauseButton.addEventListener('click', () => {
|
||||
this.sim.pause();
|
||||
this.updateButtons();
|
||||
this.sim.panning = undefined;
|
||||
if (this.sim.playing) {
|
||||
this.sim.playing = false;
|
||||
this.updateButtons();
|
||||
}
|
||||
});
|
||||
|
||||
playButton.addEventListener('click', () => {
|
||||
this.sim.play();
|
||||
this.updateButtons();
|
||||
if (!this.sim.playing) {
|
||||
this.sim.playing = true;
|
||||
this.updateButtons();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
18
tool/zoom.js
18
tool/zoom.js
@ -1,11 +1,10 @@
|
||||
import { Tool } from '../tool.js';
|
||||
import {
|
||||
TOOL_INFO_CLASSNAME,
|
||||
WIDE_CLASSNAME,
|
||||
ZOOM_IN_FACTOR,
|
||||
ZOOM_OUT_FACTOR,
|
||||
WIDE_CLASSNAME,
|
||||
TOOL_INFO_CLASSNAME,
|
||||
} from '../config.js';
|
||||
import {Tool} from '../tool.js';
|
||||
import {zero} from '../vector.js';
|
||||
|
||||
export class Zoom extends Tool {
|
||||
currentScaleEl = undefined;
|
||||
@ -24,8 +23,8 @@ export class Zoom extends Tool {
|
||||
});
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
constructor(container) {
|
||||
super(container);
|
||||
|
||||
const currentScale = document.createElement('button')
|
||||
const zoomOut = document.createElement('button');
|
||||
@ -74,7 +73,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();
|
||||
const netVelocity = {
|
||||
x: netMomentum.x / totalMass,
|
||||
y: netMomentum.y / totalMass,
|
||||
@ -84,7 +83,7 @@ export class Zoom extends Tool {
|
||||
|
||||
zeroVelocity.addEventListener('click', () => {
|
||||
// Determine center of mass and average momentum
|
||||
const {totalMass, netMomentum} = this.sim.system.computeSystemCenter();
|
||||
const { totalMass, netMomentum } = this.sim.system.computeSystemCenter();
|
||||
const netVelocity = {
|
||||
x: netMomentum.x / totalMass,
|
||||
y: netMomentum.y / totalMass,
|
||||
@ -97,7 +96,8 @@ export class Zoom extends Tool {
|
||||
});
|
||||
|
||||
// Cancel panning
|
||||
this.sim.panning.setVelocity(zero);
|
||||
this.sim.panning = undefined;
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,14 +28,8 @@ export class ToolbarGroup {
|
||||
return this;
|
||||
}
|
||||
|
||||
applyExpanded() {
|
||||
for (const toolbar of this.toolbars) {
|
||||
toolbar.applyExpanded();
|
||||
}
|
||||
}
|
||||
|
||||
frame() {
|
||||
for (const toolbar of this.toolbars) {
|
||||
for (let toolbar of this.toolbars) {
|
||||
toolbar.frame();
|
||||
}
|
||||
}
|
||||
|
||||
35
vector.js
35
vector.js
@ -1,35 +0,0 @@
|
||||
export const copy = (v) => ({x: v.x, y: v.y});
|
||||
export const dot = (a, b) => a.x * b.x + a.y * b.y;
|
||||
export const cross = (a, b) => a.x * b.y - a.y * b.x;
|
||||
export const add = (a, b) => ({x: a.x + b.x, y: a.y + b.y});
|
||||
export const sub = (a, b) => ({x: a.x - b.x, y: a.y - b.y});
|
||||
export const square = ({x, y}) => x ** 2 + y ** 2;
|
||||
export const magnitude = ({x, y}) => Math.sqrt(square({x, y}));
|
||||
|
||||
// Arguments must be one vector and one scalar, but can be transposed
|
||||
export const mult = (v, m) => {
|
||||
if (typeof v === 'object') return {x: v.x * m, y: v.y * m};
|
||||
else if (typeof m === 'object') return {x: m.x * v, y: m.y * v};
|
||||
else throw new Error('mult: no vector provided');
|
||||
};
|
||||
|
||||
export const div = (v, m) => ({x: v.x / m, y: v.y / m});
|
||||
export const zero = {x: 0, y: 0};
|
||||
|
||||
export const weightedAvg = (items) => {
|
||||
let res = zero;
|
||||
let W = 0;
|
||||
for (const [v, w] of items) {
|
||||
res = add(res, mult(v, w));
|
||||
W += w;
|
||||
}
|
||||
return div(res, W);
|
||||
};
|
||||
|
||||
export const components = (mag, dir) => mult(mag, {
|
||||
x: Math.cos(dir),
|
||||
y: Math.sin(dir),
|
||||
});
|
||||
|
||||
export const direction = ({x, y}) => Math.atan2(y, x);
|
||||
export const degrees = (rad) => rad * 180 / Math.PI;
|
||||
16
zoom.js
16
zoom.js
@ -3,7 +3,6 @@ import {
|
||||
SCALE_POWER_MAX,
|
||||
SCALE_POWER_MIN,
|
||||
} from './config.js';
|
||||
import {div, sub} from './vector.js';
|
||||
|
||||
export class Zoom {
|
||||
sim = undefined;
|
||||
@ -28,7 +27,7 @@ export class Zoom {
|
||||
// x, y should be in Sim coordinates
|
||||
// velocity should be in Sim coordinate scale
|
||||
zoom({x, y, factor, velocity}) {
|
||||
const {display} = this.sim;
|
||||
const { display } = this.sim;
|
||||
// x, y are the mouse coordinates, which should be the center of the new view frame
|
||||
// the new view origin should be x, y minus half the new view width and height
|
||||
// compute new scale
|
||||
@ -36,15 +35,22 @@ export class Zoom {
|
||||
let scalePower = display.scalePower + factor;
|
||||
scalePower = Math.max(scalePower, SCALE_POWER_MIN);
|
||||
scalePower = Math.min(scalePower, SCALE_POWER_MAX);
|
||||
display.scalePower = scalePower;
|
||||
this.sim.display.scalePower = scalePower;
|
||||
|
||||
// compute coordinates of new view frame
|
||||
display.viewOrigin = sub({x, y}, div({x: display.width, y: display.height}, 2));
|
||||
display.viewOrigin.x = x - display.width / 2;
|
||||
display.viewOrigin.y = y - display.height / 2;
|
||||
|
||||
// Pointer history is stored in client coordinates, so we shouldn't need to clear it?
|
||||
// this.pointer.clearPointerHistory();
|
||||
|
||||
// TODO: If paused, set panning velocity on resume
|
||||
// if (this.sim.playing && velocity) {
|
||||
if (velocity) {
|
||||
this.sim.panning.setVelocity(velocity);
|
||||
this.sim.panning = {
|
||||
velocity: { ...velocity },
|
||||
paused: !this.sim.playing,
|
||||
};
|
||||
}
|
||||
|
||||
const e = new CustomEvent(EVENT_ZOOM);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user