Feature: Objects Tool

This commit is contained in:
Ladd 2026-01-04 16:44:25 -06:00
parent 71854d2a95
commit f05d1ed399
10 changed files with 187 additions and 80 deletions

View File

@ -16,13 +16,13 @@ TODO
----
- [x] Selection Box
- [ ] Feature: Object List
- [ ] Feature: Object Detail
- [ ] Feature: Zoom to Object
- [x] Feature: Object List
- [x] Feature: Object Detail
- [x] 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
- [x] Enhancement: Create Vector class and refactor to use
- [x] Enhancement: Create Panning class and refactor to use
- [ ] Enhancement: Handle pointerleave or other mechanism when window loses focus
- [ ] Enhancement: Calculate Work as FxD as measure of energy flux
- [ ] Feature: Automatically slow time when energy flux is greater
@ -32,7 +32,7 @@ TODO
- [ ] Feature: Polar Coordinates
- [ ] Feature: Cylindrical Coordinates
- [ ] Feature: Spherical Coordinates
- [ ] Feature: Camera Velocity Display
- [x] Feature: Camera Velocity Display
- [ ] Enhancement: World State Snapshots
- [ ] Feature: List / Save / Load World States
- [ ] Feature: Left Button Panning
@ -52,5 +52,4 @@ TODO
- [ ] Feature: Time Control: Reverse Time
- [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow)
- [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y)
- [x] Task: Verify stationary pointer leads to zero pointer velocity
- [ ] Fix: Unpause panning when initiated while sim is paused
- [x] Fix: Unpause panning when initiated while sim is paused

View File

@ -54,6 +54,9 @@ export const EVENT_MODE_ENTER = 'lhg-mode-enter';
export const EVENT_ZOOM = 'lhg-zoom-event';
export const EVENT_OPTION_SET = 'lhg-option-set';
export const EVENT_PLAY_PAUSE = 'lhg-play-pause';
export const EVENT_SELECT = 'lhg-select';
export const EVENT_OBJECT_CREATE = 'lhg-object-create';
export const EVENT_OBJECT_MERGE = 'lhg-object-merge';
// MODES
export const MODE_MASS_GENERATION = 'mass-gen';

View File

