Feature: Save and load from url JSON strings

This commit is contained in:
Ladd 2026-01-04 23:30:32 -06:00
parent f05d1ed399
commit a0e45f00b6
11 changed files with 219 additions and 18 deletions

View File

@ -33,8 +33,10 @@ TODO
- [ ] Feature: Cylindrical Coordinates - [ ] Feature: Cylindrical Coordinates
- [ ] Feature: Spherical Coordinates - [ ] Feature: Spherical Coordinates
- [x] Feature: Camera Velocity Display - [x] Feature: Camera Velocity Display
- [ ] Enhancement: World State Snapshots - [x] Enhancement: World State Snapshots
- [ ] Feature: List / Save / Load World States - [x] Feature: List / Save / Load World States
- [ ] Enhancement: Save / Load Snapshots from Local Storage
- [ ] Feature: Import / Export / Share Snapshots
- [ ] Feature: Left Button Panning - [ ] Feature: Left Button Panning
- [ ] Feature: Middle Button Pause - [ ] Feature: Middle Button Pause
- [ ] Feature: Parameter Slider (Invisible, mouse/touch drag) - [ ] Feature: Parameter Slider (Invisible, mouse/touch drag)
@ -53,3 +55,4 @@ TODO
- [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow) - [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow)
- [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y) - [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y)
- [x] 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

View File

