Compare commits

..

10 Commits

Author SHA1 Message Date
Ladd
fe7e9f43ad possible wide toolbar groups 2026-01-01 15:13:13 -06:00
Ladd
debf31e9b0 target frame rate 2026-01-01 13:02:12 -06:00
Ladd
d010d19495 fixed display vectors 2026-01-01 00:00:44 -06:00
Ladd
bfaa2b2437 fixup panning 2025-12-31 22:53:54 -06:00
Ladd
f53a82f594 adjusted zoom to fit ratio 2025-12-31 20:01:48 -06:00
Ladd
106707d6b2 calibrations 2025-12-31 19:03:15 -06:00
Ladd
a50c7c2a58 fix zoom and pan 2025-12-31 18:41:50 -06:00
Ladd
f5512c1be3 work in progress 2025-12-31 15:56:01 -06:00
Ladd
fe6a263164 moved sim options to separate file 2025-12-31 01:29:57 -06:00
Ladd
49feb0c106 fixed option save/load 2025-12-31 01:28:32 -06:00
27 changed files with 794 additions and 496 deletions

1
.ladd Normal file
View File

@ -0,0 +1 @@
Hi :P

23
Layout.md Normal file
View File

@ -0,0 +1,23 @@
Overlay > Info Box
Each toolbar gives itself a header tool which expands/collapses the toolbar.
- Toolbar Group: Left Side
- Toolbar: Tools
- Tool: Zoom
- Toolbar: Modes
- Tool: Mode Switch
- Toolbar: Utils
- Tool: Utility Tool
- Toolbar Group: Right Side
- Toolbar: Options
- Tool: Options Tool
- Section: Pause During
- Section: Display
- Section: Collision
- Toolbar: Params
- Tool: Options Tool
- Section: Param
- Toolbar: Debug
- Tool: Options Tool
- Section: Debug

View File

@ -8,21 +8,23 @@ Uses `npm` for `eslint`.
Screenshots Screenshots
----------- -----------
![A small body orbiting a pair of larger ones](./gravity-simulator-2.png "Gravity Simulator Screenshot 2") ![A binary pair orbiting a larger partner](./gravity-simulator-4.png "Gravity Simulator Screenshot 4")
![A body orbiting a pair, all three of similar size](./gravity-simulator-3.png "Gravity Simulator Screenshot 3")
![A greater mass attracts others which had been on escape trajectories](./gravity-simulator-5.png "Gravity Simulator Screenshot 5")
TODO TODO
---- ----
- [ ] Parameter Slider (Invisible, mouse/touch drag)
- [ ] Selection Box - [ ] Selection Box
- [ ] Object List - [ ] Object List
- [ ] Object Detail - [ ] Object Detail
- [ ] Left Button Panning - [ ] Left Button Panning
- [ ] Middle Button Pause - [ ] Middle Button Pause
- [ ] Parameter Slider (Invisible, mouse/touch drag)
- [ ] Grid - [ ] Grid
- [ ] Zoom Easing - [ ] Zoom Easing
- [ ] 2-touch Pan & Zoom - [ ] 2-touch Pan & Zoom
- Either continuous zoom, or discrete with animation of bounding box proposed changes
- [ ] Multi-touch Mass Create - [ ] Multi-touch Mass Create
- [ ] Camera Rotation - [ ] Camera Rotation
- [ ] 2-touch Rotation - [ ] 2-touch Rotation
@ -31,10 +33,9 @@ TODO
- [ ] Undo "Clear Traces" Action - [ ] Undo "Clear Traces" Action
- [ ] Undo "Reset - [ ] Undo "Reset
- [ ] Time Control: Reverse Time - [ ] Time Control: Reverse Time
- [x] Save to LocalStorage
- [ ] Lossy Rescaling To Widen Zoom (Handling overflow/underflow) - [ ] Lossy Rescaling To Widen Zoom (Handling overflow/underflow)
- [ ] Track farthest reaches, min/max in each dimension (x, y) - [ ] Track farthest reaches, min/max in each dimension (x, y)
- [x] Compute Net Angular Momentum
- [ ] Display Net Angular Momentum
- [ ] Calculate Work as FxD as measure of energy flux - [ ] Calculate Work as FxD as measure of energy flux
- [ ] Option to automatically slow time when energy flux is greater - [ ] Option to automatically slow time when energy flux is greater
- [ ] Handle pointerleave or other mechanism when window loses focus
- [ ] Verify stationary pointer leads to zero pointer velocity

View File

@ -1,11 +1,9 @@
// VELOCITY // VELOCITY
export const VELOCITY_VECTOR_SCALE = 8E0;
export const VELOCITY_VECTOR_COLOR = 'rgba(150, 150, 150, 0.8)'; // optionally set to 'object color' export const VELOCITY_VECTOR_COLOR = 'rgba(150, 150, 150, 0.8)'; // optionally set to 'object color'
export const VELOCITY_VECTOR_WIDTH = 1.5; export const VELOCITY_VECTOR_WIDTH = 1.5;
export const VELOCITY_VECTOR_ARROWHEAD = true; export const VELOCITY_VECTOR_ARROWHEAD = true;
// ACCELERATION // ACCELERATION
export const ACCELERATION_VECTOR_SCALE = 8E0;
export const ACCELERATION_VECTOR_COLOR = 'rgba(0, 128, 0, 0.8)'; // optionally set to 'object color' export const ACCELERATION_VECTOR_COLOR = 'rgba(0, 128, 0, 0.8)'; // optionally set to 'object color'
export const ACCELERATION_VECTOR_WIDTH = 1.5; export const ACCELERATION_VECTOR_WIDTH = 1.5;
export const ACCELERATION_VECTOR_ARROWHEAD = true; export const ACCELERATION_VECTOR_ARROWHEAD = true;
@ -18,6 +16,7 @@ export const PATH_TRACES_DASHED_OPACITY = 1.0;
// SIZES // SIZES
export const POINTER_HISTORY_SIZE = 20; export const POINTER_HISTORY_SIZE = 20;
export const OBJECT_HISTORY_SIZE = 1e5;
export const FRAMERATE_SAMPLE_DURATION = 2.0; // Seconds export const FRAMERATE_SAMPLE_DURATION = 2.0; // Seconds
export const POINTER_DOWN_HISTORY_SIZE = 5; export const POINTER_DOWN_HISTORY_SIZE = 5;
export const ARROWHEAD_LENGTH = 7; export const ARROWHEAD_LENGTH = 7;
@ -25,7 +24,6 @@ export const ARROWHEAD_WIDTH = 5;
export const OFFSCREEN_OBJECT_LINE_SCALE = 7; export const OFFSCREEN_OBJECT_LINE_SCALE = 7;
export const OFFSCREEN_OBJECT_LINE_WIDTH = 2; export const OFFSCREEN_OBJECT_LINE_WIDTH = 2;
export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15; export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15;
export const ZOOM_TO_FIT_PADDING = 50;
export const ZOOM_IN_FACTOR = 1; export const ZOOM_IN_FACTOR = 1;
export const ZOOM_OUT_FACTOR = -1; export const ZOOM_OUT_FACTOR = -1;
export const SCALE_POWER_MAX = 8; export const SCALE_POWER_MAX = 8;
@ -39,6 +37,8 @@ export const TOOLBAR_HEADER_CLASSNAME = 'lhg-toolbar-header';
export const WIDE_CLASSNAME = 'lhg-wide'; export const WIDE_CLASSNAME = 'lhg-wide';
export const TALL_CLASSNAME = 'lhg-tall'; export const TALL_CLASSNAME = 'lhg-tall';
export const OVERLAY_INFO_BOX_CLASSNAME = 'lhg-overlay-info-box'; export const OVERLAY_INFO_BOX_CLASSNAME = 'lhg-overlay-info-box';
export const OPTION_GROUP_CLASSNAME = 'lhg-option-group';
export const TOOLBAR_GROUP_CLASSNAME = 'lhg-toolbar-group';
// EVENT NAMES // EVENT NAMES
export const EVENT_MODE_LEAVE = 'lhg-mode-leave'; export const EVENT_MODE_LEAVE = 'lhg-mode-leave';
@ -51,5 +51,8 @@ export const MODE_MASS_GENERATION = 'mass-gen';
export const MODE_PAN_VIEW = 'pan-view'; export const MODE_PAN_VIEW = 'pan-view';
export const MODE_OBJECT_SELECT = 'select'; export const MODE_OBJECT_SELECT = 'select';
// LOCAL STORAGE PREFIXES/SUFFIXES // LOCAL STORAGE NAMESPACES
export const TOOLBAR_EXPANDED_SUFFIX = 'lhg-toolbar-expanded'; export const TOOLBAR_EXPANDED_SUFFIX = 'lhg-toolbar-expanded';
export const OBJECT_MAGIC_PROP_PREFIX = '_lhg_';

View File

