Compare commits

...

4 Commits

Author SHA1 Message Date
Ladd
4eec03dd1f Feature: Select; Object Tool 2026-01-03 18:40:04 -06:00
Ladd
eac659822b refactored panning to its own class 2026-01-03 13:10:38 -06:00
Ladd
0483a8ab52 started vector helper refactor 2026-01-03 10:34:32 -06:00
Ladd
3a855b478f Readme 2026-01-02 12:00:25 -06:00
22 changed files with 587 additions and 551 deletions

View File

@ -16,65 +16,41 @@ TODO
---- ----
- [x] Selection Box - [x] Selection Box
- [ ] Enhancement: Create Vector class and refactor to use
- [ ] Feature: Object List - [ ] Feature: Object List
- [ ] Feature: Object Detail - [ ] Feature: Object Detail
- [ ] Feature: Zoom to Object
- [ ] Feature: Teleport Object
- [ ] Enhancement: Create Time class and refactor to use
- [ ] Enhancement: Create Vector class and refactor to use
- [ ] Enhancement: Create Panning class and refactor to use
- [ ] Enhancement: Handle pointerleave or other mechanism when window loses focus - [ ] Enhancement: Handle pointerleave or other mechanism when window loses focus
- [ ] Enhancement: Calculate Work as FxD as measure of energy flux - [ ] Enhancement: Calculate Work as FxD as measure of energy flux
- [ ] Feature: Automatically slow time when energy flux is greater - [ ] Feature: Automatically slow time when energy flux is greater
- [ ] Enhancement: Add z-axis (Initially nothing changes, z = 0) - [ ] Enhancement: Add z-axis (Initially nothing changes, z = 0)
- [ ] Feature: Isometric 3d View - [ ] Feature: Isometric 3d View
- [ ] Feature: Grid - [ ] Feature: Grid
- [ ] Feature: Polar Coordinates - [ ] Feature: Polar Coordinates
- [ ] Feature: Cylindrical Coordinates - [ ] Feature: Cylindrical Coordinates
- [ ] Feature: Spherical Coordinates - [ ] Feature: Spherical Coordinates
- [ ] Feature: Camera Velocity Display - [ ] Feature: Camera Velocity Display
- [ ] Enhancement: World State Snapshots - [ ] Enhancement: World State Snapshots
- [ ] Feature: List / Save / Load World States - [ ] Feature: List / Save / Load World States
- [ ] Feature: Left Button Panning - [ ] Feature: Left Button Panning
- [ ] Feature: Middle Button Pause - [ ] Feature: Middle Button Pause
- [ ] Feature: Parameter Slider (Invisible, mouse/touch drag) - [ ] Feature: Parameter Slider (Invisible, mouse/touch drag)
- [ ] Enhancement: Zoom Easing - [ ] Enhancement: Zoom Easing
- [ ] Feature: 2-touch Pan & Zoom - [ ] Feature: 2-touch Pan & Zoom
Either continuous zoom, or discrete with animation of bounding box proposed changes Either continuous zoom, or discrete with animation of bounding box proposed changes
- [ ] Feature: Multi-touch Mass Create - [ ] Feature: Multi-touch Mass Create
- [ ] Feature: Camera Rotation - [ ] Feature: Camera Rotation
- [ ] 2-touch Rotation - [ ] 2-touch Rotation
- [ ] Spinning Frame - [ ] Spinning Frame
- [ ] Feature: Undo - [ ] Feature: Undo
- [ ] Undo "Clear Traces" Action - [ ] Undo "Clear Traces" Action
- [ ] In general have a user action history - [ ] In general have a user action history
- [ ] Point-in-time restoral of world state (Marked points, i.e. Snapshots) - [ ] Point-in-time restoral of world state (Marked points, i.e. Snapshots)
- [ ] Feature: Time Control: Reverse Time - [ ] Feature: Time Control: Reverse Time
- [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow) - [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow)
- [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y) - [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y)
- [x] Task: Verify stationary pointer leads to zero pointer velocity - [x] Task: Verify stationary pointer leads to zero pointer velocity
- [ ] Fix: Unpause panning when initiated while sim is paused

View File

@ -8,6 +8,11 @@ export const ACCELERATION_VECTOR_COLOR = 'rgba(0, 128, 0, 0.8)'; // optionally s
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;
// SELECTION
export const SELECTED_GROUP_COLOR = 'rgba(0, 255, 0, 0.50)';
export const SELECTED_SINGLE_COLOR = 'rgba(0, 255, 0, 1.0)';
export const SELECTED_MARGIN = 5;
// PATH TRACES // PATH TRACES
export const PATH_TRACES_COLOR = 'object color'; export const PATH_TRACES_COLOR = 'object color';
export const PATH_TRACES_OPACITY = 0.8; export const PATH_TRACES_OPACITY = 0.8;
@ -45,6 +50,7 @@ export const EVENT_MODE_LEAVE = 'lhg-mode-leave';
export const EVENT_MODE_ENTER = 'lhg-mode-enter'; export const EVENT_MODE_ENTER = 'lhg-mode-enter';
export const EVENT_ZOOM = 'lhg-zoom-event'; export const EVENT_ZOOM = 'lhg-zoom-event';
export const EVENT_OPTION_SET = 'lhg-option-set'; export const EVENT_OPTION_SET = 'lhg-option-set';
export const EVENT_PLAY_PAUSE = 'lhg-play-pause';
// MODES // MODES
export const MODE_MASS_GENERATION = 'mass-gen'; export const MODE_MASS_GENERATION = 'mass-gen';

View File

@ -4,59 +4,23 @@ import {
} from './config.js'; } from './config.js';
export class Display { export class Display {
sim = undefined;
scalePower = 0; scalePower = 0;
viewOrigin = {x: 0, y: 0}; viewOrigin = {x: 0, y: 0};
constructor(sim) { constructor(sim) {
this.sim = sim;
// Create canvas that fills the window
// If the window resizes, also resize the canvas
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
this.canvas = canvas; this.canvas = canvas;
this.sim.div.appendChild(canvas);
canvas.style.position = 'absolute'; if (sim) {
canvas.style.top = 0; // Create canvas that fills the window
canvas.style.left = 0; // If the window resizes, also resize the canvas
sim.div.appendChild(canvas);
this.fullscreen(); this.fullscreen();
window.addEventListener('resize', () => this.fullscreen()); window.addEventListener('resize', () => this.fullscreen());
} }
frame(elapsedTime) {
const {
touchStart: start,
touchLatest: latest,
} = this.sim.pointer ?? {};
// Direct translate, unless using fast panning
if (start && latest && !this.sim.getOption('compensate.fastPanning')) {
this.viewOrigin.x = start.viewOrigin.x - (latest.x - start.x) / this.scale;
this.viewOrigin.y = start.viewOrigin.y - (latest.y - start.y) / this.scale;
}
// Apply update to viewOrigin based on panning
if (this.sim.panning && !this.sim.panning.paused) {
// elapsedTime is scaled by time scale, is that what we want?
// Yes because if panning.velocity == obj.velocity, object should stay in view
this.viewOrigin.x += this.sim.panning.velocity.x * elapsedTime;
this.viewOrigin.y += this.sim.panning.velocity.y * elapsedTime;
}
// Update what's considered start
if (start && latest) {
start.viewOrigin = {...this.viewOrigin};
start.x = latest.x;
start.y = latest.y;
}
if (this.sim.getOption('debug.panningInfo')) {
const {x, y} = this.sim.panning?.velocity ?? {};
this.sim.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)];
const {centerOfMass} = this.sim.system.computeSystemCenter();
this.sim.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)];
this.sim.info['Net Angular Momentum'] = this.sim.system.computeSystemAngularMomentum().toPrecision(6);
} }
frame() {
// Clear canvas in preparation for other modules to render this frame // Clear canvas in preparation for other modules to render this frame
this.fillCanvas(); this.fillCanvas();
} }

