diff --git a/Readme.md b/Readme.md index 0f374ab..40ba8ab 100644 --- a/Readme.md +++ b/Readme.md @@ -12,19 +12,22 @@ Screenshots ![A greater mass attracts others which had been on escape trajectories](./gravity-simulator-5.png "Gravity Simulator Screenshot 5") +![A small object orbits a more massive binary pair](./gravity-simulator-6.png "Gravity Simulator Screenshot 6") + + 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 +- [x] Enhancement: Calculate Work as FxD as measure of energy flux - [ ] Feature: Automatically slow time when energy flux is greater - [ ] Enhancement: Add z-axis (Initially nothing changes, z = 0) - [ ] Feature: Isometric 3d View @@ -32,9 +35,11 @@ TODO - [ ] Feature: Polar Coordinates - [ ] Feature: Cylindrical Coordinates - [ ] Feature: Spherical Coordinates -- [ ] Feature: Camera Velocity Display -- [ ] Enhancement: World State Snapshots -- [ ] Feature: List / Save / Load World States +- [x] Feature: Camera Velocity Display +- [x] Enhancement: World State Snapshots +- [x] Feature: List / Save / Load World States +- [ ] Enhancement: Save / Load Snapshots from Local Storage +- [x] Feature: Import / Export / Share Snapshots - [ ] Feature: Left Button Panning - [ ] Feature: Middle Button Pause - [ ] Feature: Parameter Slider (Invisible, mouse/touch drag) @@ -52,5 +57,5 @@ 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 +- [ ] Enhancement: Refactor to use viewOrigin as center of display canvas diff --git a/config.js b/config.js index 8ce6543..cb706d7 100644 --- a/config.js +++ b/config.js @@ -19,6 +19,9 @@ export const PATH_TRACES_OPACITY = 0.8; export const PATH_TRACES_WIDTH = 1.5; export const PATH_TRACES_DASHED_OPACITY = 1.0; +// PANNING +export const PANNING_ZERO_TOUCH_THRESHOLD = 200; + // SIZES export const POINTER_HISTORY_SIZE = 20; export const OBJECT_HISTORY_SIZE = 1e5; @@ -51,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'; diff --git a/display.js b/display.js index 38ed700..db26bb3 100644 --- a/display.js +++ b/display.js @@ -20,6 +20,18 @@ export class Display { } } + toJSON() { + return { + scalePower: this.scalePower, + viewOrigin: this.viewOrigin, + }; + } + + fromJSON({scalePower, viewOrigin}) { + this.scalePower = scalePower; + this.viewOrigin = viewOrigin; + } + frame() { // Clear canvas in preparation for other modules to render this frame this.fillCanvas(); @@ -140,7 +152,7 @@ export class Display { ctx.resetTransform(); } - drawBox({start, end}) { + drawBox(start, end) { const ctx = this.ctx; ctx.strokeStyle = 'rgb(0, 255, 0)'; ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y); diff --git a/gravity-simulator-6.png b/gravity-simulator-6.png new file mode 100644 index 0000000..d0117d2 Binary files /dev/null and b/gravity-simulator-6.png differ diff --git a/helper.js b/helper.js index 33d0443..16a2a1f 100644 --- a/helper.js +++ b/helper.js @@ -1,2 +1,51 @@ -export function makeUtilityButton() { +// `items` is an array of which `item` is a member +// `item` must let us read/write property `hidden` +// `parentEl` is the containing element for `itemEl` +// `itemEl` is the +// +// The idea is that item remains a member of items, but +// its elementmay be added and removed from the parent element. +// We use the items array to determine the placement of itemEl +export function show({items, item, parentEl, itemEl}) { + if (items.length < 2) { + parentEl.appendChild(itemEl); + return; + } + // To determine placement, + // Start with our index in the toolbar tools; + // iterate through toolbar tools before this one, + // and subtract hidden ones from the index. + + let countHidden = 0; + let index = items.indexOf(item); + for (let i = 0; i < index; i++) { + const sibling = items[i]; + if (sibling.hidden) countHidden += 1; + } + index -= countHidden; + + // Now we need to find our place. + // Add to parent using insertBefore. + let idx = 0; + let nextEl = parentEl.firstChild; + while (idx < index) { + nextEl = nextEl.nextSibling; + idx += 1; + } + parentEl.insertBefore(itemEl, nextEl); + item.hidden = false; +} + +export function hide({items, item, parentEl, itemEl}) { + if (items.indexOf(item) < 0) return; + parentEl.removeChild(itemEl); + item.hidden = true; +} + +// Copied from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest +export async function hash(text) { + const msgUint8 = new TextEncoder().encode(text); // encode as (utf-8) Uint8Array + const hashBuffer = await window.crypto.subtle.digest("SHA-256", msgUint8); // hash the message + const hashHex = new Uint8Array(hashBuffer).toHex(); // Convert ArrayBuffer to hex string. + return hashHex; } diff --git a/object.js b/object.js index a1336c5..ab403e9 100644 --- a/object.js +++ b/object.js @@ -16,7 +16,7 @@ import { VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_WIDTH, } from './config.js'; -import {add, components, direction, div, magnitude, zero} from './vector.js'; +import {add, components, copy, direction, div, magnitude, zero} from './vector.js'; export class MassObject { sim = undefined; @@ -27,7 +27,7 @@ export class MassObject { velocity = zero; acceleration = zero; color = {r: undefined, g: undefined, b: undefined}; - created = undefined; + timeCreated = undefined; forces = []; // [{x, y}] history = []; alive = true; @@ -44,17 +44,39 @@ export class MassObject { this.color.g = Math.random() * 256; this.color.b = Math.random() * 256; this.timeCreated = this.sim.time; - this.rawTimeCreated = this.sim.rawTime; + } + + toJSON() { + return { + id: this.id, + mass: this.mass, + density: this.density, + position: this.position, + velocity: this.velocity, + color: this.color, + timeCreated: this.timeCreated, + alive: this.alive, + // TODO: optional export history + }; + } + + fromJSON(obj) { + this.id = obj.id; + this.mass = obj.mass; + this.density = obj.density; + this.position = copy(obj.position); + this.velocity = copy(obj.velocity); + this.color = obj.color; + this.timeCreated = obj.timeCreated; + this.alive = obj.alive; + // TODO: optional import history + this.history = []; } get age() { return this.sim.time - this.timeCreated; } - get rawAge() { - return this.sim.rawTime - this.rawTimeCreated; - } - get radius() { // radius should be proportional to cube root of mass return Math.pow(this.mass / this.density, 1 / 3); diff --git a/panning.js b/panning.js index fefd8ba..d53463b 100644 --- a/panning.js +++ b/panning.js @@ -1,4 +1,5 @@ -import {add, copy, div, mult, sub, zero} from "./vector.js"; +import {PANNING_ZERO_TOUCH_THRESHOLD} from "./config.js"; +import {add, copy, div, mult, zero} from "./vector.js"; export class Panning { sim = undefined; @@ -11,10 +12,6 @@ export class Panning { this.sim = sim; } - handlePointerDown({x, y}) { - this.initializeTouch({x, y}); - } - initializeTouch({x, y}) { this.touchStart = { x, @@ -30,6 +27,25 @@ export class Panning { }; } + toJSON() { + return { + velocity: this.velocity, + paused: this.paused, + }; + } + + fromJSON({velocity, paused}) { + this.velocity = copy(velocity); + this.paused = paused; + } + + handlePointerDown({x, y}) { + this.initializeTouch({x, y}); + if (this.paused) { + this.paused = false; + } + } + // With fast panning, panning velocity calculation happens every move; // With normal panning, calculation only happens at pointer up. handlePointerMove({x, y}) { @@ -42,49 +58,46 @@ export class Panning { dy: x - this.touchStart.y, dt: this.sim.rawTime - this.touchStart.t, }; - if (this.sim.getOption('compensate.fastPanning')) { - this.updateVelocity(); + + // Convert pointer velocity to simulation scale + let velocity = div(this.sim.pointer.latestVelocity, this.sim.display.scale); + + // Optional time scale compensation + if (this.sim.getOption('compensate.timeScale')) { + velocity = div(velocity, this.sim.timeScale); } + + // Additional scaling factor + velocity = mult(velocity, this.sim.getOption('display.panningSpeed')); + + // TODO: Make it easier to slow down the camera + + // Add pointer velocity to current panning velocity + this.velocity = add(this.velocity, velocity); } } handlePointerUp() { if (this.touchStart && this.touchLatest) { - if (this.touchLatest.dt === 0) { + if (this.touchLatest.dt < PANNING_ZERO_TOUCH_THRESHOLD) { 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); + display.viewOrigin = add(display.viewOrigin, mult(this.velocity, elapsedTime)); } if (this.sim.getOption('debug.panningInfo')) { @@ -96,18 +109,6 @@ export class Panning { } } - 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) { diff --git a/pointer.js b/pointer.js index 0977cb4..2aaeca1 100644 --- a/pointer.js +++ b/pointer.js @@ -3,63 +3,52 @@ import { MODE_OBJECT_SELECT, MODE_PAN_VIEW, POINTER_HISTORY_SIZE, - TOOLBAR_CLASSNAME, ZOOM_IN_FACTOR, - ZOOM_OUT_FACTOR, + ZOOM_OUT_FACTOR } from './config.js'; export class Pointer { sim = undefined; pointerHistory = []; - touchStart = undefined; // {x: undefined, y: undefined, t: undefined}; - touchLatest = undefined; // {x: undefined, y: undefined, t: undefined}; suppressClick = false; constructor(sim) { this.sim = sim; // Monitor mouse movements - const el = window; + 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 => { - let target = e.target; - while (target && !target.classList?.contains(TOOLBAR_CLASSNAME)) { - target = target.parentNode; - } - if (target) { - return; - } - + 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'); }); } diff --git a/select.js b/select.js index 67144c4..fd0e064 100644 --- a/select.js +++ b/select.js @@ -1,4 +1,5 @@ -import {copy} from './vector.js'; +import {EVENT_SELECT} from './config.js'; +import {add, copy, mult} from './vector.js'; export class Select { sim = undefined; @@ -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,12 +52,24 @@ 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, }; } + frame(elapsedTime) { + if (!this.box.start) return; + // If panning, let's update the position of our box so it doesn't drift away + const {velocity} = this.sim.panning; + const delta = mult(velocity, elapsedTime); + this.box.start = add(this.box.start, delta); + this.box.end = add(this.box.end, delta); + // Display the box + this.sim.display.drawBox(this.box.start, this.box.end); + } + getSelectedObjects() { const {start, end} = this.box; if (!start) return; @@ -67,8 +80,11 @@ export class Select { this.selectedSingle = this.selectedGroup[0] ?? undefined; } - frame() { - if (!this.box.start) return; - this.sim.display.drawBox(this.box) + // cb: ({selectedGroup, selectedSingle}) => undefined + onSelect(cb) { + this.sim.div.addEventListener(EVENT_SELECT, () => { + const {selectedGroup, selectedSingle} = this; + cb({selectedGroup, selectedSingle}); + }); } } diff --git a/sim-options.js b/sim-options.js index 598f2be..c79a310 100644 --- a/sim-options.js +++ b/sim-options.js @@ -8,10 +8,11 @@ export const simOptions = { 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'}], - zoomVectors: ['Zoom Vectors', 'boolean', true] + dashedTraces: ['Dashed', 'boolean', false, {tall: true, showIf: 'display.traces'}], + velocityScale: ['Velocity
Vec Scale', 'number', 80, {showIf: 'display.velocity'}], + accelerationScale: ['Accel
Vec Scale', 'number', 800, {showIf: 'display.acceleration'}], + zoomVectors: ['Zoom Vectors', 'boolean', true], + panningSpeed: ['Pan
Speed', 'number', 0.1], }, compensate: { timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}], diff --git a/sim-tools.js b/sim-tools.js index 0abf9ab..81da2e6 100644 --- a/sim-tools.js +++ b/sim-tools.js @@ -1,8 +1,10 @@ +import {CameraTool} from './tool/camera.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 {PlayPause} from './tool/play-pause.js'; +import {StateTool} from './tool/state.js'; import {UtilityTool} from './tool/utility.js'; import {Zoom} from './tool/zoom.js'; import {ToolbarGroup} from './toolbar-group.js'; @@ -11,6 +13,12 @@ import {Toolbar} from './toolbar.js'; export function initializeTools(sim) { sim.toolbarGroups = { left: new ToolbarGroup(sim) + .addToolbar(new Toolbar(sim, 'Camera') + .addTool(new CameraTool()) + ) + .addToolbar(new Toolbar(sim, 'State') + .addTool(new StateTool()) + ) .addToolbar(new Toolbar(sim, 'Tools') .addTool(new Zoom()) .addTool(new PlayPause()) diff --git a/simulator.js b/simulator.js index 4d02bd8..d63fea7 100644 --- a/simulator.js +++ b/simulator.js @@ -4,15 +4,15 @@ import { FRAMERATE_SAMPLE_DURATION, } from './config.js'; import {Display} from './display.js'; -import {System} from './system.js'; -import {Overlay} from './overlay.js'; -import {Pointer} from './pointer.js'; import {Options} from './options.js'; -import {Zoom} from './zoom.js'; +import {Overlay} from './overlay.js'; import {Panning} from './panning.js'; +import {Pointer} from './pointer.js'; import {Select} from './select.js'; import {simOptions} from './sim-options.js'; import {initializeTools} from './sim-tools.js'; +import {System} from './system.js'; +import {Zoom} from './zoom.js'; export class Sim { info = {}; @@ -88,13 +88,19 @@ export class Sim { this.zoom.frame(elapsedTime); this.panning.frame(elapsedTime); this.pointer.frame(elapsedTime); + // The display.frame() wipes out the canvas, so all + // main canvas drawing routines must come after the next line. this.display.frame(elapsedTime); this.select.frame(elapsedTime); + // The system.frame() renders objects this.system.frame(elapsedTime); this.overlay.frame(elapsedTime); for (const group in this.toolbarGroups) { this.toolbarGroups[group].frame(elapsedTime); } + // Schedule our next iteration + // TODO: Consider waiting until the next frame is likely to execute, + // in order to aim closer to the target frame rate requestAnimationFrame(t => this.frame(t)); } @@ -122,6 +128,27 @@ export class Sim { return false; } + toJSON() { + return { + dateSaved: new Date().toISOString(), + system: this.system.toJSON(), + panning: this.panning.toJSON(), + display: this.display.toJSON(), + time: this.time, + timeScale: this.timeScale, + currentMode: this.getCurrentMode(), + }; + } + + fromJSON(state) { + this.system.fromJSON(state.system); + this.panning.fromJSON(state.panning); + this.display.fromJSON(state.display); + this.time = state.time; + this.timeScale = state.timeScale; + this.setCurrentMode(state.currentMode); + } + // velocity should be in Sim coordinate scale scheduleZoom({x, y}, factor, velocity) { this.zoom.scheduleZoom({x, y}, factor, velocity); diff --git a/style.css b/style.css index f969938..943ecc3 100644 --- a/style.css +++ b/style.css @@ -33,6 +33,10 @@ div[id=simulator] > canvas { left: 0; } +div.lhg-toolbar-group button { + /* opacity: 0.8; */ +} + /* normal toolbar group */ div.lhg-toolbar-group div.lhg-tool { width: 12em; @@ -102,7 +106,12 @@ div.lhg-tool button, div.lhg-tool input { box-sizing: border-box; } -div.lhg-tool button:hover { +div.lhg-tool button a { + color: #5f5; + text-decoration: none; +} + +div.lhg-tool button:hover, div.lhg-tool input:hover { background-color: #444; } diff --git a/system.js b/system.js index 637fd34..90f7317 100644 --- a/system.js +++ b/system.js @@ -1,9 +1,8 @@ -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, - direction, div, magnitude, mult, - square, sub, weightedAvg, zero + add, copy, cross, degrees, direction, div, + dot, magnitude, mult, square, sub, weightedAvg, zero } from './vector.js'; export class System { @@ -11,13 +10,30 @@ export class System { creatingObject = undefined; selectedObject = undefined; selectObjectStart = undefined; - panVelocityPaused = undefined; paused = false; constructor(sim) { this.sim = sim; } + toJSON() { + return { + objects: this.objects.map(obj => obj.toJSON()), + } + } + + fromJSON({objects} = {}) { + objects = objects ?? []; + // Replace current state with the provided one. + // Assumes a backup has already been saved if desired. + this.objects = []; + for (const objectJSON of objects) { + const obj = new MassObject(this.sim, 0, 0); + obj.fromJSON(objectJSON); + this.objects.push(obj); + } + } + handlePointerDown({x, y}) { // If pointer is touching an object, select the object const touchingObject = this.objectAtLocation(x, y); @@ -76,12 +92,11 @@ export class System { if (this.sim.playing) { // Predict positions (Velocity verlet method) this.forEachObject(obj => { - obj.currentAcceleration = {...obj.acceleration}; + obj.currentAcceleration = copy(obj.acceleration); - // If this object is being created/selected, clamp its position - if (obj.id === this.getSelectedOrCreating()?.id) { - return; - } + // If this object is being created/selected, we're not going to let it move... + // but we can calculate the work being done by holding it in place. + obj.currentPosition = copy(obj.position); obj.position = add(obj.position, mult( elapsedTime, @@ -134,6 +149,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}); }); @@ -143,10 +160,21 @@ export class System { // Predict velocities this.forEachObject(obj => { - const acceleration = {...obj.acceleration}; + const acceleration = copy(obj.acceleration); obj.acceleration = div(add(obj.currentAcceleration, acceleration), 2); obj.velocity = add(obj.velocity, mult(obj.acceleration, elapsedTime)); + + // If the user is positioning this object, we'll leave its position unchanged; + // but let's compute how much work we're doing to accomplish it! + if (obj.id === this.getSelectedOrCreating()?.id) { + const delta = sub(obj.currentPosition, obj.position); + const netForce = mult(obj.acceleration, obj.mass); + const work = dot(netForce, delta); + console.log('work', work); + obj.position = obj.currentPosition; + } + // Append to object history obj.history.push({position: {...obj.position}}); @@ -189,6 +217,8 @@ export class System { this.drawObjects(); } + // Pause and resume to enable automatic pause on object create/select + // in this mode (mass generation) pause() { this.sim.pause(); this.paused = true; @@ -212,8 +242,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 +254,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 +298,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 +321,6 @@ export class System { y: Math.max(end.y, gy), } }; - return ret; }, { start: {x: undefined, y: undefined}, end: {x: undefined, y: undefined}, @@ -373,15 +416,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 +436,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}; } @@ -411,5 +459,4 @@ export class System { return acc + obj.mass * s / d; }, 0); } - } diff --git a/tool.js b/tool.js index 0513e7b..91a7b2d 100644 --- a/tool.js +++ b/tool.js @@ -9,6 +9,7 @@ import { export class Tool { container = undefined; sim = undefined; + hidden = false; constructor() { const div = document.createElement('div'); diff --git a/tool/camera.js b/tool/camera.js new file mode 100644 index 0000000..a645a58 --- /dev/null +++ b/tool/camera.js @@ -0,0 +1,42 @@ +import {VELOCITY_VECTOR_COLOR} from '../config.js'; +import {Tool} from '../tool.js'; +import {add, components, direction, div, magnitude} from '../vector.js'; + +export class CameraTool extends Tool { + setContainer(container) { + super.setContainer(container); + + // Use the main sim display, but create a placeholder and draw inside it. + // That way we aren't blocking the main display more than necessary + } + + constructor() { + super(); + + this.div.style.width = '150px'; + this.div.style.height = '150px'; + } + + frame() { + if (!this.container.expanded) return; + + const {display, panning} = this.sim; + const {left, top, width, height} = this.div.getBoundingClientRect(); + const vecScale = this.sim.getOption('display.velocityScale'); + + // Draw a vector for the camera velocity + const offset = add(display.viewOrigin, div({x: left, y: top}, display.scale)); + const start = add(offset, div({x: width, y: height}, 2 * display.scale)); + let speed = magnitude(panning.velocity); + let arrowLength = Math.log10(speed + 1) * vecScale; + const arrowDirection = direction(panning.velocity); + if (!this.sim.getOption('display.zoomVectors')) { + arrowLength /= display.scale; + } + const end = add(start, components(arrowLength, arrowDirection)); + display.drawArrow(start.x, start.y, end.x, end.y, { + style: VELOCITY_VECTOR_COLOR, + ifShort: 'head', + }); + } +} diff --git a/tool/objects.js b/tool/objects.js index a7d61d7..b9c4585 100644 --- a/tool/objects.js +++ b/tool/objects.js @@ -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 = ` + ` + + '  ' + + `${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, + }); + } + } } } diff --git a/tool/options.js b/tool/options.js index fe04a1e..83ac97f 100644 --- a/tool/options.js +++ b/tool/options.js @@ -1,45 +1,92 @@ // Options picker import { - TOOL_INFO_CLASSNAME, OPTION_GROUP_CLASSNAME, - WIDE_CLASSNAME, TALL_CLASSNAME, + TOOL_INFO_CLASSNAME, + WIDE_CLASSNAME, } from '../config.js'; -import { Tool } from '../tool.js'; +import {Tool} from '../tool.js'; +import {show, hide} from '../helper.js'; export class OptionsTool extends Tool { - sections = undefined; + sectionNames = undefined; + groups = {}; - constructor(sections) { + constructor(sectionNames) { super(); - this.sections = sections; + this.sectionNames = sectionNames; } setContainer(container) { super.setContainer(container); - for (const sectionName of this.sections) { - const option = this.sim.options.getSection(sectionName); - const item = this.visitItem(option); + // Initialize + for (const sectionName of this.sectionNames) { + const group = this.sim.options.getSection(sectionName); + const item = this.visitItem(group); this.div.appendChild(item); } } + // For now, `showIf` must be the name of a boolean property, with optional negation + shouldShow(option) { + if (option.showIf === undefined) return true; + const {name, value} = this.deconstructOption(option.showIf); + return this.sim.getOption(name) === value; + } + + deconstructOption(showIf) { + let name = showIf; + let value = true; + if (name.startsWith('!')) { + value = false; + name = name.slice(1); + } + return {name, value}; + } + visitItem(item, path) { path = [path, item.name].filter(x => !!x).join('.'); switch (item.type) { case 'group': { - const group = document.createElement('div'); - group.classList.add(OPTION_GROUP_CLASSNAME); + const groupEl = document.createElement('div'); + groupEl.classList.add(OPTION_GROUP_CLASSNAME); + const group = {groupEl, items: []}; + this.groups[path] = group; if (item.title) { const heading = document.createElement('h3'); heading.innerHTML = item.title; - group.appendChild(heading); + groupEl.appendChild(heading); + groupEl.items.push({itemEl: heading}); } for (const next of item.items) { - const child = this.visitItem(next, path); - group.appendChild(child); + const optionEl = this.visitItem(next, path); + group.items.push(next); + if (this.shouldShow(next)) { + groupEl.appendChild(optionEl); + } + if (next.showIf) { + const {name} = this.deconstructOption(next.showIf); + this.sim.onOptionSet(name, () => { + if (this.shouldShow(next)) { + show({ + items: group.items, + item: next, + parentEl: groupEl, + itemEl: optionEl, + }); + } else { + hide({ + items: group.items, + item: next, + parentEl: groupEl, + itemEl: optionEl, + }); + } + }); + } + } - return group; + return groupEl; } case 'boolean': { const button = document.createElement('button'); diff --git a/tool/state.js b/tool/state.js new file mode 100644 index 0000000..e7ab3ba --- /dev/null +++ b/tool/state.js @@ -0,0 +1,108 @@ +import {TOOL_INFO_CLASSNAME, WIDE_CLASSNAME} from '../config.js'; +import {hash} from '../helper.js'; +import {Tool} from '../tool.js'; + +export class StateTool extends Tool { + stored = []; + + async setContainer(container) { + super.setContainer(container); + + const buttons = document.createElement('div'); + const save = document.createElement('button'); + const list = document.createElement('div'); + + save.innerHTML = 'Save'; + + save.classList.add(WIDE_CLASSNAME); + buttons.style.display = 'flex'; + buttons.style.flexDirection = 'row'; + buttons.appendChild(save); + list.style.display = 'flex'; + list.style.flexDirection = 'column'; + this.div.appendChild(buttons); + this.div.appendChild(list); + + save.addEventListener('click', async () => { + const state = this.sim.toJSON(); + this.stored.push(state); + const item = await this.createItem(state); + list.appendChild(item); + }); + + // Check url query parameter, and load specified state if found + await this.fromUrl(); + } + + async toUrl(state) { + const stateText = JSON.stringify(state); + const digest = await hash(stateText); + const rawUrl = `./?state=${stateText}&digest=${digest}`; + const url = encodeURI(rawUrl); + return {url, digest}; + } + + async fromUrl() { + const paramsString = window.location.search; + const searchParams = new URLSearchParams(paramsString); + const stateEnc = searchParams.get("state"); + const rxDigest = searchParams.get("digest"); + if (stateEnc) { + const stateText = decodeURI(stateEnc); + console.log('decoded state text', stateText); + const state = JSON.parse(stateText); + const digest = await hash(stateText); + if (digest !== rxDigest) { + throw new Error('state query parameter does not match digest query parameter'); + } + // Tools in this system can be very powerful + this.sim.pause(); + this.sim.fromJSON(state); + } + } + + getStateDescription(state) { + const date = new Date(state.dateSaved); + const Y = date.getFullYear().toString(); + const M = (date.getMonth() + 1).toString().padStart(2, '0'); + const D = date.getDate().toString().padStart(2, '0'); + const h = date.getHours().toString().padStart(2, '0'); + const m = date.getMinutes().toString().padStart(2, '0'); + const s = date.getSeconds().toString().padStart(2, '0'); + return `${Y}-${M}-${D} ${h}:${m}:${s}`; + } + + async createItem(state) { + const item = document.createElement('div'); + item.style.display = 'flex'; + item.style.flexDirection = 'row'; + item.style.flexWrap = 'wrap'; + + const description = document.createElement('button'); + description.style.flex = '2'; + description.classList.add(TOOL_INFO_CLASSNAME); + description.innerHTML = this.getStateDescription(state); + + const load = document.createElement('button'); + load.style.flex = '1'; + + const link = document.createElement('a'); + const {url, digest} = await this.toUrl(state); + link.href = url; + link.innerHTML = digest.slice(0, 5); + + load.appendChild(link); + + item.appendChild(description); + item.appendChild(load); + + load.addEventListener('click', (e) => { + e.preventDefault(); + // Tools in this system can wield great power + this.sim.pause(); + this.sim.fromJSON(state); + }); + + return item; + } +} diff --git a/tool/utility.js b/tool/utility.js index 20d8c27..5a3cfac 100644 --- a/tool/utility.js +++ b/tool/utility.js @@ -7,42 +7,13 @@ 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; } - constructor(container) { - super(container); + constructor() { + super(); const clearTraces = document.createElement('button'); const currentTime = document.createElement('button'); @@ -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(':'); + } + } diff --git a/tool/zoom.js b/tool/zoom.js index f01e8ba..545b9fd 100644 --- a/tool/zoom.js +++ b/tool/zoom.js @@ -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, @@ -84,7 +85,9 @@ export class Zoom extends Tool { zeroVelocity.addEventListener('click', () => { // Determine center of mass and average momentum - const {totalMass, netMomentum} = this.sim.system.computeSystemCenter(); + const objects = this.sim.select.selectedGroup; + const {netMomentum} = this.sim.system.computeSystemCenter(objects); + const {totalMass} = this.sim.system.computeSystemCenter(); const netVelocity = { x: netMomentum.x / totalMass, y: netMomentum.y / totalMass,