// edges.js — the SVG edge layer. // // Edges are cubic beziers anchored at the midpoint of a node side, with control // points pushed out along that side's normal. When a side is unspecified the // dominant axis between the two centres picks one, which keeps auto-routed // edges predictable instead of hunting for a global minimum. // // The SVG sits inside the transformed world and draws in world coordinates, so // it pans and zooms with the nodes for free. window.Canvas = window.Canvas || {}; Canvas.Edges = (function () { var Doc = Canvas.Doc; var Select = Canvas.Select; var SVGNS = 'http://www.w3.org/2000/svg'; var ARROW = 11; var svg = null; var groups = {}; // edge id -> var temp = null; // the rubber-band path while drawing a new edge function el(name, attrs) { var node = document.createElementNS(SVGNS, name); for (var k in attrs) { if (Object.prototype.hasOwnProperty.call(attrs, k)) node.setAttribute(k, attrs[k]); } return node; } // sideAnchor returns the connection point on a node side together with that // side's outward normal, which the control points and arrowheads both need. function sideAnchor(n, side) { switch (side) { case 'top': return { x: n.x + n.width / 2, y: n.y, nx: 0, ny: -1 }; case 'bottom': return { x: n.x + n.width / 2, y: n.y + n.height, nx: 0, ny: 1 }; case 'left': return { x: n.x, y: n.y + n.height / 2, nx: -1, ny: 0 }; default: return { x: n.x + n.width, y: n.y + n.height / 2, nx: 1, ny: 0 }; } } // autoSides picks the facing sides for an edge with no explicit ones: the // dominant axis between the node centres wins. Simple and stable — the edge // does not flip sides every time a node moves a few pixels. function autoSides(a, b) { var dx = (b.x + b.width / 2) - (a.x + a.width / 2); var dy = (b.y + b.height / 2) - (a.y + a.height / 2); if (Math.abs(dx) >= Math.abs(dy)) { return dx >= 0 ? ['right', 'left'] : ['left', 'right']; } return dy >= 0 ? ['bottom', 'top'] : ['top', 'bottom']; } function geometry(e) { var a = Doc.node(e.fromNode); var b = Doc.node(e.toNode); if (!a || !b) return null; var auto = autoSides(a, b); var fromSide = e.fromSide || auto[0]; // A self-loop has no meaningful "facing" side, so it gets a fixed pair // that always produces a visible arc out of and back into the node. var toSide = e.toSide || (a === b ? 'top' : auto[1]); var p0 = sideAnchor(a, fromSide); var p3 = sideAnchor(b, toSide); var dist = Math.sqrt((p3.x - p0.x) * (p3.x - p0.x) + (p3.y - p0.y) * (p3.y - p0.y)); var k = Math.max(40, dist / 3); return { p0: p0, p3: p3, c1: { x: p0.x + p0.nx * k, y: p0.y + p0.ny * k }, c2: { x: p3.x + p3.nx * k, y: p3.y + p3.ny * k } }; } function curve(g) { return 'M' + g.p0.x + ',' + g.p0.y + 'C' + g.c1.x + ',' + g.c1.y + ' ' + g.c2.x + ',' + g.c2.y + ' ' + g.p3.x + ',' + g.p3.y; } // midpoint of a cubic at t=0.5, closed form — no DOM measurement needed to // place a label. function midpoint(g) { return { x: (g.p0.x + 3 * g.c1.x + 3 * g.c2.x + g.p3.x) / 8, y: (g.p0.y + 3 * g.c1.y + 3 * g.c2.y + g.p3.y) / 8 }; } // arrowhead draws a triangle with its tip at pt, its base offset along the // outward normal — so the arrow points into the node it terminates on. function arrowhead(pt) { var bx = pt.x + pt.nx * ARROW; var by = pt.y + pt.ny * ARROW; var px = -pt.ny * ARROW * 0.42; var py = pt.nx * ARROW * 0.42; return 'M' + pt.x + ',' + pt.y + 'L' + (bx + px) + ',' + (by + py) + 'L' + (bx - px) + ',' + (by - py) + 'Z'; } function colorVar(c) { if (!c) return 'var(--canvas-edge)'; if (c.charAt(0) === '#') return c; return 'var(--canvas-' + c + ')'; } function build(e) { var g = el('g', { 'class': 'canvas-edge' }); g.dataset.id = e.id; // A transparent fat stroke under the visible line is the hit target: // a 2px curve is essentially unclickable otherwise. g.appendChild(el('path', { 'class': 'canvas-edge-hit', fill: 'none' })); g.appendChild(el('path', { 'class': 'canvas-edge-line', fill: 'none' })); g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-to' })); g.appendChild(el('path', { 'class': 'canvas-edge-head canvas-edge-head-from' })); var label = el('text', { 'class': 'canvas-edge-label', 'text-anchor': 'middle', 'dominant-baseline': 'middle' }); g.appendChild(label); return g; } function update(g, e) { var geo = geometry(e); if (!geo) { g.setAttribute('display', 'none'); return; } g.removeAttribute('display'); var d = curve(geo); var stroke = colorVar(e.color); g.querySelector('.canvas-edge-hit').setAttribute('d', d); var line = g.querySelector('.canvas-edge-line'); line.setAttribute('d', d); line.setAttribute('stroke', stroke); // Spec defaults: fromEnd is "none", toEnd is "arrow". var toHead = g.querySelector('.canvas-edge-head-to'); if ((e.toEnd || 'arrow') === 'arrow') { toHead.setAttribute('d', arrowhead(geo.p3)); toHead.setAttribute('fill', stroke); toHead.removeAttribute('display'); } else { toHead.setAttribute('display', 'none'); } var fromHead = g.querySelector('.canvas-edge-head-from'); if ((e.fromEnd || 'none') === 'arrow') { fromHead.setAttribute('d', arrowhead(geo.p0)); fromHead.setAttribute('fill', stroke); fromHead.removeAttribute('display'); } else { fromHead.setAttribute('display', 'none'); } var label = g.querySelector('.canvas-edge-label'); if (e.label) { var m = midpoint(geo); label.setAttribute('x', m.x); label.setAttribute('y', m.y); label.textContent = e.label; label.removeAttribute('display'); } else { label.setAttribute('display', 'none'); } g.classList.toggle('is-selected', Select.hasEdge(e.id)); } return { init: function (svgEl) { svg = svgEl; }, sideAnchor: sideAnchor, autoSides: autoSides, render: function () { var seen = {}; Doc.edges().forEach(function (e) { seen[e.id] = true; var g = groups[e.id]; if (!g) { g = build(e); groups[e.id] = g; svg.appendChild(g); } update(g, e); }); Object.keys(groups).forEach(function (id) { if (seen[id]) return; groups[id].remove(); delete groups[id]; }); }, // syncFor repaints only the edges touching the given nodes. Called on // every frame of a drag, where re-running the full reconcile would be // pure overhead. syncFor: function (nodeIds) { Doc.edgesFor(nodeIds).forEach(function (e) { var g = groups[e.id]; if (g) update(g, e); }); }, syncSelection: function () { Doc.edges().forEach(function (e) { var g = groups[e.id]; if (g) g.classList.toggle('is-selected', Select.hasEdge(e.id)); }); }, // --- rubber band shown while dragging a new edge out of a port --- beginTemp: function () { if (temp) temp.remove(); temp = el('path', { 'class': 'canvas-edge-temp', fill: 'none' }); svg.appendChild(temp); }, updateTemp: function (node, side, to) { if (!temp) return; var p0 = sideAnchor(node, side); var dist = Math.sqrt((to.x - p0.x) * (to.x - p0.x) + (to.y - p0.y) * (to.y - p0.y)); var k = Math.max(40, dist / 3); temp.setAttribute('d', 'M' + p0.x + ',' + p0.y + 'C' + (p0.x + p0.nx * k) + ',' + (p0.y + p0.ny * k) + ' ' + to.x + ',' + to.y + ' ' + to.x + ',' + to.y); }, endTemp: function () { if (!temp) return; temp.remove(); temp = null; } }; })();