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: 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
|
||||||
|
|||||||
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() {
|
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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
36
object.js
36
object.js
@ -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);
|
||||||
|
|||||||
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}) {
|
handlePointerDown({x, y}) {
|
||||||
this.initializeTouch({x, y});
|
this.initializeTouch({x, y});
|
||||||
if (this.paused) {
|
if (this.paused) {
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
37
simulator.js
37
simulator.js
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
22
system.js
22
system.js
@ -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
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;
|
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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user