From a0e45f00b6e5a5aa9756c4132fdfebf7e4a993e6 Mon Sep 17 00:00:00 2001 From: Ladd Date: Sun, 4 Jan 2026 23:30:32 -0600 Subject: [PATCH] Feature: Save and load from url JSON strings --- Readme.md | 7 ++-- display.js | 12 +++++++ helper.js | 8 +++++ object.js | 36 +++++++++++++++---- panning.js | 12 +++++++ sim-tools.js | 4 +++ simulator.js | 37 +++++++++++++++++--- style.css | 2 +- system.js | 22 ++++++++++-- tool/state.js | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ tool/utility.js | 4 +-- 11 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 tool/state.js diff --git a/Readme.md b/Readme.md index 0b7a18d..f7c273f 100644 --- a/Readme.md +++ b/Readme.md @@ -33,8 +33,10 @@ TODO - [ ] Feature: Cylindrical Coordinates - [ ] Feature: Spherical Coordinates - [x] Feature: Camera Velocity Display -- [ ] Enhancement: World State Snapshots -- [ ] Feature: List / Save / Load World States +- [x] Enhancement: World State Snapshots +- [x] Feature: List / Save / Load World States +- [ ] Enhancement: Save / Load Snapshots from Local Storage +- [ ] Feature: Import / Export / Share Snapshots - [ ] Feature: Left Button Panning - [ ] Feature: Middle Button Pause - [ ] Feature: Parameter Slider (Invisible, mouse/touch drag) @@ -53,3 +55,4 @@ TODO - [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow) - [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y) - [x] Fix: Unpause panning when initiated while sim is paused +- [ ] Enhancement: Refactor to use viewOrigin as center of display canvas diff --git a/display.js b/display.js index 1e48cc2..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(); diff --git a/helper.js b/helper.js index b3b9a0e..16a2a1f 100644 --- a/helper.js +++ b/helper.js @@ -41,3 +41,11 @@ export function hide({items, item, parentEl, itemEl}) { 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 6bdfafc..d53463b 100644 --- a/panning.js +++ b/panning.js @@ -27,6 +27,18 @@ 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) { diff --git a/sim-tools.js b/sim-tools.js index a626e53..81da2e6 100644 --- a/sim-tools.js +++ b/sim-tools.js @@ -4,6 +4,7 @@ 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'; @@ -15,6 +16,9 @@ export function initializeTools(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..c124a65 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,29 @@ export class Sim { return false; } + toJSON() { + return { + dateSaved: new Date().toISOString(), + system: this.system.toJSON(), + panning: this.panning.toJSON(), + display: this.display.toJSON(), + playing: this.playing, + 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.playing = state.playing; + 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 4c78af1..ee9410f 100644 --- a/style.css +++ b/style.css @@ -87,7 +87,7 @@ div.lhg-tool div.lhg-wide { flex-direction: row; } -div.lhg-tool button, div.lhg-tool input { +div.lhg-tool button, div.lhg-tool input, div.lhg-tool a { font-family: monospace; font-size: 10pt; background-color: #333; diff --git a/system.js b/system.js index 518a2a1..34d4c38 100644 --- a/system.js +++ b/system.js @@ -11,13 +11,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); @@ -191,6 +208,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; @@ -431,5 +450,4 @@ export class System { return acc + obj.mass * s / d; }, 0); } - } diff --git a/tool/state.js b/tool/state.js new file mode 100644 index 0000000..a055d2f --- /dev/null +++ b/tool/state.js @@ -0,0 +1,93 @@ +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 = []; + + 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 + const paramsString = window.location.search; + const searchParams = new URLSearchParams(paramsString); + const stateEnc = searchParams.get("state"); // a + if (stateEnc) { + const stateText = decodeURI(stateEnc); + const state = JSON.parse(stateText); + // Tools in this system can be very powerful + this.sim.fromJSON(state); + } + } + + getStateDescription(state) { + const d = new Date(state.dateSaved); + return `${d.toLocaleDateString()} ${d.toLocaleTimeString()}`; + } + + 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'; + load.innerHTML = 'Load'; + + const link = document.createElement('a'); + const {url, digest} = await this.toUrl(state); + link.classList.add(TOOL_INFO_CLASSNAME); + link.classList.add(WIDE_CLASSNAME); + link.href = url; + link.innerHTML = digest.slice(0, 6); + + item.appendChild(description); + item.appendChild(load); + item.appendChild(link); + + load.addEventListener('click', () => { + // Tools in this system can wield great power + this.sim.fromJSON(state); + }); + + return item; + } + + async toUrl(state) { + const stateText = JSON.stringify(state); + // const stateB64 = window.btoa(stateText); + const rawUrl = `./?state=${stateText}`; + const url = encodeURI(rawUrl); + const digest = await hash(stateText); + return {url, digest}; + } + +} diff --git a/tool/utility.js b/tool/utility.js index 8c10baa..5a3cfac 100644 --- a/tool/utility.js +++ b/tool/utility.js @@ -12,8 +12,8 @@ export class UtilityTool extends Tool { this.currentTimeEl.innerHTML = this.timeText; } - constructor(container) { - super(container); + constructor() { + super(); const clearTraces = document.createElement('button'); const currentTime = document.createElement('button');