Files
avcf/persist.js
adrien b8e8ef9113 overhaul: modular architecture, tests, doc generation, warning system, and UX improvements
- Modular block files: split into blocks-algo, blocks-data, blocks-proc
- New modules: sections.js (labelled canvas areas), proc-browser.js (procedure tree modal), lib-loader.js (lazy library loading), templates.js (project templates), errno-data.js (errno table)
- Test infrastructure: browser test suite (28 tests) and Node pure-function tests (75 tests)
- Warning system: unified .block-warn for disconnected inputs, unconnected error hooks, and zero-exit-in-error-path
- Error path highlighting: red flow paths and block borders for error-only subgraphs
- Input hooks: hidden by default, shown during linking; outputs always at bottom
- Block editor modal: pencil icon, colour picker for accent, compact field labels
- Doc generation: standalone node script extracts JSDoc -> docs/API.md (124 declarations)
- Persistence: save/restore sections, errno selection, block colours
- Fixes: _segmentsIntersect bug, WCEnd crash from closest(this), drawpath guard on detached elements
2026-06-22 18:31:23 +02:00

275 lines
10 KiB
JavaScript

// ── Workspace persistence (localStorage) ──────────────────────────────────────
//
// Serialises and deserialises the full workspace state — all blocks in all
// three tab panes, their positions and data, plus SVG connection topology.
//
// Depends on: dom.js ($, $id, svgNamespace, STORAGE_KEY)
// helpers.js (refreshAll, splitValueAndUnit)
// draw.js (drawpath)
//
/**
* Reads the position and type-specific data from a single block element.
* Delegates to the block's saveData() method for type-specific fields.
* @param {HTMLElement} block - a WCBlock or WCProgram child element
* @returns {{ x: number, y: number, ... }}
*/
function saveBlockData(block) {
const data = {
x: parseInt(block.style.left) || 0,
y: parseInt(block.style.top) || 0
};
Object.assign(data, block.saveData());
return data;
}
/**
* Serialises the entire workspace to localStorage under STORAGE_KEY.
* Captures all blocks from algorithm, measurements, and data panes,
* plus every SVG connection path.
*/
function saveWorkspace() {
const prog = $id("main-program");
const dataArea = $id("data-area");
const varArea = $id("variable-area");
const connectionsSvg = $id("connections");
if (!prog || !dataArea || !varArea || !connectionsSvg) {
showError("Cannot save: missing core UI elements");
return;
}
const state = {
version: 1,
counter: prog.counter,
blocks: { algorithm: [], measurements: [], data: [], procedures: [] },
connections: [],
sections: typeof saveSections === "function" ? saveSections() : []
};
for (const block of prog.children) {
if (!block.saveData) continue;
state.blocks.algorithm.push({
id: block.id, tag: block.tagName.toLowerCase(),
data: saveBlockData(block)
});
}
for (const block of dataArea.children) {
if (!block.saveData) continue;
state.blocks.measurements.push({
id: block.id, tag: block.tagName.toLowerCase(),
data: saveBlockData(block)
});
}
for (const block of varArea.children) {
if (!block.saveData) continue;
state.blocks.data.push({
id: block.id, tag: block.tagName.toLowerCase(),
data: saveBlockData(block)
});
}
for (const block of document.querySelectorAll(".lib-pane .procedures-area > [data-block]")) {
state.blocks.procedures.push({
id: block.id, tag: block.tagName.toLowerCase(),
data: saveBlockData(block)
});
}
for (const path of connectionsSvg.querySelectorAll("path.flow, path.flow-data")) {
state.connections.push({
fromBlock: path.fromElement ? path.fromElement.id : "",
fromHook: path.fromHook ? (path.fromHook.id || null) : null,
toBlock: path.toElement ? path.toElement.id : "",
toHook: path.toHook ? (path.toHook.id || null) : null
});
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
/**
* Deep-clones a block <template> by tag name and restores position + data onto it.
* Delegates to the element's restoreData() method for type-specific fields.
* @param {{ id: string, tag: string, data: object }} info - saved block descriptor
* @returns {HTMLElement|null} the restored element, or null for unknown tags
*/
function restoreBlock(info) {
const reg = BLOCK_REGISTRY[info.tag];
if (!reg) return null;
if (!info.data) { showError("Missing data for block " + info.id); return null; }
const tmpl = document.querySelector(reg.template);
if (!tmpl) { showError("Missing template for " + info.tag); return null; }
const frag = document.importNode(tmpl.content, true);
const el = frag.firstElementChild;
if (!el) { showError("Empty template for " + info.tag); return null; }
el.id = info.id;
/* Only set position for algorithm tab blocks (they have flow connections) */
if (info.data.x !== undefined) el.style.left = info.data.x + "px";
if (info.data.y !== undefined) el.style.top = info.data.y + "px";
return el;
}
/**
* Restores a group of block descriptors into a container in a single pass.
* Creates each block, appends it, then calls restoreData on all of them.
* @param {{ id: string, tag: string, data: object }[]} infos - saved block descriptors
* @param {HTMLElement} container - parent element to append blocks into
*/
function restoreGroup(infos, container) {
const els = [];
for (const info of infos) {
const el = restoreBlock(info);
if (el) { container.appendChild(el); els.push({ el, data: info.data || {} }); }
}
for (const { el, data } of els) el.restoreData(data);
}
/**
* Deserialises the workspace from localStorage and reconstructs all blocks,
* connections, and internal state. Existing user blocks are removed first.
*/
function loadWorkspace() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
let state;
try { state = JSON.parse(raw); } catch { showError("Could not load workspace: saved data is corrupted."); return; }
const prog = $id("main-program");
const dataArea = $id("data-area");
const varArea = $id("variable-area");
const connectionsSvg = $id("connections");
if (!prog || !dataArea || !varArea || !connectionsSvg) {
showError("Missing core UI elements for workspace load");
return;
}
/* ── 1. Tear down existing state ── */
_clearWorkspace({ keepStorage: true });
prog.counter = state.counter;
/* ── 2. Restore measurements (types + units) ── */
restoreGroup(state.blocks.measurements, dataArea);
const firstUnit = dataArea.querySelector("wc-unit");
if (firstUnit) {
let unitHeader = dataArea.querySelector(".list-header");
if (!unitHeader) {
unitHeader = document.createElement("div");
unitHeader.className = "list-header";
unitHeader.style.borderTop = "2px solid var(--border)";
unitHeader.textContent = "Units \u2014 Fullname \u00a0 Type \u00a0 Suffix \u00a0 SI Factor \u00a0 Resolved";
}
dataArea.insertBefore(unitHeader, firstUnit);
}
/* ── 3. Restore procedures — BEFORE algorithm (WCProcCall depends on them) ── */
const libMap = {};
const procEls = [];
const paneProcedures = $id("pane-procedures");
const procTabBar = document.querySelector(".lib-tab-bar");
for (const info of state.blocks.procedures) {
const lib = (info.data && info.data.library) || "unknown";
if (!libMap[lib]) {
markLibLoaded(lib);
const tab = document.createElement("button");
tab.className = "lib-tab";
tab.dataset.libName = lib;
tab.textContent = lib;
tab.addEventListener("click", () => switchLibTab(lib));
if (procTabBar) procTabBar.appendChild(tab);
const pane = document.createElement("div");
pane.className = "lib-pane";
pane.dataset.libName = lib;
const hdr = document.createElement("div");
hdr.className = "list-header";
hdr.textContent = "Procedure \u00a0 Inputs \u2192 Outputs";
pane.appendChild(hdr);
const pa = document.createElement("div");
pa.className = "procedures-area";
pane.appendChild(pa);
if (paneProcedures) paneProcedures.appendChild(pane);
libMap[lib] = pa;
}
const el = restoreBlock(info);
if (el) { libMap[lib].appendChild(el); procEls.push({ el, data: info.data || {} }); }
}
for (const { el, data } of procEls) el.restoreData(data);
/* Activate first library tab */
const firstTab = document.querySelector(".lib-tab");
if (firstTab) switchLibTab(firstTab.dataset.libName);
/* ── 4. Restore algorithm blocks (reposition start/end, recreate others) ── */
const algoEls = [];
for (const info of state.blocks.algorithm) {
if (info.id === "main-start" || info.id === "main-end") {
const el = $id(info.id);
if (el) {
el.style.left = info.data.x + "px";
el.style.top = info.data.y + "px";
}
} else {
const el = restoreBlock(info);
if (el) { prog.appendChild(el); algoEls.push({ el, data: info.data || {} }); }
}
}
for (const { el, data } of algoEls) el.restoreData(data);
/* ── 5. Restore data (datum / variable blocks) ── */
restoreGroup(state.blocks.data, varArea);
/* ── 6. Restore SVG connections ── */
for (const conn of state.connections) {
const fromBlock = $id(conn.fromBlock);
const toBlock = $id(conn.toBlock);
if (!fromBlock || !toBlock) continue;
const fromHook = conn.fromHook
? (fromBlock.querySelector('[id="' + conn.fromHook + '"]') || fromBlock.querySelector('[data-direction="out"]') || fromBlock.querySelector(".hook"))
: (fromBlock.querySelector('[data-direction="out"]') || fromBlock.querySelector(".hook"));
const toHook = conn.toHook
? (toBlock.querySelector('[id="' + conn.toHook + '"]') || toBlock.querySelector('[data-direction="in"]') || toBlock.querySelector(".hook"))
: (toBlock.querySelector('[data-direction="in"]') || toBlock.querySelector(".hook"));
if (!fromHook || !toHook) continue;
const linkType = fromHook.dataset.linkType || "logic";
const path = document.createElementNS(svgNamespace, "path");
path.dataset.linkType = linkType;
path.classList.add(linkType === "data" ? "flow-data" : "flow");
path.toElement = toBlock;
path.fromElement = fromBlock;
path.fromHook = fromHook;
path.toHook = toHook;
toBlock.wcForeFlow.push(path);
fromBlock.wcNextFlow.push(path);
connectionsSvg.appendChild(path);
drawpath(path);
}
/* ── 7. Restore logic sections ── */
if (state.sections && typeof restoreSections === "function") {
restoreSections(state.sections);
}
/* ── 8. Refresh dropdowns & SI displays (after all blocks + connections exist) ── */
refreshAll();
if (dataArea) for (const u of dataArea.querySelectorAll("wc-unit")) u.updateSiDisplay();
if (varArea) for (const d of varArea.querySelectorAll("wc-scalar")) d.updateSiDisplay();
}
/**
* Clears the saved workspace (removes localStorage entry) and reloads the page,
* returning the app to its pristine default state.
*/
function newWorkspace() {
if (typeof showNewProjectModal === "function") {
showNewProjectModal();
}
}