possible wide toolbar groups

This commit is contained in:
Ladd 2026-01-01 15:13:13 -06:00
parent debf31e9b0
commit fe7e9f43ad
12 changed files with 139 additions and 89 deletions

View File

@ -10,6 +10,8 @@ Screenshots
![A binary pair orbiting a larger partner](./gravity-simulator-4.png "Gravity Simulator Screenshot 4") ![A binary pair orbiting a larger partner](./gravity-simulator-4.png "Gravity Simulator Screenshot 4")
![A greater mass attracts others which had been on escape trajectories](./gravity-simulator-5.png "Gravity Simulator Screenshot 5")
TODO TODO
---- ----
@ -35,3 +37,5 @@ TODO
- [ ] Track farthest reaches, min/max in each dimension (x, y) - [ ] Track farthest reaches, min/max in each dimension (x, y)
- [ ] 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

@ -16,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;
@ -37,6 +38,7 @@ 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 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';

BIN
gravity-simulator-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@ -183,8 +183,10 @@ export class MassObject {
// const panning = this.sim.panning?.velocity ?? {x: 0, y: 0}; // const panning = this.sim.panning?.velocity ?? {x: 0, y: 0};
// velocity.x = vx + (pointerV.x + panning.x) * scale; // velocity.x = vx + (pointerV.x + panning.x) * scale;
// velocity.y = vy + (pointerV.y + panning.y) * scale; // velocity.y = vy + (pointerV.y + panning.y) * scale;
velocity.x = vx + pointerV.x / this.sim.timeScale; if (this.sim.getOption('compensate.timeScale')) {
velocity.y = vy + pointerV.y / this.sim.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 speed = Math.sqrt(velocity.x ** 2, velocity.y ** 2) / this.sim.display.scale;
const arrowDirection = Math.atan2(velocity.y, velocity.x); const arrowDirection = Math.atan2(velocity.y, velocity.x);

View File

@ -69,7 +69,14 @@ export class Options {
} }
getOption(path) { getOption(path) {
return this.values[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) {

View File

@ -205,10 +205,10 @@ export class Pointer {
frame() { frame() {
// Add another entry for the current pointer position // Add another entry for the current pointer position
const {pointerHistory} = this.sim.pointer ?? {}; const { pointerHistory } = this;
if (pointerHistory?.length) { if (pointerHistory.length) {
const currentPointer = pointerHistory[pointerHistory.length - 1]; const currentPointer = pointerHistory[pointerHistory.length - 1];
this.sim.pointer.updatePointer(currentPointer); this.updatePointer(currentPointer);
} }
} }
} }

View File

@ -10,10 +10,10 @@ export const simOptions = {
acceleration: ['Accel Vectors', 'boolean', true], acceleration: ['Accel Vectors', 'boolean', true],
velocityScale: ['Velocity<br>Vec Scale', 'number', 80, {hideUnless: 'display.velocity'}], velocityScale: ['Velocity<br>Vec Scale', 'number', 80, {hideUnless: 'display.velocity'}],
accelerationScale: ['Accel<br>Vec Scale', 'number', 800, {hideUnless: 'display.acceleration'}], accelerationScale: ['Accel<br>Vec Scale', 'number', 800, {hideUnless: 'display.acceleration'}],
targetFrameRate: ['Target Frame Rate', 'number', 60], targetFrameRate: ['Frame Rate', 'number', 60],
}, },
collision: { collision: {
merge: ['Merge Masses<br>on Collision', 'boolean', true, {wide: true}], merge: ['Merge Masses on Collision', 'boolean', true, {wide: true}],
}, },
compensate: { compensate: {
timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}], timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}],

View File