@ -67,10 +67,6 @@ export class Display {
ctx.fillRect(this.viewOrigin.x, this.viewOrigin.y, this.width, this.height); ctx.fillRect(this.viewOrigin.x, this.viewOrigin.y, this.width, this.height);
} }
drawObjects() {
this.sim.objects.forEachObject(obj => obj.drawObject(this.sim), {alive: null});
}
drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) { drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) {
const ctx = this.ctx; const ctx = this.ctx;
ctx.strokeStyle = style; ctx.strokeStyle = style;
@ -139,28 +135,47 @@ export class Display {
ctx.resetTransform(); ctx.resetTransform();
} }
computePanning(elapsedTime) { frame(elapsedTime) {
// Add another entry for the current pointer position
const { const {
pointerHistory, touchStart: start,
panTouchStart: start, touchLatest: latest,
panTouchLatest: latest,
} = this.sim.pointer ?? {}; } = this.sim.pointer ?? {};
if (pointerHistory?.length) {
const currentPointer = pointerHistory[pointerHistory.length - 1];
this.sim.pointer.updatePointer(currentPointer);
}
if (start && latest) { if (start && latest) {
// Direct translate // Direct translate
this.viewOrigin.x = start.viewOrigin.x - (latest.x - start.x) / this.scale; this.viewOrigin.x = start.viewOrigin.x - (latest.x - start.x) / this.scale;
this.viewOrigin.y = start.viewOrigin.y - (latest.y - start.y) / this.scale; this.viewOrigin.y = start.viewOrigin.y - (latest.y - start.y) / this.scale;
} else if (this.sim.panning && !this.sim.panning.paused) {
// Apply update to viewOrigin based on panning
const { velocity } = this.sim.panning;
// TODO: something with time scale? Panning is too fast.
this.viewOrigin.x -= velocity.x * elapsedTime / 1000; // millisecond conversion?
this.viewOrigin.y -= velocity.y * elapsedTime / 1000;
}
} }
let pdx = 0;
let pdy = 0;
if (this.sim.panning && !this.sim.panning.paused) {
// Apply update to viewOrigin based on panning
// TODO: elapsedTime is scaled by time scale, is that what we want?
pdx = this.sim.panning.velocity.x * elapsedTime;
pdy = this.sim.panning.velocity.y * elapsedTime;
}
this.viewOrigin.x += pdx;
this.viewOrigin.y += pdy;
if (start && latest) {
// Update what's considered start
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();
}
} }

BIN
gravity-simulator-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
gravity-simulator-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

2
helper.js Normal file
View File

@ -0,0 +1,2 @@
export function makeUtilityButton() {
}

View File

