Feature: Save and load from url JSON strings
This commit is contained in:
parent
f05d1ed399
commit
a0e45f00b6
@ -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
|
||||
|
||||
12
display.js
12
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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
36
object.js
36
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);
|
||||
|
||||
12
panning.js
12
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) {
|
||||
|
||||
@ -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())
|
||||
|
||||
37
simulator.js
37
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);
|
||||
|
||||
@ -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;
|
||||
|
||||
22
system.js
22
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
93
tool/state.js
Normal file
93
tool/state.js
Normal 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};
|
||||
}
|
||||
|
||||
}
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user