@ -52,7 +52,38 @@ export class Sim {
// 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) { markFrame(t) {
@ -110,39 +141,4 @@ export class Sim {
}); });
} }
// Main loop
loop(currentTime) {
const early = this.markFrame(currentTime);
if (early) {
// Slow down :)
requestAnimationFrame(t => this.loop(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.display.frame(elapsedTime);
this.system.frame(elapsedTime);
this.overlay.frame();
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,10 +122,6 @@ 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 {
height: 3.666em; height: 3.666em;
} }
@ -119,7 +134,14 @@ div.lhg-overlay-info-box {
z-index: 1; z-index: 1;
} }
div.lhg-option-group {
display: flex;
flex-wrap: wrap;
}
/*
div.lhg-option-group > * { div.lhg-option-group > * {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
} }
*/

View File

@ -1,4 +1,5 @@
import { MassObject } from './object.js'; import {OBJECT_HISTORY_SIZE} from './config.js';
import {MassObject} from './object.js';
export class System { export class System {
objects = []; objects = [];
@ -88,10 +89,10 @@ export class System {
get boundingBox() { get boundingBox() {
const box = this.reduce(({start, end}, obj) => { const box = this.reduce(({start, end}, obj) => {
const lx = obj.position.x - obj.radius; const lx = obj.position.x - obj.radius;
const gx = obj.position.x + obj.radius; const gx = obj.position.x + obj.radius;
const ly = obj.position.y - obj.radius; const ly = obj.position.y - obj.radius;
const gy = obj.position.y + obj.radius; const gy = obj.position.y + obj.radius;
let ret; let ret;
if (start.x === undefined) { if (start.x === undefined) {
ret = { ret = {
@ -112,7 +113,7 @@ export class System {
}; };
return ret; return ret;
}, { }, {
start: {x: undefined, y: undefined}, start: {x: undefined, y: undefined},
end: {x: undefined, y: undefined}, end: {x: undefined, y: undefined},
}); });
@ -128,7 +129,7 @@ export class System {
this.selectedObjectStart = 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;
@ -140,7 +141,7 @@ export class System {
handlePointerDown({x, y}) { handlePointerDown({x, y}) {
// If pointer is touching an object, select the object // If pointer is touching an object, select the object
const touchingObject = this.objectAtLocation(x, y); const touchingObject = this.objectAtLocation(x, y);
if (touchingObject !== undefined) { if (touchingObject !== undefined) {
this.selectObject(touchingObject, {x, y}); this.selectObject(touchingObject, {x, y});
} else { } else {
@ -226,8 +227,8 @@ export class System {
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
@ -245,9 +246,13 @@ export class System {
if (this.creatingObject !== undefined) { if (this.creatingObject !== undefined) {
const obj = this.objects[this.creatingObject]; const obj = this.objects[this.creatingObject];
let massCreationRate = this.sim.getOption('param.massCreationRate') / this.sim.display.scale; let massCreationRate = this.sim.getOption('param.massCreationRate');
massCreationRate /= this.sim.display.scale;
// Keep consistent time scale // Keep consistent time scale
obj.mass += massCreationRate * elapsedTime / this.sim.timeScale; if (this.sim.getOption('compensate.timeScale')) {
massCreationRate /= this.sim.timeScale;
}
obj.mass += massCreationRate * elapsedTime;
} }
// Calculate forces due to gravity. // Calculate forces due to gravity.
@ -259,9 +264,9 @@ export class System {
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
@ -313,7 +318,7 @@ export class System {
}, {alive: true, startWith: i + 1}); }, {alive: true, startWith: i + 1});
}); });
} }
// Recompute forces // Recompute forces
this.computeForces(); this.computeForces();
@ -328,36 +333,41 @@ export class System {
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 // First clear info from previous frame
this.forEachObject((_obj, i) => { this.forEachObject((_obj, i) => {
delete this.sim.info[`Object ${i}`]; delete this.sim.info[`Object ${i}`];
}, { alive: null }); }, {alive: null});
if (this.sim.getOption('debug.objectsInfo')) { if (this.sim.getOption('debug.objectsInfo')) {
const aliveOnly = this.sim.getOption('debug.aliveObjects'); 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); 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 accelDir = Math.atan2(-obj.acceleration.y, obj.acceleration.x) * 180 / Math.PI;
const {r, g, b} = obj.color; const {r, g, b} = obj.color;
this.sim.info[`Object ${i}`] = [ this.sim.info[`Object ${i}`] = [
`<span style="background-color: rgb(${r},${g},${b});">&nbsp;&nbsp;</span>`, `<span style="background-color: rgb(${r},${g},${b});">&nbsp;&nbsp;</span>`,
`${obj.position.x.toPrecision(4)}, `, `${obj.position.x.toPrecision(4)}, `,
`${obj.position.y.toPrecision(4)}, `, `${obj.position.y.toPrecision(4)}, `,
`${obj.mass.toPrecision(4)} kg, `, `${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)}°`, `${accel.toPrecision(2)} m/s<sup>2</sup>, ${accelDir.toPrecision(2)}°`,
`Alive: ${obj.alive}`, `Alive: ${obj.alive}`,
]; ];
}, { alive: aliveOnly || null }); }, {alive: aliveOnly || null});
} }
// Render the objects // Render the objects
@ -366,7 +376,7 @@ export class System {
computeSystemCenter() { computeSystemCenter() {
// Determine center of mass // Determine center of mass
const { totalMass, count, totalMassLocation } = const {totalMass, count, totalMassLocation} =
this.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,
@ -387,11 +397,11 @@ export class System {
// Determine average momentum // Determine average momentum
const netMomentum = this.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

@ -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

@ -45,7 +45,8 @@ export class Zoom {
// this.pointer.clearPointerHistory(); // this.pointer.clearPointerHistory();
// TODO: If paused, set panning velocity on resume // TODO: If paused, set panning velocity on resume
if (this.sim.playing && velocity) { // if (this.sim.playing && velocity) {
if (velocity) {
this.sim.panning = { this.sim.panning = {
velocity: { ...velocity } velocity: { ...velocity }
}; };