@ -1,7 +1,6 @@
import { import {
ACCELERATION_VECTOR_ARROWHEAD, ACCELERATION_VECTOR_ARROWHEAD,
ACCELERATION_VECTOR_COLOR, ACCELERATION_VECTOR_COLOR,
ACCELERATION_VECTOR_SCALE,
ACCELERATION_VECTOR_WIDTH, ACCELERATION_VECTOR_WIDTH,
OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
OFFSCREEN_OBJECT_LINE_SCALE, OFFSCREEN_OBJECT_LINE_SCALE,
@ -12,11 +11,12 @@ import {
PATH_TRACES_WIDTH, PATH_TRACES_WIDTH,
VELOCITY_VECTOR_ARROWHEAD, VELOCITY_VECTOR_ARROWHEAD,
VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_SCALE,
VELOCITY_VECTOR_WIDTH, VELOCITY_VECTOR_WIDTH,
} from './config.js'; } from './config.js';
export class MassObject { export class MassObject {
sim = undefined;
id = undefined;
mass = 0; mass = 0;
density = 1; density = 1;
position = {x: undefined, y: undefined}; position = {x: undefined, y: undefined};
@ -28,17 +28,24 @@ export class MassObject {
history = []; history = [];
alive = true; alive = true;
constructor(x, y) { constructor(sim, x, y) {
this.sim = sim;
this.id = crypto.randomUUID();
this.position.x = x; this.position.x = x;
this.position.y = y; this.position.y = y;
this.color.r = Math.random() * 256; this.color.r = Math.random() * 256;
this.color.g = Math.random() * 256; this.color.g = Math.random() * 256;
this.color.b = Math.random() * 256; this.color.b = Math.random() * 256;
this.created = document.timeline.currentTime; this.timeCreated = this.sim.time;
this.rawTimeCreated = this.sim.rawTime;
} }
get age() { get age() {
return document.timeline.currentTime - this.created; return this.sim.time - this.timeCreated;
}
get rawAge() {
return this.sim.rawTime - this.rawTimeCreated;
} }
get radius() { get radius() {
@ -86,7 +93,7 @@ export class MassObject {
const opacity = dashedTraces ? PATH_TRACES_DASHED_OPACITY : PATH_TRACES_OPACITY; const opacity = dashedTraces ? PATH_TRACES_DASHED_OPACITY : PATH_TRACES_OPACITY;
ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ? ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ?
`rgba(${r}, ${g}, ${b}, ${opacity})` : PATH_TRACES_COLOR; `rgba(${r}, ${g}, ${b}, ${opacity})` : PATH_TRACES_COLOR;
ctx.lineWidth = PATH_TRACES_WIDTH / this.scale; ctx.lineWidth = PATH_TRACES_WIDTH / this.sim.display.scale;
ctx.beginPath(); ctx.beginPath();
let dash = false; let dash = false;
for (let i = 0; i < this.history.length; i++) { for (let i = 0; i < this.history.length; i++) {
@ -142,8 +149,8 @@ export class MassObject {
const arrowDirection = Math.atan2(py - cy, px - cx); const arrowDirection = Math.atan2(py - cy, px - cx);
// Length of arrow based on distance (logarithmic scale) // Length of arrow based on distance (logarithmic scale)
const distance = Math.sqrt((x - px) ** 2, (y - py) ** 2) * this.scale; const distance = Math.sqrt((x - px) ** 2, (y - py) ** 2);
const arrowLength = Math.log(distance) * OFFSCREEN_OBJECT_LINE_SCALE / this.scale; const arrowLength = Math.log(distance + 1) * OFFSCREEN_OBJECT_LINE_SCALE / this.sim.display.scale;
const startAx = px - arrowLength * Math.cos(arrowDirection); const startAx = px - arrowLength * Math.cos(arrowDirection);
const startAy = py - arrowLength * Math.sin(arrowDirection); const startAy = py - arrowLength * Math.sin(arrowDirection);
sim.display.drawArrow(startAx, startAy, px, py, { sim.display.drawArrow(startAx, startAy, px, py, {
@ -165,9 +172,29 @@ export class MassObject {
// Draw arrow for the velocity // Draw arrow for the velocity
if (sim.getOption('display.velocity')) { if (sim.getOption('display.velocity')) {
const speed = Math.sqrt(vx ** 2 + vy ** 2); // If this object is being dragged by the user,
const endVx = x + VELOCITY_VECTOR_SCALE * vx / speed * Math.log(speed); // show the pointer velocity instead of object velocity
const endVy = y + VELOCITY_VECTOR_SCALE * vy / speed * Math.log(speed); 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 + radius;
const endVx = x + arrowLength * Math.cos(arrowDirection);
const endVy = y + arrowLength * Math.sin(arrowDirection);
const style = VELOCITY_VECTOR_COLOR === 'object color' ? const style = VELOCITY_VECTOR_COLOR === 'object color' ?
`rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR; `rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR;
sim.display.drawArrow(x, y, endVx, endVy, { sim.display.drawArrow(x, y, endVx, endVy, {
@ -181,11 +208,15 @@ export class MassObject {
// Draw arrow for acceleration // Draw arrow for acceleration
if (sim.getOption('display.acceleration')) { if (sim.getOption('display.acceleration')) {
const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2); const vecScale = this.sim.getOption('display.accelerationScale');
const endAx = x + ACCELERATION_VECTOR_SCALE * acceleration.x / const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2) /
accelerationMagnitude * Math.log(accelerationMagnitude); this.sim.display.scale;
const endAy = y + ACCELERATION_VECTOR_SCALE * acceleration.y / const arrowDirection = Math.atan2(acceleration.y, acceleration.x);
accelerationMagnitude * Math.log(accelerationMagnitude); // Prevent negative numbers by adding 1
const arrowLength = Math.log10(accelerationMagnitude + 1) * vecScale + radius;
//const arrowLength = accelerationMagnitude * vecScale;
const endAx = x + arrowLength * Math.cos(arrowDirection);
const endAy = y + arrowLength * Math.sin(arrowDirection);
const style = ACCELERATION_VECTOR_COLOR === 'object color' ? const style = ACCELERATION_VECTOR_COLOR === 'object color' ?
`rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR; `rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR;
sim.display.drawArrow(x, y, endAx, endAy, { sim.display.drawArrow(x, y, endAx, endAy, {

View File

@ -1,11 +1,14 @@
import { import {
EVENT_OPTION_SET, EVENT_OPTION_SET,
OBJECT_MAGIC_PROP_PREFIX,
} from './config.js'; } from './config.js';
export class Options { export class Options {
sim = undefined; sim = undefined;
options = undefined; options = undefined;
values = {}; values = {};
undefinedObj = { [OBJECT_MAGIC_PROP_PREFIX + 'undefined']: true};
nullObj = { [OBJECT_MAGIC_PROP_PREFIX + 'null']: true};
getStorageKey(path) { getStorageKey(path) {
return `${path}:options`; return `${path}:options`;
@ -24,29 +27,62 @@ export class Options {
for (const groupName of Object.keys(options)) { for (const groupName of Object.keys(options)) {
for (const [name, [, , defaultValue]] of Object.entries(this.options[groupName])) { for (const [name, [, , defaultValue]] of Object.entries(this.options[groupName])) {
const path = [groupName, name].join('.'); const path = [groupName, name].join('.');
let value = this.getOption(path) let value = this.getFromLocalStorage(path);
if (value === undefined) { if (value === undefined) {
value = defaultValue; value = defaultValue;
this.setOption(path, value);
} }
this.values[path] = value;
} }
} }
} }
getOption(path) { toStored(value) {
let value = this.values[path];
if (value === undefined) { if (value === undefined) {
value = localStorage.getItem(this.getStorageKey(path)); // Do we want to interpret this as removing from storage?
if (value === 'false') value = false; // Let's just treat it as a value for now;
else if (value === 'true') value = true; // Semantically it works because when retrieved, it will return undefined,
this.values[path] = value; // 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; return value;
} }
getOption(path) {
const [group, name] = path.split('.');
const {type} = this.options[group][name];
const value = this.values[path];
switch (type) {
case 'number': return Number(value);
case 'boolean': return value === true || value === 'true';
default: return value;
}
}
setOption(path, value) { setOption(path, value) {
this.values[path] = value; this.values[path] = value;
window.localStorage.setItem(this.getStorageKey(path), value); const storageKey = this.getStorageKey(path);
window.localStorage.setItem(storageKey, this.toStored(value));
const e = new CustomEvent(EVENT_OPTION_SET, {detail: {path, value}}); const e = new CustomEvent(EVENT_OPTION_SET, {detail: {path, value}});
this.sim.div.dispatchEvent(e); this.sim.div.dispatchEvent(e);
} }

View File

@ -12,7 +12,7 @@ export class Overlay {
infoBox.classList.add(OVERLAY_INFO_BOX_CLASSNAME); infoBox.classList.add(OVERLAY_INFO_BOX_CLASSNAME);
} }
renderInfo() { frame() {
this.infoBox.innerHTML = ''; this.infoBox.innerHTML = '';
const table = document.createElement('table'); const table = document.createElement('table');
for (let [k, v] of Object.entries(this.sim.info)) { for (let [k, v] of Object.entries(this.sim.info)) {

View File

@ -3,7 +3,6 @@ import {
MODE_OBJECT_SELECT, MODE_OBJECT_SELECT,
MODE_PAN_VIEW, MODE_PAN_VIEW,
POINTER_HISTORY_SIZE, POINTER_HISTORY_SIZE,
POINTER_DOWN_HISTORY_SIZE,
TOOLBAR_CLASSNAME, TOOLBAR_CLASSNAME,
ZOOM_IN_FACTOR, ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR, ZOOM_OUT_FACTOR,
@ -13,8 +12,8 @@ export class Pointer {
sim = undefined; sim = undefined;
pointerHistory = []; pointerHistory = [];
panTouchStart = undefined; // {x: undefined, y: undefined, t: undefined}; touchStart = undefined; // {x: undefined, y: undefined, t: undefined};
panTouchLatest = undefined; // {x: undefined, y: undefined, t: undefined}; touchLatest = undefined; // {x: undefined, y: undefined, t: undefined};
suppressClick = false; suppressClick = false;
constructor(sim) { constructor(sim) {
@ -61,7 +60,7 @@ export class Pointer {
getPointerVelocity(points = POINTER_HISTORY_SIZE) { getPointerVelocity(points = POINTER_HISTORY_SIZE) {
// Average over pointer history // Average over pointer history
if (this.pointerHistory.length < 2) { if (this.pointerHistory.length < 2) {
return this.latestPointerVelocity ?? {x: 0, y: 0, dt: 1}; return this.latestVelocity;
} }
points = Math.min(points, POINTER_HISTORY_SIZE, this.pointerHistory.length); points = Math.min(points, POINTER_HISTORY_SIZE, this.pointerHistory.length);
const start = this.pointerHistory[this.pointerHistory.length - points]; const start = this.pointerHistory[this.pointerHistory.length - points];
@ -74,12 +73,13 @@ export class Pointer {
}; };
} }
// Keep the specified number of entries at the end of the array (most recent)
clearPointerHistory(keep = 0) { clearPointerHistory(keep = 0) {
this.pointerHistory.splice(0, this.pointerHistory.length - keep) this.pointerHistory.splice(keep, this.pointerHistory.length - keep)
} }
updatePointer({x, y}) { updatePointer({x, y}) {
const t = document.timeline.currentTime; const t = this.sim.rawTime;
while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) { while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) {
this.pointerHistory.shift(); this.pointerHistory.shift();
} }
@ -87,83 +87,128 @@ export class Pointer {
this.pointerHistory.push({t, x, y, v}); this.pointerHistory.push({t, x, y, v});
} }
get latestPointerVelocity() { get latestVelocity() {
const latestPointer = this.pointerHistory[this.pointerHistory.length - 1]; const latestPointer = this.pointerHistory[this.pointerHistory.length - 1];
return latestPointer?.v; return {
x: 0,
y: 0,
...latestPointer?.v
}
} }
handlePointerDown({x: clientX, y: clientY}) { handlePointerDown({x: clientX, y: clientY}) {
this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE); // this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE);
this.updatePointer({x: clientX, y: clientY}); this.updatePointer({x: clientX, y: clientY});
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) { switch (this.sim.getCurrentMode()) {
case MODE_MASS_GENERATION: {
const {x, y} = this.sim.screenToSim(clientX, clientY) const {x, y} = this.sim.screenToSim(clientX, clientY)
this.sim.objects.handlePointerDown({x, y}); this.sim.system.handlePointerDown({x, y});
break;
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { }
this.panTouchStart = { case MODE_PAN_VIEW: {
this.touchStart = {
x: clientX, x: clientX,
y: clientY, y: clientY,
t: document.timeline.currentTime, t: this.sim.rawTime,
viewOrigin: {...this.sim.display.viewOrigin}, viewOrigin: {...this.sim.display.viewOrigin},
}; };
this.panTouchLatest = {...this.panTouchStart}; // Since we've processed this increment, reset
this.touchLatest = {
} else if (this.sim.isCurrentMode(MODE_OBJECT_SELECT)) { ...this.touchStart,
// TODO: Start a selection box dx: 0,
} dy: 0,
} dt: 0,
handlePointerUp({x: clientX, y: clientY}) {
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
const {x, y} = this.sim.screenToSim(clientX, clientY);
this.sim.objects.handlePointerUp({x, y});
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
// Set panning velocity
if (this.panTouchStart && this.panTouchLatest) {
const dt = (this.panTouchLatest.t - this.panTouchStart.t) / 1000;
if (!dt) {
this.sim.panning = undefined;
} else {
const v = {...this.latestPointerVelocity};
// Convert pointer velocity to simulation scale
v.x /= this.sim.display.scale;
v.y /= this.sim.display.scale;
this.sim.panning = {
velocity: v
}; };
break;
} }
case MODE_OBJECT_SELECT: {
this.panTouchStart = undefined; // TODO: Start a selection box
break;
} }
} }
} }
// Handle cursor (mouse or touch) movement // Handle cursor (mouse or touch) movement
// TODO: If e.touches.length > 1, user may be engaging pinch to zoom
handlePointerMove({x: clientX, y: clientY}) { handlePointerMove({x: clientX, y: clientY}) {
// TODO: If e.touches.length > 1, user may be engaging pinch to zoom
this.updatePointer({x: clientX, y: clientY}); this.updatePointer({x: clientX, y: clientY});
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) { switch (this.sim.getCurrentMode()) {
// Convert pointer velocity to simulation scale case MODE_MASS_GENERATION: {
const vx = this.latestPointerVelocity.x / this.sim.display.scale;
const vy = this.latestPointerVelocity.y / this.sim.display.scale;
const {x, y} = this.sim.screenToSim(clientX, clientY); const {x, y} = this.sim.screenToSim(clientX, clientY);
this.sim.objects.handlePointerMove({x, y, vx, vy}); this.sim.system.handlePointerMove({x, y});
break;
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { }
if (this.panTouchStart) { case MODE_PAN_VIEW: {
if (this.touchStart) {
// Event loop should be able to read // Event loop should be able to read
this.panTouchLatest = { this.touchLatest = {
x: clientX, x: clientX,
y: clientY, y: clientY,
t: this.sim.rawTime, t: this.sim.rawTime,
dx: clientX - this.touchStart.x,
dy: clientY - this.touchStart.y,
dt: this.sim.rawTime - this.touchStart.t,
}; };
} }
break;
}
case MODE_OBJECT_SELECT: {
break;
}
} }
} }
handlePointerUp({x: clientX, y: clientY}) {
switch (this.sim.getCurrentMode()) {
case MODE_MASS_GENERATION: {
const {x, y} = this.sim.screenToSim(clientX, clientY);
this.sim.system.handlePointerUp({x, y});
break;
}
case MODE_PAN_VIEW: {
// Set panning velocity
if (this.touchStart && this.touchLatest) {
if (!this.touchLatest.dt) {
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.touchStart = undefined;
}
break;
}
case MODE_OBJECT_SELECT: {
break;
}
}
}
frame() {
// Add another entry for the current pointer position
const { pointerHistory } = this;
if (pointerHistory.length) {
const currentPointer = pointerHistory[pointerHistory.length - 1];
this.updatePointer(currentPointer);
}
}
} }

35
sim-options.js Normal file
View File

@ -0,0 +1,35 @@
export const simOptions = {
pauseDuring: {
creation: ['Pause While Creating', 'boolean', true],
selection: ['Pause While Selecting', 'boolean', true],
},
display: {
traces: ['Path Traces', 'boolean', true],
dashedTraces: ['Dashed', 'boolean', false, {tall: true}],
velocity: ['Velocity Vectors', 'boolean', true],
acceleration: ['Accel Vectors', 'boolean', true],
velocityScale: ['Velocity<br>Vec Scale', 'number', 80, {hideUnless: 'display.velocity'}],
accelerationScale: ['Accel<br>Vec Scale', 'number', 800, {hideUnless: 'display.acceleration'}],
targetFrameRate: ['Frame Rate', 'number', 60],
},
collision: {
merge: ['Merge Masses on Collision', 'boolean', true, {wide: true}],
},
compensate: {
timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}],
},
param: {
gravity: ['Gravity', 'number', 1],
timeScale: ['Time Scale', 'number', 0.1],
massCreationRate: ['Mass Creation Rate', 'number', 1],
},
debug: {
objectsInfo: ['Objects Info', 'boolean', false],
aliveObjects: ['Alive Only', 'boolean', false],
cursorInfo: ['Cursor Info', 'boolean', false],
frameRate: ['Frame Rate', 'boolean', false],
currentMode: ['Current Mode', 'boolean', false],
panningInfo: ['Panning Info', 'boolean', false],
},
};

54
sim-tools.js Normal file
View File

@ -0,0 +1,54 @@
import {Overlay} from './overlay.js';
import {Pointer} from './pointer.js';
import {ModeSwitch} from './tool/modes.js';
import {OptionsTool} from './tool/options.js';
import {PlayPause} from './tool/play-pause.js';
import {UtilityTool} from './tool/utility.js';
import {Zoom} from './tool/zoom.js';
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',
'collision',
'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(tools)
.addToolbar(modes)
.addToolbar(utils),
right: new ToolbarGroup(sim).topRight()
.addToolbar(options)
.addToolbar(params)
.addToolbar(debug),
};
sim.overlay = new Overlay(sim);
sim.pointer = new Pointer(sim);
for (const id in sim.toolbars) {
const toolbar = sim.toolbars[id];
toolbar.applyExpanded();
}
}

View File

@ -1,64 +1,31 @@
import { import {
EVENT_ZOOM, EVENT_ZOOM,
FRAMERATE_SAMPLE_DURATION, FRAMERATE_SAMPLE_DURATION,
SCALE_POWER_MAX,
SCALE_POWER_MIN,
} from './config.js'; } from './config.js';
import { Display } from './display.js'; import { Display } from './display.js';
import { Objects } from './objects.js'; import { System } from './system.js';
import { Overlay } from './overlay.js';
import { Pointer } from './pointer.js';
import { Options } from './options.js'; import { Options } from './options.js';
import { ModeSwitch } from './tool/modes.js'; import { Zoom } from './zoom.js';
import { OptionsTool } from './tool/options.js'; import { simOptions } from './sim-options.js';
import { PlayPause } from './tool/play-pause.js'; import { initializeTools } from './sim-tools.js';
import { Zoom } from './tool/zoom.js';
import { UtilityTool } from './tool/utility.js';
import { Toolbar } from './toolbar.js';
import { ToolbarGroup } from './toolbar-group.js';
const simOptions = {
pauseDuring: {
creation: ['Pause While Creating', 'boolean', true],
selection: ['Pause While Selecting', 'boolean', true],
},
display: {
velocity: ['Velocity Vectors', 'boolean', true],
acceleration: ['Accel. Vectors', 'boolean', true],
traces: ['Path Traces', 'boolean', true],
dashedTraces: ['Dashed Traces', 'boolean', false],
},
collision: {
merge: ['Merge Masses<br>on Collision', 'boolean', true, {wide: true}],
},
param: {
gravity: ['Gravity', 'number', 4E4],
timeScale: ['Time Scale', 'number', 0.2],
massCreationRate: ['Mass Creation Rate', 'number', 10],
},
debug: {
objectsInfo: ['Objects Info', 'boolean', false],
cursorInfo: ['Cursor Info', 'boolean', false],
frameRate: ['Frame Rate', 'boolean', false, {wide: true}],
currentMode: ['Current Mode', 'boolean', false],
panningInfo: ['Panning Info', 'boolean', false],
},
};
export class Sim { export class Sim {
info = {}; info = {};
rawTime = undefined; rawTime = undefined;
time = undefined; time = undefined;
timeScale = undefined;
nextZoom = undefined; nextZoom = undefined;
playing = true; playing = true;
recentFrames = []; recentFrames = [];
panning = undefined;
frameRate = 0; frameRate = 0;
objects = undefined; system = undefined;
display = undefined; display = undefined;
overlay = undefined; overlay = undefined;
pointer = undefined; pointer = undefined;
panning = undefined; zoom = undefined;
toolbarGroups = {}; toolbarGroups = {};
toolbars = {}; toolbars = {};
@ -66,23 +33,10 @@ export class Sim {
getCurrentMode = () => undefined; getCurrentMode = () => undefined;
setCurrentMode = () => undefined; setCurrentMode = () => undefined;
getOption = () => undefined; getOption = () => undefined;
setOption = () => undefined;
onModeEnter = () => undefined; onModeEnter = () => undefined;
onModeLeave = () => undefined; onModeLeave = () => undefined;
markFrame(t) {
const { recentFrames: rfs } = this;
rfs.push(t);
if (rfs.length < 2) return;
const oldest = rfs[0];
const newest = rfs[rfs.length - 1];
const count = rfs.length;
const duration = (newest - oldest) / 1000; // ms to s
this.frameRate = count / duration;
if (duration >= FRAMERATE_SAMPLE_DURATION) {
rfs.shift();
}
}
constructor(divId) { constructor(divId) {
this.divId = divId; this.divId = divId;
const div = document.getElementById(this.divId); const div = document.getElementById(this.divId);
@ -90,95 +44,75 @@ export class Sim {
this.options = new Options(this, simOptions); this.options = new Options(this, simOptions);
this.display = new Display(this); this.display = new Display(this);
this.objects = new Objects(this); this.system = new System(this);
this.toolbars = { this.zoom = new Zoom(this);
tools: new Toolbar(this, 'Tools'),
modes: new Toolbar(this, 'Modes'),
utils: new Toolbar(this, 'Utility', { expanded: false }),
options: new Toolbar(this, 'Options'),
params: new Toolbar(this, 'Parameters'),
debug: new Toolbar(this, 'Debug', { expanded: false }),
};
const { tools, modes, options, params, debug, utils } = this.toolbars;
this.toolbarGroups = {
left: new ToolbarGroup(this)
.addToolbar(tools)
.addToolbar(modes)
.addToolbar(utils),
right: new ToolbarGroup(this).topRight()
.addToolbar(options)
.addToolbar(params)
.addToolbar(debug),
};
this.overlay = new Overlay(this);
this.pointer = new Pointer(this);
// Configure toolbars initializeTools(this);
// Primary
tools.addTool(new Zoom(tools));
tools.addTool(new PlayPause(tools));
// Secondary
modes.addTool(new ModeSwitch(modes));
// Utility
utils.addTool(new UtilityTool(utils));
// Options
options.addTool(new OptionsTool(options, ['pauseDuring', 'display', 'collision']));
// Parameters
params.addTool(new OptionsTool(params, ['param']));
// Debug
debug.addTool(new OptionsTool(debug, ['debug']));
for (const id in this.toolbars) {
const toolbar = this.toolbars[id];
toolbar.applyExpanded();
}
// Initiate main loop // Initiate main loop
this.rawTime = document.timeline.currentTime; this.rawTime = document.timeline.currentTime;
this.time = 0; this.time = 0;
requestAnimationFrame(t => this.loop(t)); requestAnimationFrame(t => this.frame(t));
}
// Main loop
frame(currentTime) {
const early = this.markFrame(currentTime);
if (early) {
// Slow down :)
requestAnimationFrame(t => this.frame(t));
return;
}
this.timeScale = this.getOption('param.timeScale');
const elapsedTime = (currentTime - this.rawTime) * this.timeScale;
this.rawTime = currentTime;
if (this.playing) {
this.time += elapsedTime;
}
if (this.getOption('debug.currentMode')) {
this.info['Mode'] = this.getCurrentMode();
}
if (this.getOption('debug.frameRate')) {
this.info['Frame Rate'] = this.frameRate.toPrecision(3);
}
this.zoom.frame();
this.pointer.frame();
this.display.frame(elapsedTime);
this.system.frame(elapsedTime);
this.overlay.frame();
for (const group in this.toolbarGroups) {
this.toolbarGroups[group].frame();
}
requestAnimationFrame(t => this.frame(t));
}
markFrame(t) {
const { recentFrames: rfs } = this;
if (!rfs.length) {
rfs.push(t);
return;
}
let oldest = rfs[0];
let duration = t - oldest;
const count = rfs.length + 1;
const frameRate = 1000 * count / duration;
const targetFrameRate = parseInt(this.getOption('display.targetFrameRate'));
if (frameRate > targetFrameRate + 1) {
return true;
}
this.frameRate = frameRate;
rfs.push(t);
while (duration >= FRAMERATE_SAMPLE_DURATION) {
rfs.shift();
oldest = rfs[0];
duration = t - oldest;
}
return false;
} }
// It's better not to change the scale in the middle of possible frame calculations,
// so use this to schedule it and let the event loop pick it up.
// velocity should be in Sim coordinate scale // velocity should be in Sim coordinate scale
scheduleZoom({x, y}, factor, velocity) { scheduleZoom({x, y}, factor, velocity) {
this.nextZoom = {x, y, factor, velocity}; this.zoom.scheduleZoom({x, y}, factor, velocity);
}
// x, y should be in Sim coordinates
// velocity should be in Sim coordinate scale
zoom({x, y, factor, velocity}) {
// 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
this.display.scalePower += factor;
// TODO: Lossy rescaling to expand zoom range
if (this.display.scalePower > SCALE_POWER_MAX) this.display.scalePower = SCALE_POWER_MAX;
if (this.display.scalePower < SCALE_POWER_MIN) this.display.scalePower = SCALE_POWER_MIN;
// compute coordinates of new view frame
this.display.viewOrigin.x = x - this.display.width / 2;
this.display.viewOrigin.y = y - this.display.height / 2;
this.pointer.clearPointerHistory();
if (this.playing && velocity) {
this.panning = {
velocity: {
x: -velocity.x,
y: -velocity.y,
}
};
}
const e = new CustomEvent(EVENT_ZOOM);
this.div.dispatchEvent(e);
} }
// Transform display coordinates to simulator coordinates using scale and viewOrigin // Transform display coordinates to simulator coordinates using scale and viewOrigin
@ -196,7 +130,8 @@ export class Sim {
getScaleDisplay() { getScaleDisplay() {
const scale = 2 ** Math.abs(this.display.scalePower); const scale = 2 ** Math.abs(this.display.scalePower);
return this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`; const scaleText = this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;
return `${scaleText} (${this.display.scalePower})`;
} }
// cb: () => undefined // cb: () => undefined
@ -206,49 +141,4 @@ export class Sim {
}); });
} }
// Main loop
loop(currentTime) {
this.markFrame(currentTime);
const timeScale = this.getOption('param.timeScale');
// elapsedTime in milliseconds
// rawTime in milliseconds
const elapsedTime = (currentTime - this.rawTime) / timeScale;
this.rawTime = currentTime;
if (this.playing) {
this.time += elapsedTime;
}
if (this.getOption('debug.currentMode')) {
this.info['Mode'] = this.getCurrentMode();
}
if (this.getOption('debug.frameRate')) {
this.info['Frame Rate'] = this.frameRate?.toPrecision(3);
}
if (this.nextZoom) {
this.zoom(this.nextZoom);
this.nextZoom = undefined;
}
if (this.getOption('debug.panningInfo')) {
const {x, y} = this.panning?.velocity ?? {};
this.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)];
const { centerOfMass } = this.objects.computeSystemCenter();
this.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)];
this.info['Net Angular Momentum'] = this.objects.computeSystemAngularMomentum().toPrecision(6);
}
this.objects.computeFrame(elapsedTime);
this.overlay.renderInfo();
// this.display.computePanning(elapsedTime);
this.display.fillCanvas();
this.display.drawObjects();
for (const group in this.toolbarGroups) {
this.toolbarGroups[group].frame();
}
requestAnimationFrame(t => this.loop(t));
}
} }

View File

@ -27,6 +27,31 @@ div[id=simulator] {
width: 100%; width: 100%;
} }
/* normal toolbar group */
div.lhg-toolbar-group div.lhg-tool {
width: 12em;
}
div.lhg-toolbar-group div.lhg-tool button, div.lhg-toolbar-group div.lhg-tool input {
width: 6em;
}
div.lhg-toolbar-group div.lhg-tool .lhg-wide {
width: 12em;
}
/* wide toolbar group */
div.lhg-toolbar-group.lhg-wide div.lhg-tool {
width: 16em;
}
div.lhg-toolbar-group.lhg-wide div.lhg-tool button, div.lhg-toolbar-group.lhg-wide div.lhg-tool input {
width: 8em;
}
div.lhg-toolbar-group.lhg-wide div.lhg-tool .lhg-wide {
width: 16em;
}
/* end of toolbar group section */
div.lhg-toolbar { div.lhg-toolbar {
position: relative; position: relative;
z-index: 2; z-index: 2;
@ -42,7 +67,6 @@ div.lhg-tool {
position: relative; position: relative;
top: 0; top: 0;
left: 0; left: 0;
width: 12em;
/* padding: 0.5em; */ /* padding: 0.5em; */
/* margin: 0.5em; */ /* margin: 0.5em; */
text-align: middle; text-align: middle;
@ -56,7 +80,6 @@ div.lhg-tool div.lhg-wide {
div.lhg-tool button, div.lhg-tool input { div.lhg-tool button, div.lhg-tool input {
font-family: monospace; font-family: monospace;
font-size: 10pt; font-size: 10pt;
width: 6em;
background-color: #333; background-color: #333;
color: #5f5; color: #5f5;
border-radius: 0.5em; border-radius: 0.5em;
@ -73,10 +96,6 @@ div.lhg-tool button, div.lhg-tool input {
box-sizing: border-box; box-sizing: border-box;
} }
div.lhg-tool input {
width: 6em;
}
div.lhg-tool button:hover { div.lhg-tool button:hover {
background-color: #444; background-color: #444;
} }
@ -103,13 +122,8 @@ div.lhg-tool .lhg-tool-info {
border-width: 2px; border-width: 2px;
} }
div.lhg-tool .lhg-wide {
width: 12em;
}
div.lhg-tool .lhg-tall { div.lhg-tool .lhg-tall {
padding-top: 1em; height: 3.666em;
padding-bottom: 1em;
} }
div.lhg-overlay-info-box { div.lhg-overlay-info-box {
@ -119,3 +133,15 @@ div.lhg-overlay-info-box {
width: fit-content; width: fit-content;
z-index: 1; z-index: 1;
} }
div.lhg-option-group {
display: flex;
flex-wrap: wrap;
}
/*
div.lhg-option-group > * {
display: inline-block;
vertical-align: top;
}
*/

View File

@ -1,10 +1,11 @@
import { MassObject } from './object.js'; import {OBJECT_HISTORY_SIZE} from './config.js';
import { ZOOM_TO_FIT_PADDING } from './config.js'; import {MassObject} from './object.js';
export class Objects { export class System {
objects = []; objects = [];
creatingObject = undefined; creatingObject = undefined;
selectedObject = undefined; selectedObject = undefined;
selectObjectStart = undefined;
paused = false; paused = false;
panVelocityPaused = undefined; panVelocityPaused = undefined;
@ -35,8 +36,9 @@ export class Objects {
// Create an object with mass that grows as pointer is held down // Create an object with mass that grows as pointer is held down
createObject(x, y) { createObject(x, y) {
const idx = this.objects.length; const idx = this.objects.length;
const obj = new MassObject(x, y, idx); const obj = new MassObject(this.sim, x, y);
this.creatingObject = idx; this.creatingObject = idx;
this.selectedObjectStart = {x, y, pointer: {x, y}};
this.objects.push(obj); this.objects.push(obj);
// Pause the simulation during mass creation; this avoids some complex local dynamics // Pause the simulation during mass creation; this avoids some complex local dynamics
if (this.sim.getOption('pauseDuring.creation')) { if (this.sim.getOption('pauseDuring.creation')) {
@ -59,8 +61,10 @@ export class Objects {
return this.objects[i]; return this.objects[i];
} }
selectObject(i) { selectObject(i, pointer) {
this.selectedObject = i; this.selectedObject = i;
const {x, y} = this.object(i).position;
this.selectedObjectStart = {x, y, pointer};
if (this.sim.getOption('pauseDuring.selection')) { if (this.sim.getOption('pauseDuring.selection')) {
this.pause(); this.pause();
} }
@ -68,6 +72,7 @@ export class Objects {
deselect() { deselect() {
this.selectedObject = undefined; this.selectedObject = undefined;
this.selectedObjectStart = undefined;
this.resume(); this.resume();
} }
@ -83,33 +88,48 @@ export class Objects {
} }
get boundingBox() { get boundingBox() {
const box = this.reduce((acc, obj) => { const box = this.reduce(({start, end}, obj) => {
if (acc.start.x === undefined) { const lx = obj.position.x - obj.radius;
acc.start = {...obj.position}; const gx = obj.position.x + obj.radius;
acc.end = {...obj.position}; const ly = obj.position.y - obj.radius;
} else { const gy = obj.position.y + obj.radius;
if (obj.position.x < acc.start.x) acc.start.x = obj.position.x; let ret;
if (obj.position.x > acc.end.x) acc.end.x = obj.position.x; if (start.x === undefined) {
if (obj.position.y < acc.start.y) acc.start.y = obj.position.y; ret = {
if (obj.position.y > acc.end.y) acc.end.y = obj.position.y; start: {x: lx, y: ly},
end: {x: gx, y: gy},
};
return ret;
} }
ret = {
start: {
x: Math.min(start.x, lx),
y: Math.min(start.y, ly),
},
end: {
x: Math.max(end.x, gx),
y: Math.max(end.y, gy),
}
};
return ret;
}, { }, {
start: {x: undefined, y: undefined}, start: {x: undefined, y: undefined},
end: {x: undefined, y: undefined}, end: {x: undefined, y: undefined},
}); });
box.start.x = (box.start.x ?? 0) - ZOOM_TO_FIT_PADDING; box.start.x = (box.start.x ?? 0);
box.start.y = (box.start.y ?? 0) - ZOOM_TO_FIT_PADDING; box.start.y = (box.start.y ?? 0);
box.end.x = (box.end.x ?? 0) + ZOOM_TO_FIT_PADDING; box.end.x = (box.end.x ?? 0);
box.end.y = (box.end.y ?? 0) + ZOOM_TO_FIT_PADDING; box.end.y = (box.end.y ?? 0);
return box; return box;
} }
objectAtLocation(x, y) { objectAtLocation(x, y) {
let idx = undefined; let idx = undefined;
this.selectedObjectStart = undefined;
this.forEachObject((obj, i) => { this.forEachObject((obj, i) => {
// If distance to object is less than object's radius, we are touching the object // If distance to object is less than object's radius, we are touching the object
const dist = Math.pow((obj.position.x - x)**2 + (obj.position.y - y)**2, 1/2); const dist = Math.pow((obj.position.x - x) ** 2 + (obj.position.y - y) ** 2, 1 / 2);
if (dist <= obj.radius) { if (dist <= obj.radius) {
idx = i; idx = i;
return null; return null;
@ -123,7 +143,7 @@ export class Objects {
const touchingObject = this.objectAtLocation(x, y); const touchingObject = this.objectAtLocation(x, y);
if (touchingObject !== undefined) { if (touchingObject !== undefined) {
this.selectObject(touchingObject); this.selectObject(touchingObject, {x, y});
} else { } else {
// Otherwise, create a new object // Otherwise, create a new object
this.createObject(x, y); this.createObject(x, y);
@ -131,24 +151,37 @@ export class Objects {
} }
handlePointerUp() { handlePointerUp() {
const obj = this.getSelectedOrCreating();
if (obj === undefined) return;
this.doneCreatingObject(); this.doneCreatingObject();
this.deselect(); this.deselect();
// Convert pointer velocity to simulation 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.x /= this.sim.timeScale;
obj.velocity.y /= this.sim.timeScale;
} }
handlePointerMove({x, y, vx, vy}) { if (this.sim.panning?.velocity) {
obj.velocity.x += this.sim.panning.velocity.x;
obj.velocity.y += this.sim.panning.velocity.y;
}
}
handlePointerMove({x, y}) {
// If the cursor moves while creating an object, or while an object is selected, // If the cursor moves while creating an object, or while an object is selected,
// update the position and velocity of the object
// update the position using the pointer motion but the velocity using the pointer velocity // update the position using the pointer motion but the velocity using the pointer velocity
const obj = this.getSelectedOrCreating(); const obj = this.getSelectedOrCreating();
if (obj === undefined) return; if (obj === undefined) return;
if (this.sim.panning?.velocity) { const start = this.selectedObjectStart;
vx += this.sim.panning.velocity.x; obj.position.x = start.x + (x - start.pointer.x);
vy += this.sim.panning.velocity.y; obj.position.y = start.y + (y - start.pointer.y);
} obj.velocity.x = 0;
obj.position.x = x; obj.velocity.y = 0;
obj.position.y = y;
obj.velocity.x = vx;
obj.velocity.y = vy;
} }
// cb: (obj, idx) => {} // cb: (obj, idx) => {}
@ -163,6 +196,10 @@ export class Objects {
} }
} }
drawObjects() {
this.forEachObject(obj => obj.drawObject(this.sim), {alive: null});
}
// cb: (acc, obj, idx) => {} // cb: (acc, obj, idx) => {}
reduce(cb, initial, opts) { reduce(cb, initial, opts) {
let acc = initial; let acc = initial;
@ -190,8 +227,8 @@ export class Objects {
const F = gravity * A.mass * B.mass / dSquared; const F = gravity * A.mass * B.mass / dSquared;
const Fx = F * dx / d; const Fx = F * dx / d;
const Fy = F * dy / d; const Fy = F * dy / d;
A.forces.push({ x: Fx, y: Fy }); A.forces.push({x: Fx, y: Fy});
B.forces.push({ x: -Fx, y: -Fy }); B.forces.push({x: -Fx, y: -Fy});
}, {alive: true, startWith: i + 1}); }, {alive: true, startWith: i + 1});
}); });
// Also compute acceleration // Also compute acceleration
@ -201,37 +238,35 @@ export class Objects {
} }
// elapsedTime is given in milliseconds // elapsedTime is given in milliseconds
computeFrame(elapsedTime) { frame(elapsedTime) {
// convert elapsed time to seconds
elapsedTime /= 1000;
// If we're creating an object, increment its mass // If we're creating an object, increment its mass
// with the mass creation rate accelerating over time // with the mass creation rate accelerating over time
// Scaling this parameter because of millisecond conversion // Scaling this parameter because of millisecond conversion
const massCreationRate = this.sim.getOption('param.massCreationRate') / 1000;
if (this.creatingObject !== undefined) { if (this.creatingObject !== undefined) {
const obj = this.objects[this.creatingObject]; const obj = this.objects[this.creatingObject];
const rate = massCreationRate * obj.age; let massCreationRate = this.sim.getOption('param.massCreationRate');
console.log('obj.age', obj.age, 'mass creation rate', rate, 'elapsedTime', elapsedTime); massCreationRate /= this.sim.display.scale;
// TODO: After objects merge during creation, mass creation rate can accelerate // Keep consistent time scale
obj.mass += rate * elapsedTime; if (this.sim.getOption('compensate.timeScale')) {
massCreationRate /= this.sim.timeScale;
}
obj.mass += massCreationRate * elapsedTime;
} }
// Calculate forces due to gravity. // Calculate forces due to gravity.
this.computeForces(); this.computeForces();
if (this.sim.playing) { if (this.sim.playing) {
// TODO: If creating/selected object, clamp its position to the cursor
// Predict positions (Velocity verlet method) // Predict positions (Velocity verlet method)
this.forEachObject(obj => { this.forEachObject(obj => {
obj.currentAcceleration = {...obj.acceleration}; obj.currentAcceleration = {...obj.acceleration};
obj.position.x += elapsedTime * obj.position.x += elapsedTime *
(obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime); (obj.velocity.x + 1 / 2 * obj.currentAcceleration.x * elapsedTime);
obj.position.y += elapsedTime * obj.position.y += elapsedTime *
(obj.velocity.y + 1/2 * obj.currentAcceleration.y * elapsedTime); (obj.velocity.y + 1 / 2 * obj.currentAcceleration.y * elapsedTime);
}); });
// Collisions // Collisions
@ -298,33 +333,51 @@ export class Objects {
obj.velocity.y += obj.acceleration.y * elapsedTime; obj.velocity.y += obj.acceleration.y * elapsedTime;
// Append to object history // Append to object history
// TODO: enforce object history length
// TODO: store object color changes in history
obj.history.push({position: {...obj.position}}); obj.history.push({position: {...obj.position}});
// TODO: store object color changes in history
// Enforce object history length
while (obj.history.length > OBJECT_HISTORY_SIZE) {
obj.history.shift();
}
}); });
} }
// Display objects info // Display objects info
// First clear info from previous frame
this.forEachObject((_obj, i) => {
delete this.sim.info[`Object ${i}`];
}, {alive: null});
if (this.sim.getOption('debug.objectsInfo')) { if (this.sim.getOption('debug.objectsInfo')) {
const aliveOnly = this.sim.getOption('debug.aliveObjects');
this.forEachObject((obj, i) => { this.forEachObject((obj, i) => {
const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1/2); 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 // 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 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}`] = [ this.sim.info[`Object ${i}`] = [
`${obj.position.x.toPrecision(6)}, `, `<span style="background-color: rgb(${r},${g},${b});">&nbsp;&nbsp;</span>`,
`${obj.position.y.toPrecision(6)}, `, `${obj.position.x.toPrecision(4)}, `,
`${obj.mass.toPrecision(6)} kg, `, `${obj.position.y.toPrecision(4)}, `,
`${obj.mass.toPrecision(4)} kg, `,
`${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`, `${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`,
`${accel.toPrecision(2)} m/s<sup>2</sup>, ${accelDir.toPrecision(2)}°`,
`Alive: ${obj.alive}`, `Alive: ${obj.alive}`,
]; ];
}, { alive: null }); }, {alive: aliveOnly || null});
} }
// Render the objects
this.drawObjects();
} }
computeSystemCenter() { computeSystemCenter() {
// Determine center of mass // Determine center of mass
const { totalMass, count, totalMassLocation } = const {totalMass, count, totalMassLocation} =
this.sim.objects.reduce((acc, obj) => ({ this.reduce((acc, obj) => ({
count: acc.count + 1, count: acc.count + 1,
totalMass: acc.totalMass + obj.mass, totalMass: acc.totalMass + obj.mass,
totalMassLocation: { totalMassLocation: {
@ -343,12 +396,12 @@ export class Objects {
} : {x: 0, y: 0}; } : {x: 0, y: 0};
// Determine average momentum // Determine average momentum
const netMomentum = this.sim.objects.reduce((acc, obj) => ({ const netMomentum = this.reduce((acc, obj) => ({
x: acc.x + obj.mass * obj.velocity.x, x: acc.x + obj.mass * obj.velocity.x,
y: acc.y + obj.mass * obj.velocity.y, y: acc.y + obj.mass * obj.velocity.y,
}), { x: 0, y: 0 }); }), {x: 0, y: 0});
return { totalMass, count, totalMassLocation, centerOfMass, netMomentum }; return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
} }
computeSystemAngularMomentum(centerOfMass) { computeSystemAngularMomentum(centerOfMass) {

View File

@ -10,13 +10,16 @@ export class Tool {
container = undefined; container = undefined;
sim = undefined; sim = undefined;
constructor(container) { constructor() {
this.container = container;
this.sim = this.container.sim;
const div = document.createElement('div'); const div = document.createElement('div');
this.div = div; this.div = div;
div.classList.add(TOOL_CLASSNAME) div.classList.add(TOOL_CLASSNAME)
} }
setContainer(container) {
this.container = container;
this.sim = this.container.sim;
}
frame() {} frame() {}
} }

View File

@ -2,7 +2,6 @@ import {TOOLBAR_HEADER_CLASSNAME} from '../config.js';
import { Tool } from '../tool.js'; import { Tool } from '../tool.js';
export class Header extends Tool { export class Header extends Tool {
constructor(container, title = 'Tools') { constructor(container, title = 'Tools') {
super(container); super(container);
this.title = document.createElement('h1'); this.title = document.createElement('h1');
@ -10,7 +9,6 @@ export class Header extends Tool {
this.toggleButton = document.createElement('button'); this.toggleButton = document.createElement('button');
this.div.addEventListener('click', () => this.toggle()); this.div.addEventListener('click', () => this.toggle());
this.updateButton();
this.div.appendChild(this.title); this.div.appendChild(this.title);
this.div.appendChild(this.toggleButton); this.div.appendChild(this.toggleButton);
@ -22,6 +20,11 @@ export class Header extends Tool {
this.div.classList.add(TOOLBAR_HEADER_CLASSNAME); this.div.classList.add(TOOLBAR_HEADER_CLASSNAME);
} }
setContainer(container) {
super.setContainer(container);
this.updateButton();
}
updateButton() { updateButton() {
this.toggleButton.innerHTML = this.container.expanded ? '˅' : '˄'; this.toggleButton.innerHTML = this.container.expanded ? '˅' : '˄';
} }

View File

@ -18,6 +18,21 @@ export class ModeSwitch extends Tool {
]; ];
buttons = []; buttons = [];
setContainer(container) {
super.setContainer(container);
// First listed mode is the default
const [[currentModeID, ]] = this.modes;
this.setCurrentMode(currentModeID);
// Add global method to set/get current mode
this.sim.setCurrentMode = (modeID) => this.setCurrentMode(modeID);
this.sim.getCurrentMode = () => this.currentMode;
this.sim.isCurrentMode = (modeID) => modeID === this.currentMode;
this.sim.onModeLeave = (modeID, cb) => this.onModeLeave(modeID, cb);
this.sim.onModeEnter = (modeID, cb) => this.onModeEnter(modeID, cb);
}
constructor(container) { constructor(container) {
super(container); super(container);
@ -42,17 +57,6 @@ export class ModeSwitch extends Tool {
button.addEventListener('click', () => this.setCurrentMode(modeID)); button.addEventListener('click', () => this.setCurrentMode(modeID));
} }
// First listed mode is the default
const [[currentModeID, ]] = this.modes;
this.setCurrentMode(currentModeID);
// Add global method to set/get current mode
this.sim.setCurrentMode = (modeID) => this.setCurrentMode(modeID);
this.sim.getCurrentMode = () => this.currentMode;
this.sim.isCurrentMode = (modeID) => modeID === this.currentMode;
this.sim.onModeLeave = (modeID, cb) => this.onModeLeave(modeID, cb);
this.sim.onModeEnter = (modeID, cb) => this.onModeEnter(modeID, cb);
} }
setModesOpacity() { setModesOpacity() {

View File

@ -1,16 +1,35 @@
// Options picker // Options picker
import { import {
TOOL_INFO_CLASSNAME, TOOL_INFO_CLASSNAME,
OPTION_GROUP_CLASSNAME,
WIDE_CLASSNAME, WIDE_CLASSNAME,
TALL_CLASSNAME,
} from '../config.js'; } from '../config.js';
import { Tool } from '../tool.js'; import { Tool } from '../tool.js';
export class OptionsTool extends Tool { export class OptionsTool extends Tool {
sections = undefined;
constructor(sections) {
super();
this.sections = sections;
}
setContainer(container) {
super.setContainer(container);
for (const sectionName of this.sections) {
const option = this.sim.options.getSection(sectionName);
const child = this.visitItem(option);
this.div.appendChild(child);
}
}
visitItem(item, path) { visitItem(item, path) {
path = [path, item.name].filter(x => !!x).join('.'); path = [path, item.name].filter(x => !!x).join('.');
switch (item.type) { switch (item.type) {
case 'group': { case 'group': {
const group = document.createElement('div'); const group = document.createElement('div');
group.classList.add(OPTION_GROUP_CLASSNAME);
if (item.title) { if (item.title) {
const heading = document.createElement('h3'); const heading = document.createElement('h3');
heading.innerHTML = item.title; heading.innerHTML = item.title;
@ -25,22 +44,15 @@ export class OptionsTool extends Tool {
case 'boolean': { case 'boolean': {
const button = document.createElement('button'); const button = document.createElement('button');
button.innerHTML = item.title; button.innerHTML = item.title;
if (item.wide === true) { if (item.wide === true) button.classList.add(WIDE_CLASSNAME);
button.classList.add(WIDE_CLASSNAME); if (item.tall === true) button.classList.add(TALL_CLASSNAME);
}
const value = this.sim.getOption(path); const value = this.sim.getOption(path);
if (value === undefined) {
this.sim.setOption(path, item.default);
}
button.style.opacity = value ? '100%' : '50%'; button.style.opacity = value ? '100%' : '50%';
this.sim.onOptionSet(path, value => { this.sim.onOptionSet(path, value => {
console.log('option set cb', path, value);
button.style.opacity = value ? '100%' : '50%'; button.style.opacity = value ? '100%' : '50%';
console.log('button opacity', button.style.opacity);
}); });
button.addEventListener('click', () => { button.addEventListener('click', () => {
const value = this.sim.options.getOption(path, true); const value = this.sim.getOption(path);
console.log('click, option value', value);
this.sim.setOption(path, !value); this.sim.setOption(path, !value);
}); });
return button; return button;
@ -59,8 +71,7 @@ export class OptionsTool extends Tool {
input.classList.add(WIDE_CLASSNAME); input.classList.add(WIDE_CLASSNAME);
} }
title.innerHTML = item.title; title.innerHTML = item.title;
input.value = item.default; input.value = this.sim.getOption(path);
this.sim.setOption(path, item.default);
input.addEventListener('input', () => { input.addEventListener('input', () => {
input.value = input.value.slice(0, maxLength); input.value = input.value.slice(0, maxLength);
@ -77,18 +88,7 @@ export class OptionsTool extends Tool {
return div; return div;
} }
default: default:
console.error('Unknown option type', item);
throw new Error('Unknown option type'); throw new Error('Unknown option type');
} }
} }
constructor(container, sections) {
super(container);
for (const sectionName of sections) {
const option = this.sim.options.getSection(sectionName);
const child = this.visitItem(option);
this.div.appendChild(child);
}
}
} }

View File

@ -14,6 +14,11 @@ export class PlayPause extends Tool {
this.playButton.style.opacity = this.sim.playing ? '50%' : '100%'; this.playButton.style.opacity = this.sim.playing ? '50%' : '100%';
} }
setContainer(container) {
super.setContainer(container);
this.updateButtons();
}
constructor(container) { constructor(container) {
super(container); super(container);
@ -32,8 +37,6 @@ export class PlayPause extends Tool {
playButton.classList.add(TALL_CLASSNAME); playButton.classList.add(TALL_CLASSNAME);
pauseButton.classList.add(TALL_CLASSNAME); pauseButton.classList.add(TALL_CLASSNAME);
this.updateButtons();
pauseButton.addEventListener('click', () => { pauseButton.addEventListener('click', () => {
this.sim.panning = undefined; this.sim.panning = undefined;
if (this.sim.playing) { if (this.sim.playing) {

View File

@ -7,10 +7,6 @@ import {
export class UtilityTool extends Tool { export class UtilityTool extends Tool {
currentTimeEl = undefined; currentTimeEl = undefined;
get displayScaleText() {
return `Scale: ${this.sim.getScaleDisplay()}`;
}
get timeText() { get timeText() {
let time = this.sim.time; let time = this.sim.time;
// Time in milliseconds // Time in milliseconds
@ -40,100 +36,39 @@ export class UtilityTool extends Tool {
} }
} }
setContainer(container) {
super.setContainer(container);
this.currentTimeEl.innerHTML = this.timeText;
}
constructor(container) { constructor(container) {
super(container); super(container);
const zeroVelocity = document.createElement('button');
const clearTraces = document.createElement('button'); const clearTraces = document.createElement('button');
const zoomAll = document.createElement('button');
const currentScale = document.createElement('button')
const currentTime = document.createElement('button'); const currentTime = document.createElement('button');
const clearDebug = document.createElement('button'); const clearDebug = document.createElement('button');
this.currentTimeEl = currentTime; this.currentTimeEl = currentTime;
this.div.appendChild(currentTime); this.div.appendChild(currentTime);
this.div.appendChild(currentScale);
this.div.appendChild(zoomAll);
this.div.appendChild(zeroVelocity);
this.div.appendChild(clearTraces); this.div.appendChild(clearTraces);
this.div.appendChild(clearDebug); this.div.appendChild(clearDebug);
zeroVelocity.classList.add(WIDE_CLASSNAME);
clearTraces.classList.add(WIDE_CLASSNAME); clearTraces.classList.add(WIDE_CLASSNAME);
zoomAll.classList.add(WIDE_CLASSNAME);
currentScale.classList.add(WIDE_CLASSNAME);
currentScale.classList.add(TOOL_INFO_CLASSNAME);
currentTime.classList.add(TOOL_INFO_CLASSNAME); currentTime.classList.add(TOOL_INFO_CLASSNAME);
currentTime.classList.add(WIDE_CLASSNAME); currentTime.classList.add(WIDE_CLASSNAME);
clearDebug.classList.add(WIDE_CLASSNAME); clearDebug.classList.add(WIDE_CLASSNAME);
zeroVelocity.innerHTML = 'Zero Momentum';
clearTraces.innerHTML = 'Clear Traces'; clearTraces.innerHTML = 'Clear Traces';
zoomAll.innerHTML = 'Zoom to Fit';
currentScale.innerHTML = this.displayScaleText;
currentTime.innerHTML = this.timeText;
clearDebug.innerHTML = 'Clear Debug'; clearDebug.innerHTML = 'Clear Debug';
this.sim.onZoom(() => {
currentScale.innerHTML = this.displayScaleText;
});
zeroVelocity.addEventListener('click', () => {
// Determine center of mass and average momentum
const { totalMass, netMomentum } = this.sim.objects.computeSystemCenter();
const netVelocity = {
x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass,
};
// Apply offset to all object velocities
this.sim.objects.forEachObject(obj => {
obj.velocity.x -= netVelocity.x;
obj.velocity.y -= netVelocity.y;
});
// Cancel panning
this.sim.panning = undefined;
});
clearTraces.addEventListener('click', () => { clearTraces.addEventListener('click', () => {
// Obliterate object histories // Obliterate object histories
this.sim.objects.forEachObject(obj => { this.sim.system.forEachObject(obj => {
obj.history = []; obj.history = [];
}, {alive: null}); }, {alive: null});
}); });
zoomAll.addEventListener('click', () => {
// Determine bounding box
const box = this.sim.objects.boundingBox;
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;
const heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height;
const biggerRatio = Math.max(widthRatio, heightRatio);
const base2factor = Math.log(1/biggerRatio) / Math.log(2) - 0.5;
const factor = Math.floor(base2factor);
// Determine average momentum and set panning velocity to match
const netMomentum = {x: 0, y: 0};
let totalMass = 0;
let count = 0;
this.sim.objects.forEachObject(obj => {
count++;
netMomentum.x += obj.mass * obj.velocity.x;
netMomentum.y += obj.mass * obj.velocity.y;
totalMass += obj.mass;
});
if (!count) {
return;
}
const netVelocity = {
x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass,
};
this.sim.scheduleZoom({x, y}, factor, netVelocity)
});
clearDebug.addEventListener('click', () => { clearDebug.addEventListener('click', () => {
this.sim.info = {}; this.sim.info = {};
}); });

View File

@ -2,20 +2,51 @@ import { Tool } from '../tool.js';
import { import {
ZOOM_IN_FACTOR, ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR, ZOOM_OUT_FACTOR,
WIDE_CLASSNAME,
TOOL_INFO_CLASSNAME,
} from '../config.js'; } from '../config.js';
export class Zoom extends Tool { export class Zoom extends Tool {
currentScaleEl = undefined;
get displayScaleText() {
return `Scale: ${this.sim.getScaleDisplay()}`;
}
setContainer(container) {
super.setContainer(container);
this.currentScaleEl.innerHTML = this.displayScaleText;
this.sim.onZoom(() => {
this.currentScaleEl.innerHTML = this.displayScaleText;
});
}
constructor(container) { constructor(container) {
super(container); super(container);
const currentScale = document.createElement('button')
const zoomOut = document.createElement('button'); const zoomOut = document.createElement('button');
const zoomIn = document.createElement('button'); const zoomIn = document.createElement('button');
const zoomAll = document.createElement('button');
const zeroVelocity = document.createElement('button');
this.currentScaleEl = currentScale;
this.div.appendChild(currentScale);
this.div.appendChild(zoomOut); this.div.appendChild(zoomOut);
this.div.appendChild(zoomIn); this.div.appendChild(zoomIn);
this.div.appendChild(zoomAll);
this.div.appendChild(zeroVelocity);
currentScale.classList.add(WIDE_CLASSNAME);
currentScale.classList.add(TOOL_INFO_CLASSNAME);
zoomOut.innerHTML = 'Zoom<br>Out'; zoomOut.innerHTML = 'Zoom<br>Out';
zoomIn.innerHTML = 'Zoom<br>In'; zoomIn.innerHTML = 'Zoom<br>In';
zoomAll.innerHTML = 'Zoom to Fit';
zeroVelocity.innerHTML = 'Zero Momentum';
zoomOut.addEventListener('click', () => { zoomOut.addEventListener('click', () => {
// Aim at center of view // Aim at center of view
@ -30,5 +61,43 @@ export class Zoom extends Tool {
const y = this.sim.display.height * this.sim.display.scale / 2; const y = this.sim.display.height * this.sim.display.scale / 2;
this.sim.scheduleZoom(this.sim.screenToSim(x, y), ZOOM_IN_FACTOR); this.sim.scheduleZoom(this.sim.screenToSim(x, y), ZOOM_IN_FACTOR);
}); });
zoomAll.addEventListener('click', () => {
// Determine bounding box
const box = this.sim.system.boundingBox;
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;
const heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height;
const ratio = Math.max(widthRatio, heightRatio) * 4;
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 netVelocity = {
x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass,
};
this.sim.scheduleZoom({x, y}, factor, netVelocity)
});
zeroVelocity.addEventListener('click', () => {
// Determine center of mass and average momentum
const { totalMass, netMomentum } = this.sim.system.computeSystemCenter();
const netVelocity = {
x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass,
};
// Apply offset to all object velocities
this.sim.system.forEachObject(obj => {
obj.velocity.x -= netVelocity.x;
obj.velocity.y -= netVelocity.y;
});
// Cancel panning
this.sim.panning = undefined;
});
} }
} }

View File

@ -1,12 +1,13 @@
import {TOOLBAR_GROUP_CLASSNAME, WIDE_CLASSNAME} from './config.js';
export class ToolbarGroup { export class ToolbarGroup {
sim = undefined; sim = undefined;
toolbars = []; toolbars = [];
constructor(sim) { constructor(sim) {
this.sim = sim; this.sim = sim;
const div = document.createElement('div'); this.div = document.createElement('div');
this.div = div; this.sim.div.appendChild(this.div);
this.sim.div.appendChild(div); this.div.classList.add(TOOLBAR_GROUP_CLASSNAME);
} }
topRight() { topRight() {
@ -16,6 +17,11 @@ export class ToolbarGroup {
return this; return this;
} }
wide() {
this.div.classList.add(WIDE_CLASSNAME);
return this;
}
addToolbar(toolbar) { addToolbar(toolbar) {
this.div.appendChild(toolbar.div); this.div.appendChild(toolbar.div);
this.toolbars.push(toolbar); this.toolbars.push(toolbar);

View File

@ -38,6 +38,7 @@ export class Toolbar {
// tool: instance of Tool // tool: instance of Tool
addTool(tool) { addTool(tool) {
this.div.appendChild(tool.div); this.div.appendChild(tool.div);
tool.setContainer(this);
this.tools.push(tool); this.tools.push(tool);
return this; return this;
} }

59
zoom.js Normal file
View File

@ -0,0 +1,59 @@
import {
EVENT_ZOOM,
SCALE_POWER_MAX,
SCALE_POWER_MIN,
} from './config.js';
export class Zoom {
sim = undefined;
nextZoom = undefined;
constructor(sim) {
this.sim = sim;
}
// velocity should be in Sim coordinate scale
scheduleZoom({x, y}, factor, velocity) {
this.nextZoom = {x, y, factor, velocity};
}
frame() {
if (this.nextZoom) {
this.zoom(this.nextZoom);
this.nextZoom = undefined;
}
}
// x, y should be in Sim coordinates
// velocity should be in Sim coordinate scale
zoom({x, y, factor, velocity}) {
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
// TODO: Lossy rescaling to expand zoom range
let scalePower = display.scalePower + factor;
scalePower = Math.max(scalePower, SCALE_POWER_MIN);
scalePower = Math.min(scalePower, SCALE_POWER_MAX);
this.sim.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();
// TODO: If paused, set panning velocity on resume
// if (this.sim.playing && velocity) {
if (velocity) {
this.sim.panning = {
velocity: { ...velocity }
};
}
const e = new CustomEvent(EVENT_ZOOM);
this.sim.div.dispatchEvent(e);
}
}