Files
avcf/sections.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

668 lines
22 KiB
JavaScript

// ── Logic Sections — labelled polygonal areas on the algorithm canvas ────
//
// Users draw labelled polygons (grid-snapped, 0/45/90° edges) to group
// related blocks. Sections sit in #connections behind blocks/links.
//
// Dependencies: dom.js ($id, svgNamespace), draw.js (drawpath), helpers.js
// Load order: after draw.js, before app.js (add toolbar button handler)
/**
* Internal state for the sections subsystem.
* @type {{
* list: Array<{id:string, points:Array<{x:number,y:number}>, title:string}>,
* nextId: number,
* creating: boolean,
* curPoints: Array<{x:number,y:number}>,
* editingId: string|null,
* editOverlay: HTMLElement|null,
* previewPoly: SVGPolygonElement|null,
* previewLine: SVGLineElement|null,
* ghostCircles: SVGCircleElement[]
* }}
*/
const _sec = {
list: [],
nextId: 1,
creating: false,
curPoints: [],
editingId: null,
editOverlay: null,
previewPoly: null,
previewLine: null,
ghostCircles: [],
};
// ── Helpers ────────────────────────────────────────────────────────────
/** @returns {number} The CSS `--grid-size` value, defaulting to 25. */
function _gridSize() {
const el = document.querySelector("[data-block]");
if (!el) return 25;
return parseInt(window.getComputedStyle(el).getPropertyValue("--grid-size")) || 25;
}
/**
* Snaps a coordinate value to the nearest grid multiple.
* @param {number} v
* @returns {number}
*/
function _snap(v) { const g = _gridSize(); return Math.round(v / g) * g; }
/**
* Constrains a new point to 0°, 45°, or 90° relative to the previous point.
* @param {{x:number,y:number}} prev
* @param {{x:number,y:number}} raw
* @returns {{x:number,y:number}}
*/
function _snapAngle(prev, raw) {
const dx = raw.x - prev.x, dy = raw.y - prev.y;
const adx = Math.abs(dx), ady = Math.abs(dy);
let sx = dx >= 0 ? 1 : -1, sy = dy >= 0 ? 1 : -1;
if (adx === 0 && ady === 0) return { x: prev.x, y: prev.y };
if (adx > ady * 2) return { x: raw.x, y: prev.y };
if (ady > adx * 2) return { x: prev.x, y: raw.y };
const m = Math.max(adx, ady);
return { x: prev.x + sx * m, y: prev.y + sy * m };
}
/**
* Converts a mouse event to grid-snapped coordinates relative to #draw-area.
* @param {MouseEvent} ev
* @returns {{x:number, y:number}}
*/
function _xy(ev) {
const area = $id("draw-area");
if (!area) return { x: 0, y: 0 };
const r = area.getBoundingClientRect();
return { x: _snap(ev.clientX - r.left + area.scrollLeft), y: _snap(ev.clientY - r.top + area.scrollTop) };
}
/**
* Converts absolute page coordinates to SVG-relative coordinates.
* @param {number} x
* @param {number} y
* @returns {{x:number, y:number}}
*/
function _svgPt(x, y) {
const svg = $id("connections");
if (!svg) return { x: 0, y: 0 };
const r = svg.getBoundingClientRect();
return { x: x - r.left, y: y - r.top };
}
/**
* Computes the signed area of a polygon via the shoelace formula.
* Used to reject tiny/collinear sections.
* @param {Array<{x:number,y:number}>} pts
* @returns {number}
*/
function _shoelace(pts) {
if (pts.length < 3) return 0;
let s = 0;
for (let i = 0; i < pts.length; i++) {
const j = (i + 1) % pts.length;
s += pts[i].x * pts[j].y - pts[j].x * pts[i].y;
}
return Math.abs(s) / 2;
}
/**
* Ray-casting point-in-polygon test.
* @param {number} px
* @param {number} py
* @param {Array<{x:number,y:number}>} pts Polygon vertices in order
* @returns {boolean}
*/
function _ptInPoly(px, py, pts) {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const xi = pts[i].x, yi = pts[i].y, xj = pts[j].x, yj = pts[j].y;
if ((yi > py) !== (yj > py) && px < (xj - xi) * (py - yi) / (yj - yi) + xi)
inside = !inside;
}
return inside;
}
/**
* Checks whether two line segments AB and CD intersect (including endpoints).
* @param {{x:number,y:number}} a
* @param {{x:number,y:number}} b
* @param {{x:number,y:number}} c
* @param {{x:number,y:number}} d
* @returns {boolean}
*/
function _segmentsIntersect(a, b, c, d) {
const det = (b.y - a.y) * (d.x - c.x) - (b.x - a.x) * (d.y - c.y);
if (Math.abs(det) < 1) return false;
const t = ((c.y - a.y) * (d.x - c.x) - (c.x - a.x) * (d.y - c.y)) / det;
const u = ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / det;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
/**
* Checks whether a rectangle overlaps a polygon (any edge crossing or
* one fully containing the other).
* @param {number} rx Rectangle left
* @param {number} ry Rectangle top
* @param {number} rw Rectangle width
* @param {number} rh Rectangle height
* @param {Array<{x:number,y:number}>} pts Polygon vertices
* @returns {boolean}
*/
function _rectPolyOverlap(rx, ry, rw, rh, pts) {
const corners = [
{ x: rx, y: ry }, { x: rx + rw, y: ry },
{ x: rx + rw, y: ry + rh }, { x: rx, y: ry + rh }
];
for (let i = 0; i < pts.length; i++) {
const j = (i + 1) % pts.length;
for (const c of corners) {
if (_segmentsIntersect(pts[i], pts[j], { x: rx, y: ry }, c)) return true;
}
}
for (let i = 0; i < corners.length; i++) {
const j = (i + 1) % corners.length;
for (let k = 0; k < pts.length; k++) {
const l = (k + 1) % pts.length;
if (_segmentsIntersect(corners[i], corners[j], pts[k], pts[l])) return true;
}
}
for (const c of corners) { if (_ptInPoly(c.x, c.y, pts)) return true; }
for (const p of pts) { if (p.x >= rx && p.x <= rx + rw && p.y >= ry && p.y <= ry + rh) return true; }
return false;
}
/**
* Converts a points array to an SVG `points` attribute string ("x,y x2,y2 …").
* @param {Array<{x:number,y:number}>} pts
* @returns {string}
*/
function _pointsAttr(pts) {
return pts.map(p => `${p.x},${p.y}`).join(" ");
}
/**
* Returns the top-left-most point in a polygon (used for label positioning).
* @param {Array<{x:number,y:number}>} pts
* @returns {{x:number,y:number}}
*/
function _topLeftOf(pts) {
if (!pts.length) return { x: 0, y: 0 };
let tl = pts[0];
for (const p of pts) { if (p.x < tl.x || (p.x === tl.x && p.y < tl.y)) tl = p; }
return tl;
}
// ── SVG rendering ──────────────────────────────────────────────────────
/** @returns {SVGGElement|null} The `<g class="sections-group">`, creating it if needed. */
function _ensureSectionGroup() {
const svg = $id("connections");
if (!svg) return null;
let g = svg.querySelector(".sections-group");
if (!g) {
g = document.createElementNS(svgNamespace, "g");
g.classList.add("sections-group");
svg.insertBefore(g, svg.firstChild);
}
return g;
}
/**
* Renders one section as SVG polygon + text label.
* @param {{id:string, points:Array<{x:number,y:number}>, title:string}} s
*/
function renderSection(s) {
const g = _ensureSectionGroup();
if (!g) return;
let poly = g.querySelector(`polygon[data-section-id="${s.id}"]`);
if (!poly) {
poly = document.createElementNS(svgNamespace, "polygon");
poly.dataset.sectionId = s.id;
poly.classList.add("section-bg");
g.appendChild(poly);
}
poly.setAttribute("points", _pointsAttr(s.points));
let label = g.querySelector(`text[data-section-id="${s.id}"]`);
if (!label) {
label = document.createElementNS(svgNamespace, "text");
label.dataset.sectionId = s.id;
label.classList.add("section-label");
label.addEventListener("click", ev => { ev.stopPropagation(); _editTitle(s.id); });
g.appendChild(label);
}
const tl = _topLeftOf(s.points);
label.setAttribute("x", tl.x + 6);
label.setAttribute("y", tl.y + 14);
label.textContent = s.title;
}
/** Removes all SVG elements for a given section ID. @param {string} id */
function _removeSectionEls(id) {
const g = _ensureSectionGroup();
if (!g) return;
for (const el of g.querySelectorAll(`[data-section-id="${id}"]`)) el.remove();
}
/** Re-renders every section from scratch. */
function renderAllSections() {
const g = _ensureSectionGroup();
if (g) g.innerHTML = "";
for (const s of _sec.list) renderSection(s);
}
// ── Create / edit mode overlay ─────────────────────────────────────────
/** Shows the dimming overlay that blocks interaction with blocks/links during section editing. */
function _showOverlay() {
if (_sec.editOverlay) return;
const area = $id("draw-area");
if (!area) return;
const ov = document.createElement("div");
ov.className = "section-edit-overlay";
area.appendChild(ov);
_sec.editOverlay = ov;
area.classList.add("section-editing");
}
/** Hides the dimming overlay. */
function _hideOverlay() {
if (_sec.editOverlay) {
_sec.editOverlay.remove();
_sec.editOverlay = null;
}
const area = $id("draw-area");
if (area) area.classList.remove("section-editing");
}
// ── Point placement preview helpers ────────────────────────────────────
/** Removes all preview visuals (polygon, line, ghost circles). */
function _clearPreview() {
const svg = $id("connections");
if (!svg) return;
if (_sec.previewPoly) { _sec.previewPoly.remove(); _sec.previewPoly = null; }
if (_sec.previewLine) { _sec.previewLine.remove(); _sec.previewLine = null; }
for (const c of _sec.ghostCircles) c.remove();
_sec.ghostCircles = [];
}
/**
* Updates the visual preview during polygon point placement.
* @param {Array<{x:number,y:number}>} pts Points placed so far
* @param {{x:number,y:number}|null} mouse Current snapped mouse position
*/
function _updatePreview(pts, mouse) {
_clearPreview();
const svg = $id("connections");
if (!svg) return;
const g = _ensureSectionGroup();
if (pts.length >= 3) {
const poly = document.createElementNS(svgNamespace, "polygon");
poly.classList.add("section-preview-bg");
poly.setAttribute("points", _pointsAttr([...pts, pts[0]]));
g.appendChild(poly);
_sec.previewPoly = poly;
}
if (pts.length > 0 && mouse) {
const line = document.createElementNS(svgNamespace, "line");
line.classList.add("section-preview-line");
line.setAttribute("x1", pts[pts.length - 1].x);
line.setAttribute("y1", pts[pts.length - 1].y);
line.setAttribute("x2", mouse.x);
line.setAttribute("y2", mouse.y);
g.appendChild(line);
_sec.previewLine = line;
}
for (const p of pts) {
const circ = document.createElementNS(svgNamespace, "circle");
circ.classList.add("section-ghost");
circ.setAttribute("cx", p.x);
circ.setAttribute("cy", p.y);
circ.setAttribute("r", 4);
g.appendChild(circ);
_sec.ghostCircles.push(circ);
}
}
// ── Create mode ────────────────────────────────────────────────────────
/** @param {MouseEvent} ev Handles a click during polygon creation (adds point or closes polygon). */
function _onCreateClick(ev) {
if (!_sec.creating) return;
const pos = _xy(ev);
const pts = _sec.curPoints;
if (pts.length > 0) {
const snapped = _snapAngle(pts[pts.length - 1], pos);
if (pts.length >= 3) {
const first = pts[0];
if (Math.abs(snapped.x - first.x) < _gridSize() && Math.abs(snapped.y - first.y) < _gridSize()) {
_finishSection(pts);
return;
}
}
pts.push(snapped);
} else {
pts.push(pos);
}
_updatePreview(pts, null);
}
/** @param {MouseEvent} ev Updates the preview line during mouse movement in create mode. */
function _onCreateMove(ev) {
if (!_sec.creating || _sec.curPoints.length === 0) return;
const pos = _xy(ev);
const snapped = _snapAngle(_sec.curPoints[_sec.curPoints.length - 1], pos);
_updatePreview(_sec.curPoints, snapped);
}
/** @param {MouseEvent} ev Finishes polygon creation on double-click. */
function _onCreateDblClick(ev) {
if (!_sec.creating) return;
if (_sec.curPoints.length >= 3) _finishSection(_sec.curPoints);
}
/**
* Finalises a section from the placed points, adds it to the list, renders it,
* and exits create mode.
* @param {Array<{x:number,y:number}>} pts
*/
function _finishSection(pts) {
if (pts.length < 3) return;
if (_shoelace(pts) < 100) { _cancelCreate(); return; }
const id = "section-" + (_sec.nextId++);
_sec.list.push({ id, points: pts.map(p => ({ x: p.x, y: p.y })), title: "Section" });
renderSection(_sec.list[_sec.list.length - 1]);
_exitCreateMode();
setTimeout(() => _editTitle(id), 100);
}
/** Cancels section creation without saving. */
function _cancelCreate() {
_exitCreateMode();
}
/** Cleans up all create-mode event listeners and preview elements. */
function _exitCreateMode() {
_sec.creating = false;
_sec.curPoints = [];
_clearPreview();
_hideOverlay();
const area = $id("draw-area");
if (area) {
area.removeEventListener("click", _onCreateClick);
area.removeEventListener("mousemove", _onCreateMove);
area.removeEventListener("dblclick", _onCreateDblClick);
}
document.removeEventListener("keydown", _onCreateKey);
}
/** @param {KeyboardEvent} ev Handles Escape (cancel) / Enter (finish) during creation. */
function _onCreateKey(ev) {
if (!_sec.creating) return;
if (ev.key === "Escape") _cancelCreate();
if (ev.key === "Enter" && _sec.curPoints.length >= 3) _finishSection(_sec.curPoints);
}
/** Enters polygon creation mode: sets up event listeners and the dimming overlay. */
function enterCreateMode() {
if (_sec.creating) return;
_sec.creating = true;
_sec.curPoints = [];
_showOverlay();
const area = $id("draw-area");
if (!area) return;
area.addEventListener("click", _onCreateClick);
area.addEventListener("mousemove", _onCreateMove);
area.addEventListener("dblclick", _onCreateDblClick);
document.addEventListener("keydown", _onCreateKey);
}
// ── Title editing ──────────────────────────────────────────────────────
/**
* Shows an inline text input (via SVG foreignObject) to edit a section's title.
* @param {string} id Section ID
*/
function _editTitle(id) {
const s = _sec.list.find(x => x.id === id);
if (!s) return;
_showOverlay();
const svg = $id("connections");
if (!svg) return;
const existing = svg.querySelector(`foreignObject[data-section-id="${id}"]`);
if (existing) return;
const tl = _topLeftOf(s.points);
const fo = document.createElementNS(svgNamespace, "foreignObject");
fo.dataset.sectionId = id;
fo.setAttribute("x", tl.x + 4);
fo.setAttribute("y", tl.y + 2);
fo.setAttribute("width", "200");
fo.setAttribute("height", "28");
const input = document.createElement("input");
input.className = "section-title-input";
input.type = "text";
input.value = s.title;
input.style.width = "192px";
input.addEventListener("blur", () => _commitTitle(id, input.value, fo));
input.addEventListener("keydown", ev => {
if (ev.key === "Enter") input.blur();
if (ev.key === "Escape") { _cancelEdit(id); }
});
fo.appendChild(input);
svg.appendChild(fo);
setTimeout(() => input.focus(), 0);
}
/**
* Commits an edited section title and removes the editing foreignObject.
* @param {string} id
* @param {string} title
* @param {SVGForeignObjectElement} fo
*/
function _commitTitle(id, title, fo) {
const s = _sec.list.find(x => x.id === id);
if (s) {
s.title = title || "Section";
renderSection(s);
}
if (fo) fo.remove();
_hideOverlayIfIdle();
}
/**
* Cancels section title editing without saving.
* @param {string} id
*/
function _cancelEdit(id) {
const svg = $id("connections");
if (svg) {
const fo = svg.querySelector(`foreignObject[data-section-id="${id}"]`);
if (fo) fo.remove();
}
_hideOverlayIfIdle();
}
/** Hides the overlay if neither creation nor editing is active. */
function _hideOverlayIfIdle() {
if (!_sec.creating && !_sec.editingId) _hideOverlay();
}
// ── Block border collision ─────────────────────────────────────────────
/**
* Checks whether a block's bounding rectangle overlaps any section polygon.
* Called during drag to prevent blocks from sitting on section borders.
* @param {HTMLElement} block A WCBlock element
* @returns {boolean}
*/
function blockOnSectionBorder(block) {
const rx = block.offsetLeft, ry = block.offsetTop;
const rw = block.offsetWidth, rh = block.offsetHeight;
if (!rw || !rh) return false;
for (const s of _sec.list) {
if (_rectPolyOverlap(rx, ry, rw, rh, s.points)) return true;
}
return false;
}
// ── Persistence ────────────────────────────────────────────────────────
/** @returns {Array<{id:string, title:string, points:Array<{x:number,y:number}>}>} */
function saveSections() {
return _sec.list.map(s => ({
id: s.id, title: s.title, points: s.points.map(p => ({ x: p.x, y: p.y }))
}));
}
/**
* Deserialises and restores sections from saved data.
* @param {Array<{id?:string, title?:string, points?:Array<{x:number,y:number}>}>} data
*/
function restoreSections(data) {
if (!Array.isArray(data)) return;
_sec.list = [];
for (const s of data) {
_sec.list.push({ id: s.id || ("section-" + _sec.nextId), title: s.title || "Section", points: s.points || [] });
_sec.nextId = Math.max(_sec.nextId, (parseInt(s.id.split("-")[1]) || 0) + 1);
}
renderAllSections();
}
// ── Selection / deletion (user clicks on section) ──────────────────────
/**
* Handles a click on a section polygon: selects it, shows draggable vertex handles.
* @param {MouseEvent} ev
*/
function _onSectionClick(ev) {
const poly = ev.target.closest(".section-bg");
if (!poly) return;
const id = poly.dataset.sectionId;
if (!id) return;
ev.stopPropagation();
for (const el of document.querySelectorAll(".section-bg.selected-section")) el.classList.remove("selected-section");
poly.classList.add("selected-section");
_sec.editingId = id;
const s = _sec.list.find(x => x.id === id);
if (!s) return;
_showOverlay();
const svg = $id("connections");
if (!svg) return;
for (let i = 0; i < s.points.length; i++) {
const p = s.points[i];
const circ = document.createElementNS(svgNamespace, "circle");
circ.classList.add("section-handle");
circ.dataset.sectionId = id;
circ.dataset.idx = i;
circ.setAttribute("cx", p.x);
circ.setAttribute("cy", p.y);
circ.setAttribute("r", 5);
const g = _ensureSectionGroup();
g.appendChild(circ);
let dragged = false, startX, startY, origPts;
const down = de => {
de.stopPropagation();
dragged = false;
startX = de.clientX;
startY = de.clientY;
origPts = s.points.map(pt => ({ x: pt.x, y: pt.y }));
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
};
const move = me => {
dragged = true;
const curId = circ.dataset.sectionId;
const sec = _sec.list.find(x => x.id === curId);
if (!sec) return;
const idx = parseInt(circ.dataset.idx);
if (idx < 0 || idx >= sec.points.length) return;
const prevPt = sec.points[(idx - 1 + sec.points.length) % sec.points.length];
const newPos = { x: _snap(origPts[idx].x + me.clientX - startX), y: _snap(origPts[idx].y + me.clientY - startY) };
sec.points[idx] = _snapAngle(prevPt, newPos);
renderSection(sec);
circ.setAttribute("cx", sec.points[idx].x);
circ.setAttribute("cy", sec.points[idx].y);
};
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
};
circ.addEventListener("mousedown", down);
}
}
/**
* Deselects a section when clicking outside all section elements.
* @param {MouseEvent} ev
*/
function _deselectSection(ev) {
if (ev.target.closest(".section-bg") || ev.target.closest(".section-label") || ev.target.closest(".section-handle") || ev.target.closest(".section-title-input")) return;
if (_sec.editingId) {
_sec.editingId = null;
_hideOverlayIfIdle();
for (const el of document.querySelectorAll(".section-bg.selected-section")) el.classList.remove("selected-section");
for (const el of document.querySelectorAll(".section-handle")) el.remove();
}
}
/** Deletes the currently selected/editing section. */
function deleteSelectedSection() {
if (!_sec.editingId) return;
const idx = _sec.list.findIndex(x => x.id === _sec.editingId);
if (idx < 0) return;
_sec.list.splice(idx, 1);
_removeSectionEls(_sec.editingId);
_sec.editingId = null;
_hideOverlayIfIdle();
for (const el of document.querySelectorAll(".section-handle")) el.remove();
}
// ── Initialisation ─────────────────────────────────────────────────────
/** Wires up section-related event listeners (SVG click, document click, delete key). */
function initSections() {
const svg = $id("connections");
if (svg) {
svg.addEventListener("click", _onSectionClick);
document.addEventListener("click", _deselectSection);
document.addEventListener("keydown", ev => {
if (ev.key === "Delete" || ev.key === "Del" || ev.key === "Backspace") {
if (_sec.editingId && !ev.target.closest("input") && !ev.target.closest("textarea")) {
ev.preventDefault();
deleteSelectedSection();
}
}
});
}
}
// Expose global API
window.LOGIC_SECTIONS = _sec;
window.enterSectionCreateMode = enterCreateMode;
window.saveSections = saveSections;
window.restoreSections = restoreSections;
window.renderAllSections = renderAllSections;
window.blockOnSectionBorder = blockOnSectionBorder;
window.initSections = initSections;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initSections);
} else {
initSections();
}