From a50c7c2a58859b746a84a1611b9dc1d58238b3ad Mon Sep 17 00:00:00 2001 From: Ladd Date: Wed, 31 Dec 2025 18:41:50 -0600 Subject: [PATCH] fix zoom and pan --- config.js | 5 +-- display.js | 31 ++++++++++++++--- helper.js | 2 ++ object.js | 8 ++--- options.js | 17 ++++++++-- pointer.js | 8 ++--- sim-tools.js | 4 +-- simulator.js | 89 +++++++++++++++---------------------------------- system.js | 41 +++++++++++++++-------- tool/utility.js | 47 -------------------------- tool/zoom.js | 42 +++++++++++++++++++++++ zoom.js | 58 ++++++++++++++++++++++++++++++++ 12 files changed, 208 insertions(+), 144 deletions(-) create mode 100644 helper.js create mode 100644 zoom.js diff --git a/config.js b/config.js index 60a9a12..70bf0e2 100644 --- a/config.js +++ b/config.js @@ -23,7 +23,6 @@ export const ARROWHEAD_WIDTH = 5; export const OFFSCREEN_OBJECT_LINE_SCALE = 7; export const OFFSCREEN_OBJECT_LINE_WIDTH = 2; export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15; -export const ZOOM_TO_FIT_PADDING = 50; export const ZOOM_IN_FACTOR = 1; export const ZOOM_OUT_FACTOR = -1; export const SCALE_POWER_MAX = 8; @@ -50,6 +49,8 @@ export const MODE_MASS_GENERATION = 'mass-gen'; export const MODE_PAN_VIEW = 'pan-view'; export const MODE_OBJECT_SELECT = 'select'; -// LOCAL STORAGE PREFIXES/SUFFIXES +// LOCAL STORAGE NAMESPACES export const TOOLBAR_EXPANDED_SUFFIX = 'lhg-toolbar-expanded'; +export const OBJECT_MAGIC_PROP_PREFIX = '_lhg_'; + diff --git a/display.js b/display.js index 0a58386..b383c09 100644 --- a/display.js +++ b/display.js @@ -145,12 +145,33 @@ export class Display { // Direct translate this.viewOrigin.x = start.viewOrigin.x - (latest.x - start.x) / this.scale; this.viewOrigin.y = start.viewOrigin.y - (latest.y - start.y) / this.scale; - } else if (this.sim.panning && !this.sim.panning.paused) { + } + + let pdx = 0; + let pdy = 0; + + if (this.sim.panning && !this.sim.panning.paused) { // Apply update to viewOrigin based on panning - const { velocity } = this.sim.panning; - // TODO: something with time scale? Panning is too fast. - this.viewOrigin.x += velocity.x * elapsedTime; - this.viewOrigin.y += velocity.y * elapsedTime; + pdx = this.sim.panning.velocity.x * elapsedTime; + pdy = this.sim.panning.velocity.y * elapsedTime; + } + + this.viewOrigin.x += pdx; + this.viewOrigin.y += pdy; + + if (start && latest) { + // Update what's considered start + 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); } // Clear canvas in preparation for other modules to render this frame diff --git a/helper.js b/helper.js new file mode 100644 index 0000000..33d0443 --- /dev/null +++ b/helper.js @@ -0,0 +1,2 @@ +export function makeUtilityButton() { +} diff --git a/object.js b/object.js index 6571989..62e0038 100644 --- a/object.js +++ b/object.js @@ -145,7 +145,7 @@ export class MassObject { const arrowDirection = Math.atan2(py - cy, px - cx); // Length of arrow based on distance (logarithmic scale) const distance = Math.sqrt((x - px) ** 2, (y - py) ** 2); - const arrowLength = Math.log(distance) * OFFSCREEN_OBJECT_LINE_SCALE / this.sim.display.scale; + const arrowLength = Math.log(distance + 1) * OFFSCREEN_OBJECT_LINE_SCALE / this.sim.display.scale; const startAx = px - arrowLength * Math.cos(arrowDirection); const startAy = py - arrowLength * Math.sin(arrowDirection); sim.display.drawArrow(startAx, startAy, px, py, { @@ -176,8 +176,8 @@ export class MassObject { {x: vx, y: vy}; const speed = Math.sqrt(velocity.x ** 2, velocity.y ** 2); const arrowDirection = Math.atan2(velocity.y, velocity.x); - // Prevent negative numbers by adding e - const arrowLength = Math.log(speed + 3) * vecScale / this.sim.display.scale; + // Prevent negative numbers by adding 1 + const arrowLength = Math.log(speed + 1) * vecScale / this.sim.display.scale; const endVx = x + arrowLength * Math.cos(arrowDirection); const endVy = y + arrowLength * Math.sin(arrowDirection); const style = VELOCITY_VECTOR_COLOR === 'object color' ? @@ -197,7 +197,7 @@ export class MassObject { const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2); const arrowDirection = Math.atan2(acceleration.y, acceleration.x); // Prevent negative numbers by adding e - const arrowLength = Math.log(accelerationMagnitude + 3) * vecScale / this.sim.display.scale; + const arrowLength = Math.log(accelerationMagnitude + 1) * vecScale / this.sim.display.scale; const endAx = x + arrowLength * Math.cos(arrowDirection); const endAy = y + arrowLength * Math.sin(arrowDirection); const style = ACCELERATION_VECTOR_COLOR === 'object color' ? diff --git a/options.js b/options.js index aa2263d..86e30f1 100644 --- a/options.js +++ b/options.js @@ -1,12 +1,14 @@ import { EVENT_OPTION_SET, + OBJECT_MAGIC_PROP_PREFIX, } from './config.js'; export class Options { sim = undefined; options = undefined; values = {}; - undefinedObj = {_undefined: true}; + undefinedObj = { [OBJECT_MAGIC_PROP_PREFIX + 'undefined']: true}; + nullObj = { [OBJECT_MAGIC_PROP_PREFIX + 'null']: true}; getStorageKey(path) { return `${path}:options`; @@ -29,7 +31,6 @@ export class Options { if (value === undefined) { value = defaultValue; } - console.log('initializing option', {path, defaultValue, value}); this.values[path] = value; } } @@ -37,15 +38,25 @@ export class Options { toStored(value) { if (value === undefined) { + // TODO: 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 === JSON.stringify(this.undefinedObj)) { + 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); } diff --git a/pointer.js b/pointer.js index 77b23a7..94f865d 100644 --- a/pointer.js +++ b/pointer.js @@ -132,8 +132,10 @@ export class Pointer { } else { const v = {...this.latestVelocity}; // Convert pointer velocity to simulation scale - v.x /= this.sim.display.scale; - v.y /= this.sim.display.scale; + // Also multiply by -1 because the camera is panning opposite to + // the pointer velocity + v.x /= -this.sim.display.scale; + v.y /= -this.sim.display.scale; this.sim.panning = { velocity: v @@ -172,11 +174,9 @@ export class Pointer { frame() { // Add another entry for the current pointer position const { pointerHistory } = this.sim.pointer ?? {}; - console.log('pointer history length', pointerHistory?.length); if (pointerHistory?.length) { const currentPointer = pointerHistory[pointerHistory.length - 1]; this.sim.pointer.updatePointer(currentPointer); - console.log('updating in case of idle pointer', currentPointer.x, currentPointer.y); } } } diff --git a/sim-tools.js b/sim-tools.js index 4325e88..afe850a 100644 --- a/sim-tools.js +++ b/sim-tools.js @@ -12,9 +12,9 @@ export function initializeTools(sim) { sim.toolbars = { tools: new Toolbar(sim, 'Tools'), modes: new Toolbar(sim, 'Modes'), - utils: new Toolbar(sim, 'Utility', { expanded: false }), + utils: new Toolbar(sim, 'Utils', { expanded: false }), options: new Toolbar(sim, 'Options'), - params: new Toolbar(sim, 'Parameters'), + params: new Toolbar(sim, 'Params'), debug: new Toolbar(sim, 'Debug', { expanded: false }), }; const { tools, modes, options, params, debug, utils } = sim.toolbars; diff --git a/simulator.js b/simulator.js index f480e2e..7004112 100644 --- a/simulator.js +++ b/simulator.js @@ -1,12 +1,11 @@ import { EVENT_ZOOM, FRAMERATE_SAMPLE_DURATION, - SCALE_POWER_MAX, - SCALE_POWER_MIN, } from './config.js'; import { Display } from './display.js'; import { System } from './system.js'; import { Options } from './options.js'; +import { Zoom } from './zoom.js'; import { simOptions } from './sim-options.js'; import { initializeTools } from './sim-tools.js'; @@ -19,12 +18,14 @@ export class Sim { playing = true; recentFrames = []; frameRate = 0; + panning = undefined; system = undefined; display = undefined; overlay = undefined; pointer = undefined; - panning = undefined; + zoom = undefined; + toolbarGroups = {}; toolbars = {}; @@ -36,6 +37,24 @@ export class Sim { onModeEnter = () => undefined; onModeLeave = () => undefined; + constructor(divId) { + this.divId = divId; + const div = document.getElementById(this.divId); + this.div = div; + + this.options = new Options(this, simOptions); + this.display = new Display(this); + this.system = new System(this); + this.zoom = new Zoom(this); + + initializeTools(this); + + // Initiate main loop + this.rawTime = document.timeline.currentTime; + this.time = 0; + requestAnimationFrame(t => this.loop(t)); + } + markFrame(t) { const { recentFrames: rfs } = this; rfs.push(t); @@ -50,54 +69,9 @@ export class Sim { } } - constructor(divId) { - this.divId = divId; - const div = document.getElementById(this.divId); - this.div = div; - - this.options = new Options(this, simOptions); - this.display = new Display(this); - this.system = new System(this); - - initializeTools(this); - - // Initiate main loop - this.rawTime = document.timeline.currentTime; - this.time = 0; - requestAnimationFrame(t => this.loop(t)); - } - - // 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. // velocity should be in Sim coordinate scale scheduleZoom({x, y}, factor, velocity) { - this.nextZoom = {x, y, factor, velocity}; - } - - // x, y should be in Sim coordinates - // 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 - // the new view origin should be x, y minus half the new view width and height - // compute new scale - this.display.scalePower += factor; - // TODO: Lossy rescaling to expand zoom range - if (this.display.scalePower > SCALE_POWER_MAX) this.display.scalePower = SCALE_POWER_MAX; - if (this.display.scalePower < SCALE_POWER_MIN) this.display.scalePower = SCALE_POWER_MIN; - // compute coordinates of new view frame - this.display.viewOrigin.x = x - this.display.width / 2; - this.display.viewOrigin.y = y - this.display.height / 2; - - this.pointer.clearPointerHistory(); - - if (this.playing && velocity) { - this.panning = { - velocity: { ...velocity } - }; - } - - const e = new CustomEvent(EVENT_ZOOM); - this.div.dispatchEvent(e); + this.zoom.scheduleZoom({x, y}, factor, velocity); } // Transform display coordinates to simulator coordinates using scale and viewOrigin @@ -115,7 +89,8 @@ export class Sim { getScaleDisplay() { const scale = 2 ** Math.abs(this.display.scalePower); - return this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`; + const scaleText = this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`; + return `${scaleText} (${this.display.scalePower})`; } // cb: () => undefined @@ -145,19 +120,7 @@ export class Sim { this.info['Frame Rate'] = this.frameRate?.toPrecision(3); } - if (this.nextZoom) { - this.zoom(this.nextZoom); - this.nextZoom = undefined; - } - - if (this.getOption('debug.panningInfo')) { - const {x, y} = this.panning?.velocity ?? {}; - this.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)]; - const { centerOfMass } = this.system.computeSystemCenter(); - this.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)]; - this.info['Net Angular Momentum'] = this.system.computeSystemAngularMomentum().toPrecision(6); - } - + this.zoom.frame(); this.display.frame(elapsedTime); this.system.frame(elapsedTime); this.overlay.frame(); diff --git a/system.js b/system.js index aae419a..4094406 100644 --- a/system.js +++ b/system.js @@ -1,5 +1,4 @@ import { MassObject } from './object.js'; -import { ZOOM_TO_FIT_PADDING } from './config.js'; export class System { objects = []; @@ -88,25 +87,39 @@ export class System { } get boundingBox() { - const box = this.reduce((acc, obj) => { - if (acc.start.x === undefined) { - acc.start = {...obj.position}; - acc.end = {...obj.position}; - } else { - if (obj.position.x < acc.start.x) acc.start.x = obj.position.x; - if (obj.position.x > acc.end.x) acc.end.x = obj.position.x; - if (obj.position.y < acc.start.y) acc.start.y = obj.position.y; - if (obj.position.y > acc.end.y) acc.end.y = obj.position.y; + const box = this.reduce(({start, end}, obj) => { + 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 = { + start: {x: lx, y: ly}, + end: {x: gx, y: gy}, + }; + return ret; } + ret = { + start: { + x: Math.min(start.x, lx), + y: Math.min(start.y, ly), + }, + end: { + x: Math.max(end.x, gx), + y: Math.max(end.y, gy), + } + }; + return ret; }, { start: {x: undefined, y: undefined}, end: {x: undefined, y: undefined}, }); - box.start.x = (box.start.x ?? 0) - ZOOM_TO_FIT_PADDING; - box.start.y = (box.start.y ?? 0) - ZOOM_TO_FIT_PADDING; - box.end.x = (box.end.x ?? 0) + ZOOM_TO_FIT_PADDING; - box.end.y = (box.end.y ?? 0) + ZOOM_TO_FIT_PADDING; + box.start.x = (box.start.x ?? 0); + box.start.y = (box.start.y ?? 0); + box.end.x = (box.end.x ?? 0); + box.end.y = (box.end.y ?? 0); return box; } diff --git a/tool/utility.js b/tool/utility.js index a5c3f70..46dccd6 100644 --- a/tool/utility.js +++ b/tool/utility.js @@ -7,10 +7,6 @@ import { export class UtilityTool extends Tool { currentTimeEl = undefined; - get displayScaleText() { - return `Scale: ${this.sim.getScaleDisplay()}`; - } - get timeText() { let time = this.sim.time; // Time in milliseconds @@ -45,40 +41,27 @@ export class UtilityTool extends Tool { const zeroVelocity = document.createElement('button'); const clearTraces = document.createElement('button'); - const zoomAll = document.createElement('button'); - const currentScale = document.createElement('button') const currentTime = document.createElement('button'); const clearDebug = document.createElement('button'); this.currentTimeEl = currentTime; this.div.appendChild(currentTime); - this.div.appendChild(currentScale); - this.div.appendChild(zoomAll); this.div.appendChild(zeroVelocity); this.div.appendChild(clearTraces); this.div.appendChild(clearDebug); zeroVelocity.classList.add(WIDE_CLASSNAME); clearTraces.classList.add(WIDE_CLASSNAME); - zoomAll.classList.add(WIDE_CLASSNAME); - currentScale.classList.add(WIDE_CLASSNAME); - currentScale.classList.add(TOOL_INFO_CLASSNAME); currentTime.classList.add(TOOL_INFO_CLASSNAME); currentTime.classList.add(WIDE_CLASSNAME); clearDebug.classList.add(WIDE_CLASSNAME); zeroVelocity.innerHTML = 'Zero Momentum'; clearTraces.innerHTML = 'Clear Traces'; - zoomAll.innerHTML = 'Zoom to Fit'; - currentScale.innerHTML = this.displayScaleText; currentTime.innerHTML = this.timeText; clearDebug.innerHTML = 'Clear Debug'; - this.sim.onZoom(() => { - currentScale.innerHTML = this.displayScaleText; - }); - zeroVelocity.addEventListener('click', () => { // Determine center of mass and average momentum const { totalMass, netMomentum } = this.sim.system.computeSystemCenter(); @@ -104,36 +87,6 @@ export class UtilityTool extends Tool { }, {alive: null}); }); - zoomAll.addEventListener('click', () => { - // Determine bounding box - const box = this.sim.system.boundingBox; - 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; - const heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height; - const biggerRatio = Math.max(widthRatio, heightRatio); - const base2factor = Math.log(1/biggerRatio) / Math.log(2) - 0.5; - const factor = Math.floor(base2factor); - // Determine average momentum and set panning velocity to match - const netMomentum = {x: 0, y: 0}; - let totalMass = 0; - let count = 0; - this.sim.system.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, - }; - this.sim.scheduleZoom({x, y}, factor, netVelocity) - }); - clearDebug.addEventListener('click', () => { this.sim.info = {}; }); diff --git a/tool/zoom.js b/tool/zoom.js index ff738f2..5a4cea2 100644 --- a/tool/zoom.js +++ b/tool/zoom.js @@ -2,20 +2,42 @@ import { Tool } from '../tool.js'; import { ZOOM_IN_FACTOR, ZOOM_OUT_FACTOR, + WIDE_CLASSNAME, + TALL_CLASSNAME, + TOOL_INFO_CLASSNAME, } from '../config.js'; export class Zoom extends Tool { + get displayScaleText() { + return `Scale: ${this.sim.getScaleDisplay()}`; + } + constructor(container) { super(container); + const currentScale = document.createElement('button') const zoomOut = document.createElement('button'); const zoomIn = document.createElement('button'); + const zoomAll = document.createElement('button'); + this.div.appendChild(currentScale); this.div.appendChild(zoomOut); this.div.appendChild(zoomIn); + this.div.appendChild(zoomAll); + currentScale.classList.add(WIDE_CLASSNAME); + currentScale.classList.add(TOOL_INFO_CLASSNAME); + zoomAll.classList.add(WIDE_CLASSNAME); + zoomAll.classList.add(TALL_CLASSNAME); + + currentScale.innerHTML = this.displayScaleText; zoomOut.innerHTML = 'Zoom
Out'; zoomIn.innerHTML = 'Zoom
In'; + zoomAll.innerHTML = 'Zoom to Fit'; + + this.sim.onZoom(() => { + currentScale.innerHTML = this.displayScaleText; + }); zoomOut.addEventListener('click', () => { // Aim at center of view @@ -30,5 +52,25 @@ export class Zoom extends Tool { const y = this.sim.display.height * this.sim.display.scale / 2; this.sim.scheduleZoom(this.sim.screenToSim(x, y), ZOOM_IN_FACTOR); }); + + zoomAll.addEventListener('click', () => { + // Determine bounding box + const box = this.sim.system.boundingBox; + 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; + const heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height; + const biggerRatio = Math.max(widthRatio, heightRatio); + const base2factor = Math.log2(1 / biggerRatio) - 1; + const factor = Math.ceil(base2factor); + + // Determine average momentum and set panning velocity to match + const { netMomentum, totalMass } = this.sim.system.computeSystemCenter(); + const netVelocity = { + x: netMomentum.x / totalMass, + y: netMomentum.y / totalMass, + }; + this.sim.scheduleZoom({x, y}, factor, netVelocity) + }); } } diff --git a/zoom.js b/zoom.js new file mode 100644 index 0000000..f0876f0 --- /dev/null +++ b/zoom.js @@ -0,0 +1,58 @@ +import { + EVENT_ZOOM, + SCALE_POWER_MAX, + SCALE_POWER_MIN, +} from './config.js'; + +export class Zoom { + sim = undefined; + nextZoom = undefined; + + constructor(sim) { + this.sim = sim; + } + + // velocity should be in Sim coordinate scale + scheduleZoom({x, y}, factor, velocity) { + this.nextZoom = {x, y, factor, velocity}; + } + + frame() { + if (this.nextZoom) { + this.zoom(this.nextZoom); + this.nextZoom = undefined; + } + } + + // x, y should be in Sim coordinates + // velocity should be in Sim coordinate scale + zoom({x, y, factor, velocity}) { + const { display } = this.sim; + // 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 + // compute new scale + // TODO: Lossy rescaling to expand zoom range + let scalePower = display.scalePower + factor; + scalePower = Math.max(scalePower, SCALE_POWER_MIN); + scalePower = Math.min(scalePower, SCALE_POWER_MAX); + this.sim.display.scalePower = scalePower; + + // compute coordinates of new view frame + display.viewOrigin.x = x - display.width / 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 + if (this.sim.playing && velocity) { + this.sim.panning = { + velocity: { ...velocity } + }; + } + + const e = new CustomEvent(EVENT_ZOOM); + this.sim.div.dispatchEvent(e); + } + +}