Files
avcf/helpers.js
T
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

535 lines
19 KiB
JavaScript

// ── Utility functions & dropdown refresh ─────────────────────────────────────
//
// Pure helper functions shared across blocks, persistence, and app modules.
// Depends on: dom.js ($, $id)
//
// Exports:
// splitValueAndUnit() → parse "5 km" → {numVal, unitSuffix}
// rebuildSelect() → replace <select> options preserving selection
// refreshQuantityOptions() → sync quantity-name dropdowns + "boolean"
// refreshUnitOptions() → sync unit-suffix dropdowns from unit blocks
// getDefaultUnitSuffix() → lookup default unit for a quantity name
// refreshDefaultUnitOptions() → sync default-unit dropdowns on quantity blocks
// refreshVarRefOptions() → sync variable-select dropdowns
// switchLibTab() → activate library sub-tab
// findDataSource() → find block connected to a data hook
// escapeString() → escape special chars for C string literal
// refreshBlockWarnings() → check every algorithm block for missing input
// refreshErrorPaths() → mark blocks only reachable via error-flow paths
// refreshAll() → run all refresh passes in order
// copyCode() → copy generated C code to clipboard
/**
* Parses a string like "5 km" into a numeric value and an optional unit suffix.
* Falls back to parsing the entire string as a bare number when no suffix is present.
* @param {string} str - e.g. "5 km", "42", ""
* @param {number} defaultNum - fallback when parseFloat yields NaN
* @returns {{ numVal: number, unitSuffix: string|null }}
*/
function splitValueAndUnit(str, defaultNum) {
const trimmed = (str || "").trim();
const parts = trimmed.split(/\s+/);
let numVal, unitSuffix;
if (parts.length < 2 || isNaN(parseFloat(parts[0]))) {
numVal = parseFloat(trimmed);
unitSuffix = null;
} else {
numVal = parseFloat(parts[0]);
unitSuffix = parts.slice(1).join(" ");
}
return { numVal: isNaN(numVal) ? defaultNum : numVal, unitSuffix };
}
/**
* Replaces all options in a <select>, preserving the current selection if it still exists.
* @param {HTMLSelectElement} sel - the select element to rebuild
* @param {string} placeholder - text for the first (empty-value) option
* @param {string[]} values - option value/label pairs to add
* @param {string} current - currently selected value to attempt to preserve
*/
function rebuildSelect(sel, placeholder, values, current) {
sel.innerHTML = `<option value="">${placeholder}</option>`;
const frag = document.createDocumentFragment();
for (const v of values) {
const opt = document.createElement("option");
opt.value = v;
opt.textContent = v;
frag.appendChild(opt);
}
sel.appendChild(frag);
if (current && [...sel.options].some(o => o.value === current)) {
sel.value = current;
}
}
/**
* Refreshes every quantity-name dropdown (.var-type, .unit-type) by scanning
* all wc-quantity blocks in the data area.
*/
function refreshQuantityOptions() {
const dataArea = $id("data-area");
if (!dataArea) return;
const qNames = Array.from(
dataArea.querySelectorAll("wc-quantity")
).map(q => {
const el = q.querySelector(".quantity-name");
return el ? (el.value || "unnamed") : "unnamed";
});
document.querySelectorAll(
"#variable-area .var-type, #data-area .unit-type"
).forEach(sel => {
const placeholder = sel.classList.contains("var-type")
? "(no type)" : "(select quantity)";
rebuildSelect(sel, placeholder, qNames, sel.value);
});
}
/**
* Refreshes the unit-suffix dropdown on every datum block to show only units
* that match the datum's currently selected quantity.
*/
function refreshUnitOptions() {
const varArea = $id("variable-area");
const dataArea = $id("data-area");
if (!varArea || !dataArea) return;
varArea.querySelectorAll("wc-scalar").forEach(d => {
const typeEl = d.querySelector(".var-type");
const sel = d.querySelector(".var-unit");
if (!typeEl || !sel) return;
const quantityName = typeEl.value;
const suffixes = Array.from(
dataArea.querySelectorAll("wc-unit")
)
.filter(
u => {
const t = u.querySelector(".unit-type");
const s = u.querySelector(".unit-suffix");
return t && s && t.value === quantityName && s.value;
}
)
.map(u => {
const s = u.querySelector(".unit-suffix");
return s ? s.value : "";
});
rebuildSelect(sel, "(unit)", suffixes, sel.value);
});
}
/**
* Looks up the default unit suffix for a given quantity name.
* @param {string} quantityName
* @returns {string} default unit suffix, or "" if not found
*/
function getDefaultUnitSuffix(quantityName) {
const dataArea = $id("data-area");
if (!dataArea) return "";
for (const q of dataArea.querySelectorAll("wc-quantity")) {
const nameEl = q.querySelector(".quantity-name");
if (nameEl && (nameEl.value || "unnamed") === quantityName) {
const unitEl = q.querySelector(".quantity-default-unit");
return unitEl ? (unitEl.value || "") : "";
}
}
return "";
}
/**
* Refreshes the default-unit dropdown on every quantity block with suffixes
* of matching unit blocks.
*/
function refreshDefaultUnitOptions() {
const dataArea = $id("data-area");
if (!dataArea) return;
dataArea.querySelectorAll("wc-quantity").forEach(q => {
const nameEl = q.querySelector(".quantity-name");
const sel = q.querySelector(".quantity-default-unit");
if (!nameEl || !sel) return;
const quantityName = nameEl.value || "unnamed";
const suffixes = Array.from(
dataArea.querySelectorAll("wc-unit")
)
.filter(
u => {
const t = u.querySelector(".unit-type");
const s = u.querySelector(".unit-suffix");
return t && s && t.value === quantityName && s.value;
}
)
.map(u => {
const s = u.querySelector(".unit-suffix");
return s ? s.value : "";
});
rebuildSelect(sel, "(none)", suffixes, sel.value);
});
}
/**
* Refreshes the variable-select dropdown on every wc-varref block in the
* algorithm pane. Scans all boolean, scalar, string, and array blocks.
*/
function refreshVarRefOptions() {
const selects = document.querySelectorAll(".varref-select, .assign-target");
if (!selects.length) return;
const varArea = $id("variable-area");
const vars = [];
if (varArea) {
for (const b of varArea.querySelectorAll("wc-boolean, wc-scalar, wc-string, wc-array, wc-vector")) {
const nameEl = b.querySelector(".var-name");
const name = nameEl ? (nameEl.value || "unnamed") : "unnamed";
const tag = b.tagName.toLowerCase().replace("wc-", "");
vars.push({ id: b.id, label: tag + ": " + name });
}
}
for (const sel of selects) {
const current = sel.dataset.restoredValue || sel.value;
delete sel.dataset.restoredValue;
sel.innerHTML = '<option value="">(select variable)</option>';
for (const v of vars) {
const opt = document.createElement("option");
opt.value = v.id;
opt.textContent = v.label;
sel.appendChild(opt);
}
if (current && [...sel.options].some(o => o.value === current)) {
sel.value = current;
}
}
}
/**
* Activates the clicked library sub-tab and shows its corresponding pane.
* @param {string} name - library name (matches data-lib-name attribute)
*/
function switchLibTab(name) {
for (const tab of document.querySelectorAll(".lib-tab")) {
tab.classList.toggle("active", tab.dataset.libName === name);
}
for (const pane of document.querySelectorAll(".lib-pane")) {
pane.classList.toggle("active", pane.dataset.libName === name);
}
}
/** @type {WeakMap<HTMLElement,HTMLElement>} Cache mapping data hooks to their source blocks. */
const _dataSourceCache = new WeakMap();
/**
* Finds the block connected to a given data hook via any .flow-data path.
* Results are cached in _dataSourceCache.
* @param {HTMLElement} hook - the data hook button element
* @returns {HTMLElement|null} the source block, or null if nothing is connected
*/
function findDataSource(hook) {
if (_dataSourceCache.has(hook)) return _dataSourceCache.get(hook);
for (const path of document.querySelectorAll("path.flow-data")) {
if (path.toHook === hook) {
_dataSourceCache.set(hook, path.fromElement);
return path.fromElement;
}
}
return null;
}
/** Copies the generated C code text to the clipboard. */
function copyCode() {
const el = $id("c-code");
if (el && el.textContent) navigator.clipboard.writeText(el.textContent).catch(() => {});
}
/**
* Escapes special characters for embedding in a C string literal.
* Handles backslash, double-quote, newline, carriage return, and tab.
* @param {string} str - raw string
* @returns {string} escaped string safe for C "..." literals
*/
function escapeString(str) {
return str
.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
.replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")
.replace(/\f/g, "\\f").replace(/\v/g, "\\v").replace(/\0/g, "\\0");
}
/** @type {Set<string>} C reserved keywords — toCName appends "_" to avoid clashes. */
const C_KEYWORDS = new Set([
"auto","break","case","char","const","continue","default","do","double",
"else","enum","extern","float","for","goto","if","int","long","register",
"return","short","signed","sizeof","static","struct","switch","typedef",
"union","unsigned","void","volatile","while"
]);
/**
* Converts a natural-language name into a valid C identifier.
* Lowercases, replaces non-alphanumeric chars with underscores,
* collapses runs of underscores, strips leading/trailing underscores,
* and prepends an underscore if the result starts with a digit.
* @param {string} str - natural name like "My Variable 2"
* @returns {string} valid C identifier like "my_variable_2"
*/
function toCName(str) {
let s = (str || "").trim().toLowerCase();
s = s.replace(/[^a-z0-9_]/g, "_");
s = s.replace(/_+/g, "_");
s = s.replace(/^_|_$/g, "");
if (/^\d/.test(s)) s = "_" + s;
if (!s || C_KEYWORDS.has(s)) s = (s || "var") + "_";
return s;
}
/**
* Derives a human-readable natural name from a C function/tech name.
* Converts camelCase and snake_case to words, strips common prefixes.
* @param {string} techName - e.g. "SDL_CreateWindow", "glfwInit", "puts"
* @returns {string} e.g. "Create Window", "Init GLFW", "Puts"
*/
function deriveNaturalName(techName) {
if (!techName) return "";
let s = techName;
s = s.replace(/^(SDL_|glfw|gl_?)/i, '');
const known = {
puts: "Print String",
printf: "Print Formatted",
getchar: "Get Char",
putchar: "Put Char",
perror: "Print Error",
malloc: "Allocate Memory",
atoi: "Parse Int",
atof: "Parse Float",
strlen: "String Length",
strcmp: "Compare Strings",
fabs: "Abs Float",
fmod: "Float Modulo",
};
if (known[s]) return known[s];
s = s.replace(/([a-z])([A-Z])/g, '$1 $2');
s = s.replace(/_/g, ' ');
s = s.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
s = s.replace(/\s+/g, ' ').trim();
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Builds a C comment prefix from a variable's natural name and an optional comment.
* @param {string} naturalName - the human-readable variable name
* @param {string} comment - the optional comment text
* @returns {string} e.g. "// My Flag: indicates status\n" or "" when no comment
*/
function commentPrefix(naturalName, comment) {
naturalName = (naturalName || "").trim();
comment = (comment || "").trim();
return comment && naturalName ? `// ${naturalName}: ${comment}\n` : comment ? `// ${comment}\n` : "";
}
/**
* Resolves the ultimate C variable name from a block by checking for
* .var-name, .expr-name, and following .varref-select chains.
* Returns the empty string when nothing is found.
* @param {HTMLElement} block - any block that may hold a variable reference
* @param {Set} [visited] - cycle detection set (internal use)
* @returns {string} the C name, or ""
*/
function resolveSourceName(block, visited) {
visited = visited || new Set();
if (visited.has(block)) return "";
visited.add(block);
const ni = block.querySelector(".var-name");
if (ni) return toCName(ni.value) || "x";
const ei = block.querySelector(".expr-name");
if (ei) return ei.value || "res";
const vr = block.querySelector(".varref-select");
if (vr) {
const target = vr.value ? $id(vr.value) : null;
if (target) return resolveSourceName(target, visited);
return vr.value || "var";
}
return "";
}
/**
* Opens a modal dialog that shows the block's editable fields in a compact,
* full-width layout for easier editing.
* @param {HTMLElement} block
*/
function showBlockEditor(block) {
let overlay = $id("block-editor-overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.id = "block-editor-overlay";
overlay.className = "block-editor-overlay";
overlay.addEventListener("click", ev => { if (ev.target === overlay) overlay.classList.remove("active"); });
document.body.appendChild(overlay);
const editorDiv = document.createElement("div");
editorDiv.className = "block-editor-modal";
editorDiv.innerHTML =
'<div class="block-editor-header">' +
' <span class="block-editor-title">Edit Block</span>' +
' <button class="block-editor-close">&times;</button>' +
'</div>' +
'<div class="block-editor-body"></div>' +
'<div class="block-editor-footer">' +
' <button class="block-editor-cancel">Cancel</button>' +
' <button class="block-editor-apply">Apply</button>' +
'</div>';
overlay.appendChild(editorDiv);
editorDiv.querySelector(".block-editor-close").addEventListener("click", () => overlay.classList.remove("active"));
editorDiv.querySelector(".block-editor-cancel").addEventListener("click", () => overlay.classList.remove("active"));
}
const body = overlay.querySelector(".block-editor-body");
body.innerHTML = "";
/* Colour picker row; error blocks default to red, others to blue */
const defaultColor = block.classList.contains("error-path") || block.tagName === "WC-END" ? "#ef5350" : "#4a90d9";
const colorRow = document.createElement("div");
colorRow.className = "block-editor-color-row";
colorRow.innerHTML =
'<label class="block-editor-color-label">Accent colour</label>' +
'<input type="color" class="block-editor-color-picker" value="' + (block.dataset.blockColor || defaultColor) + '">' +
'<button class="block-editor-color-reset" title="reset to default">Reset</button>';
body.appendChild(colorRow);
const colorPicker = colorRow.querySelector(".block-editor-color-picker");
const origColor = block.dataset.blockColor || "";
colorRow.querySelector(".block-editor-color-reset").addEventListener("click", () => {
colorPicker.value = defaultColor;
});
const clone = block.cloneNode(true);
clone.style.position = "static";
clone.style.width = "";
clone.style.height = "";
// Remove interactive buttons from clone (edit, delete, resize)
for (const btn of clone.querySelectorAll(".edit-btn, .delete-btn, .resize-handle, .hook")) btn.remove();
// Restore hidden inputs and remove their labels so the modal shows editable fields
for (const label of clone.querySelectorAll(".field-display")) {
const input = label.nextElementSibling;
if (input && (input.tagName === "INPUT" || input.tagName === "SELECT" || input.tagName === "TEXTAREA")) {
input.style.display = "";
}
label.remove();
}
body.appendChild(clone);
overlay.querySelector(".block-editor-title").textContent = "Edit " + (block.tagName || "Block");
overlay.classList.add("active");
overlay.querySelector(".block-editor-apply").onclick = () => {
// Sync inputs/selects/textareas from clone back to original
const srcFields = block.querySelectorAll("input, select, textarea");
const cloneFields = clone.querySelectorAll("input, select, textarea");
for (let i = 0; i < srcFields.length && i < cloneFields.length; i++) {
if (srcFields[i].type !== "hidden") srcFields[i].value = cloneFields[i].value;
}
// Trigger change and input events so block internals sync
for (const el of srcFields) {
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("input", { bubbles: true }));
}
// For END blocks: parse and select the errno from the synced value
if (block.tagName === "WC-END" && typeof block._selectByCode === "function") {
const val = block._el?.input?.value || "";
const code = parseInt(val);
if (!isNaN(code)) block._selectByCode(code);
}
/* Apply colour */
const newColor = colorPicker.value;
if (newColor !== defaultColor) {
block.dataset.blockColor = newColor;
block.style.borderColor = newColor;
block.style.borderWidth = "2px";
} else {
delete block.dataset.blockColor;
block.style.borderColor = "";
block.style.borderWidth = "";
}
overlay.classList.remove("active");
};
}
/** Check every algorithm block and flag those without an incoming logic connection. */
function refreshBlockWarnings() {
for (const block of document.querySelectorAll("#main-program > [data-block]")) {
if (typeof block._checkLogicInput === "function") block._checkLogicInput();
}
/* Warn when a wc-proccall has no connection from its error (!) output hook. */
for (const block of document.querySelectorAll("wc-proccall")) {
const errHook = block.querySelector(".proccall-error-hook");
if (!errHook) continue;
const hasConnection = block.wcNextFlow.some(p => p.fromHook === errHook);
block.classList.toggle("warn-error-unlinked", !hasConnection);
}
/* Warn when an error-path END block returns 0 (success in an error branch). */
for (const block of document.querySelectorAll("wc-end.error-path")) {
block.classList.toggle("warn-zero-in-error", block._selectedCode === 0);
}
}
/**
* Marks algorithm blocks that are reachable ONLY via error-flow paths.
* Adds/removes the `.error-path` class so they render with a red highlight.
*/
function refreshErrorPaths() {
for (const el of document.querySelectorAll("[data-block].error-path")) {
el.classList.remove("error-path");
}
for (const p of document.querySelectorAll("path.flow.flow-error")) {
p.classList.remove("flow-error");
}
const errorHooks = document.querySelectorAll("path.flow.flow-error-source");
if (errorHooks.length === 0) return;
const errorRoots = new Set();
for (const path of errorHooks) {
if (path.toElement) errorRoots.add(path.toElement);
path.classList.add("flow-error");
}
const normalReachable = new Set();
const start = $id("main-start");
if (start) {
const q = [start];
while (q.length) {
const el = q.pop();
if (normalReachable.has(el)) continue;
normalReachable.add(el);
for (const p of el.wcNextFlow) {
if (p.classList.contains("flow-error-source")) continue;
if (p.toElement) q.push(p.toElement);
}
}
}
const eq = [...errorRoots];
while (eq.length) {
const el = eq.pop();
if (normalReachable.has(el)) continue;
if (el.classList.contains("error-path")) continue;
el.classList.add("error-path");
for (const p of el.wcNextFlow) {
if (p.toElement) {
p.classList.add("flow-error");
eq.push(p.toElement);
}
}
}
}
/** Runs all refresh passes in dependency order. */
function refreshAll() {
refreshQuantityOptions();
refreshUnitOptions();
refreshDefaultUnitOptions();
refreshVarRefOptions();
refreshBlockWarnings();
refreshErrorPaths();
}