245
object.js
View File

@ -9,10 +9,14 @@ import {
PATH_TRACES_DASHED_OPACITY, PATH_TRACES_DASHED_OPACITY,
PATH_TRACES_OPACITY, PATH_TRACES_OPACITY,
PATH_TRACES_WIDTH, PATH_TRACES_WIDTH,
SELECTED_GROUP_COLOR,
SELECTED_MARGIN,
SELECTED_SINGLE_COLOR,
VELOCITY_VECTOR_ARROWHEAD, VELOCITY_VECTOR_ARROWHEAD,
VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_WIDTH, VELOCITY_VECTOR_WIDTH,
} from './config.js'; } from './config.js';
import {add, components, direction, div, magnitude, zero} from './vector.js';
export class MassObject { export class MassObject {
sim = undefined; sim = undefined;
@ -20,8 +24,8 @@ export class MassObject {
mass = 0; mass = 0;
density = 1; density = 1;
position = {x: undefined, y: undefined}; position = {x: undefined, y: undefined};
velocity = {x: 0, y: 0}; velocity = zero;
acceleration = {x: 0, y: 0}; acceleration = zero;
color = {r: undefined, g: undefined, b: undefined}; color = {r: undefined, g: undefined, b: undefined};
created = undefined; created = undefined;
forces = []; // [{x, y}] forces = []; // [{x, y}]
@ -57,46 +61,21 @@ export class MassObject {
} }
getAcceleration() { getAcceleration() {
let ax = 0; const netForce = this.forces.reduce((t, f) => add(t, f), zero);
let ay = 0; return div(netForce, this.mass);
for (let {x, y} of this.forces) {
ax += x;
ay += y;
}
return {
x: ax / this.mass,
y: ay / this.mass,
};
} }
drawObject(sim) { drawPath(display) {
const { const {ctx} = display;
color: {r, g, b}, const {color: {r, g, b}} = this;
position: {x, y},
velocity: {x: vx, y: vy},
acceleration,
radius,
} = this;
const {
display: {
ctx,
height: H,
width: W,
viewOrigin: {x: ox, y: oy},
}
} = sim;
const cx = ox + W / 2;
const cy = oy + H / 2;
// Draw path traces // Draw path traces
if (sim.getOption('display.traces') && this.history?.length) { if (this.sim.getOption('display.traces') && this.history?.length) {
const dashedTraces = sim.getOption('display.dashedTraces'); const dashedTraces = this.sim.getOption('display.dashedTraces');
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.sim.display.scale; ctx.lineWidth = PATH_TRACES_WIDTH / 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++) {
@ -115,6 +94,114 @@ export class MassObject {
} }
ctx.stroke(); ctx.stroke();
} }
}
drawArrows(display) {
const {
color: {r, g, b},
position: {x, y},
velocity: {x: vx, y: vy},
acceleration,
radius,
} = this;
const {
height: H,
width: W,
viewOrigin: {x: ox, y: oy},
} = display;
const cx = ox + W / 2;
const cy = oy + H / 2;
if (!this.alive) return;
// If the object is outside the display area, skip drawing arrows
if (Math.abs(x - cx) - radius >= W / 2 ||
Math.abs(y - cy) - radius >= H / 2) {
return;
}
// Draw arrow for the velocity
if (this.sim.getOption('display.velocity')) {
const vecScale = this.sim.getOption('display.velocityScale');
const selected = this.sim.system.getSelectedOrCreating();
const isSelected = selected?.id === this.id;
let velocity = {x: vx, y: vy};
if (isSelected) {
// If this object is being dragged by the user,
// show the pointer velocity instead of object velocity
let pointerV = this.sim.pointer.latestVelocity;
if (this.sim.getOption('compensate.timeScale')) {
pointerV = div(pointerV, this.sim.timeScale);
}
velocity = add(velocity, pointerV);
}
const speed = magnitude(velocity);
const arrowDirection = direction(velocity);
// Prevent negative numbers by adding 1
// Make arrow lengths appear consistent regardless of display scale
let arrowLength = Math.log10(speed + 1) * vecScale;
if (!this.sim.getOption('display.zoomVectors')) {
arrowLength /= display.scale;
}
// const offset = mult({x: Math.cos(
const offset = components(radius, arrowDirection)
const start = add({x, y}, offset);
const end = add(start, components(arrowLength, arrowDirection));
const style = VELOCITY_VECTOR_COLOR === 'object color' ?
`rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR;
display.drawArrow(start.x, start.y, end.x, end.y, {
style,
width: VELOCITY_VECTOR_WIDTH,
arrowhead: VELOCITY_VECTOR_ARROWHEAD,
fill: false,
ifShort: 'head'
});
}
// Draw arrow for acceleration
if (this.sim.getOption('display.acceleration')) {
const vecScale = this.sim.getOption('display.accelerationScale');
const accel = magnitude(acceleration);
const arrowDirection = direction(acceleration);
// Prevent negative numbers by adding 1
// Make arrow lengths appear consistent regardless of display scale
let arrowLength = Math.log10(accel + 1) * vecScale;
if (!this.sim.getOption('display.zoomVectors')) {
arrowLength /= this.sim.display.scale;
}
const offset = components(radius, arrowDirection)
const start = add({x, y}, offset);
const end = add(start, components(arrowLength, arrowDirection));
const style = ACCELERATION_VECTOR_COLOR === 'object color' ?
`rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR;
display.drawArrow(start.x, start.y, end.x, end.y, {
style,
width: ACCELERATION_VECTOR_WIDTH,
arrowhead: ACCELERATION_VECTOR_ARROWHEAD,
fill: false,
ifShort: 'tail'
});
}
}
drawObject(display) {
const {
color: {r, g, b},
position: {x, y},
radius,
} = this;
const {
ctx,
height: H,
width: W,
viewOrigin: {x: ox, y: oy},
} = display;
const cx = ox + W / 2;
const cy = oy + H / 2;
if (!this.alive) return; if (!this.alive) return;
@ -152,11 +239,11 @@ 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); const d = Math.sqrt((x - px) ** 2, (y - py) ** 2);
const arrowLength = Math.log(distance + 1) * OFFSCREEN_OBJECT_LINE_SCALE / this.sim.display.scale; const arrowLength = Math.log(d + 1) * OFFSCREEN_OBJECT_LINE_SCALE / 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, { display.drawArrow(startAx, startAy, px, py, {
style: `rgb(${r}, ${g}, ${b})`, style: `rgb(${r}, ${g}, ${b})`,
width: OFFSCREEN_OBJECT_LINE_WIDTH, width: OFFSCREEN_OBJECT_LINE_WIDTH,
arrowheadLength: OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, arrowheadLength: OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
@ -172,74 +259,26 @@ export class MassObject {
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI); ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
// Draw arrow for the velocity
if (sim.getOption('display.velocity')) {
// If this object is being dragged by the user,
// show the pointer velocity instead of object velocity
const vecScale = this.sim.getOption('display.velocityScale');
const selected = this.sim.system.getSelectedOrCreating();
const isSelected = selected?.id === this.id;
let velocity = {x: vx, y: vy};
if (isSelected) {
const pointerV = this.sim.pointer.latestVelocity;
// const panning = this.sim.panning?.velocity ?? {x: 0, y: 0};
// velocity.x = vx + (pointerV.x + panning.x) * scale;
// velocity.y = vy + (pointerV.y + panning.y) * scale;
if (this.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;
const offsetX = radius * Math.cos(arrowDirection);
const offsetY = radius * Math.sin(arrowDirection);
const startVx = x + offsetX;
const startVy = y + offsetY;
const endVx = startVx + arrowLength * Math.cos(arrowDirection);
const endVy = startVy + arrowLength * Math.sin(arrowDirection);
const style = VELOCITY_VECTOR_COLOR === 'object color' ?
`rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR;
sim.display.drawArrow(startVx, startVy, endVx, endVy, {
style,
width: VELOCITY_VECTOR_WIDTH,
arrowhead: VELOCITY_VECTOR_ARROWHEAD,
fill: false,
ifShort: 'head'
});
} }
// Draw arrow for acceleration drawSelection(display) {
if (sim.getOption('display.acceleration')) { const {
const vecScale = this.sim.getOption('display.accelerationScale'); position: {x, y},
const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2) / radius,
this.sim.display.scale; } = this;
const arrowDirection = Math.atan2(acceleration.y, acceleration.x); const {ctx} = display;
// Prevent negative numbers by adding 1
const arrowLength = Math.log10(accelerationMagnitude + 1) * vecScale;
//const arrowLength = accelerationMagnitude * vecScale;
const offsetX = radius * Math.cos(arrowDirection);
const offsetY = radius * Math.sin(arrowDirection);
const startAx = x + offsetX;
const startAy = y + offsetY;
const endAx = startAx + arrowLength * Math.cos(arrowDirection);
const endAy = startAy + arrowLength * Math.sin(arrowDirection);
const style = ACCELERATION_VECTOR_COLOR === 'object color' ?
`rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR;
sim.display.drawArrow(startAx, startAy, endAx, endAy, {
style,
width: ACCELERATION_VECTOR_WIDTH,
arrowhead: ACCELERATION_VECTOR_ARROWHEAD,
fill: false,
ifShort: 'tail'
});
}
} if (!this.alive) return;
// If object is currently selected, draw a circle around it
const isInSelectedGroup = this.sim.select.selectedGroup?.some(({id}) => id === this.id);
const isSelectedSingle = this.sim.select.selectedSingle?.id === this.id;
if (isSelectedSingle || isInSelectedGroup) {
ctx.strokeStyle = isSelectedSingle ? SELECTED_SINGLE_COLOR : SELECTED_GROUP_COLOR;
ctx.beginPath();
ctx.arc(x, y, radius + SELECTED_MARGIN / display.scale, 0, 2 * Math.PI);
ctx.stroke();
}
}
} }

View File

@ -1,129 +0,0 @@
import {
EVENT_OPTION_SET,
OBJECT_MAGIC_PROP_PREFIX,
} from './config.js';
export class Options {
sim = undefined;
options = undefined;
values = {};
undefinedObj = {[OBJECT_MAGIC_PROP_PREFIX + 'undefined']: true};
nullObj = {[OBJECT_MAGIC_PROP_PREFIX + 'null']: true};
getStorageKey(path) {
return `${path}:options`;
}
constructor(sim, options) {
this.sim = sim;
this.options = options;
// Global methods to get/set current option values
this.sim.getOption = (path) => this.getOption(path);
this.sim.setOption = (path, value) => this.setOption(path, value);
this.sim.onOptionSet = (path, cb) => this.onOptionSet(path, cb);
// Initialize values from localStorage
for (const groupName of Object.keys(options)) {
for (const [name, [, , defaultValue]] of Object.entries(this.options[groupName])) {
const path = [groupName, name].join('.');
let value = this.getFromLocalStorage(path);
if (value === undefined) {
value = defaultValue;
}
this.values[path] = value;
}
}
}
toStored(value) {
if (value === undefined) {
// Do we want to interpret this as removing from storage?
// Let's just treat it as a value for now;
// Semantically it works because when retrieved, it will return undefined,
// which is the same result you get if the key is not set
return JSON.stringify(this.undefinedObj);
} else if (value === null) {
return JSON.stringify(this.nullObj);
}
return JSON.stringify(value);
}
// value: string
fromStored(value) {
if (value === null) {
return undefined;
} else if (value === JSON.stringify(this.undefinedObj)) {
return undefined;
} else if (value === JSON.stringify(this.nullObj)) {
return null;
}
return JSON.parse(value);
}
getFromLocalStorage(path) {
const storageKey = this.getStorageKey(path);
const value = this.fromStored(window.localStorage.getItem(storageKey));
this.values[path] = value;
return value;
}
getOption(path) {
const [groupName, name] = path.split('.');
const group = this.options[groupName];
const item = group[name];
const {type} = item;
const value = this.values[path];
switch (type) {
case 'number': return Number(value);
case 'boolean': return value === true || value === 'true';
default: {
console.log({
path, groupName, name,
group, item,
type, value
});
throw new Error('unknown option type');
}
}
}
setOption(path, value) {
this.values[path] = value;
const storageKey = this.getStorageKey(path);
window.localStorage.setItem(storageKey, this.toStored(value));
const e = new CustomEvent(EVENT_OPTION_SET, {detail: {path, value}});
this.sim.div.dispatchEvent(e);
}
// cb: (value) => undefined
onOptionSet(path, cb) {
this.sim.div.addEventListener(EVENT_OPTION_SET, (e) => {
if (path === e.detail.path) {
cb(e.detail.value);
}
});
}
getSection(sectionName) {
const section = this.options[sectionName];
const group = {
type: 'group',
name: sectionName,
title: section._title,
items: [],
};
for (const name in section) {
if (name.startsWith('_')) continue;
const [title, type, defaultValue, opts] = section[name];
group.items.push({
name,
type,
title,
default: defaultValue,
...opts
})
}
return group;
}
}

View File

@ -96,11 +96,11 @@ export class Options {
this.sim.div.dispatchEvent(e); this.sim.div.dispatchEvent(e);
} }
// cb: (value) => undefined // cb: ({path, value}) => undefined
onOptionSet(path, cb) { onOptionSet(path, cb) {
this.sim.div.addEventListener(EVENT_OPTION_SET, (e) => { this.sim.div.addEventListener(EVENT_OPTION_SET, (e) => {
if (path === e.detail.path) { if (!path || path === e.detail.path) {
cb(e.detail.value); cb({ path, value: e.detail.value });
} }
}); });
} }

117
panning.js Normal file
View File

@ -0,0 +1,117 @@
import {add, copy, div, mult, sub, zero} from "./vector.js";
export class Panning {
sim = undefined;
touchStart = undefined;
touchLatest = undefined;
paused = false;
velocity = zero;
constructor(sim) {
this.sim = sim;
}
handlePointerDown({x, y}) {
this.initializeTouch({x, y});
}
initializeTouch({x, y}) {
this.touchStart = {
x,
y,
t: this.sim.rawTime,
viewOrigin: copy(this.sim.display.viewOrigin),
};
this.touchLatest = {
...this.touchStart,
dx: 0,
dy: 0,
dt: 0,
};
}
// With fast panning, panning velocity calculation happens every move;
// With normal panning, calculation only happens at pointer up.
handlePointerMove({x, y}) {
if (this.touchStart) {
this.touchLatest = {
x,
y,
t: this.sim.rawTime,
dx: x - this.touchStart.x,
dy: x - this.touchStart.y,
dt: this.sim.rawTime - this.touchStart.t,
};
if (this.sim.getOption('compensate.fastPanning')) {
this.updateVelocity();
}
}
}
handlePointerUp() {
if (this.touchStart && this.touchLatest) {
if (this.touchLatest.dt === 0) {
this.velocity = zero;
}
this.touchStart = undefined;
if (this.sim.getOption('compensate.fastPanning')) {
this.velocity = zero;
} else {
this.updateVelocity();
}
}
}
frame(elapsedTime) {
const {touchStart: start, touchLatest: latest} = this;
const {display} = this.sim;
// Direct translate, unless using fast panning
if (start && latest && !this.sim.getOption('compensate.fastPanning')) {
// start and latest are in screen coordinates, need to convert to sim scale
const delta = div(sub(latest, start), display.scale);
display.viewOrigin = sub(start.viewOrigin, delta);
}
// Apply update to viewOrigin based on panning
if (!this.paused) {
// elapsedTime is scaled by time scale, is that what we want?
// Yes because if panning.velocity == obj.velocity, object should stay in view
const delta = mult(this.velocity, elapsedTime);
display.viewOrigin = add(display.viewOrigin, delta);
}
// Update what's considered start
if (start && latest) {
this.initializeTouch(this.touchLatest);
}
if (this.sim.getOption('debug.panningInfo')) {
const {x, y} = this.sim.panning?.velocity ?? {};
this.sim.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)];
const {centerOfMass} = this.sim.system.computeSystemCenter();
this.sim.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)];
this.sim.info['Net Angular Momentum'] = this.sim.system.computeSystemAngularMomentum().toPrecision(6);
}
}
updateVelocity() {
// Convert pointer velocity to simulation scale, and multiply by -1
// because the camera is panning opposite to the pointer velocity.
let velocity = div(this.sim.pointer.latestVelocity, -this.sim.display.scale);
if (this.sim.getOption('compensate.timeScale')) {
velocity = div(velocity, this.sim.timeScale);
}
// Also add current panning
velocity = add(velocity, this.velocity);
this.velocity = velocity;
}
setVelocity(velocity) {
this.velocity = velocity;
if (!this.sim.playing) {
this.paused = true;
}
}
}

