Fix options localStorage

This commit is contained in:
Lentil Hoffman 2025-12-30 19:02:46 -06:00
parent e50be0b874
commit d6c3db8e45
Signed by: lentil
GPG Key ID: 0F5B99F3F4D0C087
17 changed files with 280 additions and 186 deletions

View File

@ -8,7 +8,8 @@ Uses `npm` for `eslint`.
Screenshots
-----------
![Simulation of a small body orbiting a pair of larger ones](./gravity-simulator-2.png "Gravity Simulator Screenshot 2")
![A small body orbiting a pair of larger ones](./gravity-simulator-2.png "Gravity Simulator Screenshot 2")
![A body orbiting a pair, all three of similar size](./gravity-simulator-3.png "Gravity Simulator Screenshot 3")
TODO
----
@ -30,10 +31,10 @@ TODO
- [ ] Undo "Clear Traces" Action
- [ ] Undo "Reset
- [ ] Time Control: Reverse Time
- [ ] Save to LocalStorage
- [x] Save to LocalStorage
- [ ] Lossy Rescaling To Widen Zoom (Handling overflow/underflow)
- [ ] Track farthest reaches, min/max in each dimension (x, y)
- [ ] Enabling Zoom to Fit Traces
- [x] Compute Net Angular Momentum
- [ ] Display Net Angular Momentum
- [ ] Calculate Work as FxD as measure of energy flux
- [ ] Option to automatically slow time when energy flux is greater

View File

@ -18,6 +18,7 @@ export const PATH_TRACES_DASHED_OPACITY = 1.0;
// SIZES
export const POINTER_HISTORY_SIZE = 20;
export const FRAMERATE_SAMPLE_DURATION = 2.0; // Seconds
export const POINTER_DOWN_HISTORY_SIZE = 5;
export const ARROWHEAD_LENGTH = 7;
export const ARROWHEAD_WIDTH = 5;
@ -36,6 +37,7 @@ export const TOOL_INFO_CLASSNAME = 'lhg-tool-info';
export const TOOLBAR_CLASSNAME = 'lhg-toolbar';
export const TOOLBAR_HEADER_CLASSNAME = 'lhg-toolbar-header';
export const WIDE_CLASSNAME = 'lhg-wide';
export const TALL_CLASSNAME = 'lhg-tall';
export const OVERLAY_INFO_BOX_CLASSNAME = 'lhg-overlay-info-box';
// EVENT NAMES
@ -48,3 +50,6 @@ export const EVENT_OPTION_SET = 'lhg-option-set';
export const MODE_MASS_GENERATION = 'mass-gen';
export const MODE_PAN_VIEW = 'pan-view';
export const MODE_OBJECT_SELECT = 'select';
// LOCAL STORAGE PREFIXES/SUFFIXES
export const TOOLBAR_EXPANDED_SUFFIX = 'lhg-toolbar-expanded';

View File

@ -59,9 +59,6 @@ export class Display {
fullscreen() {
this.canvas.width = document.documentElement.clientWidth;
this.canvas.height = document.documentElement.clientHeight;
if (this.sim.getOption('debug.canvasSize')) {
this.sim.info['Canvas'] = [`${this.canvas.width}`, `${this.canvas.height}`];
}
}
fillCanvas() {
@ -160,8 +157,9 @@ export class Display {
} else if (this.sim.panning && !this.sim.panning.paused) {
// Apply update to viewOrigin based on panning
const { velocity } = this.sim.panning;
this.viewOrigin.x -= velocity.x * elapsedTime;
this.viewOrigin.y -= velocity.y * elapsedTime;
// 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;
}
}

BIN
gravity-simulator-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@ -200,13 +200,20 @@ export class Objects {
});
}
// elapsedTime is given in milliseconds
computeFrame(elapsedTime) {
// convert elapsed time to seconds
elapsedTime /= 1000;
// If we're creating an object, increment its mass
// with the mass creation rate accelerating over time
const massCreationRate = this.sim.getOption('param.massCreationRate');
// Scaling this parameter because of millisecond conversion
const massCreationRate = this.sim.getOption('param.massCreationRate') / 1000;
if (this.creatingObject !== undefined) {
const obj = this.objects[this.creatingObject];
const rate = massCreationRate * obj.age;
console.log('obj.age', obj.age, 'mass creation rate', rate, 'elapsedTime', elapsedTime);
// TODO: After objects merge during creation, mass creation rate can accelerate
obj.mass += rate * elapsedTime;
}
@ -215,6 +222,8 @@ export class Objects {
this.computeForces();
if (this.sim.playing) {
// TODO: If creating/selected object, clamp its position to the cursor
// Predict positions (Velocity verlet method)
this.forEachObject(obj => {
obj.currentAcceleration = {...obj.acceleration};
@ -306,8 +315,9 @@ export class Objects {
`${obj.position.y.toPrecision(6)}, `,
`${obj.mass.toPrecision(6)} kg, `,
`${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`,
`Alive: ${obj.alive}`,
];
});
}, { alive: null });
}
}

