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

265 lines
8.7 KiB
JavaScript

// ── SVG connection drawing, direction routing, shadow link & deletion ──────────
//
// Manages the two-click link system between block hooks, draws polyline SVG
// paths constrained to {0°, 45°, 90°} angles, renders a live shadow path
// from the selected output hook to the cursor while linking, and handles
// link deletion with full block array cleanup.
//
// Depends on: dom.js (svgNamespace, $, $id)
//
/** @type {number} Pixels the path extends from a hook in its exit direction. */
const EXT = 25;
/** @type {{block: HTMLElement, hook: HTMLElement}|null} First hook clicked in a two-click link operation. */
let linkageFirst = null;
/** @type {SVGPathElement|null} The shadow path element shown while linking, or null. */
let shadowPath = null;
/**
* Routes a 45°-constrained polyline between two points.
* Returns the SVG path data (everything after the initial "M") as a string
* suitable for appending to an existing moveto.
* @param {number} x1 - start x
* @param {number} y1 - start y
* @param {number} x2 - end x
* @param {number} y2 - end y
* @returns {string} SVG path data segment (e.g. "L 100 50 L 150 50")
*/
function routeBetween(x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const adx = Math.abs(dx), ady = Math.abs(dy);
if (adx === 0 && ady === 0) return `L ${x2} ${y2}`;
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
const R45 = Math.PI / 4, R90 = Math.PI / 2, TOL = 5 * Math.PI / 180;
const normAngle = (() => {
const raw = Math.abs(Math.atan2(dy, dx));
return raw > R90 ? Math.PI - raw : raw;
})();
if (
Math.abs(normAngle) < TOL ||
Math.abs(normAngle - R45) < TOL ||
Math.abs(normAngle - R90) < TOL
) {
return `L ${x2} ${y2}`;
}
if (adx >= ady) {
const x1m = x1 + sx * ((adx - ady) / 2);
const x2m = x1m + sx * ady;
return `L ${x1m} ${y1} L ${x2m} ${y2} L ${x2} ${y2}`;
} else {
const y1m = y1 + sy * ((ady - adx) / 2);
const y2m = y1m + sy * adx;
return `L ${x1} ${y1m} L ${x2} ${y2m} L ${x2} ${y2}`;
}
}
/**
* Resolves a hook DOM element, preferring the explicitly stored reference
* and falling back to the block's first .hook child.
* @param {SVGPathElement} path
* @param {string} which - "from" or "to"
* @returns {HTMLElement} the hook button
*/
function resolveHook(path, which) {
const key = which === "from" ? "fromHook" : "toHook";
const elKey = which === "from" ? "fromElement" : "toElement";
if (path[key]) return path[key];
const el = path[elKey];
return el ? el.querySelector(".hook") : null;
}
/**
* Computes the extended control point for a hook: the hook centre pushed
* `EXT` pixels in the hook's declared exit direction.
* @param {HTMLElement} hook - the hook button element
* @param {DOMRect} svgRect - bounding rect of the SVG viewport
* @returns {{ x: number, y: number }}
*/
function getHookExit(hook, svgRect) {
if (!hook) return { x: 0, y: 0 };
const r = hook.getBoundingClientRect();
const x = (r.left + r.right) / 2 - svgRect.left;
const y = (r.top + r.bottom) / 2 - svgRect.top;
const dir = hook.dataset.exit;
if (dir === "right") return { x: x + EXT, y };
if (dir === "left") return { x: x - EXT, y };
if (dir === "down") return { x, y: y + EXT };
if (dir === "up") return { x, y: y - EXT };
return { x, y };
}
/**
* Draws (or redraws) an SVG connection path between two linked block hooks.
* Each segment starts by extending EXT pixels in the source hook's exit
* direction and ends by approaching from the target hook's exit direction.
* The middle portion uses 45°-constrained routing.
* @param {SVGPathElement} path - path element with fromHook / toHook references
*/
function drawpath(path) {
if (!path || !path.isConnected) return;
const svg = path.closest("svg");
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
const fromHook = resolveHook(path, "from");
const toHook = resolveHook(path, "to");
if (!fromHook || !toHook || !fromHook.isConnected || !toHook.isConnected) return;
const rFrom = fromHook.getBoundingClientRect();
const rTo = toHook.getBoundingClientRect();
const fx = (rFrom.left + rFrom.right) / 2 - svgRect.left;
const fy = (rFrom.top + rFrom.bottom) / 2 - svgRect.top;
const tx = (rTo.left + rTo.right) / 2 - svgRect.left;
const ty = (rTo.top + rTo.bottom) / 2 - svgRect.top;
const srcExit = getHookExit(fromHook, svgRect);
const tgtExit = getHookExit(toHook, svgRect);
path.setAttribute("d",
`M ${fx} ${fy} L ${srcExit.x} ${srcExit.y}` +
routeBetween(srcExit.x, srcExit.y, tgtExit.x, tgtExit.y) +
` L ${tx} ${ty}`
);
}
/**
* Programmatically connects two hooks with a logic flow link.
* @param {HTMLElement} fromBlock - source block element
* @param {string} fromHookSel - CSS selector for the source hook (e.g. ".hook", "#hook-fore")
* @param {HTMLElement} toBlock - target block element
* @param {string} toHookSel - CSS selector for the target hook
*/
function _linkFlow(fromBlock, fromHookSel, toBlock, toHookSel) {
const fromHook = fromBlock.querySelector(fromHookSel);
const toHook = toBlock.querySelector(toHookSel);
if (!fromHook || !toHook) return;
const path = document.createElementNS(svgNamespace, "path");
path.toElement = toBlock;
path.fromElement = fromBlock;
path.fromHook = fromHook;
path.toHook = toHook;
toHook.dataset.connected = "";
path.dataset.linkType = "logic";
path.classList.add("flow");
if (fromHook.classList.contains("proccall-error-hook")) {
path.classList.add("flow-error-source");
path.classList.add("flow-error");
} else if (fromBlock.classList.contains("error-path")) {
path.classList.add("flow-error");
}
toBlock.wcForeFlow.push(path);
fromBlock.wcNextFlow.push(path);
document.getElementById("connections").appendChild(path);
drawpath(path);
refreshErrorPaths();
}
// ── Shadow (preview) link ─────────────────────────────────────────────────────
/**
* Begins showing a dashed shadow path from the given hook to the cursor.
* @param {HTMLElement} hook - the source hook button
*/
function startShadowLink(hook) {
if (shadowPath) endShadowLink();
const svg = $id("connections");
shadowPath = document.createElementNS(svgNamespace, "path");
shadowPath.classList.add(hook.dataset.linkType === "data" ? "flow-shadow-data" : "flow-shadow");
shadowPath._hook = hook;
svg.appendChild(shadowPath);
document.addEventListener("mousemove", onShadowMove);
const area = $id("draw-area");
if (area) area.classList.add("linking");
}
/** Updates the shadow path endpoint to follow the mouse. */
function onShadowMove(ev) {
if (!shadowPath) return;
const svg = shadowPath.closest("svg");
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
const mx = ev.clientX - svgRect.left;
const my = ev.clientY - svgRect.top;
const hook = shadowPath._hook;
const rHook = hook.getBoundingClientRect();
const fx = (rHook.left + rHook.right) / 2 - svgRect.left;
const fy = (rHook.top + rHook.bottom) / 2 - svgRect.top;
const exit = getHookExit(hook, svgRect);
shadowPath.setAttribute("d",
`M ${fx} ${fy} L ${exit.x} ${exit.y}` +
routeBetween(exit.x, exit.y, mx, my)
);
}
/** Removes the shadow path, stops tracking the mouse, and hides empty input hooks. */
function endShadowLink() {
if (shadowPath) {
shadowPath.remove();
shadowPath = null;
}
document.removeEventListener("mousemove", onShadowMove);
const area = $id("draw-area");
if (area) area.classList.remove("linking");
}
/** Cancels any pending link operation and removes the shadow. */
function resetLinkage() {
linkageFirst = null;
endShadowLink();
}
// ── Link deletion ────────────────────────────────────────────────────────────
/**
* Removes an SVG connection path and cleans up both blocks' flow arrays.
* @param {SVGPathElement} path - the flow path element to delete
*/
function deleteLink(path) {
if (!path.classList.contains("flow") && !path.classList.contains("flow-data")) return;
const src = path.fromElement;
const tgt = path.toElement;
if (src && src.wcNextFlow) {
const i = src.wcNextFlow.indexOf(path);
if (i !== -1) src.wcNextFlow.splice(i, 1);
}
if (tgt && tgt.wcForeFlow) {
const i = tgt.wcForeFlow.indexOf(path);
if (i !== -1) tgt.wcForeFlow.splice(i, 1);
}
if (path.toHook) {
delete path.toHook.dataset.connected;
_dataSourceCache.delete(path.toHook);
}
path.remove();
path.fromElement = null;
path.toElement = null;
path.fromHook = null;
path.toHook = null;
refreshBlockWarnings();
refreshErrorPaths();
}