Compare commits

..

12 Commits

Author SHA1 Message Date
Ladd
c63f44ab8f updated defaults 2025-12-29 00:48:20 -06:00
Ladd
34168676a1 Readme 2025-12-29 00:23:50 -06:00
Ladd
4cfdfc902b time scale 2025-12-29 00:22:53 -06:00
Ladd
6640ba9b1d readme formatting 2025-12-28 22:30:14 -06:00
Ladd
c882749786 no extra ssh on commit 2025-12-28 22:28:08 -06:00
Ladd
a6678ced37 Revert "no extra ssh on commit"
This reverts commit 403ce4b6830bf3943c03bc772b0a0d9648b270cb.
2025-12-28 22:22:05 -06:00
Ladd
403ce4b683 no extra ssh on commit 2025-12-28 22:16:38 -06:00
Ladd
bff693ee9f first numeric option: gravitation constant 2025-12-28 22:15:25 -06:00
Ladd
8458a3e948 tweak headings 2025-12-28 21:27:51 -06:00
Ladd
69b1413771 todo 2025-12-28 20:25:16 -06:00
Ladd
d561e9565d Readme 2025-12-28 20:04:49 -06:00
Ladd
1da0be3657 pointer history size 2025-12-28 19:52:17 -06:00
14 changed files with 264 additions and 95 deletions

22
Readme.md Normal file
View File

@ -0,0 +1,22 @@
Gravity Simulator
=================
Runs in a browser. Just serve `index.html` and associated `css` and `js`.
Uses `npm` for `eslint`.
TODO
----
- [x] Numeric Option Type
- [x] Time Indicator
- [ ] Selection Box
- [ ] Object List
- [ ] Object Detail
- [ ] Left Button Panning
- [ ] Middle Button Pause
- [ ] Grid
- [ ] Zoom Easing
- [ ] 2-touch Pan & Zoom
- [ ] Multi-touch Mass Create
- [ ] Tool to Clear Traces

22
commit
View File

