webcola
Version:
WebCola =======
672 lines (622 loc) • 31 kB
text/typescript
import {Point} from './geom'
import {Rectangle} from './rectangle'
import {Constraint, Solver, Variable} from './vpsc'
import {Calculator} from './shortestpaths'
export interface NodeAccessor<Node>{
getChildren(v:Node) : number[];
getBounds(v:Node) : Rectangle;
}
export class NodeWrapper {
leaf: boolean;
parent: NodeWrapper;
ports: Vert[];
constructor(public id: number, public rect: Rectangle, public children: number[]) {
this.leaf = typeof children === 'undefined' || children.length === 0;
}
}
export class Vert {
constructor(public id: number, public x:number, public y: number, public node: NodeWrapper = null, public line = null) {}
}
export class LongestCommonSubsequence<T> {
length: number;
si: number;
ti: number;
reversed: boolean;
constructor(public s: T[], public t: T[]) {
var mf = LongestCommonSubsequence.findMatch(s, t);
var tr = t.slice(0).reverse();
var mr = LongestCommonSubsequence.findMatch(s, tr);
if (mf.length >= mr.length) {
this.length = mf.length;
this.si = mf.si;
this.ti = mf.ti;
this.reversed = false;
} else {
this.length = mr.length;
this.si = mr.si;
this.ti = t.length - mr.ti - mr.length;
this.reversed = true;
}
}
private static findMatch<T>(s: T[], t: T[]) {
var m = s.length;
var n = t.length;
var match = { length: 0, si: -1, ti: -1 };
var l = new Array(m);
for (var i = 0; i < m; i++) {
l[i] = new Array(n);
for (var j = 0; j < n; j++)
if (s[i] === t[j]) {
var v = l[i][j] = (i === 0 || j === 0) ? 1 : l[i - 1][j - 1] + 1;
if (v > match.length) {
match.length = v;
match.si = i - v + 1;
match.ti = j - v + 1;
};
} else l[i][j] = 0;
}
return match;
}
getSequence(): T[]{
return this.length >= 0 ? this.s.slice(this.si, this.si + this.length) : [];
}
}
// a horizontal or vertical line of nodes
export interface GridLine {
nodes: NodeWrapper[];
pos: number;
}
export class GridRouter<Node> {
leaves: NodeWrapper[] = null;
groups: NodeWrapper[];
nodes: NodeWrapper[];
cols: GridLine[];
rows: GridLine[];
root;
verts: Vert[];
edges;
backToFront;
obstacles;
passableEdges;
private avg(a) { return a.reduce((x, y) => x + y) / a.length }
// in the given axis, find sets of leaves overlapping in that axis
// center of each GridLine is average of all nodes in column
private getGridLines(axis): GridLine[] {
var columns = [];
var ls = this.leaves.slice(0, this.leaves.length);
while (ls.length > 0) {
// find a column of all leaves overlapping in axis with the first leaf
let overlapping = ls.filter(v=> v.rect['overlap' + axis.toUpperCase()](ls[0].rect));
let col = {
nodes: overlapping,
pos: this.avg(overlapping.map(v=> v.rect['c' + axis]()))
};
columns.push(col);
col.nodes.forEach(v=> ls.splice(ls.indexOf(v), 1));
}
columns.sort((a, b) => a.pos - b.pos)
return columns;
}
// get the depth of the given node in the group hierarchy
private getDepth(v) {
var depth = 0;
while (v.parent !== this.root) {
depth++;
v = v.parent;
}
return depth;
}
// medial axes between node centres and also boundary lines for the grid
private midPoints(a) {
var gap = a[1] - a[0];
var mids = [a[0] - gap / 2];
for (var i = 1; i < a.length; i++) {
mids.push((a[i] + a[i - 1]) / 2);
}
mids.push(a[a.length - 1] + gap / 2);
return mids;
}
constructor(public originalnodes: Node[], accessor: NodeAccessor<Node>, public groupPadding: number = 12) {
this.nodes = originalnodes.map((v, i) => new NodeWrapper(i, accessor.getBounds(v), accessor.getChildren(v)));
this.leaves = this.nodes.filter(v=> v.leaf);
this.groups = this.nodes.filter(g=> !g.leaf);
this.cols = this.getGridLines('x');
this.rows = this.getGridLines('y');
// create parents for each node or group that is a member of another's children
this.groups.forEach(v=>
v.children.forEach(c=> this.nodes[<number>c].parent = v));
// root claims the remaining orphans
this.root = { children: [] };
this.nodes.forEach(v=> {
if (typeof v.parent === 'undefined') {
v.parent = this.root;
this.root.children.push(v.id);
}
// each node will have grid vertices associated with it,
// some inside the node and some on the boundary
// leaf nodes will have exactly one internal node at the center
// and four boundary nodes
// groups will have potentially many of each
v.ports = []
});
// nodes ordered by their position in the group hierarchy
this.backToFront = this.nodes.slice(0);
this.backToFront.sort((x, y) => this.getDepth(x) - this.getDepth(y));
// compute boundary rectangles for each group
// has to be done from front to back, i.e. inside groups to outside groups
// such that each can be made large enough to enclose its interior
var frontToBackGroups = this.backToFront.slice(0).reverse().filter(g=> !g.leaf);
frontToBackGroups.forEach(v=> {
var r = Rectangle.empty();
v.children.forEach(c=> r = r.union(this.nodes[c].rect));
v.rect = r.inflate(this.groupPadding);
});
var colMids = this.midPoints(this.cols.map(r=> r.pos));
var rowMids = this.midPoints(this.rows.map(r=> r.pos));
// setup extents of lines
var rowx = colMids[0], rowX = colMids[colMids.length - 1];
var coly = rowMids[0], colY = rowMids[rowMids.length - 1];
// horizontal lines
var hlines = this.rows.map(r=> <any>{ x1: rowx, x2: rowX, y1: r.pos, y2: r.pos })
.concat(rowMids.map(m=> <any>{ x1: rowx, x2: rowX, y1: m, y2: m }));
// vertical lines
var vlines = this.cols.map(c=> <any>{ x1: c.pos, x2: c.pos, y1: coly, y2: colY })
.concat(colMids.map(m=> <any>{ x1: m, x2: m, y1: coly, y2: colY }));
// the full set of lines
var lines = hlines.concat(vlines);
// we record the vertices associated with each line
lines.forEach(l=> l.verts = []);
// the routing graph
this.verts = [];
this.edges = [];
// create vertices at the crossings of horizontal and vertical grid-lines
hlines.forEach(h=>
vlines.forEach(v=> {
var p = new Vert(this.verts.length, v.x1, h.y1);
h.verts.push(p);
v.verts.push(p);
this.verts.push(p);
// assign vertices to the nodes immediately under them
var i = this.backToFront.length;
while (i-- > 0) {
var node = this.backToFront[i],
r = node.rect;
var dx = Math.abs(p.x - r.cx()),
dy = Math.abs(p.y - r.cy());
if (dx < r.width() / 2 && dy < r.height() / 2) {
(<any>p).node = node;
break;
}
}
})
);
lines.forEach((l, li) => {
// create vertices at the intersections of nodes and lines
this.nodes.forEach((v, i) => {
v.rect.lineIntersections(l.x1, l.y1, l.x2, l.y2).forEach((intersect, j) => {
//console.log(li+','+i+','+j+':'+intersect.x + ',' + intersect.y);
var p = new Vert(this.verts.length, intersect.x, intersect.y, v, l);
this.verts.push(p);
l.verts.push(p);
v.ports.push(p);
});
});
// split lines into edges joining vertices
var isHoriz = Math.abs(l.y1 - l.y2) < 0.1;
var delta = (a, b) => isHoriz ? b.x - a.x : b.y - a.y;
l.verts.sort(delta);
for (var i = 1; i < l.verts.length; i++) {
var u = l.verts[i - 1], v = l.verts[i];
if (u.node && u.node === v.node && u.node.leaf) continue;
this.edges.push({ source: u.id, target: v.id, length: Math.abs(delta(u, v)) });
}
});
}
// find path from v to root including both v and root
private findLineage(v) {
var lineage = [v];
do {
v = v.parent;
lineage.push(v);
} while (v !== this.root);
return lineage.reverse();
}
// find path connecting a and b through their lowest common ancestor
private findAncestorPathBetween(a, b) {
var aa = this.findLineage(a), ba = this.findLineage(b), i = 0;
while (aa[i] === ba[i]) i++;
// i-1 to include common ancestor only once (as first element)
return { commonAncestor: aa[i - 1], lineages: aa.slice(i).concat(ba.slice(i)) };
}
// when finding a path between two nodes a and b, siblings of a and b on the
// paths from a and b to their least common ancestor are obstacles
siblingObstacles(a, b) {
var path = this.findAncestorPathBetween(a, b);
var lineageLookup = {};
path.lineages.forEach(v=> lineageLookup[v.id] = {});
var obstacles = path.commonAncestor.children.filter(v=> !(v in lineageLookup));
path.lineages
.filter(v=> v.parent !== path.commonAncestor)
.forEach(v=> obstacles = obstacles.concat(v.parent.children.filter(c=> c !== v.id)));
return obstacles.map(v=> this.nodes[v]);
}
// for the given routes, extract all the segments orthogonal to the axis x
// and return all them grouped by x position
static getSegmentSets(routes, x, y) {
// vsegments is a list of vertical segments sorted by x position
var vsegments = [];
for (var ei = 0; ei < routes.length; ei++) {
var route = routes[ei];
for (var si = 0; si < route.length; si++) {
var s = <any>route[si];
s.edgeid = ei;
s.i = si;
var sdx = s[1][x] - s[0][x];
if (Math.abs(sdx) < 0.1) {
vsegments.push(s);
}
}
}
vsegments.sort((a, b) => a[0][x] - b[0][x]);
// vsegmentsets is a set of sets of segments grouped by x position
var vsegmentsets = [];
var segmentset = null;
for (var i = 0; i < vsegments.length; i++) {
var s = vsegments[i];
if (!segmentset || Math.abs(s[0][x] - segmentset.pos) > 0.1) {
segmentset = { pos: s[0][x], segments: [] };
vsegmentsets.push(segmentset);
}
segmentset.segments.push(s);
}
return vsegmentsets;
}
// for all segments in this bundle create a vpsc problem such that
// each segment's x position is a variable and separation constraints
// are given by the partial order over the edges to which the segments belong
// for each pair s1,s2 of segments in the open set:
// e1 = edge of s1, e2 = edge of s2
// if leftOf(e1,e2) create constraint s1.x + gap <= s2.x
// else if leftOf(e2,e1) create cons. s2.x + gap <= s1.x
static nudgeSegs(x: string, y: string, routes, segments, leftOf, gap: number) {
var n = segments.length;
if (n <= 1) return;
var vs = segments.map(s => new Variable(s[0][x]));
var cs = [];
for (var i = 0; i < n; i++) {
for (var j = 0; j < n; j++) {
if (i === j) continue;
var s1 = segments[i],
s2 = segments[j],
e1 = s1.edgeid,
e2 = s2.edgeid,
lind = -1,
rind = -1;
// in page coordinates (not cartesian) the notion of 'leftof' is flipped in the horizontal axis from the vertical axis
// that is, when nudging vertical segments, if they increase in the y(conj) direction the segment belonging to the
// 'left' edge actually needs to be nudged to the right
// when nudging horizontal segments, if the segments increase in the x direction
// then the 'left' segment needs to go higher, i.e. to have y pos less than that of the right
if (x == 'x') {
if (leftOf(e1, e2)) {
//console.log('s1: ' + s1[0][x] + ',' + s1[0][y] + '-' + s1[1][x] + ',' + s1[1][y]);
if (s1[0][y] < s1[1][y]) {
lind = j, rind = i;
} else {
lind = i, rind = j;
}
}
} else {
if (leftOf(e1, e2)) {
if (s1[0][y] < s1[1][y]) {
lind = i, rind = j;
} else {
lind = j, rind = i;
}
}
}
if (lind >= 0) {
//console.log(x+' constraint: ' + lind + '<' + rind);
cs.push(new Constraint(vs[lind], vs[rind], gap));
}
}
}
var solver = new Solver(vs, cs);
solver.solve();
vs.forEach((v, i) => {
var s = segments[i];
var pos = v.position();
s[0][x] = s[1][x] = pos;
var route = routes[s.edgeid];
if (s.i > 0) route[s.i - 1][1][x] = pos;
if (s.i < route.length - 1) route[s.i + 1][0][x] = pos;
});
}
static nudgeSegments(routes, x: string, y: string, leftOf: (e1: number, e2: number) => boolean, gap: number) {
var vsegmentsets = GridRouter.getSegmentSets(routes, x, y);
// scan the grouped (by x) segment sets to find co-linear bundles
for (var i = 0; i < vsegmentsets.length; i++) {
var ss = vsegmentsets[i];
var events = [];
for (var j = 0; j < ss.segments.length; j++) {
var s = ss.segments[j];
events.push({ type: 0, s: s, pos: Math.min(s[0][y], s[1][y]) });
events.push({ type: 1, s: s, pos: Math.max(s[0][y], s[1][y]) });
}
events.sort((a, b) => a.pos - b.pos + a.type - b.type);
var open = [];
var openCount = 0;
events.forEach(e=> {
if (e.type === 0) {
open.push(e.s);
openCount++;
} else {
openCount--;
}
if (openCount == 0) {
GridRouter.nudgeSegs(x, y, routes, open, leftOf, gap);
open = [];
}
});
}
}
// obtain routes for the specified edges, nicely nudged apart
// warning: edge paths may be reversed such that common paths are ordered consistently within bundles!
// @param edges list of edges
// @param nudgeGap how much to space parallel edge segements
// @param source function to retrieve the index of the source node for a given edge
// @param target function to retrieve the index of the target node for a given edge
// @returns an array giving, for each edge, an array of segments, each segment a pair of points in an array
routeEdges<Edge>(edges: Edge[], nudgeGap: number, source: (e: Edge) => number, target: (e: Edge) => number): Point[][][] {
var routePaths = edges.map(e=> this.route(source(e), target(e)));
var order = GridRouter.orderEdges(routePaths);
var routes = routePaths.map(function (e) { return GridRouter.makeSegments(e); });
GridRouter.nudgeSegments(routes, 'x', 'y', order, nudgeGap);
GridRouter.nudgeSegments(routes, 'y', 'x', order, nudgeGap);
GridRouter.unreverseEdges(routes, routePaths);
return routes;
}
// path may have been reversed by the subsequence processing in orderEdges
// so now we need to restore the original order
static unreverseEdges(routes, routePaths) {
routes.forEach((segments, i) => {
var path = routePaths[i];
if ((<any>path).reversed) {
segments.reverse(); // reverse order of segments
segments.forEach(function (segment) {
segment.reverse(); // reverse each segment
});
}
});
}
static angleBetween2Lines(line1: Point[], line2: Point[]): number {
var angle1 = Math.atan2(line1[0].y - line1[1].y,
line1[0].x - line1[1].x);
var angle2 = Math.atan2(line2[0].y - line2[1].y,
line2[0].x - line2[1].x);
var diff = angle1 - angle2;
if (diff > Math.PI || diff < -Math.PI) {
diff = angle2 - angle1;
}
return diff;
}
// does the path a-b-c describe a left turn?
private static isLeft(a, b, c) {
return ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) <= 0;
}
// for the given list of ordered pairs, returns a function that (efficiently) looks-up a specific pair to
// see if it exists in the list
private static getOrder(pairs: { l: number; r: number }[]): (l: number, r: number) => boolean {
var outgoing = {};
for (var i = 0; i < pairs.length; i++) {
var p = pairs[i];
if (typeof outgoing[p.l] === 'undefined') outgoing[p.l] = {};
outgoing[p.l][p.r] = true;
}
return (l, r) => typeof outgoing[l] !== 'undefined' && outgoing[l][r];
}
// returns an ordering (a lookup function) that determines the correct order to nudge the
// edge paths apart to minimize crossings
static orderEdges(edges) {
var edgeOrder = [];
for (var i = 0; i < edges.length - 1; i++) {
for (var j = i + 1; j < edges.length; j++) {
var e = edges[i],
f = edges[j],
lcs = new LongestCommonSubsequence(e, f);
var u, vi, vj;
if (lcs.length === 0)
continue; // no common subpath
if (lcs.reversed) {
// if we found a common subpath but one of the edges runs the wrong way,
// then reverse f.
f.reverse();
f.reversed = true;
lcs = new LongestCommonSubsequence(e, f);
}
if ((lcs.si <= 0 || lcs.ti <= 0) &&
(lcs.si + lcs.length >= e.length || lcs.ti + lcs.length >= f.length)) {
// the paths do not diverge, so make an arbitrary ordering decision
edgeOrder.push({ l: i, r: j });
continue;
}
if (lcs.si + lcs.length >= e.length || lcs.ti + lcs.length >= f.length) {
// if the common subsequence of the
// two edges being considered goes all the way to the
// end of one (or both) of the lines then we have to
// base our ordering decision on the other end of the
// common subsequence
u = e[lcs.si + 1];
vj = e[lcs.si - 1];
vi = f[lcs.ti - 1];
} else {
u = e[lcs.si + lcs.length - 2];
vi = e[lcs.si + lcs.length];
vj = f[lcs.ti + lcs.length];
}
if (GridRouter.isLeft(u, vi, vj)) {
edgeOrder.push({ l: j, r: i });
} else {
edgeOrder.push({ l: i, r: j });
}
}
}
//edgeOrder.forEach(function (e) { console.log('l:' + e.l + ',r:' + e.r) });
return GridRouter.getOrder(edgeOrder);
}
// for an orthogonal path described by a sequence of points, create a list of segments
// if consecutive segments would make a straight line they are merged into a single segment
// segments are over cloned points, not the original vertices
static makeSegments(path: Point[]): Point[][] {
function copyPoint(p: Point) {
return <Point>{ x: p.x, y: p.y };
}
var isStraight = (a, b, c) => Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) < 0.001;
var segments = [];
var a = copyPoint(path[0]);
for (var i = 1; i < path.length; i++) {
var b = copyPoint(path[i]), c = i < path.length - 1 ? path[i + 1] : null;
if (!c || !isStraight(a, b, c)) {
segments.push([a, b]);
a = b;
}
}
return segments;
}
// find a route between node s and node t
// returns an array of indices to verts
route(s: number, t: number): Point[] {
var source = this.nodes[<number>s], target = this.nodes[<number>t];
this.obstacles = this.siblingObstacles(source, target);
var obstacleLookup = {};
this.obstacles.forEach(o => obstacleLookup[o.id] = o);
this.passableEdges = this.edges.filter(e=> {
var u = this.verts[e.source],
v = this.verts[e.target];
return !(u.node && u.node.id in obstacleLookup
|| v.node && v.node.id in obstacleLookup);
});
// add dummy segments linking ports inside source and target
for (var i = 1; i < source.ports.length; i++) {
var u = source.ports[0].id;
var v = source.ports[i].id;
this.passableEdges.push({
source: u,
target: v,
length: 0
});
}
for (var i = 1; i < target.ports.length; i++) {
var u = target.ports[0].id;
var v = target.ports[i].id;
this.passableEdges.push({
source: u,
target: v,
length: 0
});
}
var getSource = e=> e.source,
getTarget = e=> e.target,
getLength = e=> e.length;
var shortestPathCalculator = new Calculator(this.verts.length, this.passableEdges, getSource, getTarget, getLength);
var bendPenalty = (u, v, w) => {
var a = this.verts[u], b = this.verts[v], c = this.verts[w];
var dx = Math.abs(c.x - a.x), dy = Math.abs(c.y - a.y);
// don't count bends from internal node edges
if (a.node === source && a.node === b.node || b.node === target && b.node === c.node)
return 0;
return dx > 1 && dy > 1 ? 1000 : 0;
};
// get shortest path
var shortestPath = shortestPathCalculator.PathFromNodeToNodeWithPrevCost(
source.ports[0].id, target.ports[0].id,
bendPenalty);
// shortest path is reversed and does not include the target port
var pathPoints = shortestPath.reverse().map(vi => this.verts[vi]);
pathPoints.push(this.nodes[target.id].ports[0]);
// filter out any extra end points that are inside the source or target (i.e. the dummy segments above)
return pathPoints.filter((v, i) =>
!(i < pathPoints.length - 1 && pathPoints[i + 1].node === source && v.node === source
|| i > 0 && v.node === target && pathPoints[i - 1].node === target));
}
static getRoutePath(route: Point[][], cornerradius: number, arrowwidth: number, arrowheight: number): { routepath: string; arrowpath: string } {
var result = {
routepath: 'M ' + route[0][0].x + ' ' + route[0][0].y + ' ',
arrowpath: ''
};
if (route.length > 1) {
for (var i = 0; i < route.length; i++) {
var li = route[i];
var x = li[1].x, y = li[1].y;
var dx = x - li[0].x;
var dy = y - li[0].y;
if (i < route.length - 1) {
if (Math.abs(dx) > 0) {
x -= dx / Math.abs(dx) * cornerradius;
} else {
y -= dy / Math.abs(dy) * cornerradius;
}
result.routepath += 'L ' + x + ' ' + y + ' ';
var l = route[i + 1];
var x0 = l[0].x, y0 = l[0].y;
var x1 = l[1].x;
var y1 = l[1].y;
dx = x1 - x0;
dy = y1 - y0;
var angle = GridRouter.angleBetween2Lines(li, l) < 0 ? 1 : 0;
//console.log(cola.GridRouter.angleBetween2Lines(li, l))
var x2, y2;
if (Math.abs(dx) > 0) {
x2 = x0 + dx / Math.abs(dx) * cornerradius;
y2 = y0;
} else {
x2 = x0;
y2 = y0 + dy / Math.abs(dy) * cornerradius;
}
var cx = Math.abs(x2 - x);
var cy = Math.abs(y2 - y);
result.routepath += 'A ' + cx + ' ' + cy + ' 0 0 ' + angle + ' ' + x2 + ' ' + y2 + ' ';
} else {
var arrowtip = [x, y];
var arrowcorner1, arrowcorner2;
if (Math.abs(dx) > 0) {
x -= dx / Math.abs(dx) * arrowheight;
arrowcorner1 = [x, y + arrowwidth];
arrowcorner2 = [x, y - arrowwidth];
} else {
y -= dy / Math.abs(dy) * arrowheight;
arrowcorner1 = [x + arrowwidth, y];
arrowcorner2 = [x - arrowwidth, y];
}
result.routepath += 'L ' + x + ' ' + y + ' ';
if (arrowheight > 0) {
result.arrowpath = 'M ' + arrowtip[0] + ' ' + arrowtip[1] + ' L ' + arrowcorner1[0] + ' ' + arrowcorner1[1]
+ ' L ' + arrowcorner2[0] + ' ' + arrowcorner2[1];
}
}
}
} else {
var li = route[0];
var x = li[1].x, y = li[1].y;
var dx = x - li[0].x;
var dy = y - li[0].y;
var arrowtip = [x, y];
var arrowcorner1, arrowcorner2;
if (Math.abs(dx) > 0) {
x -= dx / Math.abs(dx) * arrowheight;
arrowcorner1 = [x, y + arrowwidth];
arrowcorner2 = [x, y - arrowwidth];
} else {
y -= dy / Math.abs(dy) * arrowheight;
arrowcorner1 = [x + arrowwidth, y];
arrowcorner2 = [x - arrowwidth, y];
}
result.routepath += 'L ' + x + ' ' + y + ' ';
if (arrowheight > 0) {
result.arrowpath = 'M ' + arrowtip[0] + ' ' + arrowtip[1] + ' L ' + arrowcorner1[0] + ' ' + arrowcorner1[1]
+ ' L ' + arrowcorner2[0] + ' ' + arrowcorner2[1];
}
}
return result;
}
}