View File

@ -2,54 +2,51 @@ import {
EVENT_OPTION_SET,
} from './config.js';
export const optionsLayout = {
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],
canvasSize: ['Canvas Size', 'boolean', false],
currentMode: ['Current Mode', 'boolean', false],
panningInfo: ['Panning Info', 'boolean', false],
},
};
export class Options {
sim = undefined;
options = undefined;
values = {};
constructor(sim) {
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.getOption(path)
if (value === undefined) {
value = defaultValue;
this.setOption(path, value);
}
}
}
}
getOption(path) {
const val = this.values[path];
return val;
let value = this.values[path];
if (value === undefined) {
value = localStorage.getItem(this.getStorageKey(path));
if (value === 'false') value = false;
else if (value === 'true') value = true;
this.values[path] = value;
}
return value;
}
setOption(path, value) {
this.values[path] = value;
window.localStorage.setItem(this.getStorageKey(path), value);
const e = new CustomEvent(EVENT_OPTION_SET, {detail: {path, value}});
this.sim.div.dispatchEvent(e);
}
@ -63,8 +60,8 @@ export class Options {
});
}
static getSection(layout, sectionName) {
const section = layout[sectionName];
getSection(sectionName) {
const section = this.options[sectionName];
const group = {
type: 'group',
name: sectionName,

View File

@ -66,7 +66,7 @@ export class Pointer {
points = Math.min(points, POINTER_HISTORY_SIZE, this.pointerHistory.length);
const start = this.pointerHistory[this.pointerHistory.length - points];
const end = this.pointerHistory[this.pointerHistory.length - 1];
const dt = (end.t - start.t) / 1000;
const dt = (end.t - start.t);
return {
x: (end.x - start.x) / dt,
y: (end.y - start.y) / dt,
@ -145,14 +145,13 @@ export class Pointer {
// TODO: If e.touches.length > 1, user may be engaging pinch to zoom
handlePointerMove({x: clientX, y: clientY}) {
this.updatePointer({x: clientX, y: clientY});
const v = this.latestPointerVelocity;
// Convert pointer velocity to simulation scale
v.x /= this.sim.display.scale;
v.y /= this.sim.display.scale;
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
// Convert pointer velocity to simulation scale
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);
this.sim.objects.handlePointerMove({x, y, vx: v.x, vy: v.y});
this.sim.objects.handlePointerMove({x, y, vx, vy});
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
if (this.panTouchStart) {
@ -160,7 +159,7 @@ export class Pointer {
this.panTouchLatest = {
x: clientX,
y: clientY,
t: document.timeline.currentTime,
t: this.sim.rawTime,
};
}
}

View File

@ -1,5 +1,6 @@
import {
EVENT_ZOOM,
FRAMERATE_SAMPLE_DURATION,
SCALE_POWER_MAX,
SCALE_POWER_MIN,
} from './config.js';
@ -16,12 +17,42 @@ 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 {
info = {};
rawTime = undefined;
time = undefined;
nextZoom = undefined;
playing = true;
recentFrames = [];
frameRate = 0;
objects = undefined;
display = undefined;
@ -38,12 +69,26 @@ export class Sim {
onModeEnter = () => 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) {
this.divId = divId;
const div = document.getElementById(this.divId);
this.div = div;
this.options = new Options(this);
this.options = new Options(this, simOptions);
this.display = new Display(this);
this.objects = new Objects(this);
this.toolbars = {
@ -95,7 +140,7 @@ export class Sim {
}
// Initiate main loop
this.rawTime = document.timeline.currentTime / 1000;
this.rawTime = document.timeline.currentTime;
this.time = 0;
requestAnimationFrame(t => this.loop(t));
}
@ -163,10 +208,14 @@ export class Sim {
// Main loop
loop(currentTime) {
currentTime /= 1000;
this.markFrame(currentTime);
const timeScale = this.getOption('param.timeScale');
const elapsedTime = (currentTime - this.rawTime) * timeScale;
// elapsedTime in milliseconds
// rawTime in milliseconds
const elapsedTime = (currentTime - this.rawTime) / timeScale;
this.rawTime = currentTime;
if (this.playing) {
this.time += elapsedTime;
}
@ -175,6 +224,10 @@ export class Sim {
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;
@ -188,15 +241,14 @@ export class Sim {
this.info['Net Angular Momentum'] = this.objects.computeSystemAngularMomentum().toPrecision(6);
}
this.display.computePanning(elapsedTime);
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

@ -33,8 +33,8 @@ div.lhg-toolbar {
width: fit-content;
margin: 0.5em;
border-radius: 0.5em;
border-width: 1px;
border-color: #282;
border-width: 2px;
border-color: #151;
border-style: solid;
}
@ -44,7 +44,7 @@ div.lhg-tool {
left: 0;
width: 12em;
/* padding: 0.5em; */
margin: 0.5em;
/* margin: 0.5em; */
text-align: middle;
}
@ -107,6 +107,11 @@ div.lhg-tool .lhg-wide {
width: 12em;
}
div.lhg-tool .lhg-tall {
padding-top: 1em;
padding-bottom: 1em;
}
div.lhg-overlay-info-box {
position: absolute;
top: 0px;

12
tool.js
View File

@ -1,18 +1,18 @@
// Idea here is, tool can declare its parameters;
// can call back to toolbar for whatever...
// through toolbar can access sim
// can call back to container for whatever...
// through container can access sim
import {
TOOL_CLASSNAME,
} from './config.js';
export class Tool {
toolbar = undefined;
container = undefined;
sim = undefined;
constructor(toolbar) {
this.toolbar = toolbar;
this.sim = this.toolbar.sim;
constructor(container) {
this.container = container;
this.sim = this.container.sim;
const div = document.createElement('div');
this.div = div;
div.classList.add(TOOL_CLASSNAME)

View File

@ -2,8 +2,9 @@ import {TOOLBAR_HEADER_CLASSNAME} from '../config.js';
import { Tool } from '../tool.js';
export class Header extends Tool {
constructor(toolbar, title = 'Tools') {
super(toolbar);
constructor(container, title = 'Tools') {
super(container);
this.title = document.createElement('h1');
this.title.innerHTML = title;
@ -22,12 +23,12 @@ export class Header extends Tool {
}
updateButton() {
this.toggleButton.innerHTML = this.toolbar.expanded ? '˄' : ';
this.toggleButton.innerHTML = this.container.expanded ? '˅' : '˄';
}
toggle() {
this.toolbar.expanded = !this.toolbar.expanded;
this.toolbar.applyExpanded();
this.container.expanded = !this.container.expanded;
this.container.applyExpanded();
this.updateButton();
}
}

View File

@ -18,8 +18,8 @@ export class ModeSwitch extends Tool {
];
buttons = [];
constructor(toolbar) {
super(toolbar);
constructor(container) {
super(container);
const modesDiv = document.createElement('div');
const heading = document.createElement('h2');

View File

@ -4,7 +4,6 @@ import {
WIDE_CLASSNAME,
} from '../config.js';
import { Tool } from '../tool.js';
import { Options, optionsLayout } from '../options.js';
export class OptionsTool extends Tool {
visitItem(item, path) {
@ -29,14 +28,20 @@ export class OptionsTool extends Tool {
if (item.wide === true) {
button.classList.add(WIDE_CLASSNAME);
}
this.sim.setOption(path, item.default);
button.style.opacity = this.sim.getOption(path) ? '100%' : '50%';
button.addEventListener('click', () => {
this.sim.setOption(path, !this.sim.getOption(path));
button.style.opacity = this.sim.getOption(path) ? '100%' : '50%';
});
const value = this.sim.getOption(path);
if (value === undefined) {
this.sim.setOption(path, item.default);
}
button.style.opacity = value ? '100%' : '50%';
this.sim.onOptionSet(path, value => {
console.log('option set cb', path, value);
button.style.opacity = value ? '100%' : '50%';
console.log('button opacity', button.style.opacity);
});
button.addEventListener('click', () => {
const value = this.sim.options.getOption(path, true);
console.log('click, option value', value);
this.sim.setOption(path, !value);
});
return button;
}
@ -77,12 +82,12 @@ export class OptionsTool extends Tool {
}
}
constructor(toolbar, sections) {
super(toolbar);
constructor(container, sections) {
super(container);
for (const sectionName of sections) {
const item = Options.getSection(optionsLayout, sectionName);
const child = this.visitItem(item);
const option = this.sim.options.getSection(sectionName);
const child = this.visitItem(option);
this.div.appendChild(child);
}
}

View File

@ -1,68 +1,36 @@
import {TOOL_INFO_CLASSNAME, WIDE_CLASSNAME} from '../config.js';
import {
TALL_CLASSNAME
} from '../config.js';
import {Tool} from '../tool.js';
export class PlayPause extends Tool {
playHTML = 'Play';
pauseHTML = 'Pause';
currentTimeEl = undefined;
pauseButton = undefined;
playButton = undefined;
get timeText() {
let time = this.sim.time;
// Time in seconds
const s = time % 60;
time = (time - s) / 60;
const m = time % 60;
time = (time - m) / 60;
const h = time % 24;
time = (time - h) / 24;
const d = time;
time -= m * 60;
const ms = (s - Math.floor(s)) * 1000;
return [
d || undefined,
h.toString().padStart(2, '0'),
m.toString().padStart(2, '0'),
[
s.toFixed(0).padStart(2, '0'),
ms.toFixed(0).padStart(3, '0'),
].join('.')
].filter(x => x !== undefined).join(':');
}
frame() {
if (this.currentTimeEl) {
this.currentTimeEl.innerHTML = this.timeText;
}
}
updateButtons() {
this.pauseButton.style.opacity = this.sim.playing ? '100%' : '50%';
this.playButton.style.opacity = this.sim.playing ? '50%' : '100%';
}
constructor(toolbar) {
super(toolbar);
constructor(container) {
super(container);
const currentTime = document.createElement('button');
const pauseButton = document.createElement('button');
const playButton = document.createElement('button');
this.pauseButton = pauseButton;
this.playButton = playButton;
this.currentTimeEl = currentTime;
this.div.appendChild(currentTime);
this.div.appendChild(pauseButton);
this.div.appendChild(playButton);
currentTime.classList.add(TOOL_INFO_CLASSNAME);
currentTime.classList.add(WIDE_CLASSNAME);
pauseButton.innerHTML = this.pauseHTML;
playButton.innerHTML = this.playHTML;
currentTime.innerHTML = this.timeText;
playButton.classList.add(TALL_CLASSNAME);
pauseButton.classList.add(TALL_CLASSNAME);
this.updateButtons();

View File

@ -1,23 +1,83 @@
import {Tool} from '../tool.js';
import {
TOOL_INFO_CLASSNAME,
WIDE_CLASSNAME,
} from '../config.js';
export class UtilityTool extends Tool {
constructor(toolbar) {
super(toolbar);
currentTimeEl = undefined;
get displayScaleText() {
return `Scale: ${this.sim.getScaleDisplay()}`;
}
get timeText() {
let time = this.sim.time;
// Time in milliseconds
const ms = Math.floor(time % 1000);
time = (time - ms) / 1000;
const s = Math.floor(time % 60);
time = (time - s) / 60;
const m = Math.floor(time % 60);
time = (time - m) / 60;
const h = Math.floor(time % 24);
time = (time - h) / 24;
const d = Math.floor(time);
return [
d || undefined,
h.toString().padStart(2, '0'),
m.toString().padStart(2, '0'),
[
s.toString().padStart(2, '0'),
ms.toString().padStart(3, '0'),
].join('.')
].filter(x => x !== undefined).join(':');
}
frame() {
if (this.currentTimeEl) {
this.currentTimeEl.innerHTML = this.timeText;
}
}
constructor(container) {
super(container);
const zeroVelocity = document.createElement('button');
const clearTraces = document.createElement('button');
const zoomAll = document.createElement('button');
const currentScale = document.createElement('button')
const currentTime = document.createElement('button');
const clearDebug = document.createElement('button');
this.div.appendChild(clearTraces);
this.currentTimeEl = currentTime;
this.div.appendChild(currentTime);
this.div.appendChild(currentScale);
this.div.appendChild(zoomAll);
this.div.appendChild(zeroVelocity);
this.div.appendChild(clearTraces);
this.div.appendChild(clearDebug);
zeroVelocity.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(WIDE_CLASSNAME);
clearDebug.classList.add(WIDE_CLASSNAME);
zeroVelocity.innerHTML = 'Zero Momentum';
clearTraces.innerHTML = 'Clear Traces';
zoomAll.innerHTML = 'Zoom to Fit';
currentScale.innerHTML = this.displayScaleText;
currentTime.innerHTML = this.timeText;
clearDebug.innerHTML = 'Clear Debug';
this.sim.onZoom(() => {
currentScale.innerHTML = this.displayScaleText;
});
zeroVelocity.addEventListener('click', () => {
// Determine center of mass and average momentum
@ -43,5 +103,39 @@ export class UtilityTool extends Tool {
obj.history = [];
}, {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', () => {
this.sim.info = {};
});
}
}

View File

@ -1,45 +1,21 @@
import { Tool } from '../tool.js';
import {
TOOL_INFO_CLASSNAME,
WIDE_CLASSNAME,
ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR,
} from '../config.js';
export class Zoom extends Tool {
get displayScale() {
return this.sim.getScaleDisplay();
}
constructor(container) {
super(container);
get displayScaleText() {
return `Scale: ${this.displayScale}`;
}
constructor(toolbar) {
super(toolbar);
const currentScale = document.createElement('button')
const zoomOut = document.createElement('button');
const zoomIn = document.createElement('button');
const zoomAll = document.createElement('button');
this.div.appendChild(currentScale);
this.div.appendChild(zoomOut);
this.div.appendChild(zoomIn);
this.div.appendChild(zoomAll);
zoomAll.classList.add(WIDE_CLASSNAME);
currentScale.classList.add(WIDE_CLASSNAME);
currentScale.classList.add(TOOL_INFO_CLASSNAME);
zoomOut.innerHTML = 'Zoom<br>Out';
zoomIn.innerHTML = 'Zoom<br>In';
zoomAll.innerHTML = 'Zoom to Fit';
currentScale.innerHTML = this.displayScaleText;
this.sim.onZoom(() => {
currentScale.innerHTML = this.displayScaleText;
});
zoomOut.addEventListener('click', () => {
// Aim at center of view
@ -54,35 +30,5 @@ export class Zoom extends Tool {
const y = this.sim.display.height * this.sim.display.scale / 2;
this.sim.scheduleZoom(this.sim.screenToSim(x, y), ZOOM_IN_FACTOR);
});
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) - 1;
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)
});
}
}

View File

@ -1,5 +1,6 @@
import {
TOOLBAR_CLASSNAME,
TOOLBAR_EXPANDED_SUFFIX,
} from './config.js';
import {Header} from './tool/header.js';
@ -8,10 +9,17 @@ export class Toolbar {
tools = [];
expanded = undefined;
header = undefined;
title = undefined;
constructor(sim, title, { expanded } = {}) {
// TODO: Index on something more durable than title
getExpandedKey() {
return [this.title, TOOLBAR_EXPANDED_SUFFIX].join(':');
}
constructor(sim, title) {
this.sim = sim;
this.expanded = expanded ?? true;
this.title = title;
this.expanded = window.localStorage.getItem(this.getExpandedKey()) === 'true';
// Create ourselves a div, as child of sim's div
const div = document.createElement('div');
@ -22,6 +30,9 @@ export class Toolbar {
const header = new Header(this, title);
this.header = header;
this.addTool(header);
header.updateButton();
this.applyExpanded();
}
// tool: instance of Tool
@ -38,8 +49,10 @@ export class Toolbar {
}
applyExpanded() {
window.localStorage.setItem(this.getExpandedKey(), this.expanded);
for (const tool of this.tools) {
if (tool === this.header) continue;
if (this.expanded) {
if (!this.div.contains(tool.div)) {
this.div.appendChild(tool.div);