@ -36,7 +36,8 @@ export function show({items, item, parentEl, itemEl}) {
item.hidden = false;
}
export function hide({item, parentEl, itemEl}) {
export function hide({items, item, parentEl, itemEl}) {
if (items.indexOf(item) < 0) return;
parentEl.removeChild(itemEl);
item.hidden = true;
}

View File

@ -17,38 +17,38 @@ export class Pointer {
this.sim = sim;
// Monitor mouse movements
const el = this.sim.display.canvas;
const {canvas} = this.sim.display;
el.addEventListener('pointermove', e => {
window.addEventListener('pointermove', e => {
if (this.sim.getOption('debug.cursorInfo')) {
this.sim.info['pointermove'] = [`${e.clientX.toPrecision(6)}, `, `${e.clientY.toPrecision(6)}`];
}
this.handlePointerMove({x: e.clientX, y: e.clientY});
});
el.addEventListener('pointerdown', e => {
canvas.addEventListener('pointerdown', e => {
this.handlePointerDown({x: e.clientX, y: e.clientY});
});
el.addEventListener('pointerup', e => {
window.addEventListener('pointerup', e => {
this.handlePointerUp({x: e.clientX, y: e.clientY});
});
el.addEventListener('pointerleave', e => {
this.handlePointerUp({x: e.clientX, y: e.clientY});
});
// window.addEventListener('pointerleave', e => {
// this.handlePointerUp({x: e.clientX, y: e.clientY});
// });
// Monitor wheel events
el.addEventListener('wheel', e => {
canvas.addEventListener('wheel', e => {
const factor = e.deltaY < 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
const {x, y} = this.sim.screenToSim(e.clientX, e.clientY);
this.sim.scheduleZoom({x, y}, factor);
});
el.addEventListener('focus', () => {
window.addEventListener('focus', () => {
console.log('window focus');
});
el.addEventListener('blur', () => {
window.addEventListener('blur', () => {
console.log('window blur');
});
}

View File

@ -1,3 +1,4 @@
import {EVENT_SELECT} from './config.js';
import {add, copy, mult} from './vector.js';
export class Select {
@ -30,7 +31,7 @@ export class Select {
handlePointerDown({x: clientX, y: clientY}) {
this.box.start = this.sim.screenToSim(clientX, clientY);
this.box.end = this.box.start;
this.getSelectedObjects();
// this.getSelectedObjects();
}
handlePointerMove({x: clientX, y: clientY}) {
@ -51,22 +52,13 @@ export class Select {
y: Math.max(start.y, end.y),
};
this.getSelectedObjects();
this.sim.div.dispatchEvent(new CustomEvent(EVENT_SELECT));
this.box = {
start: 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(elapsedTime) {
if (!this.box.start) return;
// If panning, let's update the position of our box so it doesn't drift away
@ -77,4 +69,22 @@ export class Select {
// Display the box
this.sim.display.drawBox(this.box.start, this.box.end);
}
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;
}
// cb: ({selectedGroup, selectedSingle}) => undefined
onSelect(cb) {
this.sim.div.addEventListener(EVENT_SELECT, () => {
const {selectedGroup, selectedSingle} = this;
cb({selectedGroup, selectedSingle});
});
}
}

View File

@ -1,4 +1,4 @@
import {OBJECT_HISTORY_SIZE} from './config.js';
import {EVENT_OBJECT_CREATE, EVENT_OBJECT_MERGE, OBJECT_HISTORY_SIZE} from './config.js';
import {MassObject} from './object.js';
import {
add, copy, cross, degrees,
@ -134,6 +134,8 @@ export class System {
};
T.alive = false;
T.forces = [];
const e = new CustomEvent(EVENT_OBJECT_MERGE, {detail: {surviving: S, merged: T}});
this.sim.div.dispatchEvent(e);
}
}, {alive: true, startWith: i + 1});
});
@ -212,8 +214,9 @@ export class System {
if (this.sim.getOption('pauseDuring.creation')) {
this.pause();
}
obj.velocity = copy(this.sim.panning.velocity);
const e = new CustomEvent(EVENT_OBJECT_CREATE, {detail: {obj}});
this.sim.div.dispatchEvent(e);
}
doneCreatingObject() {
@ -223,6 +226,20 @@ export class System {
}
}
// cb: (obj) => undefined
onCreate(cb) {
this.sim.div.addEventListener(EVENT_OBJECT_CREATE, ({detail: {obj}}) => {
cb(obj);
});
}
// cb: ({surviving, merged}) => undefined
onMerge(cb) {
this.sim.div.addEventListener(EVENT_OBJECT_MERGE, ({detail: {surviving, merged}}) => {
cb({surviving, merged});
});
}
object(i) {
return this.objects[i];
}
@ -253,21 +270,20 @@ export class System {
return this.objects.length;
}
get boundingBox() {
getBoundingBox(objects = []) {
const box = this.reduce(({start, end}, obj) => {
if (objects.length && !objects.includes(obj)) return {start, end};
const lx = obj.position.x - obj.radius;
const gx = obj.position.x + obj.radius;
const ly = obj.position.y - obj.radius;
const gy = obj.position.y + obj.radius;
let ret;
if (start.x === undefined) {
ret = {
return {
start: {x: lx, y: ly},
end: {x: gx, y: gy},
};
return ret;
}
ret = {
return {
start: {
x: Math.min(start.x, lx),
y: Math.min(start.y, ly),
@ -277,7 +293,6 @@ export class System {
y: Math.max(end.y, gy),
}
};
return ret;
}, {
start: {x: undefined, y: undefined},
end: {x: undefined, y: undefined},
@ -373,15 +388,18 @@ export class System {
});
}
computeSystemCenter() {
computeSystemCenter(objects = []) {
// Determine center of mass
const {totalMass, count, totalMassLocation} =
this.reduce((acc, obj) => ({
count: acc.count + 1,
totalMass: acc.totalMass + obj.mass,
totalMassLocation: add(acc.totalMassLocation,
mult(obj.position, obj.mass)),
}), {
this.reduce((acc, obj) => {
if (objects.length && !objects.includes(obj)) return acc;
return {
count: acc.count + 1,
totalMass: acc.totalMass + obj.mass,
totalMassLocation: add(acc.totalMassLocation,
mult(obj.position, obj.mass)),
};
}, {
totalMassLocation: {x: 0, y: 0},
totalMass: 0,
count: 0,
@ -390,8 +408,10 @@ export class System {
const centerOfMass = count ? div(totalMassLocation, totalMass) : zero;
// Determine average momentum
const netMomentum = this.reduce((acc, obj) =>
add(acc, mult(obj.velocity, obj.mass)), zero);
const netMomentum = this.reduce((acc, obj) => {
if (objects.length && !objects.includes(obj)) return acc;
return add(acc, mult(obj.velocity, obj.mass));
}, zero);
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
}

View File

@ -1,11 +1,82 @@
import {hide, show} from '../helper.js';
import {Tool} from '../tool.js';
import {add, magnitude, sub} from '../vector.js';
export class ObjectsTool extends Tool {
objects = [];
setContainer(container) {
super.setContainer(container);
// Display a list of the currently selected objects,
// or all objects if none are currently selected.
if (this.sim.select.selectedGroup.length) {
this.objects = this.sim.select.selectedGroup;
} else {
this.objects = this.sim.system.filter(obj => obj.alive);
}
this.populate();
this.sim.select.onSelect(({selectedGroup}) => {
this.objects = selectedGroup;
this.depopulate();
this.populate();
});
this.sim.system.onCreate(obj => {
if (!this.sim.select.selectedGroup.length) {
this.objects.push(obj);
this.populate();
}
});
this.sim.system.onMerge(({merged}) => {
if (!merged.objectsToolEl) return;
hide({
items: this.objects,
item: merged,
parentEl: this.div,
itemEl: merged.objectsToolEl,
});
});
}
constructor() {
super();
frame() {
this.populate();
}
depopulate() {
while (this.div.firstChild) {
this.div.removeChild(this.div.firstChild);
}
}
populate() {
for (const obj of this.objects) {
const objectEl = obj.objectsToolEl ?? document.createElement('div');
obj.objectsToolEl = objectEl;
const {r, g, b} = obj.color;
// Distance from center of screen
const distance = magnitude(sub(obj.position, add(this.sim.display.viewOrigin, {
x: this.sim.display.width / 2,
y: this.sim.display.height / 2,
})));
objectEl.innerHTML = `
<span style="background-color: rgb(${r},${g},${b});">` +
'&nbsp;&nbsp;</span>' +
`${obj.mass.toPrecision(3)} ` +
`${distance.toPrecision(3)}`;
// `${magnitude(obj.velocity).toExponential(0)} ` +
// `${-degrees(direction(obj.velocity)).toFixed(0)}°`;
if (!obj.hidden) {
show({
items: this.objects,
item: obj,
parentEl: this.div,
itemEl: objectEl,
});
}
}
}
}

View File

@ -76,6 +76,7 @@ export class OptionsTool extends Tool {
});
} else {
hide({
items: group.items,
item: next,
parentEl: groupEl,
itemEl: optionEl,

View File

@ -7,35 +7,6 @@ import {
export class UtilityTool extends Tool {
currentTimeEl = undefined;
get timeText() {
let time = this.sim.time;
// Time in milliseconds
const ms = Math.floor(time % 1000);
time = (time - ms) / 1000;
const s = Math.floor(time % 60);
time = (time - s) / 60;
const m = Math.floor(time % 60);
time = (time - m) / 60;
const h = Math.floor(time % 24);
time = (time - h) / 24;
const d = Math.floor(time);
return [
d || undefined,
h.toString().padStart(2, '0'),
m.toString().padStart(2, '0'),
[
s.toString().padStart(2, '0'),
ms.toString().padStart(3, '0'),
].join('.')
].filter(x => x !== undefined).join(':');
}
frame() {
if (this.currentTimeEl) {
this.currentTimeEl.innerHTML = this.timeText;
}
}
setContainer(container) {
super.setContainer(container);
this.currentTimeEl.innerHTML = this.timeText;
@ -73,4 +44,34 @@ export class UtilityTool extends Tool {
this.sim.info = {};
});
}
frame() {
if (this.currentTimeEl) {
this.currentTimeEl.innerHTML = this.timeText;
}
}
get timeText() {
let time = this.sim.time;
// Time in milliseconds
const ms = Math.floor(time % 1000);
time = (time - ms) / 1000;
const s = Math.floor(time % 60);
time = (time - s) / 60;
const m = Math.floor(time % 60);
time = (time - m) / 60;
const h = Math.floor(time % 24);
time = (time - h) / 24;
const d = Math.floor(time);
return [
d || undefined,
h.toString().padStart(2, '0'),
m.toString().padStart(2, '0'),
[
s.toString().padStart(2, '0'),
ms.toString().padStart(3, '0'),
].join('.')
].filter(x => x !== undefined).join(':');
}
}

View File

@ -65,7 +65,8 @@ export class Zoom extends Tool {
zoomAll.addEventListener('click', () => {
// Determine bounding box
const box = this.sim.system.boundingBox;
const objects = this.sim.select.selectedGroup;
const box = this.sim.system.getBoundingBox(objects);
const x = (box.start.x + box.end.x) / 2;
const y = (box.start.y + box.end.y) / 2;
const widthRatio = Math.abs(box.start.x - box.end.x) / this.sim.display.width;
@ -74,7 +75,7 @@ export class Zoom extends Tool {
const factor = Math.ceil(Math.log2(1 / ratio));
// Determine average momentum and set panning velocity to match
const {netMomentum, totalMass} = this.sim.system.computeSystemCenter();
const {netMomentum, totalMass} = this.sim.system.computeSystemCenter(objects);
const netVelocity = {
x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass,