238 lines
8.6 KiB
JavaScript
238 lines
8.6 KiB
JavaScript
// 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 -> <g>
|
|
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;
|
|
}
|
|
};
|
|
})();
|