View File

@ -55,10 +55,16 @@ export class Pointer {
const {x, y} = this.sim.screenToSim(e.clientX, e.clientY); const {x, y} = this.sim.screenToSim(e.clientX, e.clientY);
this.sim.scheduleZoom({x, y}, factor); this.sim.scheduleZoom({x, y}, factor);
}); });
el.addEventListener('focus', () => {
console.log('window focus');
});
el.addEventListener('blur', () => {
console.log('window blur');
});
} }
handlePointerDown({x: clientX, y: clientY}) { handlePointerDown({x: clientX, y: clientY}) {
// this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE);
this.updatePointer({x: clientX, y: clientY}); this.updatePointer({x: clientX, y: clientY});
switch (this.sim.getCurrentMode()) { switch (this.sim.getCurrentMode()) {
@ -68,19 +74,7 @@ export class Pointer {
break; break;
} }
case MODE_PAN_VIEW: { case MODE_PAN_VIEW: {
this.touchStart = { this.sim.panning.handlePointerDown({x: clientX, y: clientY});
x: clientX,
y: clientY,
t: this.sim.rawTime,
viewOrigin: {...this.sim.display.viewOrigin},
};
// Since we've processed this increment, reset
this.touchLatest = {
...this.touchStart,
dx: 0,
dy: 0,
dt: 0,
};
break; break;
} }
case MODE_OBJECT_SELECT: { case MODE_OBJECT_SELECT: {
@ -102,41 +96,7 @@ export class Pointer {
break; break;
} }
case MODE_PAN_VIEW: { case MODE_PAN_VIEW: {
if (this.touchStart) { this.sim.panning.handlePointerMove({x: clientX, y: clientY});
this.touchLatest = {
x: clientX,
y: clientY,
t: this.sim.rawTime,
dx: clientX - this.touchStart.x,
dy: clientY - this.touchStart.y,
dt: this.sim.rawTime - this.touchStart.t,
};
// With fast panning, this calculation happens every move
// With normal panning, this calculation only happens at pointer up
if (this.sim.getOption('compensate.fastPanning')) {
const panning = {...this.latestVelocity};
// Convert pointer velocity to simulation scale.
// Also multiply by -1 because the camera is
// panning opposite to the pointer velocity.
panning.x /= -this.sim.display.scale;
panning.y /= -this.sim.display.scale;
if (this.sim.getOption('compensate.timeScale')) {
panning.x /= this.sim.timeScale;
panning.y /= this.sim.timeScale;
}
// Also add current panning
panning.x += this.sim.panning?.velocity.x ?? 0;
panning.y += this.sim.panning?.velocity.y ?? 0;
this.sim.panning = {
velocity: panning
};
}
}
break; break;
} }
case MODE_OBJECT_SELECT: { case MODE_OBJECT_SELECT: {
@ -154,37 +114,7 @@ export class Pointer {
break; break;
} }
case MODE_PAN_VIEW: { case MODE_PAN_VIEW: {
// Set panning velocity this.sim.panning.handlePointerUp({x: clientX, y: clientY});
if (this.touchStart && this.touchLatest) {
if (this.touchLatest.dt === 0) {
this.sim.panning = undefined;
}
this.touchStart = undefined;
if (this.sim.getOption('compensate.fastPanning')) {
this.sim.panning = undefined;
} else {
const panning = {...this.latestVelocity};
// Convert pointer velocity to simulation scale.
// Also multiply by -1 because the camera is
// panning opposite to the pointer velocity.
panning.x /= -this.sim.display.scale;
panning.y /= -this.sim.display.scale;
if (this.sim.getOption('compensate.timeScale')) {
panning.x /= this.sim.timeScale;
panning.y /= this.sim.timeScale;
}
// Also add current panning
panning.x += this.sim.panning?.velocity.x ?? 0;
panning.y += this.sim.panning?.velocity.y ?? 0;
this.sim.panning = {
velocity: panning
};
}
}
break; break;
} }
case MODE_OBJECT_SELECT: { case MODE_OBJECT_SELECT: {
@ -219,11 +149,6 @@ export class Pointer {
}; };
} }
// Keep the specified number of entries at the end of the array (most recent)
clearPointerHistory(keep = 0) {
this.pointerHistory.splice(keep, this.pointerHistory.length - keep)
}
updatePointer({x, y}) { updatePointer({x, y}) {
const t = this.sim.rawTime; const t = this.sim.rawTime;
while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) { while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) {

View File

@ -1,33 +1,74 @@
import {copy} from './vector.js';
export class Select { export class Select {
sim = undefined; sim = undefined;
box = { box = {
start: undefined, start: undefined,
end: undefined, end: undefined,
}; };
selectedSingle = undefined;
selectedGroup = [];
constructor(sim) { constructor(sim) {
this.sim = sim; this.sim = sim;
// TODO: Move this to a new Keyboard class singleton
window.addEventListener('keydown', (e) => {
switch (e.key) {
case 'Tab': {
e.preventDefault();
if (!this.selectedGroup.length) return;
const currentIdx = this.selectedGroup.indexOf(this.selectedSingle);
const newIdx = (currentIdx + 1) % this.selectedGroup.length;
this.selectedSingle = this.selectedGroup[newIdx];
break;
}
}
});
} }
handlePointerDown({x: clientX, y: clientY}) { handlePointerDown({x: clientX, y: clientY}) {
this.box.start = this.sim.screenToSim(clientX, clientY); this.box.start = this.sim.screenToSim(clientX, clientY);
this.box.end = this.box.start;
this.getSelectedObjects();
} }
handlePointerMove({x: clientX, y: clientY}) { handlePointerMove({x: clientX, y: clientY}) {
if (!this.box.start) return;
this.box.end = this.sim.screenToSim(clientX, clientY); this.box.end = this.sim.screenToSim(clientX, clientY);
} }
handlePointerUp() { handlePointerUp() {
console.log('selection box', {...this.box}); if (!this.box.start) return;
const start = copy(this.box.start);
const end = copy(this.box.end);
this.box.start = {
x: Math.min(start.x, end.x),
y: Math.min(start.y, end.y),
};
this.box.end = {
x: Math.max(start.x, end.x),
y: Math.max(start.y, end.y),
};
this.getSelectedObjects();
this.box = { this.box = {
start: undefined, start: undefined,
end: undefined, end: undefined,
}; };
} }
getSelectedObjects() {
const {start, end} = this.box;
if (!start) return;
this.selectedGroup = this.sim.system.filter(({position: {x, y}}) => {
return x >= start.x && x <= end.x && y >= start.y && y <= end.y;
});
// For now, first object in group is selected single
this.selectedSingle = this.selectedGroup[0] ?? undefined;
}
frame() { frame() {
if (!this.box.start) return; if (!this.box.start) return;
this.sim.display.drawBox(this.box) this.sim.display.drawBox(this.box)
} }
} }

View File

@ -4,13 +4,14 @@ export const simOptions = {
selection: ['Pause While Selecting', 'boolean', true], selection: ['Pause While Selecting', 'boolean', true],
}, },
display: { display: {
traces: ['Path Traces', 'boolean', true], targetFrameRate: ['Frame Rate', 'number', 60],
dashedTraces: ['Dashed', 'boolean', false, {tall: true}],
velocity: ['Velocity Vectors', 'boolean', true], velocity: ['Velocity Vectors', 'boolean', true],
acceleration: ['Accel Vectors', 'boolean', true], acceleration: ['Accel Vectors', 'boolean', true],
traces: ['Path Traces', 'boolean', true],
dashedTraces: ['Dashed', 'boolean', false, {tall: true, hideUnless: 'display.traces'}],
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: ['Frame Rate', 'number', 60], zoomVectors: ['Zoom Vectors', 'boolean', true]
}, },
compensate: { compensate: {
timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}], timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}],

View File

@ -1,4 +1,6 @@
import {ModeSwitch} from './tool/modes.js'; import {ModeSwitch} from './tool/modes.js';
import {ObjectTool} from './tool/object.js';
import {ObjectsTool} from './tool/objects.js';
import {OptionsTool} from './tool/options.js'; import {OptionsTool} from './tool/options.js';
import {PlayPause} from './tool/play-pause.js'; import {PlayPause} from './tool/play-pause.js';
import {UtilityTool} from './tool/utility.js'; import {UtilityTool} from './tool/utility.js';
@ -7,43 +9,40 @@ import {ToolbarGroup} from './toolbar-group.js';
import {Toolbar} from './toolbar.js'; import {Toolbar} from './toolbar.js';
export function initializeTools(sim) { export function initializeTools(sim) {
sim.toolbars = { sim.toolbarGroups = {
tools: new Toolbar(sim, 'Tools') left: new ToolbarGroup(sim)
.addToolbar(new Toolbar(sim, 'Tools')
.addTool(new Zoom()) .addTool(new Zoom())
.addTool(new PlayPause()), .addTool(new PlayPause())
modes: new Toolbar(sim, 'Modes') )
.addTool(new ModeSwitch()), .addToolbar(new Toolbar(sim, 'Modes')
utils: new Toolbar(sim, 'Utils', {expanded: false}) .addTool(new ModeSwitch())
.addTool(new UtilityTool()), )
options: new Toolbar(sim, 'Options') .addToolbar(new Toolbar(sim, 'Utils', {expanded: false})
.addTool(new OptionsTool([ .addTool(new UtilityTool())
'pauseDuring', )
'display', .addToolbar(new Toolbar(sim, 'Objects')
'compensate', .addTool(new ObjectsTool()),
])), ),
params: new Toolbar(sim, 'Params') right: new ToolbarGroup(sim).topRight()
.addToolbar(new Toolbar(sim, 'Options')
.addTool(new OptionsTool(['pauseDuring', 'display', 'compensate']))
)
.addToolbar(new Toolbar(sim, 'Params')
.addTool(new OptionsTool([ .addTool(new OptionsTool([
'param' 'param'
])), ])),
debug: new Toolbar(sim, 'Debug', {expanded: false}) )
.addTool(new OptionsTool([ .addToolbar(new Toolbar(sim, 'Debug', {expanded: false})
'debug' .addTool(new OptionsTool(['debug'])),
])), )
}; .addToolbar(new Toolbar(sim, 'Object')
const {tools, modes, options, params, debug, utils} = sim.toolbars; .addTool(new ObjectTool()),
sim.toolbarGroups = { ),
left: new ToolbarGroup(sim)
.addToolbar(tools)
.addToolbar(modes)
.addToolbar(utils),
right: new ToolbarGroup(sim).topRight()
.addToolbar(options)
.addToolbar(params)
.addToolbar(debug),
}; };
for (const id in sim.toolbars) { for (const groupName in sim.toolbarGroups) {
const toolbar = sim.toolbars[id]; const group = sim.toolbarGroups[groupName];
toolbar.applyExpanded(); group.applyExpanded();
} }
} }

View File

@ -1,4 +1,5 @@
import { import {
EVENT_PLAY_PAUSE,
EVENT_ZOOM, EVENT_ZOOM,
FRAMERATE_SAMPLE_DURATION, FRAMERATE_SAMPLE_DURATION,
} from './config.js'; } from './config.js';
@ -8,6 +9,7 @@ import {Overlay} from './overlay.js';
import {Pointer} from './pointer.js'; import {Pointer} from './pointer.js';
import {Options} from './options.js'; import {Options} from './options.js';
import {Zoom} from './zoom.js'; import {Zoom} from './zoom.js';
import {Panning} from './panning.js';
import {Select} from './select.js'; import {Select} from './select.js';
import {simOptions} from './sim-options.js'; import {simOptions} from './sim-options.js';
import {initializeTools} from './sim-tools.js'; import {initializeTools} from './sim-tools.js';
@ -20,7 +22,6 @@ export class Sim {
nextZoom = undefined; nextZoom = undefined;
playing = true; playing = true;
recentFrames = []; recentFrames = [];
panning = undefined;
frameRate = 0; frameRate = 0;
system = undefined; system = undefined;
@ -28,6 +29,7 @@ export class Sim {
overlay = undefined; overlay = undefined;
pointer = undefined; pointer = undefined;
zoom = undefined; zoom = undefined;
panning = undefined;
select = undefined; select = undefined;
toolbarGroups = {}; toolbarGroups = {};
@ -52,6 +54,7 @@ export class Sim {
this.overlay = new Overlay(this); this.overlay = new Overlay(this);
this.pointer = new Pointer(this); this.pointer = new Pointer(this);
this.zoom = new Zoom(this); this.zoom = new Zoom(this);
this.panning = new Panning(this);
this.select = new Select(this); this.select = new Select(this);
initializeTools(this); initializeTools(this);
@ -82,14 +85,15 @@ export class Sim {
if (this.getOption('debug.frameRate')) { if (this.getOption('debug.frameRate')) {
this.info['Frame Rate'] = this.frameRate.toPrecision(3); this.info['Frame Rate'] = this.frameRate.toPrecision(3);
} }
this.zoom.frame(); this.zoom.frame(elapsedTime);
this.pointer.frame(); this.panning.frame(elapsedTime);
this.pointer.frame(elapsedTime);
this.display.frame(elapsedTime); this.display.frame(elapsedTime);
this.select.frame(); this.select.frame(elapsedTime);
this.system.frame(elapsedTime); this.system.frame(elapsedTime);
this.overlay.frame(); this.overlay.frame(elapsedTime);
for (const group in this.toolbarGroups) { for (const group in this.toolbarGroups) {
this.toolbarGroups[group].frame(); this.toolbarGroups[group].frame(elapsedTime);
} }
requestAnimationFrame(t => this.frame(t)); requestAnimationFrame(t => this.frame(t));
} }
@ -131,9 +135,12 @@ export class Sim {
play() { play() {
this.playing = true; this.playing = true;
if (this.panning?.paused) { if (this.panning.paused) {
this.panning.paused = false; this.panning.paused = false;
} }
const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}});
this.div.dispatchEvent(e);
} }
pause() { pause() {
@ -142,6 +149,9 @@ export class Sim {
if (this.panning?.velocity) { if (this.panning?.velocity) {
this.panning.paused = true; this.panning.paused = true;
} }
const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}});
this.div.dispatchEvent(e);
} }
getScaleDisplay() { getScaleDisplay() {

View File

@ -27,6 +27,12 @@ div[id=simulator] {
width: 100%; width: 100%;
} }
div[id=simulator] > canvas {
position: absolute;
top: 0;
left: 0;
}
/* normal toolbar group */ /* normal toolbar group */
div.lhg-toolbar-group div.lhg-tool { div.lhg-toolbar-group div.lhg-tool {
width: 12em; width: 12em;
@ -138,10 +144,3 @@ div.lhg-option-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
} }
/*
div.lhg-option-group > * {
display: inline-block;
vertical-align: top;
}
*/

129
system.js
View File

@ -1,5 +1,10 @@
import {OBJECT_HISTORY_SIZE} from './config.js'; import {OBJECT_HISTORY_SIZE} from './config.js';
import {MassObject} from './object.js'; import {MassObject} from './object.js';
import {
add, copy, cross, degrees,
direction, div, magnitude, mult,
square, sub, weightedAvg, zero
} from './vector.js';
export class System { export class System {
objects = []; objects = [];
@ -31,41 +36,29 @@ export class System {
this.doneCreatingObject(); this.doneCreatingObject();
this.deselect(); this.deselect();
// Convert pointer velocity to simulation scale // Convert pointer velocity to simulation scale
const pointer = {...this.sim.pointer.latestVelocity}; obj.velocity = div(this.sim.pointer.latestVelocity, this.sim.display.scale);
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 // Including time scale - if time is slow, our motion is relatively faster
if (this.sim.getOption('compensate.timeScale')) { if (this.sim.getOption('compensate.timeScale')) {
obj.velocity.x /= this.sim.timeScale; obj.velocity = div(obj.velocity, this.sim.timeScale);
obj.velocity.y /= this.sim.timeScale;
} }
if (this.sim.panning?.velocity) { obj.velocity = add(obj.velocity, this.sim.panning.velocity);
obj.velocity.x += this.sim.panning.velocity.x;
obj.velocity.y += this.sim.panning.velocity.y;
}
} }
handlePointerMove({x, y}) { handlePointerMove(r) {
// 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 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;
const start = this.selectedObjectStart; const start = this.selectedObjectStart;
obj.position.x = start.x + (x - start.pointer.x); obj.position = add(start, sub(r, start.pointer));
obj.position.y = start.y + (y - start.pointer.y); obj.velocity = zero;
obj.velocity.x = 0;
obj.velocity.y = 0;
} }
// elapsedTime is given in milliseconds
frame(elapsedTime) { frame(elapsedTime) {
// 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
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'); let massCreationRate = this.sim.getOption('param.massCreationRate');
@ -90,10 +83,13 @@ export class System {
return; return;
} }
obj.position.x += elapsedTime * obj.position = add(obj.position, mult(
(obj.velocity.x + 1 / 2 * obj.currentAcceleration.x * elapsedTime); elapsedTime,
obj.position.y += elapsedTime * add(
(obj.velocity.y + 1 / 2 * obj.currentAcceleration.y * elapsedTime); obj.velocity,
mult(obj.currentAcceleration, elapsedTime / 2)
),
));
}); });
// Collisions // Collisions
@ -125,12 +121,10 @@ export class System {
// Set position = center of mass // Set position = center of mass
// Set velocity = total momentum / total mass // Set velocity = total momentum / total mass
// Combine forces // Combine forces
// Sdd masses // Add masses
// Sverage color // Average color
S.position.x = (S.position.x * S.mass + T.position.x * T.mass) / (S.mass + T.mass); S.position = weightedAvg([[S.position, S.mass], [T.position, T.mass]]);
S.position.y = (S.position.y * S.mass + T.position.y * T.mass) / (S.mass + T.mass); S.velocity = weightedAvg([[S.velocity, S.mass], [T.velocity, 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.forces.push(...T.forces);
S.mass += T.mass; S.mass += T.mass;
S.color = { S.color = {
@ -150,12 +144,8 @@ export class System {
// Predict velocities // Predict velocities
this.forEachObject(obj => { this.forEachObject(obj => {
const acceleration = {...obj.acceleration}; const acceleration = {...obj.acceleration};
obj.acceleration = { obj.acceleration = div(add(obj.currentAcceleration, acceleration), 2);
x: (obj.currentAcceleration.x + acceleration.x) / 2, obj.velocity = add(obj.velocity, mult(obj.acceleration, elapsedTime));
y: (obj.currentAcceleration.y + acceleration.y) / 2,
};
obj.velocity.x += obj.acceleration.x * elapsedTime;
obj.velocity.y += obj.acceleration.y * elapsedTime;
// Append to object history // Append to object history
obj.history.push({position: {...obj.position}}); obj.history.push({position: {...obj.position}});
@ -177,18 +167,18 @@ export class System {
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 = magnitude(obj.velocity);
const accel = Math.pow(obj.acceleration.x ** 2 + obj.acceleration.y ** 2, 1 / 2); const accel = magnitude(obj.acceleration);
// Invert y so that the angle is counterclockwise from x-axis // Give angle counterclockwise from horizontal
const direction = Math.atan2(-obj.velocity.y, obj.velocity.x) * 180 / Math.PI; const velocityDir = -1 * degrees(direction(obj.velocity));
const accelDir = Math.atan2(-obj.acceleration.y, obj.acceleration.x) * 180 / Math.PI; const accelDir = -1 * degrees(direction(obj.acceleration));
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, ${velocityDir.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}`,
]; ];
@ -223,9 +213,7 @@ export class System {
this.pause(); this.pause();
} }
if (this.sim.panning?.velocity) { obj.velocity = copy(this.sim.panning.velocity);
obj.velocity = {...this.sim.panning.velocity};
}
} }
doneCreatingObject() { doneCreatingObject() {
@ -307,7 +295,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 = magnitude(sub(obj.position, {x, y}));
if (dist <= obj.radius) { if (dist <= obj.radius) {
idx = i; idx = i;
return null; return null;
@ -329,7 +317,11 @@ export class System {
} }
drawObjects() { drawObjects() {
this.forEachObject(obj => obj.drawObject(this.sim), {alive: null}); // Draw all paths, all objects, and then all arrows
this.forEachObject(obj => obj.drawPath(this.sim.display), {alive: null});
this.forEachObject(obj => obj.drawObject(this.sim.display), {alive: true});
this.forEachObject(obj => obj.drawSelection(this.sim.display), {alive: true});
this.forEachObject(obj => obj.drawArrows(this.sim.display), {alive: true});
} }
// cb: (acc, obj, idx) => {} // cb: (acc, obj, idx) => {}
@ -344,6 +336,18 @@ export class System {
return acc; return acc;
} }
// cb: (obj, idx) => boolean
filter(cb, opts) {
let objects = [];
this.forEachObject((obj, idx) => {
const ret = cb(obj, idx);
if (ret) {
objects.push(obj);
}
}, opts);
return objects;
}
computeForces() { computeForces() {
const gravity = this.sim.getOption('param.gravity'); const gravity = this.sim.getOption('param.gravity');
if (this.objects.length < 2) return; if (this.objects.length < 2) return;
@ -352,13 +356,13 @@ export class System {
}); });
this.forEachObject((A, i) => { this.forEachObject((A, i) => {
this.forEachObject(B => { this.forEachObject(B => {
const dx = (B.position.x - A.position.x); const r = sub(B.position, A.position);
const dy = (B.position.y - A.position.y); const dSquared = square(r);
const dSquared = dx ** 2 + dy ** 2;
const d = Math.sqrt(dSquared); const d = Math.sqrt(dSquared);
const F = gravity * A.mass * B.mass / dSquared; const F = gravity * A.mass * B.mass / dSquared;
const Fx = F * dx / d; const Fx = F * r.x / d;
const Fy = F * dy / d; const Fy = F * r.y / d;
// Equal and opposite forces
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});
@ -375,26 +379,19 @@ export class System {
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,
totalMassLocation: { totalMassLocation: add(acc.totalMassLocation,
x: acc.totalMassLocation.x + obj.position.x * obj.mass, mult(obj.position, obj.mass)),
y: acc.totalMassLocation.y + obj.position.y * obj.mass,
},
}), { }), {
totalMassLocation: {x: 0, y: 0}, totalMassLocation: {x: 0, y: 0},
totalMass: 0, totalMass: 0,
count: 0, count: 0,
}); });
const centerOfMass = count ? { const centerOfMass = count ? div(totalMassLocation, totalMass) : zero;
x: totalMassLocation.x / totalMass,
y: totalMassLocation.y / totalMass,
} : {x: 0, y: 0};
// 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, add(acc, mult(obj.velocity, obj.mass)), zero);
y: acc.y + obj.mass * obj.velocity.y,
}), {x: 0, y: 0});
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum}; return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
} }
@ -408,12 +405,8 @@ export class System {
// Angular momentum for each object is m * s / d // Angular momentum for each object is m * s / d
// where d is the distance of the object from the global center of mass // where d is the distance of the object from the global center of mass
// and s is the magnitude of the cross product of v and r // and s is the magnitude of the cross product of v and r
const r = { const r = sub(obj.position, centerOfMass);
x: obj.position.x - centerOfMass.x, const s = cross(obj.velocity, r);
y: obj.position.y - centerOfMass.y,
};
const v = obj.velocity;
const s = v.x * r.y - v.y * r.x;
const d = Math.sqrt(r.x ** 2 + r.y ** 2); const d = Math.sqrt(r.x ** 2 + r.y ** 2);
return acc + obj.mass * s / d; return acc + obj.mass * s / d;
}, 0); }, 0);

49
tool/object.js Normal file
View File

@ -0,0 +1,49 @@
import {Display} from '../display.js';
import {Tool} from '../tool.js';
import {div, sub} from '../vector.js';
export class ObjectTool extends Tool {
selected = undefined;
display = undefined;
textEl = undefined;
setContainer(container) {
super.setContainer(container);
}
constructor() {
super();
this.display = new Display();
this.display.canvas.width = 150;
this.display.canvas.height = 150;
this.textEl = document.createElement('div');
this.div.appendChild(this.textEl);
this.div.appendChild(this.display.canvas);
}
frame() {
this.display.frame();
const obj = this.sim.select.selectedSingle;
this.textEl.innerHTML = `obj id: ${obj?.id}`;
if (!obj) return;
// Show object details
// Distance from center of screen
// Render the object
// Set up this.display.viewOrigin
const {radius, position} = obj;
const widthRatio = Math.abs(2 * radius) / this.display.canvas.width;
const heightRatio = Math.abs(2 * radius) / this.display.canvas.height;
const ratio = Math.max(widthRatio, heightRatio) * 2;
this.display.scalePower = Math.log2(1 / ratio);
this.display.viewOrigin = sub(position,
div({x: this.display.width, y: this.display.height}, 2));
obj.drawObject(this.display);
obj.drawArrows(this.display);
}
}

11
tool/objects.js Normal file
View File

@ -0,0 +1,11 @@
import {Tool} from '../tool.js';
export class ObjectsTool extends Tool {
setContainer(container) {
super.setContainer(container);
}
constructor() {
super();
}
}

View File

@ -48,7 +48,7 @@ export class OptionsTool extends Tool {
if (item.tall === true) button.classList.add(TALL_CLASSNAME); if (item.tall === true) button.classList.add(TALL_CLASSNAME);
const value = this.sim.getOption(path); const value = this.sim.getOption(path);
button.style.opacity = value ? '100%' : '50%'; button.style.opacity = value ? '100%' : '50%';
this.sim.onOptionSet(path, value => { this.sim.onOptionSet(path, ({value}) => {
button.style.opacity = value ? '100%' : '50%'; button.style.opacity = value ? '100%' : '50%';
}); });
button.addEventListener('click', () => { button.addEventListener('click', () => {
@ -81,7 +81,7 @@ export class OptionsTool extends Tool {
this.sim.setOption(path, input.value); this.sim.setOption(path, input.value);
}); });
this.sim.onOptionSet(path, value => { this.sim.onOptionSet(path, ({value}) => {
input.value = value; input.value = value;
}); });

View File

@ -1,4 +1,5 @@
import { import {
EVENT_PLAY_PAUSE,
TALL_CLASSNAME TALL_CLASSNAME
} from '../config.js'; } from '../config.js';
import {Tool} from '../tool.js'; import {Tool} from '../tool.js';
@ -17,6 +18,10 @@ export class PlayPause extends Tool {
setContainer(container) { setContainer(container) {
super.setContainer(container); super.setContainer(container);
this.updateButtons(); this.updateButtons();
this.sim.div.addEventListener(EVENT_PLAY_PAUSE, () => {
this.updateButtons();
});
} }
constructor(container) { constructor(container) {
@ -38,18 +43,13 @@ export class PlayPause extends Tool {
pauseButton.classList.add(TALL_CLASSNAME); pauseButton.classList.add(TALL_CLASSNAME);
pauseButton.addEventListener('click', () => { pauseButton.addEventListener('click', () => {
this.sim.panning = undefined; this.sim.pause();
if (this.sim.playing) {
this.sim.playing = false;
this.updateButtons(); this.updateButtons();
}
}); });
playButton.addEventListener('click', () => { playButton.addEventListener('click', () => {
if (!this.sim.playing) { this.sim.play();
this.sim.playing = true;
this.updateButtons(); this.updateButtons();
}
}); });
} }
} }

View File

@ -1,10 +1,11 @@
import { Tool } from '../tool.js';
import { import {
TOOL_INFO_CLASSNAME,
WIDE_CLASSNAME,
ZOOM_IN_FACTOR, ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR, ZOOM_OUT_FACTOR,
WIDE_CLASSNAME,
TOOL_INFO_CLASSNAME,
} from '../config.js'; } from '../config.js';
import {Tool} from '../tool.js';
import {zero} from '../vector.js';
export class Zoom extends Tool { export class Zoom extends Tool {
currentScaleEl = undefined; currentScaleEl = undefined;
@ -23,8 +24,8 @@ export class Zoom extends Tool {
}); });
} }
constructor(container) { constructor() {
super(container); super();
const currentScale = document.createElement('button') const currentScale = document.createElement('button')
const zoomOut = document.createElement('button'); const zoomOut = document.createElement('button');
@ -73,7 +74,7 @@ export class Zoom extends Tool {
const factor = Math.ceil(Math.log2(1 / ratio)); const factor = Math.ceil(Math.log2(1 / ratio));
// Determine average momentum and set panning velocity to match // Determine average momentum and set panning velocity to match
const { netMomentum, totalMass } = this.sim.system.computeSystemCenter(); const {netMomentum, totalMass} = this.sim.system.computeSystemCenter();
const netVelocity = { const netVelocity = {
x: netMomentum.x / totalMass, x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass, y: netMomentum.y / totalMass,
@ -83,7 +84,7 @@ export class Zoom extends Tool {
zeroVelocity.addEventListener('click', () => { zeroVelocity.addEventListener('click', () => {
// Determine center of mass and average momentum // Determine center of mass and average momentum
const { totalMass, netMomentum } = this.sim.system.computeSystemCenter(); const {totalMass, netMomentum} = this.sim.system.computeSystemCenter();
const netVelocity = { const netVelocity = {
x: netMomentum.x / totalMass, x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass, y: netMomentum.y / totalMass,
@ -96,8 +97,7 @@ export class Zoom extends Tool {
}); });
// Cancel panning // Cancel panning
this.sim.panning = undefined; this.sim.panning.setVelocity(zero);
}); });
} }
} }

View File

@ -28,8 +28,14 @@ export class ToolbarGroup {
return this; return this;
} }
applyExpanded() {
for (const toolbar of this.toolbars) {
toolbar.applyExpanded();
}
}
frame() { frame() {
for (let toolbar of this.toolbars) { for (const toolbar of this.toolbars) {
toolbar.frame(); toolbar.frame();
} }
} }

35
vector.js Normal file
View File

@ -0,0 +1,35 @@
export const copy = (v) => ({x: v.x, y: v.y});
export const dot = (a, b) => a.x * b.x + a.y * b.y;
export const cross = (a, b) => a.x * b.y - a.y * b.x;
export const add = (a, b) => ({x: a.x + b.x, y: a.y + b.y});
export const sub = (a, b) => ({x: a.x - b.x, y: a.y - b.y});
export const square = ({x, y}) => x ** 2 + y ** 2;
export const magnitude = ({x, y}) => Math.sqrt(square({x, y}));
// Arguments must be one vector and one scalar, but can be transposed
export const mult = (v, m) => {
if (typeof v === 'object') return {x: v.x * m, y: v.y * m};
else if (typeof m === 'object') return {x: m.x * v, y: m.y * v};
else throw new Error('mult: no vector provided');
};
export const div = (v, m) => ({x: v.x / m, y: v.y / m});
export const zero = {x: 0, y: 0};
export const weightedAvg = (items) => {
let res = zero;
let W = 0;
for (const [v, w] of items) {
res = add(res, mult(v, w));
W += w;
}
return div(res, W);
};
export const components = (mag, dir) => mult(mag, {
x: Math.cos(dir),
y: Math.sin(dir),
});
export const direction = ({x, y}) => Math.atan2(y, x);
export const degrees = (rad) => rad * 180 / Math.PI;

16
zoom.js
View File

@ -3,6 +3,7 @@ import {
SCALE_POWER_MAX, SCALE_POWER_MAX,
SCALE_POWER_MIN, SCALE_POWER_MIN,
} from './config.js'; } from './config.js';
import {div, sub} from './vector.js';
export class Zoom { export class Zoom {
sim = undefined; sim = undefined;
@ -27,7 +28,7 @@ export class Zoom {
// x, y should be in Sim coordinates // x, y should be in Sim coordinates
// velocity should be in Sim coordinate scale // velocity should be in Sim coordinate scale
zoom({x, y, factor, velocity}) { zoom({x, y, factor, velocity}) {
const { display } = this.sim; const {display} = this.sim;
// x, y are the mouse coordinates, which should be the center of the new view frame // 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 // the new view origin should be x, y minus half the new view width and height
// compute new scale // compute new scale
@ -35,22 +36,15 @@ export class Zoom {
let scalePower = display.scalePower + factor; let scalePower = display.scalePower + factor;
scalePower = Math.max(scalePower, SCALE_POWER_MIN); scalePower = Math.max(scalePower, SCALE_POWER_MIN);
scalePower = Math.min(scalePower, SCALE_POWER_MAX); scalePower = Math.min(scalePower, SCALE_POWER_MAX);
this.sim.display.scalePower = scalePower; display.scalePower = scalePower;
// compute coordinates of new view frame // compute coordinates of new view frame
display.viewOrigin.x = x - display.width / 2; display.viewOrigin = sub({x, y}, div({x: display.width, y: display.height}, 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 // TODO: If paused, set panning velocity on resume
// if (this.sim.playing && velocity) { // if (this.sim.playing && velocity) {
if (velocity) { if (velocity) {
this.sim.panning = { this.sim.panning.setVelocity(velocity);
velocity: { ...velocity },
paused: !this.sim.playing,
};
} }
const e = new CustomEvent(EVENT_ZOOM); const e = new CustomEvent(EVENT_ZOOM);