diff --git a/config.js b/config.js
index b97bd0b..0a6f84b 100644
--- a/config.js
+++ b/config.js
@@ -45,6 +45,7 @@ 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';
diff --git a/display.js b/display.js
index 9fdbd22..6451f22 100644
--- a/display.js
+++ b/display.js
@@ -22,41 +22,7 @@ export class Display {
window.addEventListener('resize', () => this.fullscreen());
}
- 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);
- }
-
+ frame() {
// Clear canvas in preparation for other modules to render this frame
this.fillCanvas();
}
diff --git a/object.js b/object.js
index 7a982e0..684a841 100644
--- a/object.js
+++ b/object.js
@@ -13,6 +13,7 @@ import {
VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_WIDTH,
} from './config.js';
+import {add, components, direction, div, magnitude} from './vector.js';
export class MassObject {
sim = undefined;
@@ -80,7 +81,7 @@ export class MassObject {
const {
display: {
- ctx,
+ ctx,
height: H,
width: W,
viewOrigin: {x: ox, y: oy},
@@ -175,36 +176,35 @@ export class MassObject {
// 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 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')) {
- velocity.x = vx + pointerV.x / this.sim.timeScale;
- velocity.y = vy + pointerV.y / this.sim.timeScale;
+ pointerV = div(pointerV, this.sim.timeScale);
}
+ velocity = add(velocity, pointerV);
}
- const speed = Math.sqrt(velocity.x ** 2, velocity.y ** 2) / this.sim.display.scale;
- const arrowDirection = Math.atan2(velocity.y, velocity.x);
+ const speed = magnitude(velocity);
+ const arrowDirection = direction(velocity);
// 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);
+ // Make arrow lengths appear consistent regardless of display scale
+ let arrowLength = Math.log10(speed + 1) * vecScale;
+ if (!this.sim.getOption('display.zoomVectors')) {
+ arrowLength /= this.sim.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;
- sim.display.drawArrow(startVx, startVy, endVx, endVy, {
+ sim.display.drawArrow(start.x, start.y, end.x, end.y, {
style,
width: VELOCITY_VECTOR_WIDTH,
arrowhead: VELOCITY_VECTOR_ARROWHEAD,
@@ -216,21 +216,21 @@ export class MassObject {
// 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);
+ const accel = magnitude(acceleration);
+ const arrowDirection = direction(acceleration);
// 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);
+ // TODO: Make logarithmic vector length scale optional
+ // 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;
- sim.display.drawArrow(startAx, startAy, endAx, endAy, {
+ sim.display.drawArrow(start.x, start.y, end.x, end.y, {
style,
width: ACCELERATION_VECTOR_WIDTH,
arrowhead: ACCELERATION_VECTOR_ARROWHEAD,
diff --git a/options-1.js b/options-1.js
deleted file mode 100644
index dec1961..0000000
--- a/options-1.js
+++ /dev/null
@@ -1,129 +0,0 @@
-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;
- }
-}
diff --git a/options.js b/options.js
index eee362f..2210823 100644
--- a/options.js
+++ b/options.js
@@ -96,11 +96,11 @@ export class Options {
this.sim.div.dispatchEvent(e);
}
- // cb: (value) => undefined
+ // cb: ({path, value}) => undefined
onOptionSet(path, cb) {
this.sim.div.addEventListener(EVENT_OPTION_SET, (e) => {
- if (path === e.detail.path) {
- cb(e.detail.value);
+ if (!path || path === e.detail.path) {
+ cb({ path, value: e.detail.value });
}
});
}
diff --git a/panning.js b/panning.js
new file mode 100644
index 0000000..fefd8ba
--- /dev/null
+++ b/panning.js
@@ -0,0 +1,117 @@
+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;
+ }
+ }
+}
diff --git a/pointer.js b/pointer.js
index ac1e72f..0977cb4 100644
--- a/pointer.js
+++ b/pointer.js
@@ -46,7 +46,6 @@ export class Pointer {
});
el.addEventListener('pointerleave', e => {
- console.log('pointerleave', {x: e.clientX, y: e.clientY});
this.handlePointerUp({x: e.clientX, y: e.clientY});
});
@@ -66,7 +65,6 @@ export class Pointer {
}
handlePointerDown({x: clientX, y: clientY}) {
- // this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE);
this.updatePointer({x: clientX, y: clientY});
switch (this.sim.getCurrentMode()) {
@@ -76,19 +74,7 @@ export class Pointer {
break;
}
case MODE_PAN_VIEW: {
- 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,
- };
+ this.sim.panning.handlePointerDown({x: clientX, y: clientY});
break;
}
case MODE_OBJECT_SELECT: {
@@ -110,41 +96,7 @@ export class Pointer {
break;
}
case MODE_PAN_VIEW: {
- 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
- };
- }
- }
-
+ this.sim.panning.handlePointerMove({x: clientX, y: clientY});
break;
}
case MODE_OBJECT_SELECT: {
@@ -162,37 +114,7 @@ export class Pointer {
break;
}
case MODE_PAN_VIEW: {
- // 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
- };
- }
- }
+ this.sim.panning.handlePointerUp({x: clientX, y: clientY});
break;
}
case MODE_OBJECT_SELECT: {
@@ -227,11 +149,6 @@ 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) {
diff --git a/sim-options.js b/sim-options.js
index cee36c4..598f2be 100644
--- a/sim-options.js
+++ b/sim-options.js
@@ -4,13 +4,14 @@ export const simOptions = {
selection: ['Pause While Selecting', 'boolean', true],
},
display: {
- traces: ['Path Traces', 'boolean', true],
- dashedTraces: ['Dashed', 'boolean', false, {tall: true}],
+ targetFrameRate: ['Frame Rate', 'number', 60],
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
Vec Scale', 'number', 80, {hideUnless: 'display.velocity'}],
accelerationScale: ['Accel
Vec Scale', 'number', 800, {hideUnless: 'display.acceleration'}],
- targetFrameRate: ['Frame Rate', 'number', 60],
+ zoomVectors: ['Zoom Vectors', 'boolean', true]
},
compensate: {
timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}],
diff --git a/simulator.js b/simulator.js
index 51f0663..4d02bd8 100644
--- a/simulator.js
+++ b/simulator.js
@@ -1,4 +1,5 @@
import {
+ EVENT_PLAY_PAUSE,
EVENT_ZOOM,
FRAMERATE_SAMPLE_DURATION,
} from './config.js';
@@ -8,6 +9,7 @@ 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';
@@ -20,7 +22,6 @@ export class Sim {
nextZoom = undefined;
playing = true;
recentFrames = [];
- panning = undefined;
frameRate = 0;
system = undefined;
@@ -28,6 +29,7 @@ export class Sim {
overlay = undefined;
pointer = undefined;
zoom = undefined;
+ panning = undefined;
select = undefined;
toolbarGroups = {};
@@ -52,6 +54,7 @@ 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);
@@ -82,14 +85,15 @@ export class Sim {
if (this.getOption('debug.frameRate')) {
this.info['Frame Rate'] = this.frameRate.toPrecision(3);
}
- this.zoom.frame();
- this.pointer.frame();
+ this.zoom.frame(elapsedTime);
+ this.panning.frame(elapsedTime);
+ this.pointer.frame(elapsedTime);
this.display.frame(elapsedTime);
- this.select.frame();
+ this.select.frame(elapsedTime);
this.system.frame(elapsedTime);
- this.overlay.frame();
+ this.overlay.frame(elapsedTime);
for (const group in this.toolbarGroups) {
- this.toolbarGroups[group].frame();
+ this.toolbarGroups[group].frame(elapsedTime);
}
requestAnimationFrame(t => this.frame(t));
}
@@ -131,9 +135,12 @@ 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() {
@@ -142,6 +149,9 @@ 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() {
diff --git a/system.js b/system.js
index 446381c..ff991b6 100644
--- a/system.js
+++ b/system.js
@@ -1,9 +1,9 @@
import {OBJECT_HISTORY_SIZE} from './config.js';
import {MassObject} from './object.js';
import {
- add, cross, direction, div, magnitude, mult,
- square,
- sub, weightedAvg, zero
+ add, copy, cross, degrees,
+ direction, div, magnitude, mult,
+ square, sub, weightedAvg, zero
} from './vector.js';
export class System {
@@ -43,9 +43,7 @@ export class System {
obj.velocity = div(obj.velocity, this.sim.timeScale);
}
- if (this.sim.panning?.velocity) {
- obj.velocity = add(obj.velocity, this.sim.panning.velocity);
- }
+ obj.velocity = add(obj.velocity, this.sim.panning.velocity);
}
handlePointerMove(r) {
@@ -125,8 +123,8 @@ export class System {
// 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);
+ S.position = weightedAvg([[S.position, S.mass], [T.position, T.mass]]);
+ S.velocity = weightedAvg([[S.velocity, S.mass], [T.velocity, T.mass]]);
S.forces.push(...T.forces);
S.mass += T.mass;
S.color = {
@@ -171,9 +169,9 @@ export class System {
this.forEachObject((obj, i) => {
const speed = magnitude(obj.velocity);
const accel = magnitude(obj.acceleration);
- // Invert y so that the angle is counterclockwise from x-axis
- const velocityDir = direction(obj.velocity);
- const accelDir = direction(obj.acceleration);
+ // Give angle counterclockwise from horizontal
+ const velocityDir = -1 * degrees(direction(obj.velocity));
+ const accelDir = -1 * degrees(direction(obj.acceleration));
const {r, g, b} = obj.color;
this.sim.info[`Object ${i}`] = [
` `,
@@ -215,9 +213,7 @@ export class System {
this.pause();
}
- if (this.sim.panning?.velocity) {
- obj.velocity = {...this.sim.panning.velocity};
- }
+ obj.velocity = copy(this.sim.panning.velocity);
}
doneCreatingObject() {
@@ -367,7 +363,7 @@ export class System {
this.reduce((acc, obj) => ({
count: acc.count + 1,
totalMass: acc.totalMass + obj.mass,
- totalMassLocation: add(acc.totalMassLocation,
+ totalMassLocation: add(acc.totalMassLocation,
mult(obj.position, obj.mass)),
}), {
totalMassLocation: {x: 0, y: 0},
@@ -378,7 +374,7 @@ export class System {
const centerOfMass = count ? div(totalMassLocation, totalMass) : zero;
// Determine average momentum
- const netMomentum = this.reduce((acc, obj) =>
+ const netMomentum = this.reduce((acc, obj) =>
add(acc, mult(obj.velocity, obj.mass)), zero);
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
diff --git a/tool/options.js b/tool/options.js
index 0a87010..fe04a1e 100644
--- a/tool/options.js
+++ b/tool/options.js
@@ -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;
});
diff --git a/tool/play-pause.js b/tool/play-pause.js
index 21412e4..c9f691d 100644
--- a/tool/play-pause.js
+++ b/tool/play-pause.js
@@ -1,4 +1,5 @@
import {
+ EVENT_PLAY_PAUSE,
TALL_CLASSNAME
} from '../config.js';
import {Tool} from '../tool.js';
@@ -17,6 +18,10 @@ export class PlayPause extends Tool {
setContainer(container) {
super.setContainer(container);
this.updateButtons();
+
+ this.sim.div.addEventListener(EVENT_PLAY_PAUSE, () => {
+ this.updateButtons();
+ });
}
constructor(container) {
@@ -38,18 +43,13 @@ export class PlayPause extends Tool {
pauseButton.classList.add(TALL_CLASSNAME);
pauseButton.addEventListener('click', () => {
- this.sim.panning = undefined;
- if (this.sim.playing) {
- this.sim.playing = false;
- this.updateButtons();
- }
+ this.sim.pause();
+ this.updateButtons();
});
playButton.addEventListener('click', () => {
- if (!this.sim.playing) {
- this.sim.playing = true;
- this.updateButtons();
- }
+ this.sim.play();
+ this.updateButtons();
});
}
}
diff --git a/tool/zoom.js b/tool/zoom.js
index d682a3f..6906be4 100644
--- a/tool/zoom.js
+++ b/tool/zoom.js
@@ -1,10 +1,11 @@
-import { Tool } from '../tool.js';
-import {
+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;
@@ -73,7 +74,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,
@@ -83,7 +84,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,
@@ -96,8 +97,7 @@ export class Zoom extends Tool {
});
// Cancel panning
- this.sim.panning = undefined;
+ this.sim.panning.setVelocity(zero);
});
-
}
}
diff --git a/vector.js b/vector.js
index 1ed2934..5b53704 100644
--- a/vector.js
+++ b/vector.js
@@ -1,15 +1,21 @@
+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 (v.x !== undefined) return {x: v.x * m, y: v.y * m};
- else return {x: m.x * v, y: m.y * v};
+ 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;
@@ -20,27 +26,10 @@ export const weightedAvg = (items) => {
return div(res, W);
};
-// Angle is given counterclockwise, assuming screen coordinates
-export const direction = ({x, y}) => Math.atan2(-y, x) * 180 / Math.PI;
+export const components = (mag, dir) => mult(mag, {
+ x: Math.cos(dir),
+ y: Math.sin(dir),
+});
-export class Vector {
- x = undefined;
- y = undefined;
-
- constructor({x, y}) {
- this.x = x;
- this.y = y;
- }
-
- dot(v) {return dot(this, v);}
- cross(v) {return cross(this, v);}
- add(v) {return new Vector(add(this, v));}
- sub(v) {return new Vector(sub(this, v));}
- magnitude() {return magnitude(this);}
- mult(m) {return new Vector(mult(this, m));}
- div(m) {return new Vector(div(this, m));}
-
- toJSON() {
- return {x: this.x, y: this.y};
- }
-}
+export const direction = ({x, y}) => Math.atan2(y, x);
+export const degrees = (rad) => rad * 180 / Math.PI;
diff --git a/zoom.js b/zoom.js
index 0594a7a..fb67579 100644
--- a/zoom.js
+++ b/zoom.js
@@ -3,6 +3,7 @@ import {
SCALE_POWER_MAX,
SCALE_POWER_MIN,
} from './config.js';
+import {div, sub} from './vector.js';
export class Zoom {
sim = undefined;
@@ -27,7 +28,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
@@ -35,22 +36,15 @@ export class Zoom {
let scalePower = display.scalePower + factor;
scalePower = Math.max(scalePower, SCALE_POWER_MIN);
scalePower = Math.min(scalePower, SCALE_POWER_MAX);
- this.sim.display.scalePower = scalePower;
+ display.scalePower = scalePower;
// compute coordinates of new view frame
- 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();
+ display.viewOrigin = sub({x, y}, div({x: display.width, y: display.height}, 2));
// TODO: If paused, set panning velocity on resume
// if (this.sim.playing && velocity) {
if (velocity) {
- this.sim.panning = {
- velocity: { ...velocity },
- paused: !this.sim.playing,
- };
+ this.sim.panning.setVelocity(velocity);
}
const e = new CustomEvent(EVENT_ZOOM);