import {Constraint, Variable, Solver} from './vpsc' import {RBTree} from './rbtree' import {Point} from './geom' export interface Leaf { bounds: Rectangle; variable: Variable; } export interface ProjectionGroup { bounds: Rectangle; padding: number; stiffness: number; leaves: Leaf[]; groups: ProjectionGroup[]; minVar: Variable; maxVar: Variable; } export function computeGroupBounds(g: ProjectionGroup): Rectangle { g.bounds = typeof g.leaves !== "undefined" ? g.leaves.reduce((r: Rectangle, c) => c.bounds.union(r), Rectangle.empty()) : Rectangle.empty(); if (typeof g.groups !== "undefined") g.bounds = g.groups.reduce((r: Rectangle, c) => computeGroupBounds(c).union(r), g.bounds); g.bounds = g.bounds.inflate(g.padding); return g.bounds; } export class Rectangle { constructor( public x: number, public X: number, public y: number, public Y: number) { } static empty(): Rectangle { return new Rectangle(Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY); } cx(): number { return (this.x + this.X) / 2; } cy(): number { return (this.y + this.Y) / 2; } overlapX(r: Rectangle): number { var ux = this.cx(), vx = r.cx(); if (ux <= vx && r.x < this.X) return this.X - r.x; if (vx <= ux && this.x < r.X) return r.X - this.x; return 0; } overlapY(r: Rectangle): number { var uy = this.cy(), vy = r.cy(); if (uy <= vy && r.y < this.Y) return this.Y - r.y; if (vy <= uy && this.y < r.Y) return r.Y - this.y; return 0; } setXCentre(cx: number): void { var dx = cx - this.cx(); this.x += dx; this.X += dx; } setYCentre(cy: number): void { var dy = cy - this.cy(); this.y += dy; this.Y += dy; } width(): number { return this.X - this.x; } height(): number { return this.Y - this.y; } union(r: Rectangle): Rectangle { return new Rectangle(Math.min(this.x, r.x), Math.max(this.X, r.X), Math.min(this.y, r.y), Math.max(this.Y, r.Y)); } /** * return any intersection points between the given line and the sides of this rectangle * @method lineIntersection * @param x1 number first x coord of line * @param y1 number first y coord of line * @param x2 number second x coord of line * @param y2 number second y coord of line * @return any intersection points found */ lineIntersections(x1: number, y1: number, x2: number, y2: number): Array { var sides = [[this.x, this.y, this.X, this.y], [this.X, this.y, this.X, this.Y], [this.X, this.Y, this.x, this.Y], [this.x, this.Y, this.x, this.y]]; var intersections = []; for (var i = 0; i < 4; ++i) { var r = Rectangle.lineIntersection(x1, y1, x2, y2, sides[i][0], sides[i][1], sides[i][2], sides[i][3]); if (r !== null) intersections.push({ x: r.x, y: r.y }); } return intersections; } /** * return any intersection points between a line extending from the centre of this rectangle to the given point, * and the sides of this rectangle * @method lineIntersection * @param x2 number second x coord of line * @param y2 number second y coord of line * @return any intersection points found */ rayIntersection(x2: number, y2: number): Point { var ints = this.lineIntersections(this.cx(), this.cy(), x2, y2); return ints.length > 0 ? ints[0] : null; } vertices(): Point[] { return [ { x: this.x, y: this.y }, { x: this.X, y: this.y }, { x: this.X, y: this.Y }, { x: this.x, y: this.Y }]; } static lineIntersection( x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): Point { var dx12 = x2 - x1, dx34 = x4 - x3, dy12 = y2 - y1, dy34 = y4 - y3, denominator = dy34 * dx12 - dx34 * dy12; if (denominator == 0) return null; var dx31 = x1 - x3, dy31 = y1 - y3, numa = dx34 * dy31 - dy34 * dx31, a = numa / denominator, numb = dx12 * dy31 - dy12 * dx31, b = numb / denominator; if (a >= 0 && a <= 1 && b >= 0 && b <= 1) { return { x: x1 + a * dx12, y: y1 + a * dy12 }; } return null; } inflate(pad: number): Rectangle { return new Rectangle(this.x - pad, this.X + pad, this.y - pad, this.Y + pad); } } /** * Returns the endpoints of a line that connects the centre of two rectangles. * @param {Rectangle} [source] The source Rectangle. * @param {Rectangle} [target] The target Rectangle. * @param {number} [ah] The size of the arrow head, a distance to shorten the * line by. * @return An object with three point properties, the intersection with the * source rectangle (sourceIntersection), the intersection with then * target rectangle (targetIntersection), and the point an arrow * head of the specified size would need to start (arrowStart). */ export function makeEdgeBetween(source: Rectangle, target: Rectangle, ah: number) : { sourceIntersection: Point; targetIntersection: Point; arrowStart: Point } { const si = source.rayIntersection(target.cx(), target.cy()) || { x: source.cx(), y: source.cy() }, ti = target.rayIntersection(source.cx(), source.cy()) || { x: target.cx(), y: target.cy() }, dx = ti.x - si.x, dy = ti.y - si.y, l = Math.sqrt(dx * dx + dy * dy), al = l - ah; return { sourceIntersection: si, targetIntersection: ti, arrowStart: { x: si.x + al * dx / l, y: si.y + al * dy / l } } } /** * Returns the intersection of a line from the given point to the centre * of the target rectangle where it intersects the rectanngle. * @param [source] The source point. * @param {Rectangle} [target] The target Rectangle. * @param {number} [ah] The size of the arrow head, a distance to shorten the * line by. * @return The point an arrow head of the specified size would need to start. */ export function makeEdgeTo(s: { x: number; y: number }, target: Rectangle, ah: number): Point { var ti = target.rayIntersection(s.x, s.y); if (!ti) ti = { x: target.cx(), y: target.cy() }; var dx = ti.x - s.x, dy = ti.y - s.y, l = Math.sqrt(dx * dx + dy * dy); return { x: ti.x - ah * dx / l, y: ti.y - ah * dy / l }; } class Node { prev: RBTree; next: RBTree; constructor(public v: Variable, public r: Rectangle, public pos: number) { this.prev = makeRBTree(); this.next = makeRBTree(); } } class Event { constructor(public isOpen: boolean, public v: Node, public pos: number) {} } function compareEvents(a: Event, b: Event): number { if (a.pos > b.pos) { return 1; } if (a.pos < b.pos) { return -1; } if (a.isOpen) { // open must come before close return -1; } if (b.isOpen) { // open must come before close return 1; } return 0; } function makeRBTree(): RBTree { return new RBTree((a, b) => a.pos - b.pos); } interface RectAccessors { getCentre: (r: Rectangle) => number; getOpen: (r: Rectangle) => number; getClose: (r: Rectangle) => number; getSize: (r: Rectangle) => number; makeRect: (open: number, close: number, center: number, size: number) => Rectangle; findNeighbours: (v: Node, scanline: RBTree) => void; } var xRect: RectAccessors = { getCentre: r=> r.cx(), getOpen: r=> r.y, getClose: r=> r.Y, getSize: r=> r.width(), makeRect: (open, close, center, size) => new Rectangle(center - size / 2, center + size / 2, open, close) , findNeighbours: findXNeighbours }; var yRect: RectAccessors = { getCentre: r=> r.cy(), getOpen: r=> r.x, getClose: r=> r.X, getSize: r=> r.height(), makeRect: (open, close, center, size) => new Rectangle(open, close, center - size / 2, center + size / 2), findNeighbours: findYNeighbours }; function generateGroupConstraints(root: ProjectionGroup, f: RectAccessors, minSep: number, isContained: boolean = false): Constraint[] { var padding = root.padding, gn = typeof root.groups !== 'undefined' ? root.groups.length : 0, ln = typeof root.leaves !== 'undefined' ? root.leaves.length : 0, childConstraints: Constraint[] = !gn ? [] : root.groups.reduce((ccs: Constraint[], g) => ccs.concat(generateGroupConstraints(g, f, minSep, true)), []), n = (isContained ? 2 : 0) + ln + gn, vs: Variable[] = new Array(n), rs: Rectangle[] = new Array(n), i = 0, add = (r, v) => { rs[i] = r; vs[i++] = v }; if (isContained) { // if this group is contained by another, then we add two dummy vars and rectangles for the borders var b: Rectangle = root.bounds, c = f.getCentre(b), s = f.getSize(b) / 2, open = f.getOpen(b), close = f.getClose(b), min = c - s + padding / 2, max = c + s - padding / 2; root.minVar.desiredPosition = min; add(f.makeRect(open, close, min, padding), root.minVar); root.maxVar.desiredPosition = max; add(f.makeRect(open, close, max, padding), root.maxVar); } if (ln) root.leaves.forEach(l => add(l.bounds, l.variable)); if (gn) root.groups.forEach(g => { var b: Rectangle = g.bounds; add(f.makeRect(f.getOpen(b), f.getClose(b), f.getCentre(b), f.getSize(b)), g.minVar); }); var cs = generateConstraints(rs, vs, f, minSep); if (gn) { vs.forEach(v => { v.cOut = [], v.cIn = [] }); cs.forEach(c => { c.left.cOut.push(c), c.right.cIn.push(c) }); root.groups.forEach(g => { var gapAdjustment = (g.padding - f.getSize(g.bounds)) / 2; g.minVar.cIn.forEach(c => c.gap += gapAdjustment); g.minVar.cOut.forEach(c => { c.left = g.maxVar; c.gap += gapAdjustment; }); }); } return childConstraints.concat(cs); } function generateConstraints(rs: Rectangle[], vars: Variable[], rect: RectAccessors, minSep: number): Constraint[] { var i, n = rs.length; var N = 2 * n; console.assert(vars.length >= n); var events = new Array(N); for (i = 0; i < n; ++i) { var r = rs[i]; var v = new Node(vars[i], r, rect.getCentre(r)); events[i] = new Event(true, v, rect.getOpen(r)); events[i + n] = new Event(false, v, rect.getClose(r)); } events.sort(compareEvents); var cs = new Array(); var scanline = makeRBTree(); for (i = 0; i < N; ++i) { var e = events[i]; var v = e.v; if (e.isOpen) { scanline.insert(v); rect.findNeighbours(v, scanline); } else { // close event scanline.remove(v); var makeConstraint = (l, r) => { var sep = (rect.getSize(l.r) + rect.getSize(r.r)) / 2 + minSep; cs.push(new Constraint(l.v, r.v, sep)); }; var visitNeighbours = (forward, reverse, mkcon) => { var u, it = v[forward].iterator(); while ((u = it[forward]()) !== null) { mkcon(u, v); u[reverse].remove(v); } }; visitNeighbours("prev", "next", (u, v) => makeConstraint(u, v)); visitNeighbours("next", "prev", (u, v) => makeConstraint(v, u)); } } console.assert(scanline.size === 0); return cs; } function findXNeighbours(v: Node, scanline: RBTree): void { var f = (forward, reverse) => { var it = scanline.findIter(v); var u; while ((u = it[forward]()) !== null) { var uovervX = u.r.overlapX(v.r); if (uovervX <= 0 || uovervX <= u.r.overlapY(v.r)) { v[forward].insert(u); u[reverse].insert(v); } if (uovervX <= 0) { break; } } } f("next", "prev"); f("prev", "next"); } function findYNeighbours(v: Node, scanline: RBTree): void { var f = (forward, reverse) => { var u = scanline.findIter(v)[forward](); if (u !== null && u.r.overlapX(v.r) > 0) { v[forward].insert(u); u[reverse].insert(v); } } f("next", "prev"); f("prev", "next"); } export function generateXConstraints(rs: Rectangle[], vars: Variable[]): Constraint[] { return generateConstraints(rs, vars, xRect, 1e-6); } export function generateYConstraints(rs: Rectangle[], vars: Variable[]): Constraint[] { return generateConstraints(rs, vars, yRect, 1e-6); } export function generateXGroupConstraints(root: ProjectionGroup): Constraint[] { return generateGroupConstraints(root, xRect, 1e-6); } export function generateYGroupConstraints(root: ProjectionGroup): Constraint[] { return generateGroupConstraints(root, yRect, 1e-6); } export function removeOverlaps(rs: Rectangle[]): void { var vs = rs.map(r => new Variable(r.cx())); var cs = generateXConstraints(rs, vs); var solver = new Solver(vs, cs); solver.solve(); vs.forEach((v, i) => rs[i].setXCentre(v.position())); vs = rs.map(r=> new Variable(r.cy())); cs = generateYConstraints(rs, vs); solver = new Solver(vs, cs); solver.solve(); vs.forEach((v, i) => rs[i].setYCentre(v.position())); } export interface GraphNode extends Leaf { fixed: boolean; fixedWeight?: number; width: number; height: number; x: number; y: number; px: number; py: number; } export class IndexedVariable extends Variable { constructor(public index: number, w: number) { super(0, w); } } export class Projection { private xConstraints: Constraint[]; private yConstraints: Constraint[]; private variables: Variable[]; constructor(private nodes: GraphNode[], private groups: ProjectionGroup[], private rootGroup: ProjectionGroup = null, constraints: any[]= null, private avoidOverlaps: boolean = false) { this.variables = nodes.map((v, i) => { return v.variable = new IndexedVariable(i, 1); }); if (constraints) this.createConstraints(constraints); if (avoidOverlaps && rootGroup && typeof rootGroup.groups !== 'undefined') { nodes.forEach(v => { if (!v.width || !v.height) { //If undefined, default to nothing v.bounds = new Rectangle(v.x, v.x, v.y, v.y); return; } var w2 = v.width / 2, h2 = v.height / 2; v.bounds = new Rectangle(v.x - w2, v.x + w2, v.y - h2, v.y + h2); }); computeGroupBounds(rootGroup); var i = nodes.length; groups.forEach(g => { this.variables[i] = g.minVar = new IndexedVariable(i++, typeof g.stiffness !== "undefined" ? g.stiffness : 0.01); this.variables[i] = g.maxVar = new IndexedVariable(i++, typeof g.stiffness !== "undefined" ? g.stiffness : 0.01); }); } } private createSeparation(c: any) : Constraint { return new Constraint( this.nodes[c.left].variable, this.nodes[c.right].variable, c.gap, typeof c.equality !== "undefined" ? c.equality : false); } // simple satisfaction of alignment constraints to ensure initial feasibility private makeFeasible(c: any) { if (!this.avoidOverlaps) return; // sort nodes in constraint by position (along "guideline") var axis = 'x', dim = 'width'; if (c.axis === 'x') axis = 'y', dim = 'height'; var vs: GraphNode[] = c.offsets.map(o => this.nodes[o.node]).sort((a, b) => a[axis] - b[axis]); var p: GraphNode = null; vs.forEach(v => { // if two nodes overlap then shove the second one along if (p) { let nextPos = p[axis] + p[dim]; if (nextPos > v[axis]) { v[axis] = nextPos; } } p = v; }); } private createAlignment(c: any) { var u = this.nodes[c.offsets[0].node].variable; this.makeFeasible(c); var cs = c.axis === 'x' ? this.xConstraints : this.yConstraints; c.offsets.slice(1).forEach(o => { var v = this.nodes[o.node].variable; cs.push(new Constraint(u, v, o.offset, true)); }); } private createConstraints(constraints: any[]) { var isSep = c => typeof c.type === 'undefined' || c.type === 'separation'; this.xConstraints = constraints .filter(c => c.axis === "x" && isSep(c)) .map(c => this.createSeparation(c)); this.yConstraints = constraints .filter(c => c.axis === "y" && isSep(c)) .map(c => this.createSeparation(c)); constraints .filter(c => c.type === 'alignment') .forEach(c => this.createAlignment(c)); } private setupVariablesAndBounds(x0: number[], y0: number[], desired: number[], getDesired: (v: GraphNode) => number) { this.nodes.forEach((v, i) => { if (v.fixed) { v.variable.weight = v.fixedWeight ? v.fixedWeight : 1000; desired[i] = getDesired(v); } else { v.variable.weight = 1; } var w = (v.width || 0) / 2, h = (v.height || 0) / 2; var ix = x0[i], iy = y0[i]; v.bounds = new Rectangle(ix - w, ix + w, iy - h, iy + h); }); } xProject(x0: number[], y0: number[], x: number[]) { if (!this.rootGroup && !(this.avoidOverlaps || this.xConstraints)) return; this.project(x0, y0, x0, x, v=> v.px, this.xConstraints, generateXGroupConstraints, v => v.bounds.setXCentre(x[(v.variable).index] = v.variable.position()), g => { var xmin = x[(g.minVar).index] = g.minVar.position(); var xmax = x[(g.maxVar).index] = g.maxVar.position(); var p2 = g.padding / 2; g.bounds.x = xmin - p2; g.bounds.X = xmax + p2; }); } yProject(x0: number[], y0: number[], y: number[]) { if (!this.rootGroup && !this.yConstraints) return; this.project(x0, y0, y0, y, v=> v.py, this.yConstraints, generateYGroupConstraints, v => v.bounds.setYCentre(y[(v.variable).index] = v.variable.position()), g => { var ymin = y[(g.minVar).index] = g.minVar.position(); var ymax = y[(g.maxVar).index] = g.maxVar.position(); var p2 = g.padding / 2; g.bounds.y = ymin - p2;; g.bounds.Y = ymax + p2; }); } projectFunctions(): { (x0: number[], y0: number[], r: number[]): void }[]{ return [ (x0, y0, x) => this.xProject(x0, y0, x), (x0, y0, y) => this.yProject(x0, y0, y) ]; } private project(x0: number[], y0: number[], start: number[], desired: number[], getDesired: (v: GraphNode) => number, cs: Constraint[], generateConstraints: (g: ProjectionGroup) => Constraint[], updateNodeBounds: (v: GraphNode) => any, updateGroupBounds: (g: ProjectionGroup) => any) { this.setupVariablesAndBounds(x0, y0, desired, getDesired); if (this.rootGroup && this.avoidOverlaps) { computeGroupBounds(this.rootGroup); cs = cs.concat(generateConstraints(this.rootGroup)); } this.solve(this.variables, cs, start, desired); this.nodes.forEach(updateNodeBounds); if (this.rootGroup && this.avoidOverlaps) { this.groups.forEach(updateGroupBounds); computeGroupBounds(this.rootGroup); } } private solve(vs: Variable[], cs: Constraint[], starting: number[], desired: number[]) { var solver = new Solver(vs, cs); solver.setStartingPositions(starting); solver.setDesiredPositions(desired); solver.solve(); } }