@ -8,22 +8,16 @@ fi
set -eo pipefail
git checkout dev
./sync
if git diff; then
git add .
git commit "$@"
git push
fi
ssh lentilz "
set -oe pipefail
export NVM_DIR=\"\$HOME/.nvm\"
[ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" # This loads nvm
cd ~/code/gravity-dev
git add .
git stash
git pull
git stash apply
npm i
"
git commit "$@"
git push
echo >&2
echo >&2 "Committed and deployed to https://laddhoffman.com/gravity-dev/"
echo >&2 "Committed and pushed dev branch"

View File

@ -28,10 +28,12 @@ export const PATH_TRACES_DASHED = true;
export const PATH_TRACES_DASHED_OPACITY = 1.0;
// SCALING FACTORS
export const MASS_CREATION_RATE = 1E1;
export const POINTER_HISTORY_SIZE = 15;
export const MOTION_TIME_SCALE = 1E-4;
export const PAN_ACCELERATION = 1E1;
export const MASS_CREATION_RATE = 10;
export const POINTER_HISTORY_SIZE = 20;
export const MOTION_TIME_SCALE = 0.3;
export const GRAVITATIONAL_CONSTANT = 2E4;
// SIZES
export const ARROWHEAD_LENGTH = 7;
export const ARROWHEAD_WIDTH = 5;
export const OFFSCREEN_OBJECT_LINE_SCALE = 7;
@ -42,7 +44,6 @@ export const ZOOM_IN_FACTOR = 1;
export const ZOOM_OUT_FACTOR = -1;
export const SCALE_POWER_MAX = 8;
export const SCALE_POWER_MIN = -8;
export const GRAVITATIONAL_CONSTANT = 1E5;
// CSS CLASS NAMES
export const TOOL_CLASSNAME = 'lhg-tool';

View File

@ -6,7 +6,10 @@
<link rel="stylesheet" href="./style.css" />
<script type="module">
import { Sim } from './simulator.js';
const sim = new Sim('simulator');
// Wait for document load
document.addEventListener('DOMContentLoaded', () => {
new Sim('simulator');
});
</script>
</head>
<body>

View File

@ -1,8 +1,6 @@
import { MassObject } from './object.js';
import {
MASS_CREATION_RATE,
DISPLAY_OBJECTS_INFO,
GRAVITATIONAL_CONSTANT,
ZOOM_TO_FIT_PADDING,
} from './config.js';
@ -143,17 +141,17 @@ export class Objects {
handlePointerMove({x, y, vx, vy}) {
// 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
const obj = this.getSelectedOrCreating();
if (obj === undefined) return;
if (this.sim.pointer.panning?.velocity) {
vx += this.sim.pointer.panning.velocity.x;
vy += this.sim.pointer.panning.velocity.y;
}
if (obj !== undefined) {
obj.position.x = x;
obj.position.y = y;
obj.velocity.x = vx;
obj.velocity.y = vy;
}
obj.position.x = x;
obj.position.y = y;
obj.velocity.x = vx;
obj.velocity.y = vy;
}
// cb: (obj, idx) => {}
@ -169,6 +167,7 @@ export class Objects {
}
computeForces() {
const gravity = this.sim.getOption('param.gravity');
if (this.objects.length < 2) return;
this.forEachObject(obj => {
obj.forces = [];
@ -179,7 +178,7 @@ export class Objects {
const dy = (B.position.y - A.position.y);
const dSquared = dx ** 2 + dy ** 2;
const d = Math.sqrt(dSquared);
const F = GRAVITATIONAL_CONSTANT * A.mass * B.mass / dSquared;
const F = gravity * A.mass * B.mass / dSquared;
const Fx = F * dx / d;
const Fy = F * dy / d;
A.forces.push({ x: Fx, y: Fy });
@ -195,9 +194,11 @@ export class Objects {
computeFrame(elapsedTime) {
// If we're creating an object, increment its mass
// with the mass creation rate accelerating over time
const massCreationRate = this.sim.getOption('param.massCreationRate');
if (this.creatingObject !== undefined) {
const obj = this.objects[this.creatingObject];
const rate = MASS_CREATION_RATE * obj.age;
const rate = massCreationRate * obj.age;
// TODO: After objects merge during creation, mass creation rate can accelerate
obj.mass += rate * elapsedTime;
}
@ -224,29 +225,42 @@ export class Objects {
const dSquared = dx ** 2 + dy ** 2;
const d = Math.sqrt(dSquared);
if (d < A.radius + B.radius) {
// Merge B into A:
let S, T;
// Merge the older into the newer, in order to provide mass creation rate continuity
if (A.age > B.age) {
// A merges into B; B survives
S = B;
T = A;
// If A was selected or being created, select S instead
if (this.creatingObject === i) this.creatingObject = j;
if (this.selectedObject === i) this.selectedObject = j;
} else {
// B merges into A; A survives
S = A;
T = B;
// If B was selected or being created, select S instead
if (this.creatingObject === j) this.creatingObject = i;
if (this.selectedObject === j) this.selectedObject = i;
}
// Merge T into S:
// Set position = center of mass
// Set velocity = total momentum / total mass
// Combine forces
// Add masses
// Average color
// If B was selected or being created, select A instead
// Remove B using splice
A.position.x = (A.position.x * A.mass + B.position.x * B.mass) / (A.mass + B.mass);
A.position.y = (A.position.y * A.mass + B.position.y * B.mass) / (A.mass + B.mass);
A.velocity.x = (A.velocity.x * A.mass + B.velocity.x * B.mass) / (A.mass + B.mass);
A.velocity.y = (A.velocity.y * A.mass + B.velocity.y * B.mass) / (A.mass + B.mass);
A.forces.push(...B.forces);
A.mass += B.mass;
A.color = {
r: (A.mass * A.color.r + B.mass * B.color.r) / (A.mass + B.mass),
g: (A.mass * A.color.g + B.mass * B.color.g) / (A.mass + B.mass),
b: (A.mass * A.color.b + B.mass * B.color.b) / (A.mass + B.mass),
// Sdd masses
// Sverage color
S.position.x = (S.position.x * S.mass + T.position.x * T.mass) / (S.mass + T.mass);
S.position.y = (S.position.y * S.mass + T.position.y * T.mass) / (S.mass + T.mass);
S.velocity.x = (S.velocity.x * S.mass + T.velocity.x * T.mass) / (S.mass + T.mass);
S.velocity.y = (S.velocity.y * S.mass + T.velocity.y * T.mass) / (S.mass + T.mass);
S.forces.push(...T.forces);
S.mass += T.mass;
S.color = {
r: (S.mass * S.color.r + T.mass * T.color.r) / (S.mass + T.mass),
g: (S.mass * S.color.g + T.mass * T.color.g) / (S.mass + T.mass),
b: (S.mass * S.color.b + T.mass * T.color.b) / (S.mass + T.mass),
};
if (this.creatingObject === j) this.creatingObject = i;
if (this.selectedObject === j) this.selectedObject = i;
B.alive = false;
B.forces = [];
T.alive = false;
T.forces = [];
}
}, true, i + 1);
});

View File

@ -59,12 +59,12 @@ export class Pointer {
});
}
getPointerVelocity(points) {
getPointerVelocity(points = POINTER_HISTORY_SIZE) {
// Average over pointer history
if (this.pointerHistory.length < 2) {
return {x: 0, y: 0, dt: 1};
}
points = points || this.pointerHistory.length;
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;
@ -122,7 +122,7 @@ export class Pointer {
if (!dt) {
this.panning = undefined;
} else {
const v = this.getPointerVelocity(10);
const v = this.getPointerVelocity();
// Convert pointer velocity to simulation scale
v.x /= this.sim.display.scale;
v.y /= this.sim.display.scale;

View File

@ -1,23 +1,23 @@
import { Display } from './display.js';
import { Overlay } from './overlay.js';
import { Pointer } from './pointer.js';
import { Objects } from './objects.js';
import { Toolbar } from './toolbar.js';
import { PlayPause } from './tool/play-pause.js';
import { Zoom } from './tool/zoom.js';
import { ModeSwitch } from './tool/modes.js';
import { Options } from './tool/options.js';
import {
DISPLAY_CURRENT_MODE,
DISPLAY_CURRENT_SCALE,
EVENT_ZOOM,
SCALE_POWER_MAX,
SCALE_POWER_MIN,
DISPLAY_CURRENT_SCALE,
DISPLAY_CURRENT_MODE,
MOTION_TIME_SCALE,
EVENT_ZOOM,
} from './config.js';
import {Display} from './display.js';
import {Objects} from './objects.js';
import {Overlay} from './overlay.js';
import {Pointer} from './pointer.js';
import {ModeSwitch} from './tool/modes.js';
import {Options} from './tool/options.js';
import {PlayPause} from './tool/play-pause.js';
import {Zoom} from './tool/zoom.js';
import {Toolbar} from './toolbar.js';
export class Sim {
info = {};
rawTime = undefined;
time = undefined;
nextZoom = undefined;
playing = true;
@ -59,7 +59,8 @@ export class Sim {
this.pointer = new Pointer(this);
// Initiate main loop
this.time = document.timeline.currentTime;
this.rawTime = document.timeline.currentTime / 1000;
this.time = 0;
requestAnimationFrame(t => this.loop(t));
}
@ -114,7 +115,7 @@ export class Sim {
getScaleDisplay() {
const scale = 2 ** Math.abs(this.display.scalePower);
return this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;
return this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;
}
// cb: () => undefined
@ -126,8 +127,11 @@ export class Sim {
// Main loop
loop(currentTime) {
const elapsedTime = (currentTime - this.time) * MOTION_TIME_SCALE;
this.time = currentTime;
currentTime /= 1000;
const timeScale = this.getOption('param.timeScale');
const elapsedTime = (currentTime - this.rawTime) * timeScale;
this.rawTime = currentTime;
this.time += elapsedTime;
if (DISPLAY_CURRENT_MODE) {
this.info['Mode'] = this.getCurrentMode();
@ -147,6 +151,7 @@ export class Sim {
this.overlay.renderInfo();
this.display.fillCanvas();
this.display.drawObjects();
this.toolbar.frame();
requestAnimationFrame(t => this.loop(t));
}

View File

@ -17,7 +17,7 @@ h1, h2, h3, h4, h5, h6 {
}
h1 {
font-size: 16pt;
font-size: 14pt;
}
div[id=simulator] {
@ -34,7 +34,7 @@ div.lhg-toolbar {
border-radius: 0.5EM;
}
div.lhg-toolbar div.lhg-tool {
div.lhg-tool {
position: relative;
top: 0;
left: 0;
@ -45,9 +45,15 @@ div.lhg-toolbar div.lhg-tool {
border-width: 1px;
border-style: solid;
border-color: #282;
text-align: middle;
}
div.lhg-tool button {
div.lhg-tool div.lhg-wide {
display: flex;
flex-direction: row;
}
div.lhg-tool button, div.lhg-tool input {
font-family: monospace;
font-size: 10pt;
width: 6em;
@ -55,7 +61,7 @@ div.lhg-tool button {
color: #5f5;
border-radius: 0.5EM;
border-color: #000;
border-width: 4px;
border-width: 2px;
border-style: solid;
padding-top: 0.5EM;
padding-bottom: 0.5EM;
@ -64,6 +70,11 @@ div.lhg-tool button {
text-align: center;
margin-left: 0;
margin-right: 0;
box-sizing: border-box;
}
div.lhg-tool input {
width: 6EM;
}
div.lhg-tool button:hover {
@ -77,20 +88,22 @@ div.lhg-tool button:active {
div.lhg-toolbar-header:hover button {
background-color: #444;
}
div.lhg-toolbar-header:active button {
background-color: #252;
}
div.lhg-tool button.lhg-tool-info {
background-color: #111;
border-color: #282;
border-width: 0px;
div.lhg-toolbar-header > * {
display: inline-block;
}
div.lhg-tool button.lhg-wide {
div.lhg-tool .lhg-tool-info {
background-color: #111;
border-color: #000;
border-width: 2px;
}
div.lhg-tool .lhg-wide {
width: 12em;
}
div.lhg-tool > div {
/* border: 2px red solid; */
}

4
sync
View File

@ -8,7 +8,7 @@ fi
set -eo pipefail
do_rsync() {
rsync -ru \
rsync -vru \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.*.sw*' \
@ -18,5 +18,7 @@ do_rsync() {
do_rsync ~/code/gravity-dev/ lentilz:code/gravity-dev/
do_rsync lentilz:code/gravity-dev/ ~/code/gravity-dev/
git status
echo >&2
echo >&2 "Synced with https://laddhoffman.com/gravity-dev/"

View File

@ -6,22 +6,20 @@ export class Header extends Tool {
constructor(toolbar, title = 'Tools') {
super(toolbar);
this.title = document.createElement('h1');
this.title.innerHTML = title;
this.toggleButton = document.createElement('button');
this.updateButton();
this.div.addEventListener('click', () => this.toggle());
this.updateButton();
this.div.appendChild(this.title);
this.div.appendChild(this.toggleButton);
this.title.style.verticalAlign = 'center';
this.toggleButton.style.width = '3EM';
this.div.style.display = 'flex';
this.div.style.justifyContent = 'space-around';
this.title.style.width = '9EM';
this.toggleButton.style.width = '3EM';
this.div.classList.add(TOOLBAR_HEADER_CLASSNAME);
}

View File

@ -2,10 +2,14 @@
import {
DISPLAY_ACCELERATION_VECTORS,
DISPLAY_VELOCITY_VECTORS,
GRAVITATIONAL_CONSTANT,
MASS_CREATION_RATE,
MERGE_ON_COLLIDE,
MOTION_TIME_SCALE,
PATH_TRACES_DASHED,
PAUSE_DURING_CREATION,
PAUSE_DURING_SELECTION,
TOOL_INFO_CLASSNAME,
WIDE_CLASSNAME,
} from '../config.js';
import {Tool} from '../tool.js';
@ -30,6 +34,13 @@ export class Options extends Tool {
items: [
{type: 'boolean', name: 'merge', title: 'Merge Masses', default: MERGE_ON_COLLIDE, wide: true},
]
}, {
type: 'group', name: 'param', title: 'Parameters',
items: [
{type: 'number', name: 'gravity', title: 'Gravity', default: GRAVITATIONAL_CONSTANT},
{type: 'number', name: 'timeScale', title: 'Time Scale', default: MOTION_TIME_SCALE},
{type: 'number', name: 'massCreationRate', title: 'Mass Creation Rate', default: MASS_CREATION_RATE},
]
}];
values = {};
@ -62,6 +73,32 @@ export class Options extends Tool {
});
return button;
}
case 'number': {
const div = document.createElement('div');
const title = document.createElement('button');
const input = document.createElement('input');
const maxLength = item.maxLength || 8;
div.appendChild(title);
div.appendChild(input);
div.classList.add(WIDE_CLASSNAME);
title.classList.add(TOOL_INFO_CLASSNAME);
if (item.wide) {
title.classList.add(WIDE_CLASSNAME);
input.classList.add(WIDE_CLASSNAME);
}
title.innerHTML = item.title;
input.value = item.default;
this.setOption(path, item.default);
input.addEventListener('input', () => {
input.value = input.value.slice(0, maxLength);
});
input.addEventListener('change', () => {
this.setOption(path, input.value);
});
return div;
}
default:
throw new Error('unknown option type');
}

View File

@ -1,20 +1,59 @@
import { Tool } from '../tool.js';
import {TOOL_INFO_CLASSNAME, WIDE_CLASSNAME} from '../config.js';
import {Tool} from '../tool.js';
export class PlayPause extends Tool {
playHTML = 'Play';
pauseHTML = 'Pause';
currentTimeEl = 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;
}
}
constructor(toolbar) {
super(toolbar);
const currentTime = document.createElement('button');
const pauseButton = document.createElement('button');
const playButton = document.createElement('button');
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;
pauseButton.style.opacity = this.sim.playing ? '100%' : '50%';
playButton.style.opacity = this.sim.playing ? '50%' : '100%';

View File

@ -7,6 +7,14 @@ import {
} from '../config.js';
export class Zoom extends Tool {
get displayScale() {
return this.sim.getScaleDisplay();
}
get displayScaleText() {
return `Scale: ${this.displayScale}`;
}
constructor(toolbar) {
super(toolbar);
@ -14,23 +22,27 @@ export class Zoom extends Tool {
const zoomOut = document.createElement('button');
const zoomIn = document.createElement('button');
const zoomAll = document.createElement('button');
const zeroVelocity = document.createElement('button');
this.div.appendChild(currentScale);
this.div.appendChild(zoomOut);
this.div.appendChild(zoomIn);
this.div.appendChild(zoomAll);
this.div.appendChild(zeroVelocity);
zoomAll.classList.add(WIDE_CLASSNAME);
zeroVelocity.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 = `Scale: ${this.sim.getScaleDisplay()}`;
zeroVelocity.innerHTML = 'Zero Momentum';
currentScale.innerHTML = this.displayScaleText;
this.sim.onZoom(() => {
currentScale.innerHTML = `Scale: ${this.sim.getScaleDisplay()}`;
currentScale.innerHTML = this.displayScaleText;
});
zoomOut.addEventListener('click', () => {
@ -76,5 +88,35 @@ export class Zoom extends Tool {
};
this.sim.scheduleZoom({x, y}, factor, netVelocity)
});
zeroVelocity.addEventListener('click', () => {
// Determine average momentum
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,
};
// Apply offset to all object velocities
this.sim.objects.forEachObject(obj => {
obj.velocity.x -= netVelocity.x;
obj.velocity.y -= netVelocity.y;
});
// TODO: Zero net angular momentum
// Cancel panning
this.sim.pointer.panning = undefined;
});
}
}

View File

@ -34,8 +34,7 @@ export class Toolbar {
}
frame() {
for (let tool in this.tools) {
// TODO: tool.frame()
for (let tool of this.tools) {
tool.frame();
}
}