Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,13 @@ body.embed .analysis-controls,
above just removed straight back: the palette, the file controls, New
diagram, Open file, Save as JSON and the rest. */
body.embed #btn-mobile-menu { display: none; }
/* ...which leaves an embed at these widths, and most iframes are at these
widths, with no way to reset a run at all: the small-screen rules hide Reset
from the topbar on the assumption that the overflow menu still carries it.
An embed offers Run, Step and Reset and nothing else, so Reset comes back. */
@media (max-width: 768px) {
body.embed #btn-reset { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; }
}
body.embed #topbar { min-height: 44px; }
.embed-open {
margin-left: auto; font-size: 11px; color: var(--text-faint); white-space: nowrap;
Expand Down
8 changes: 7 additions & 1 deletion js/app-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,14 @@ class AppAnalysis {
+ `, ${runs} runs × ${steps} steps per value`
+ (seed ? `, seed <b>${this._esc(seed)}</b>` : '') + '<br>'
+ '<span style="color:var(--text-dim)">Cells show the mean final value across runs.</span></p>';
// Escaped: `name` is a parameter name straight out of the loaded diagram,
// which is untrusted input (a shared #d= link, a downloaded .json/.econ,
// a library component). Every other interpolation in this function
// escapes; this one did not, so a parameter named with an <img onerror>
// ran the diagram author's script in the app's own origin the moment the
// reader pressed Run sweep.
html += '<table><thead><tr><th>Node</th>'
+ values.map(v => `<th>${name}=${v}</th>`).join('') + '</tr></thead><tbody>';
+ values.map(v => `<th>${this._esc(name)}=${v}</th>`).join('') + '</tr></thead><tbody>';
for (let n = 0; n < results[0].nodes.length; n++) {
html += `<tr><td>${this._esc(results[0].nodes[n].label || results[0].nodes[n].type)}</td>`
+ results.map(r => `<td>${r.nodes[n].mean}</td>`).join('') + '</tr>';
Expand Down
12 changes: 9 additions & 3 deletions js/app-demos.js
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,11 @@ class AppDemos {
// grazers and a detritivore eat the base; two carnivores hunt them; Hawks are
// the apex; a decomposer loop returns dead biomass to nutrients. Predation is
// Lotka-Volterra formula rates; growth/death are register+modifier pairs. With
// no goal set, all ten populations settle into coupled, bounded oscillations.
// no goal set, the populations cycle against each other. Foxes are deliberately
// left over-exploiting: they boom, crash the rabbit base, and cannot recover,
// which is a real dynamic of this coupling rather than a mistuned constant.
// Measured: even at 3x the birth rate and a quarter of the death rate they
// still peak (167) and collapse to 1.
_demoFoodWeb() {
const b = this._demo();
b.d.resourceTypes = [
Expand Down Expand Up @@ -1071,8 +1075,10 @@ class AppDemos {
b.note(60, 1060, 540, 130,
'Predation uses Lotka-Volterra formula rates (coef·prey·pred); growth & death use ' +
'register+modifier pairs. Two periodic drivers (sunFactor, rainFactor) force the ' +
'producers. No goal is set, yet all ten populations lock into coupled, bounded ' +
'oscillations, predator peaks lagging prey. Press Run.');
'producers. No goal is set, yet the populations cycle against each other with ' +
'predator peaks lagging prey. Watch the Foxes: they boom on plentiful Rabbits, ' +
'eat the prey base out from under themselves, and crash to a level their birth ' +
'rate cannot climb back from. Press Run.');
this.renderer.render();
}

Expand Down
63 changes: 50 additions & 13 deletions js/app-library.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class AppLibrary {
{ name: 'Civilization Empire', desc: 'A 4X economy in one diagram: logistic population, five yields, building converters, and a Science-gated tech tree (irrigation, drama, banking, university).', load: () => this._demoCiv() },
{ name: 'Megafactory Line', desc: 'A 4-tier auto-factory: ore → smelting → components → widgets. A tiny circuit buffer and a slow assembly station back the line up. Watch the bottleneck.', load: () => this._demoFactory() },
{ name: 'Business Cycle', desc: 'A full circular-flow macroeconomy with households, firms, banks, government and a central bank. Countercyclical stimulus through a policy lag drives a boom-bust cycle.', load: () => this._demoBusinessCycle() },
{ name: 'Food Web', desc: 'A four-trophic ecosystem: producers, grazers, carnivores, an apex predator and a nutrient-recycling loop. Ten species lock into coupled, bounded oscillations.', load: () => this._demoFoodWeb() },
{ name: 'Food Web', desc: 'A four-trophic ecosystem: producers, grazers, carnivores, an apex predator and a nutrient-recycling loop. Populations cycle against each other, and a predator that over-hunts its prey can collapse without recovering.', load: () => this._demoFoodWeb() },
{ name: 'Auction Economy', desc: 'A player-driven MMO economy: gather, refine and craft goods, then watch the auction house prices and stocks oscillate as supply meets price-elastic demand.', load: () => this._demoAuction() },
];

Expand All @@ -38,8 +38,9 @@ class AppLibrary {
// Capture a small canvas snapshot so the row is recognisable at a glance
// (15b). Falls back to a blank thumb when rasterizing is unavailable.
this._captureThumbnail((thumb) => {
// Fresh read, so a save here does not drop what another tab added.
const lib = this._getLibrary();
const entry = { name, date: new Date().toLocaleString(), json: this._snapshot(), nodes: this.diagram.nodes.size };
const entry = { id: this._entryKey(), name, date: new Date().toLocaleString(), json: this._snapshot(), nodes: this.diagram.nodes.size };
if (thumb) entry.thumb = thumb;
lib.push(entry);
if (!this._saveLibrary(lib)) {
Expand Down Expand Up @@ -95,6 +96,37 @@ class AppLibrary {
try { return JSON.parse(localStorage.getItem('sim_library') || '[]'); } catch { return []; }
}

// A stable per-entry key so an edit can find its target in a list another tab
// may have changed since this one was rendered.
_entryKey() {
return 'e' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

// Apply an edit against what storage holds RIGHT NOW, not the array this list
// was rendered from. Delete, Duplicate and Rename each wrote back the snapshot
// taken when the list was drawn, so a diagram another tab had saved since was
// erased along with the edit. Entries are located by id, falling back to a
// content match for rows saved before ids existed.
_mutateStore(get, save, entry, index, fn, label) {
const fresh = get.call(this);
let i = -1;
if (entry && entry.id) i = fresh.findIndex(e => e && e.id === entry.id);
if (i < 0 && entry) {
i = fresh.findIndex(e => e && e.name === entry.name && e.date === entry.date && e.json === entry.json);
}
if (i < 0 && index != null && index >= 0 && index < fresh.length) i = index;
if (i < 0) {
this._toast('That entry is no longer there. It may have been changed in another tab.');
return false;
}
fn(fresh, i);
if (!save.call(this, fresh)) {
this._toast(`Could not update ${label}. Browser storage is full or blocked.`);
return false;
}
return true;
}

// Returns false when the write fails (storage full or blocked) so callers
// can tell the user instead of toasting a false "Saved".
_saveLibrary(lib) {
Expand All @@ -119,8 +151,9 @@ class AppLibrary {
const nodes = [...ids].map(id => this.diagram.nodes.get(id)).filter(Boolean).map(n => n.toJSON());
const conns = [...this.diagram.connections.values()]
.filter(c => ids.has(c.sourceId) && ids.has(c.targetId)).map(c => c.toJSON());
// Fresh read, so a save here does not drop what another tab added.
const list = this._getComponents();
list.push({ name, date: new Date().toLocaleString(), nodes, conns });
list.push({ id: this._entryKey(), name, date: new Date().toLocaleString(), nodes, conns });
if (!this._saveComponents(list)) {
this._toast(`Could not save "${name}". Browser storage is full or blocked.`);
return;
Expand Down Expand Up @@ -183,8 +216,8 @@ class AppLibrary {
delBtn.setAttribute('aria-label', 'Delete component');
delBtn.className = 'btn';
delBtn.addEventListener('click', () => {
list.splice(i, 1);
if (!this._saveComponents(list)) this._toast('Could not update components. Browser storage is blocked.');
this._mutateStore(this._getComponents, this._saveComponents, comp, i,
(l, at) => l.splice(at, 1), 'components');
this._renderComponentsList();
});
btns.appendChild(insertBtn);
Expand Down Expand Up @@ -243,6 +276,11 @@ class AppLibrary {
this.engine.reset();
this._commitReplace(prev);
this.renderer.fitView();
// Repaint the panel. The Simulation rail panel reads the diagram's metadata
// when it is drawn, so after a template load it went on showing the empty
// canvas it had been drawn against: a blank Name and a FILE block reading
// 0 nodes, 0 connections, next to a canvas full of them.
this._renderProps();
}

async _loadTemplate(t) {
Expand Down Expand Up @@ -298,9 +336,9 @@ class AppLibrary {
this._openMenu(r.left, r.bottom + 4, (add, sep) => {
add('Rename…', 'pen', () => this._renameLibraryEntry(row, entry, i));
add('Duplicate', 'clone', () => {
const copy = { ...entry, name: `${entry.name} copy`, date: new Date().toLocaleString() };
lib.splice(i + 1, 0, copy);
if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.');
const copy = { ...entry, id: this._entryKey(), name: `${entry.name} copy`, date: new Date().toLocaleString() };
this._mutateStore(this._getLibrary, this._saveLibrary, entry, i,
(list, at) => list.splice(at + 1, 0, copy), 'the Library');
this._renderLibraryList();
});
add('Export as JSON', 'download', () => {
Expand All @@ -312,8 +350,8 @@ class AppLibrary {
});
sep();
add('Delete', 'trash-can', () => {
lib.splice(i, 1);
if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.');
this._mutateStore(this._getLibrary, this._saveLibrary, entry, i,
(list, at) => list.splice(at, 1), 'the Library');
this._renderLibraryList();
}, { danger: true });
});
Expand Down Expand Up @@ -374,9 +412,8 @@ class AppLibrary {
done = true;
const name = input.value.trim();
if (save && name && name !== entry.name) {
const lib = this._getLibrary();
if (lib[index]) { lib[index].name = name; }
if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.');
this._mutateStore(this._getLibrary, this._saveLibrary, entry, index,
(list, at) => { list[at].name = name; }, 'the Library');
}
this._renderLibraryList();
};
Expand Down
30 changes: 26 additions & 4 deletions js/app-props.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,17 @@ class AppProps {
// link, then point the --font stack at the family. '' restores the
// built-in stack. If the fetch fails (offline), the fallbacks apply.
let link = document.getElementById('gfont-link');
if (meta.font) {
// Only a family from the curated list. meta.font arrives with the diagram,
// which is untrusted input, and it was pasted straight into a third-party
// stylesheet URL: opening a shared link fired a request to
// fonts.googleapis.com carrying a string of the diagram author's choosing,
// telling them the reader's IP and that they had opened it. It also went
// raw into the --font CSS value. Anything not on the list falls back to the
// built-in stack, which is what an unset font already does.
const font = GOOGLE_FONTS.includes(meta.font) ? meta.font : '';
if (font) {
const href = 'https://fonts.googleapis.com/css2?family='
+ encodeURIComponent(meta.font).replace(/%20/g, '+')
+ encodeURIComponent(font).replace(/%20/g, '+')
+ ':wght@400;600;700&display=swap';
if (!link) {
link = document.createElement('link');
Expand All @@ -111,7 +119,7 @@ class AppProps {
document.head.appendChild(link);
}
if (link.getAttribute('href') !== href) link.setAttribute('href', href);
rootStyle.setProperty('--font', `'${meta.font}', 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`);
rootStyle.setProperty('--font', `'${font}', 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`);
} else {
if (link) link.remove();
rootStyle.removeProperty('--font');
Expand Down Expand Up @@ -812,8 +820,13 @@ class AppProps {
// node it has nothing to do with while the rule silently never fired.
const targetGone = !!rule.nodeId && !interactives.some(n => n.id === rule.nodeId);
if (targetGone) {
// Shown but not choosable. As a real option it could be picked, which
// cleared the rule's target to '' and made the next render fall back to
// displaying the first interactive node with no warning: exactly the
// misleading state this placeholder exists to prevent.
const o = document.createElement('option');
o.value = ''; o.textContent = '(node deleted)'; o.selected = true;
o.value = ''; o.textContent = '(node deleted)';
o.selected = true; o.disabled = true;
ns.appendChild(o);
}
for (const n of interactives) {
Expand Down Expand Up @@ -1446,6 +1459,15 @@ class AppProps {
const delta = Math.max(0, target) - node.resources;
if (delta > 0) node.addResources(delta, color);
else if (delta < 0) node.takeResources(-delta);
// At rest this field IS the starting amount, so the nudge has to move the
// reset baseline with it. addResources/takeResources only touch the live
// count (setCount is what writes the baseline), so the +/- buttons changed
// the number on the canvas, in undo and in autosave, and then Reset or a
// reload put it straight back.
if (this.engine.step === 0) {
node._initialResources = node.resources;
node._initialColorMap = { ...node.colorMap };
}
}

_nodeProps(panel, node) {
Expand Down
79 changes: 76 additions & 3 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ class App {
this.timeline.onInspect = (nodeId, index, cx, cy) => this._showWhyPopover(nodeId, index, cx, cy);

this._bindControls();
this._watchForeignAutosave();
this._watchVisibility();
this._initLibrary();
this._initMenus();
this._initPalette();
Expand Down Expand Up @@ -516,6 +518,37 @@ class App {
this._persistAutosave();
}

// localStorage is shared by every tab on this origin and sim_autosave is a
// single slot, so the tab that saves last silently becomes the saved copy and
// the other tab carries on believing its work is safe. Nothing can merge two
// diagrams, but the tab that has been superseded can at least be told, once,
// while its work is still on screen and can be exported. The storage event
// fires only in the OTHER tabs, so a write never warns the tab that made it.
// A hidden tab suspends requestAnimationFrame but keeps the setInterval that
// drives the run, so the animation layers were produced into and never
// consumed. Drop what is in flight when the tab goes away, and repaint when it
// comes back so the canvas matches the model rather than a stale frame.
_watchVisibility() {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.renderer.balls.clear();
this.renderer.flowFx.clear();
} else {
this.renderer.render();
}
});
}

_watchForeignAutosave() {
window.addEventListener('storage', (e) => {
if (e.key !== 'sim_autosave' || e.newValue == null) return;
if (document.body.classList.contains('embed')) return;
if (this._autosaveTakenOver) return;
this._autosaveTakenOver = true;
this._toast('Another tab just saved over this browser\'s autosave. Work in this tab is no longer the saved copy, so use File > Save as JSON to keep it.');
});
}

// Two snapshots describe the same diagram. The module-level id counter rides
// along in the JSON and loadJSON only ever raises it, so that ids handed out
// since cannot collide, which means restoring a snapshot never reproduces its
Expand Down Expand Up @@ -842,8 +875,16 @@ class App {
// the URL is the document, and an embed must not write over the host
// page's autosave.
if (!document.body.classList.contains('embed')) {
this._persistAutosave();
try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ }
let prior = null;
try { prior = JSON.parse(localStorage.getItem('sim_autosave') || 'null'); } catch { /* blocked or corrupt */ }
const priorNodes = prior && Array.isArray(prior.nodes) ? prior.nodes.length : 0;
if (priorNodes && this._canLoadDiagram(prior)) {
// There is real work in the saved slot. Ask before the link takes it.
this._adoptSharedDiagram(prior);
} else {
this._persistAutosave();
try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ }
}
}
return;
}
Expand Down Expand Up @@ -1028,7 +1069,39 @@ class App {
this._syncRailFades();
}

_scrollRails() {
// A share link has just been loaded over an existing autosaved diagram. The
// shared one is already on screen; ask before it takes the saved slot, and put
// the reader's own diagram back if they decline. Without this, opening a link
// destroyed the reader's work with no prompt: a reload afterwards brought back
// the sender's diagram, not theirs, and nothing could undo it (a page load has
// no undo stack for state from before it).
async _adoptSharedDiagram(prior) {
const name = (this.diagram.meta && this.diagram.meta.name || '').trim();
const keep = await this._confirmGuard(
`Keep the shared diagram${name ? ` "${name}"` : ''}? It replaces the diagram saved in this browser.`,
'Shared diagram');
if (keep) {
this._persistAutosave();
try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ }
return;
}
// Declined: restore what they had. The hash stays, so the link still works
// if they change their mind.
this.diagram.loadJSON(prior);
this._applyMeta();
this.engine.reset();
this.renderer.balls.clear();
this.renderer.flowFx.clear();
this._clearSparklines();
this.editor._select(null, null);
this.renderer.render();
this.renderer.fitView();
this._resetHistory();
this._renderProps();
this._toast('Kept your own diagram. The shared one was not saved.');
}

_scrollRails() {
return ['palette', 'diagram-rail'].map(id => document.getElementById(id)).filter(Boolean);
}

Expand Down
Loading
Loading