@ -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() { frame() {
// Clear canvas in preparation for other modules to render this frame // Clear canvas in preparation for other modules to render this frame
this.fillCanvas(); this.fillCanvas();

View File

@ -41,3 +41,11 @@ export function hide({items, item, parentEl, itemEl}) {
parentEl.removeChild(itemEl); parentEl.removeChild(itemEl);
item.hidden = true; 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;
}

View File

@ -16,7 +16,7 @@ import {
VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_WIDTH, VELOCITY_VECTOR_WIDTH,
} from './config.js'; } 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 { export class MassObject {
sim = undefined; sim = undefined;
@ -27,7 +27,7 @@ export class MassObject {
velocity = zero; velocity = zero;
acceleration = zero; acceleration = zero;
color = {r: undefined, g: undefined, b: undefined}; color = {r: undefined, g: undefined, b: undefined};
created = undefined; timeCreated = undefined;
forces = []; // [{x, y}] forces = []; // [{x, y}]
history = []; history = [];
alive = true; alive = true;
@ -44,17 +44,39 @@ export class MassObject {
this.color.g = Math.random() * 256; this.color.g = Math.random() * 256;
this.color.b = Math.random() * 256; this.color.b = Math.random() * 256;
this.timeCreated = this.sim.time; 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() { get age() {
return this.sim.time - this.timeCreated; return this.sim.time - this.timeCreated;
} }
get rawAge() {
return this.sim.rawTime - this.rawTimeCreated;
}
get radius() { get radius() {
// radius should be proportional to cube root of mass // radius should be proportional to cube root of mass
return Math.pow(this.mass / this.density, 1 / 3); return Math.pow(this.mass / this.density, 1 / 3);

View File

@ -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}) { handlePointerDown({x, y}) {
this.initializeTouch({x, y}); this.initializeTouch({x, y});
if (this.paused) { if (this.paused) {

View File

@ -4,6 +4,7 @@ import {ObjectTool} from './tool/object.js';
import {ObjectsTool} from './tool/objects.js'; import {ObjectsTool} from './tool/objects.js';
import {OptionsTool} from './tool/options.js'; import {OptionsTool} from './tool/options.js';
import {PlayPause} from './tool/play-pause.js'; import {PlayPause} from './tool/play-pause.js';
import {StateTool} from './tool/state.js';
import {UtilityTool} from './tool/utility.js'; import {UtilityTool} from './tool/utility.js';
import {Zoom} from './tool/zoom.js'; import {Zoom} from './tool/zoom.js';
import {ToolbarGroup} from './toolbar-group.js'; import {ToolbarGroup} from './toolbar-group.js';
@ -15,6 +16,9 @@ export function initializeTools(sim) {
.addToolbar(new Toolbar(sim, 'Camera') .addToolbar(new Toolbar(sim, 'Camera')
.addTool(new CameraTool()) .addTool(new CameraTool())
) )
.addToolbar(new Toolbar(sim, 'State')
.addTool(new StateTool())
)
.addToolbar(new Toolbar(sim, 'Tools') .addToolbar(new Toolbar(sim, 'Tools')
.addTool(new Zoom()) .addTool(new Zoom())
.addTool(new PlayPause()) .addTool(new PlayPause())

View File

@ -4,15 +4,15 @@ import {
FRAMERATE_SAMPLE_DURATION, FRAMERATE_SAMPLE_DURATION,
} from './config.js'; } from './config.js';
import {Display} from './display.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 {Options} from './options.js';
import {Zoom} from './zoom.js'; import {Overlay} from './overlay.js';
import {Panning} from './panning.js'; import {Panning} from './panning.js';
import {Pointer} from './pointer.js';
import {Select} from './select.js'; import {Select} from './select.js';
import {simOptions} from './sim-options.js'; import {simOptions} from './sim-options.js';
import {initializeTools} from './sim-tools.js'; import {initializeTools} from './sim-tools.js';
import {System} from './system.js';
import {Zoom} from './zoom.js';
export class Sim { export class Sim {
info = {}; info = {};
@ -88,13 +88,19 @@ export class Sim {
this.zoom.frame(elapsedTime); this.zoom.frame(elapsedTime);
this.panning.frame(elapsedTime); this.panning.frame(elapsedTime);
this.pointer.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.display.frame(elapsedTime);
this.select.frame(elapsedTime); this.select.frame(elapsedTime);
// The system.frame() renders objects
this.system.frame(elapsedTime); this.system.frame(elapsedTime);
this.overlay.frame(elapsedTime); this.overlay.frame(elapsedTime);
for (const group in this.toolbarGroups) { for (const group in this.toolbarGroups) {
this.toolbarGroups[group].frame(elapsedTime); 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)); requestAnimationFrame(t => this.frame(t));
} }
@ -122,6 +128,29 @@ export class Sim {
return false; 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 // velocity should be in Sim coordinate scale
scheduleZoom({x, y}, factor, velocity) { scheduleZoom({x, y}, factor, velocity) {
this.zoom.scheduleZoom({x, y}, factor, velocity); this.zoom.scheduleZoom({x, y}, factor, velocity);

View File

@ -87,7 +87,7 @@ div.lhg-tool div.lhg-wide {
flex-direction: row; 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-family: monospace;
font-size: 10pt; font-size: 10pt;
background-color: #333; background-color: #333;

View File

@ -11,13 +11,30 @@ export class System {
creatingObject = undefined; creatingObject = undefined;
selectedObject = undefined; selectedObject = undefined;
selectObjectStart = undefined; selectObjectStart = undefined;
panVelocityPaused = undefined;
paused = false; paused = false;
constructor(sim) { constructor(sim) {
this.sim = 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}) { handlePointerDown({x, y}) {
// If pointer is touching an object, select the object // If pointer is touching an object, select the object
const touchingObject = this.objectAtLocation(x, y); const touchingObject = this.objectAtLocation(x, y);
@ -191,6 +208,8 @@ export class System {
this.drawObjects(); this.drawObjects();
} }
// Pause and resume to enable automatic pause on object create/select
// in this mode (mass generation)
pause() { pause() {
this.sim.pause(); this.sim.pause();
this.paused = true; this.paused = true;
@ -431,5 +450,4 @@ export class System {
return acc + obj.mass * s / d; return acc + obj.mass * s / d;
}, 0); }, 0);
} }
} }

93
tool/state.js Normal file
View File

@ -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};
}
}

View File

@ -12,8 +12,8 @@ export class UtilityTool extends Tool {
this.currentTimeEl.innerHTML = this.timeText; this.currentTimeEl.innerHTML = this.timeText;
} }
constructor(container) { constructor() {
super(container); super();
const clearTraces = document.createElement('button'); const clearTraces = document.createElement('button');
const currentTime = document.createElement('button'); const currentTime = document.createElement('button');