zoom to fit now also pans to avg momentum

This commit is contained in:
Ladd 2025-12-27 14:38:40 -06:00
parent 28909a0c82
commit 5c6e685764
8 changed files with 98 additions and 76 deletions

View File

@ -35,6 +35,7 @@ export const ARROWHEAD_WIDTH = 5;
export const OFFSCREEN_OBJECT_LINE_SCALE = 7; export const OFFSCREEN_OBJECT_LINE_SCALE = 7;
export const OFFSCREEN_OBJECT_LINE_WIDTH = 2; export const OFFSCREEN_OBJECT_LINE_WIDTH = 2;
export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15; export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15;
export const ZOOM_TO_FIT_PADDING = 50;
export const ZOOM_IN_FACTOR = 1; export const ZOOM_IN_FACTOR = 1;
export const ZOOM_OUT_FACTOR = -1; export const ZOOM_OUT_FACTOR = -1;
export const SCALE_POWER_MAX = 8; export const SCALE_POWER_MAX = 8;

View File

@ -4,6 +4,7 @@ import {
DISPLAY_OBJECTS_INFO, DISPLAY_OBJECTS_INFO,
MOTION_TIME_SCALE, MOTION_TIME_SCALE,
GRAVITATIONAL_CONSTANT, GRAVITATIONAL_CONSTANT,
ZOOM_TO_FIT_PADDING,
} from './config.js'; } from './config.js';
export class Objects { export class Objects {
@ -94,6 +95,10 @@ export class Objects {
if (obj.position.y > box.end.y) box.end.y = obj.position.y; if (obj.position.y > box.end.y) box.end.y = obj.position.y;
} }
}); });
box.start.x -= ZOOM_TO_FIT_PADDING;
box.start.y -= ZOOM_TO_FIT_PADDING;
box.end.x += ZOOM_TO_FIT_PADDING;
box.end.y += ZOOM_TO_FIT_PADDING;
return box; return box;
} }
@ -175,7 +180,6 @@ export class Objects {
} }
computeFrame(elapsedTime) { computeFrame(elapsedTime) {
elapsedTime = elapsedTime * MOTION_TIME_SCALE;
// 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
if (this.creatingObject !== undefined) { if (this.creatingObject !== undefined) {
@ -199,6 +203,7 @@ export class Objects {
}); });
// Collisions // Collisions
if (this.sim.getOption('collision.merge')) {
this.forEachObject((A, i) => { this.forEachObject((A, i) => {
this.forEachObject((B, j) => { this.forEachObject((B, j) => {
const dx = (B.position.x - A.position.x); const dx = (B.position.x - A.position.x);
@ -232,6 +237,7 @@ export class Objects {
} }
}, true, i + 1); }, true, i + 1);
}); });
}
// Recompute forces // Recompute forces
this.computeForces(); this.computeForces();
@ -255,13 +261,13 @@ export class Objects {
// Display objects info // Display objects info
if (DISPLAY_OBJECTS_INFO) { if (DISPLAY_OBJECTS_INFO) {
this.forEachObject(obj => { 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);
// 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;
this.sim.info[`Object ${i}`] = [ this.sim.info[`Object ${i}`] = [
`${obj.position.x}, `, `${obj.position.x.toPrecision(6)}, `,
`${obj.position.y}, `, `${obj.position.y.toPrecision(6)}, `,
`${obj.mass.toPrecision(6)} kg, `, `${obj.mass.toPrecision(6)} kg, `,
`${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`, `${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`,
]; ];

View File

@ -10,6 +10,7 @@ export class Overlay {
this.sim.div.appendChild(infoBox); this.sim.div.appendChild(infoBox);
this.infoBox = infoBox; this.infoBox = infoBox;
infoBox.style.position = 'relative'; infoBox.style.position = 'relative';
infoBox.style.display = 'inline-block';
infoBox.style.top = 0; infoBox.style.top = 0;
infoBox.style.left = 0; infoBox.style.left = 0;
infoBox.width = 'fit-content'; infoBox.width = 'fit-content';

View File

@ -6,7 +6,6 @@ import {
DRAGGABLE_ELEMENT_CLASSNAME, DRAGGABLE_ELEMENT_CLASSNAME,
MODE_MASS_GENERATION, MODE_MASS_GENERATION,
MODE_PAN_VIEW, MODE_PAN_VIEW,
PAN_VELOCITY_SCALE_FACTOR,
} from './config.js'; } from './config.js';
function dispatchEvent(target, eventType, data) { function dispatchEvent(target, eventType, data) {
@ -127,15 +126,13 @@ export class Pointer {
handlePointerDown({x: clientX, y: clientY}) { handlePointerDown({x: clientX, y: clientY}) {
this.updatePointer({x: clientX, y: clientY}); this.updatePointer({x: clientX, y: clientY});
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) { if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
this.panning = undefined;
const {x, y} = this.sim.screenToSim(clientX, clientY) const {x, y} = this.sim.screenToSim(clientX, clientY)
this.sim.objects.handlePointerDown({x, y}); this.sim.objects.handlePointerDown({x, y});
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { } else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
this.panning = { this.panning = {
gathering: true, gathering: true,
viewOriginStart: this.sim.display.viewOrigin,
pointerStart: {x: clientX, y: clientY},
pointerCurrent: {x: clientX, y: clientY},
velocity: {x: 0, y: 0}, velocity: {x: 0, y: 0},
}; };
} }
@ -170,8 +167,9 @@ export class Pointer {
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { } else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
if (this.panning?.gathering) { if (this.panning?.gathering) {
this.updatePointer({ x: clientX, y: clientY }); this.updatePointer({ x: clientX, y: clientY });
this.panning.pointerCurrent = {x: clientX, y: clientY}; const velocity = this.getPointerVelocity();
this.panning.velocity = this.getPointerVelocity(); this.panning.velocity.x = velocity.x;
this.panning.velocity.y = velocity.y;
} }
} }
} }
@ -185,15 +183,12 @@ export class Pointer {
// Apply update to viewOrigin based on panning // Apply update to viewOrigin based on panning
if (this.panning) { if (this.panning) {
const {pointerStart, pointerCurrent, viewOriginStart, velocity} = this.sim.pointer.panning; const {velocity} = this.panning;
// Convert pointer velocity to sim internal scale // Convert pointer velocity to sim internal scale
const vx = velocity.x / this.sim.display.scale; const vx = velocity.x / this.sim.display.scale;
const vy = velocity.y / this.sim.display.scale; const vy = velocity.y / this.sim.display.scale;
this.panning.viewOriginStart = this.sim.display.viewOrigin; this.sim.display.viewOrigin.x -= vx * elapsedTime;
this.panning.pointerStart = pointerCurrent; this.sim.display.viewOrigin.y -= vy * elapsedTime;
// const speed = Math.sqrt(velocity.x ** 2 + velocity.y ** 2);
this.sim.display.viewOrigin.x -= vx * elapsedTime * PAN_VELOCITY_SCALE_FACTOR;
this.sim.display.viewOrigin.y -= vy * elapsedTime * PAN_VELOCITY_SCALE_FACTOR;
} }
} }
} }

View File

@ -12,6 +12,9 @@ import {
SCALE_POWER_MIN, SCALE_POWER_MIN,
DISPLAY_CURRENT_SCALE, DISPLAY_CURRENT_SCALE,
DISPLAY_CURRENT_MODE, DISPLAY_CURRENT_MODE,
PAN_VELOCITY_SCALE_FACTOR,
MOTION_TIME_SCALE,
MODE_PAN_VIEW,
} from './config.js'; } from './config.js';
export class Sim { export class Sim {
@ -53,12 +56,14 @@ export class Sim {
// It's better not to change the scale in the middle of possible frame calculations, // It's better not to change the scale in the middle of possible frame calculations,
// so use this to schedule it and let the event loop pick it up. // so use this to schedule it and let the event loop pick it up.
scheduleZoom({x, y}, factor) { // velocity should be in Sim coordinate scale
this.nextZoom = {x, y, factor}; scheduleZoom({x, y}, factor, velocity) {
this.nextZoom = {x, y, factor, velocity};
} }
// x, y should be in Sim coordinates // x, y should be in Sim coordinates
zoom({x, y, factor}) { // velocity should be in Sim coordinate scale
zoom({x, y, factor, velocity}) {
// 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
@ -71,8 +76,18 @@ export class Sim {
this.display.viewOrigin.y = y - this.display.height / 2; this.display.viewOrigin.y = y - this.display.height / 2;
this.pointer.clearPointerHistory(); this.pointer.clearPointerHistory();
if (this.pointer.panning) {
this.pointer.panning = undefined; if (this.playing && velocity) {
// Switch to pan mode
this.setCurrentMode(MODE_PAN_VIEW);
this.pointer.panning = {
// Convert to screen scale since that's what we normally use for panning
velocity: {
x: -velocity.x * this.display.scale,
y: -velocity.y * this.display.scale,
}
};
} }
} }
@ -91,7 +106,7 @@ export class Sim {
// Main loop // Main loop
loop(currentTime) { loop(currentTime) {
const elapsedTime = currentTime - this.time; const elapsedTime = (currentTime - this.time) * MOTION_TIME_SCALE;
this.time = currentTime; this.time = currentTime;
if (DISPLAY_CURRENT_MODE) { if (DISPLAY_CURRENT_MODE) {

View File

@ -40,14 +40,15 @@ export class ModeSwitch extends Tool {
button.innerHTML = `<h3>${modeTitle}</h3>`; button.innerHTML = `<h3>${modeTitle}</h3>`;
button.classList.add('wide'); button.classList.add('wide');
button.addEventListener('click', (e) => this.setMode(modeID)); button.addEventListener('click', (e) => this.setCurrentMode(modeID));
} }
// First listed mode is the default // First listed mode is the default
const [[currentModeID, _]] = this.modes; const [[currentModeID, _]] = this.modes;
this.setMode(currentModeID); this.setCurrentMode(currentModeID);
// Add global method to get current mode / check mode // Add global method to set/get current mode
this.sim.setCurrentMode = (modeID) => this.setCurrentMode(modeID);
this.sim.getCurrentMode = () => this.currentMode; this.sim.getCurrentMode = () => this.currentMode;
this.sim.isCurrentMode = (modeID) => modeID === this.currentMode; this.sim.isCurrentMode = (modeID) => modeID === this.currentMode;
this.sim.onModeLeave = (modeID, cb) => this.onModeLeave(modeID, cb); this.sim.onModeLeave = (modeID, cb) => this.onModeLeave(modeID, cb);
@ -60,7 +61,7 @@ export class ModeSwitch extends Tool {
} }
} }
setMode(modeID) { setCurrentMode(modeID) {
if (modeID === this.currentMode) return; if (modeID === this.currentMode) return;
const leave = new CustomEvent(EVENT_MODE_LEAVE, {detail: {modeID: this.currentMode}}); const leave = new CustomEvent(EVENT_MODE_LEAVE, {detail: {modeID: this.currentMode}});
const enter = new CustomEvent(EVENT_MODE_LEAVE, {detail: {modeID}}); const enter = new CustomEvent(EVENT_MODE_LEAVE, {detail: {modeID}});

View File

@ -21,6 +21,7 @@ export class PlayPause extends Tool {
pauseButton.addEventListener('click', (e) => { pauseButton.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.sim.pointer.panning = undefined;
if (this.playing) { if (this.playing) {
this.playing = false; this.playing = false;
pauseButton.style.opacity = '50%'; pauseButton.style.opacity = '50%';

View File

@ -43,28 +43,30 @@ export class Zoom extends Tool {
const box = this.sim.objects.boundingBox; const box = this.sim.objects.boundingBox;
const x = (box.start.x + box.end.x) / 2; const x = (box.start.x + box.end.x) / 2;
const y = (box.start.y + box.end.y) / 2; const y = (box.start.y + box.end.y) / 2;
if (box.start.x !== box.end.x && box.start.y !== box.end.y) {
const widthRatio = Math.abs(box.start.x - box.end.x) / this.sim.display.width; const widthRatio = Math.abs(box.start.x - box.end.x) / this.sim.display.width;
const heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height; const heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height;
const biggerRatio = Math.max(widthRatio, heightRatio); const biggerRatio = Math.max(widthRatio, heightRatio);
const base2factor = Math.log(1/biggerRatio) / Math.log(2); const base2factor = Math.log(1/biggerRatio) / Math.log(2) - 0.5;
const factor = Math.floor(base2factor) - 1; const factor = Math.floor(base2factor);
this.sim.scheduleZoom({x, y}, factor); // Determine average momentum and set panning velocity to match
} const netMomentum = {x: 0, y: 0};
// Determine average velocity and set panning velocity to match let totalMass = 0;
const totalVelocity = {x: 0, y: 0};
let count = 0; let count = 0;
this.sim.objects.forEachObject(obj => { this.sim.objects.forEachObject(obj => {
count++; count++;
totalVelocity.x += obj.velocity.x; netMomentum.x += obj.mass * obj.velocity.x;
totalVelocity.y += obj.velocity.y; netMomentum.y += obj.mass * obj.velocity.y;
totalMass += obj.mass;
}); });
const vx = totalVelocity.x / count; if (!count) {
const vy = totalVelocity.y / count; return;
console.log('zoom, pan', vx, vy); }
this.sim.pointer.panning = { const netVelocity = {
velocity: {x: vx, y: vy}, x: netMomentum.x / totalMass,
y: netMomentum.y / totalMass,
}; };
const netSpeed = Math.sqrt(netVelocity.x ** 2 + netVelocity.y ** 2);
this.sim.scheduleZoom({x, y}, factor, netVelocity)
}); });
} }
} }