From eac659822b366d427feac79aa93108c676cfddb6 Mon Sep 17 00:00:00 2001 From: Ladd Date: Sat, 3 Jan 2026 13:10:38 -0600 Subject: [PATCH] refactored panning to its own class --- config.js | 1 + display.js | 36 +------------ object.js | 62 +++++++++++----------- options-1.js | 129 --------------------------------------------- options.js | 6 +-- panning.js | 117 ++++++++++++++++++++++++++++++++++++++++ pointer.js | 89 ++----------------------------- sim-options.js | 7 +-- simulator.js | 24 ++++++--- system.js | 28 +++++----- tool/options.js | 4 +- tool/play-pause.js | 18 +++---- tool/zoom.js | 16 +++--- vector.js | 39 +++++--------- zoom.js | 16 ++---- 15 files changed, 227 insertions(+), 365 deletions(-) delete mode 100644 options-1.js create mode 100644 panning.js diff --git a/config.js b/config.js index b97bd0b..0a6f84b 100644 --- a/config.js +++ b/config.js @@ -45,6 +45,7 @@ export const EVENT_MODE_LEAVE = 'lhg-mode-leave'; 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'; // MODES export const MODE_MASS_GENERATION = 'mass-gen'; diff --git a/display.js b/display.js index 9fdbd22..6451f22 100644 --- a/display.js +++ b/display.js @@ -22,41 +22,7 @@ export class Display { 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 this.fillCanvas(); } diff --git a/object.js b/object.js index 7a982e0..684a841 100644 --- a/object.js +++ b/object.js @@ -13,6 +13,7 @@ import { VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_WIDTH, } from './config.js'; +import {add, components, direction, div, magnitude} from './vector.js'; export class MassObject { sim = undefined; @@ -80,7 +81,7 @@ export class MassObject { const { display: { - ctx, + ctx, height: H, width: W, viewOrigin: {x: ox, y: oy}, @@ -175,36 +176,35 @@ export class MassObject { // 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 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')) { - velocity.x = vx + pointerV.x / this.sim.timeScale; - velocity.y = vy + pointerV.y / this.sim.timeScale; + pointerV = div(pointerV, this.sim.timeScale); } + velocity = add(velocity, pointerV); } - const speed = Math.sqrt(velocity.x ** 2, velocity.y ** 2) / this.sim.display.scale; - const arrowDirection = Math.atan2(velocity.y, velocity.x); + const speed = magnitude(velocity); + const arrowDirection = direction(velocity); // 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); + // Make arrow lengths appear consistent regardless of display scale + let arrowLength = Math.log10(speed + 1) * vecScale; + if (!this.sim.getOption('display.zoomVectors')) { + arrowLength /= this.sim.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; - sim.display.drawArrow(startVx, startVy, endVx, endVy, { + sim.display.drawArrow(start.x, start.y, end.x, end.y, { style, width: VELOCITY_VECTOR_WIDTH, arrowhead: VELOCITY_VECTOR_ARROWHEAD, @@ -216,21 +216,21 @@ export class MassObject { // Draw arrow for acceleration if (sim.getOption('display.acceleration')) { const vecScale = this.sim.getOption('display.accelerationScale'); - const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2) / - this.sim.display.scale; - const arrowDirection = Math.atan2(acceleration.y, acceleration.x); + const accel = magnitude(acceleration); + const arrowDirection = direction(acceleration); // 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); + // TODO: Make logarithmic vector length scale optional + // 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; - sim.display.drawArrow(startAx, startAy, endAx, endAy, { + sim.display.drawArrow(start.x, start.y, end.x, end.y, { style, width: ACCELERATION_VECTOR_WIDTH, arrowhead: ACCELERATION_VECTOR_ARROWHEAD, diff --git a/options-1.js b/options-1.js deleted file mode 100644 index dec1961..0000000 --- a/options-1.js +++ /dev/null @@ -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; - } -} diff --git a/options.js b/options.js index eee362f..2210823 100644 --- a/options.js +++ b/options.js @@ -96,11 +96,11 @@ export class Options { this.sim.div.dispatchEvent(e); } - // cb: (value) => undefined + // cb: ({path, value}) => undefined onOptionSet(path, cb) { this.sim.div.addEventListener(EVENT_OPTION_SET, (e) => { - if (path === e.detail.path) { - cb(e.detail.value); + if (!path || path === e.detail.path) { + cb({ path, value: e.detail.value }); } }); } diff --git a/panning.js b/panning.js new file mode 100644 index 0000000..fefd8ba --- /dev/null +++ b/panning.js @@ -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; + } + } +} diff --git a/pointer.js b/pointer.js index ac1e72f..0977cb4 100644 --- a/pointer.js +++ b/pointer.js @@ -46,7 +46,6 @@ export class Pointer { }); el.addEventListener('pointerleave', e => { - console.log('pointerleave', {x: e.clientX, y: e.clientY}); this.handlePointerUp({x: e.clientX, y: e.clientY}); }); @@ -66,7 +65,6 @@ export class Pointer { } handlePointerDown({x: clientX, y: clientY}) { - // this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE); this.updatePointer({x: clientX, y: clientY}); switch (this.sim.getCurrentMode()) { @@ -76,19 +74,7 @@ export class Pointer { break; } case MODE_PAN_VIEW: { - this.touchStart = { - 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, - }; + this.sim.panning.handlePointerDown({x: clientX, y: clientY}); break; } case MODE_OBJECT_SELECT: { @@ -110,41 +96,7 @@ export class Pointer { break; } case MODE_PAN_VIEW: { - if (this.touchStart) { - 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 - }; - } - } - + this.sim.panning.handlePointerMove({x: clientX, y: clientY}); break; } case MODE_OBJECT_SELECT: { @@ -162,37 +114,7 @@ export class Pointer { break; } case MODE_PAN_VIEW: { - // Set panning velocity - 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 - }; - } - } + this.sim.panning.handlePointerUp({x: clientX, y: clientY}); break; } case MODE_OBJECT_SELECT: { @@ -227,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}) { const t = this.sim.rawTime; while (this.pointerHistory.length >= POINTER_HISTORY_SIZE) { diff --git a/sim-options.js b/sim-options.js index cee36c4..598f2be 100644 --- a/sim-options.js +++ b/sim-options.js @@ -4,13 +4,14 @@ export const simOptions = { selection: ['Pause While Selecting', 'boolean', true], }, display: { - traces: ['Path Traces', 'boolean', true], - dashedTraces: ['Dashed', 'boolean', false, {tall: true}], + targetFrameRate: ['Frame Rate', 'number', 60], velocity: ['Velocity Vectors', 'boolean', true], acceleration: ['Accel Vectors', 'boolean', true], + traces: ['Path Traces', 'boolean', true], + dashedTraces: ['Dashed', 'boolean', false, {tall: true, hideUnless: 'display.traces'}], velocityScale: ['Velocity
Vec Scale', 'number', 80, {hideUnless: 'display.velocity'}], accelerationScale: ['Accel
Vec Scale', 'number', 800, {hideUnless: 'display.acceleration'}], - targetFrameRate: ['Frame Rate', 'number', 60], + zoomVectors: ['Zoom Vectors', 'boolean', true] }, compensate: { timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}], diff --git a/simulator.js b/simulator.js index 51f0663..4d02bd8 100644 --- a/simulator.js +++ b/simulator.js @@ -1,4 +1,5 @@ import { + EVENT_PLAY_PAUSE, EVENT_ZOOM, FRAMERATE_SAMPLE_DURATION, } from './config.js'; @@ -8,6 +9,7 @@ import {Overlay} from './overlay.js'; import {Pointer} from './pointer.js'; import {Options} from './options.js'; import {Zoom} from './zoom.js'; +import {Panning} from './panning.js'; import {Select} from './select.js'; import {simOptions} from './sim-options.js'; import {initializeTools} from './sim-tools.js'; @@ -20,7 +22,6 @@ export class Sim { nextZoom = undefined; playing = true; recentFrames = []; - panning = undefined; frameRate = 0; system = undefined; @@ -28,6 +29,7 @@ export class Sim { overlay = undefined; pointer = undefined; zoom = undefined; + panning = undefined; select = undefined; toolbarGroups = {}; @@ -52,6 +54,7 @@ export class Sim { this.overlay = new Overlay(this); this.pointer = new Pointer(this); this.zoom = new Zoom(this); + this.panning = new Panning(this); this.select = new Select(this); initializeTools(this); @@ -82,14 +85,15 @@ export class Sim { if (this.getOption('debug.frameRate')) { this.info['Frame Rate'] = this.frameRate.toPrecision(3); } - this.zoom.frame(); - this.pointer.frame(); + this.zoom.frame(elapsedTime); + this.panning.frame(elapsedTime); + this.pointer.frame(elapsedTime); this.display.frame(elapsedTime); - this.select.frame(); + this.select.frame(elapsedTime); this.system.frame(elapsedTime); - this.overlay.frame(); + this.overlay.frame(elapsedTime); for (const group in this.toolbarGroups) { - this.toolbarGroups[group].frame(); + this.toolbarGroups[group].frame(elapsedTime); } requestAnimationFrame(t => this.frame(t)); } @@ -131,9 +135,12 @@ export class Sim { play() { this.playing = true; - if (this.panning?.paused) { + if (this.panning.paused) { this.panning.paused = false; } + + const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}}); + this.div.dispatchEvent(e); } pause() { @@ -142,6 +149,9 @@ export class Sim { if (this.panning?.velocity) { this.panning.paused = true; } + + const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}}); + this.div.dispatchEvent(e); } getScaleDisplay() { diff --git a/system.js b/system.js index 446381c..ff991b6 100644 --- a/system.js +++ b/system.js @@ -1,9 +1,9 @@ import {OBJECT_HISTORY_SIZE} from './config.js'; import {MassObject} from './object.js'; import { - add, cross, direction, div, magnitude, mult, - square, - sub, weightedAvg, zero + add, copy, cross, degrees, + direction, div, magnitude, mult, + square, sub, weightedAvg, zero } from './vector.js'; export class System { @@ -43,9 +43,7 @@ export class System { obj.velocity = div(obj.velocity, this.sim.timeScale); } - if (this.sim.panning?.velocity) { - obj.velocity = add(obj.velocity, this.sim.panning.velocity); - } + obj.velocity = add(obj.velocity, this.sim.panning.velocity); } handlePointerMove(r) { @@ -125,8 +123,8 @@ export class System { // Combine forces // Add masses // Average color - S.position = weightedAvg(S.position, S.mass, T.position, T.mass); - S.velocity = weightedAvg(S.velocity, S.mass, T.velocity, T.mass); + S.position = weightedAvg([[S.position, S.mass], [T.position, T.mass]]); + S.velocity = weightedAvg([[S.velocity, S.mass], [T.velocity, T.mass]]); S.forces.push(...T.forces); S.mass += T.mass; S.color = { @@ -171,9 +169,9 @@ export class System { this.forEachObject((obj, i) => { const speed = magnitude(obj.velocity); const accel = magnitude(obj.acceleration); - // Invert y so that the angle is counterclockwise from x-axis - const velocityDir = direction(obj.velocity); - const accelDir = direction(obj.acceleration); + // Give angle counterclockwise from horizontal + const velocityDir = -1 * degrees(direction(obj.velocity)); + const accelDir = -1 * degrees(direction(obj.acceleration)); const {r, g, b} = obj.color; this.sim.info[`Object ${i}`] = [ `  `, @@ -215,9 +213,7 @@ export class System { this.pause(); } - if (this.sim.panning?.velocity) { - obj.velocity = {...this.sim.panning.velocity}; - } + obj.velocity = copy(this.sim.panning.velocity); } doneCreatingObject() { @@ -367,7 +363,7 @@ export class System { this.reduce((acc, obj) => ({ count: acc.count + 1, totalMass: acc.totalMass + obj.mass, - totalMassLocation: add(acc.totalMassLocation, + totalMassLocation: add(acc.totalMassLocation, mult(obj.position, obj.mass)), }), { totalMassLocation: {x: 0, y: 0}, @@ -378,7 +374,7 @@ export class System { const centerOfMass = count ? div(totalMassLocation, totalMass) : zero; // Determine average momentum - const netMomentum = this.reduce((acc, obj) => + const netMomentum = this.reduce((acc, obj) => add(acc, mult(obj.velocity, obj.mass)), zero); return {totalMass, count, totalMassLocation, centerOfMass, netMomentum}; diff --git a/tool/options.js b/tool/options.js index 0a87010..fe04a1e 100644 --- a/tool/options.js +++ b/tool/options.js @@ -48,7 +48,7 @@ export class OptionsTool extends Tool { if (item.tall === true) button.classList.add(TALL_CLASSNAME); const value = this.sim.getOption(path); button.style.opacity = value ? '100%' : '50%'; - this.sim.onOptionSet(path, value => { + this.sim.onOptionSet(path, ({value}) => { button.style.opacity = value ? '100%' : '50%'; }); button.addEventListener('click', () => { @@ -81,7 +81,7 @@ export class OptionsTool extends Tool { this.sim.setOption(path, input.value); }); - this.sim.onOptionSet(path, value => { + this.sim.onOptionSet(path, ({value}) => { input.value = value; }); diff --git a/tool/play-pause.js b/tool/play-pause.js index 21412e4..c9f691d 100644 --- a/tool/play-pause.js +++ b/tool/play-pause.js @@ -1,4 +1,5 @@ import { + EVENT_PLAY_PAUSE, TALL_CLASSNAME } from '../config.js'; import {Tool} from '../tool.js'; @@ -17,6 +18,10 @@ export class PlayPause extends Tool { setContainer(container) { super.setContainer(container); this.updateButtons(); + + this.sim.div.addEventListener(EVENT_PLAY_PAUSE, () => { + this.updateButtons(); + }); } constructor(container) { @@ -38,18 +43,13 @@ export class PlayPause extends Tool { pauseButton.classList.add(TALL_CLASSNAME); pauseButton.addEventListener('click', () => { - this.sim.panning = undefined; - if (this.sim.playing) { - this.sim.playing = false; - this.updateButtons(); - } + this.sim.pause(); + this.updateButtons(); }); playButton.addEventListener('click', () => { - if (!this.sim.playing) { - this.sim.playing = true; - this.updateButtons(); - } + this.sim.play(); + this.updateButtons(); }); } } diff --git a/tool/zoom.js b/tool/zoom.js index d682a3f..6906be4 100644 --- a/tool/zoom.js +++ b/tool/zoom.js @@ -1,10 +1,11 @@ -import { Tool } from '../tool.js'; -import { +import { + TOOL_INFO_CLASSNAME, + WIDE_CLASSNAME, ZOOM_IN_FACTOR, ZOOM_OUT_FACTOR, - WIDE_CLASSNAME, - TOOL_INFO_CLASSNAME, } from '../config.js'; +import {Tool} from '../tool.js'; +import {zero} from '../vector.js'; export class Zoom extends Tool { currentScaleEl = undefined; @@ -73,7 +74,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(); const netVelocity = { x: netMomentum.x / totalMass, y: netMomentum.y / totalMass, @@ -83,7 +84,7 @@ export class Zoom extends Tool { zeroVelocity.addEventListener('click', () => { // Determine center of mass and average momentum - const { totalMass, netMomentum } = this.sim.system.computeSystemCenter(); + const {totalMass, netMomentum} = this.sim.system.computeSystemCenter(); const netVelocity = { x: netMomentum.x / totalMass, y: netMomentum.y / totalMass, @@ -96,8 +97,7 @@ export class Zoom extends Tool { }); // Cancel panning - this.sim.panning = undefined; + this.sim.panning.setVelocity(zero); }); - } } diff --git a/vector.js b/vector.js index 1ed2934..5b53704 100644 --- a/vector.js +++ b/vector.js @@ -1,15 +1,21 @@ +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 (v.x !== undefined) return {x: v.x * m, y: v.y * m}; - else return {x: m.x * v, y: m.y * v}; + 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; @@ -20,27 +26,10 @@ export const weightedAvg = (items) => { return div(res, W); }; -// Angle is given counterclockwise, assuming screen coordinates -export const direction = ({x, y}) => Math.atan2(-y, x) * 180 / Math.PI; +export const components = (mag, dir) => mult(mag, { + x: Math.cos(dir), + y: Math.sin(dir), +}); -export class Vector { - x = undefined; - y = undefined; - - constructor({x, y}) { - this.x = x; - this.y = y; - } - - dot(v) {return dot(this, v);} - cross(v) {return cross(this, v);} - add(v) {return new Vector(add(this, v));} - sub(v) {return new Vector(sub(this, v));} - magnitude() {return magnitude(this);} - mult(m) {return new Vector(mult(this, m));} - div(m) {return new Vector(div(this, m));} - - toJSON() { - return {x: this.x, y: this.y}; - } -} +export const direction = ({x, y}) => Math.atan2(y, x); +export const degrees = (rad) => rad * 180 / Math.PI; diff --git a/zoom.js b/zoom.js index 0594a7a..fb67579 100644 --- a/zoom.js +++ b/zoom.js @@ -3,6 +3,7 @@ import { SCALE_POWER_MAX, SCALE_POWER_MIN, } from './config.js'; +import {div, sub} from './vector.js'; export class Zoom { sim = undefined; @@ -27,7 +28,7 @@ export class Zoom { // x, y should be in Sim coordinates // velocity should be in Sim coordinate scale 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 // the new view origin should be x, y minus half the new view width and height // compute new scale @@ -35,22 +36,15 @@ export class Zoom { let scalePower = display.scalePower + factor; scalePower = Math.max(scalePower, SCALE_POWER_MIN); scalePower = Math.min(scalePower, SCALE_POWER_MAX); - this.sim.display.scalePower = scalePower; + 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(); + display.viewOrigin = sub({x, y}, div({x: display.width, y: display.height}, 2)); // TODO: If paused, set panning velocity on resume // if (this.sim.playing && velocity) { if (velocity) { - this.sim.panning = { - velocity: { ...velocity }, - paused: !this.sim.playing, - }; + this.sim.panning.setVelocity(velocity); } const e = new CustomEvent(EVENT_ZOOM);