// ── 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 ``, 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(); }