webcola
Version:
WebCola =======
460 lines (427 loc) • 18.1 kB
text/typescript
/**
* Descent respects a collection of locks over nodes that should not move
* @class Locks
*/
export class Locks {
locks: { [key:number]:number[]} = {};
/**
* add a lock on the node at index id
* @method add
* @param id index of node to be locked
* @param x required position for node
*/
add(id: number, x: number[]) {
/* DEBUG
if (isNaN(x[0]) || isNaN(x[1])) debugger;
DEBUG */
this.locks[id] = x;
}
/**
* @method clear clear all locks
*/
clear() {
this.locks = {};
}
/**
* @isEmpty
* @returns false if no locks exist
*/
isEmpty(): boolean {
for (var l in this.locks) return false;
return true;
}
/**
* perform an operation on each lock
* @apply
*/
apply(f: (id: number, x: number[]) => void) {
for (var l in this.locks) {
f(Number(l), this.locks[l]);
}
}
}
/**
* Uses a gradient descent approach to reduce a stress or p-stress goal function over a graph with specified ideal edge lengths or a square matrix of dissimilarities.
* The standard stress function over a graph nodes with position vectors x,y,z is (mathematica input):
* stress[x_,y_,z_,D_,w_]:=Sum[w[[i,j]] (length[x[[i]],y[[i]],z[[i]],x[[j]],y[[j]],z[[j]]]-d[[i,j]])^2,{i,Length[x]-1},{j,i+1,Length[x]}]
* where: D is a square matrix of ideal separations between nodes, w is matrix of weights for those separations
* length[x1_, y1_, z1_, x2_, y2_, z2_] = Sqrt[(x1 - x2)^2 + (y1 - y2)^2 + (z1 - z2)^2]
* below, we use wij = 1/(Dij^2)
*
* @class Descent
*/
export class Descent {
public threshold: number = 0.0001;
/** Hessian Matrix
* @property H {number[][][]}
*/
public H: number[][][];
/** gradient vector
* @property G {number[][]}
*/
public g: number[][];
/** positions vector
* @property x {number[][]}
*/
public x: number[][];
/**
* @property k {number} dimensionality
*/
public k: number;
/**
* number of data-points / nodes / size of vectors/matrices
* @property n {number}
*/
public n: number;
public locks: Locks;
private static zeroDistance: number = 1e-10;
private minD: number;
// pool of arrays of size n used internally, allocated in constructor
private Hd: number[][];
private a: number[][];
private b: number[][];
private c: number[][];
private d: number[][];
private e: number[][];
private ia: number[][];
private ib: number[][];
private xtmp: number[][];
// Parameters for grid snap stress.
// TODO: Make a pluggable "StressTerm" class instead of this
// mess.
public numGridSnapNodes: number = 0;
public snapGridSize: number = 100;
public snapStrength: number = 1000;
public scaleSnapByMaxH: boolean = false;
private random = new PseudoRandom();
public project: { (x0: number[], y0: number[], r: number[]): void }[] = null;
/**
* @method constructor
* @param x {number[][]} initial coordinates for nodes
* @param D {number[][]} matrix of desired distances between pairs of nodes
* @param G {number[][]} [default=null] if specified, G is a matrix of weights for goal terms between pairs of nodes.
* If G[i][j] > 1 and the separation between nodes i and j is greater than their ideal distance, then there is no contribution for this pair to the goal
* If G[i][j] <= 1 then it is used as a weighting on the contribution of the variance between ideal and actual separation between i and j to the goal function
*/
constructor(x: number[][], public D: number[][], public G: number[][]= null) {
this.x = x;
this.k = x.length; // dimensionality
var n = this.n = x[0].length; // number of nodes
this.H = new Array(this.k);
this.g = new Array(this.k);
this.Hd = new Array(this.k);
this.a = new Array(this.k);
this.b = new Array(this.k);
this.c = new Array(this.k);
this.d = new Array(this.k);
this.e = new Array(this.k);
this.ia = new Array(this.k);
this.ib = new Array(this.k);
this.xtmp = new Array(this.k);
this.locks = new Locks();
this.minD = Number.MAX_VALUE;
var i = n, j;
while (i--) {
j = n;
while (--j > i) {
var d = D[i][j];
if (d > 0 && d < this.minD) {
this.minD = d;
}
}
}
if (this.minD === Number.MAX_VALUE) this.minD = 1;
i = this.k;
while (i--) {
this.g[i] = new Array(n);
this.H[i] = new Array(n);
j = n;
while (j--) {
this.H[i][j] = new Array(n);
}
this.Hd[i] = new Array(n);
this.a[i] = new Array(n);
this.b[i] = new Array(n);
this.c[i] = new Array(n);
this.d[i] = new Array(n);
this.e[i] = new Array(n);
this.ia[i] = new Array(n);
this.ib[i] = new Array(n);
this.xtmp[i] = new Array(n);
}
}
public static createSquareMatrix(n: number, f: (i: number, j: number) => number): number[][] {
var M = new Array(n);
for (var i = 0; i < n; ++i) {
M[i] = new Array(n);
for (var j = 0; j < n; ++j) {
M[i][j] = f(i, j);
}
}
return M;
}
private offsetDir(): number[] {
var u = new Array(this.k);
var l = 0;
for (var i = 0; i < this.k; ++i) {
var x = u[i] = this.random.getNextBetween(0.01, 1) - 0.5;
l += x * x;
}
l = Math.sqrt(l);
return u.map(x=> x *= this.minD / l);
}
// compute first and second derivative information storing results in this.g and this.H
public computeDerivatives(x: number[][]) {
var n: number = this.n;
if (n < 1) return;
var i: number;
/* DEBUG
for (var u: number = 0; u < n; ++u)
for (i = 0; i < this.k; ++i)
if (isNaN(x[i][u])) debugger;
DEBUG */
var d: number[] = new Array(this.k);
var d2: number[] = new Array(this.k);
var Huu: number[] = new Array(this.k);
var maxH: number = 0;
for (var u: number = 0; u < n; ++u) {
for (i = 0; i < this.k; ++i) Huu[i] = this.g[i][u] = 0;
for (var v = 0; v < n; ++v) {
if (u === v) continue;
// The following loop randomly displaces nodes that are at identical positions
var maxDisplaces = n; // avoid infinite loop in the case of numerical issues, such as huge values
while (maxDisplaces--) {
var sd2 = 0;
for (i = 0; i < this.k; ++i) {
var dx = d[i] = x[i][u] - x[i][v];
sd2 += d2[i] = dx * dx;
}
if (sd2 > 1e-9) break;
var rd = this.offsetDir();
for (i = 0; i < this.k; ++i) x[i][v] += rd[i];
}
var l: number = Math.sqrt(sd2);
var D: number = this.D[u][v];
var weight = this.G != null ? this.G[u][v] : 1;
if (weight > 1 && l > D || !isFinite(D)) {
for (i = 0; i < this.k; ++i) this.H[i][u][v] = 0;
continue;
}
if (weight > 1) {
weight = 1;
}
var D2: number = D * D;
var gs: number = 2 * weight * (l - D) / (D2 * l);
var l3 = l * l * l;
var hs: number = 2 * -weight / (D2 * l3);
if (!isFinite(gs))
console.log(gs);
for (i = 0; i < this.k; ++i) {
this.g[i][u] += d[i] * gs;
Huu[i] -= this.H[i][u][v] = hs * (l3 + D * (d2[i] - sd2) + l * sd2);
}
}
for (i = 0; i < this.k; ++i) maxH = Math.max(maxH, this.H[i][u][u] = Huu[i]);
}
// Grid snap forces
var r = this.snapGridSize/2;
var g = this.snapGridSize;
var w = this.snapStrength;
var k = w / (r * r);
var numNodes = this.numGridSnapNodes;
//var numNodes = n;
for (var u: number = 0; u < numNodes; ++u) {
for (i = 0; i < this.k; ++i) {
var xiu = this.x[i][u];
var m = xiu / g;
var f = m % 1;
var q = m - f;
var a = Math.abs(f);
var dx = (a <= 0.5) ? xiu - q * g :
(xiu > 0) ? xiu - (q + 1) * g : xiu - (q - 1) * g;
if (-r < dx && dx <= r) {
if (this.scaleSnapByMaxH) {
this.g[i][u] += maxH * k * dx;
this.H[i][u][u] += maxH * k;
} else {
this.g[i][u] += k * dx;
this.H[i][u][u] += k;
}
}
}
}
if (!this.locks.isEmpty()) {
this.locks.apply((u, p) => {
for (i = 0; i < this.k; ++i) {
this.H[i][u][u] += maxH;
this.g[i][u] -= maxH * (p[i] - x[i][u]);
}
});
}
/* DEBUG
for (var u: number = 0; u < n; ++u)
for (i = 0; i < this.k; ++i) {
if (isNaN(this.g[i][u])) debugger;
for (var v: number = 0; v < n; ++v)
if (isNaN(this.H[i][u][v])) debugger;
}
DEBUG */
}
private static dotProd(a: number[], b: number[]): number {
var x = 0, i = a.length;
while (i--) x += a[i] * b[i];
return x;
}
// result r = matrix m * vector v
private static rightMultiply(m: number[][], v: number[], r: number[]) {
var i = m.length;
while (i--) r[i] = Descent.dotProd(m[i], v);
}
// computes the optimal step size to take in direction d using the
// derivative information in this.g and this.H
// returns the scalar multiplier to apply to d to get the optimal step
public computeStepSize(d: number[][]): number {
var numerator = 0, denominator = 0;
for (var i = 0; i < this.k; ++i) {
numerator += Descent.dotProd(this.g[i], d[i]);
Descent.rightMultiply(this.H[i], d[i], this.Hd[i]);
denominator += Descent.dotProd(d[i], this.Hd[i]);
}
if (denominator === 0 || !isFinite(denominator)) return 0;
return 1 * numerator / denominator;
}
public reduceStress(): number {
this.computeDerivatives(this.x);
var alpha = this.computeStepSize(this.g);
for (var i = 0; i < this.k; ++i) {
this.takeDescentStep(this.x[i], this.g[i], alpha);
}
return this.computeStress();
}
private static copy(a: number[][], b: number[][]): void {
var m = a.length, n = b[0].length;
for (var i = 0; i < m; ++i) {
for (var j = 0; j < n; ++j) {
b[i][j] = a[i][j];
}
}
}
// takes a step of stepSize * d from x0, and then project against any constraints.
// result is returned in r.
// x0: starting positions
// r: result positions will be returned here
// d: unconstrained descent vector
// stepSize: amount to step along d
private stepAndProject(x0: number[][], r: number[][], d: number[][], stepSize: number): void {
Descent.copy(x0, r);
this.takeDescentStep(r[0], d[0], stepSize);
if (this.project) this.project[0](x0[0], x0[1], r[0]);
this.takeDescentStep(r[1], d[1], stepSize);
if (this.project) this.project[1](r[0], x0[1], r[1]);
// todo: allow projection against constraints in higher dimensions
for (var i = 2; i < this.k; i++)
this.takeDescentStep(r[i], d[i], stepSize);
// the following makes locks extra sticky... but hides the result of the projection from the consumer
//if (!this.locks.isEmpty()) {
// this.locks.apply((u, p) => {
// for (var i = 0; i < this.k; i++) {
// r[i][u] = p[i];
// }
// });
//}
}
private static mApply(m: number, n: number, f: (i: number, j: number) => any) {
var i = m; while (i-- > 0) {
var j = n; while (j-- > 0) f(i, j);
}
}
private matrixApply(f: (i: number, j: number) => any) {
Descent.mApply(this.k, this.n, f);
}
private computeNextPosition(x0: number[][], r: number[][]): void {
this.computeDerivatives(x0);
var alpha = this.computeStepSize(this.g);
this.stepAndProject(x0, r, this.g, alpha);
/* DEBUG
for (var u: number = 0; u < this.n; ++u)
for (var i = 0; i < this.k; ++i)
if (isNaN(r[i][u])) debugger;
DEBUG */
if (this.project) {
this.matrixApply((i, j) => this.e[i][j] = x0[i][j] - r[i][j]);
var beta = this.computeStepSize(this.e);
beta = Math.max(0.2, Math.min(beta, 1));
this.stepAndProject(x0, r, this.e, beta);
}
}
public run(iterations: number): number {
var stress = Number.MAX_VALUE, converged = false;
while (!converged && iterations-- > 0) {
var s = this.rungeKutta();
converged = Math.abs(stress / s - 1) < this.threshold;
stress = s;
}
return stress;
}
public rungeKutta(): number {
this.computeNextPosition(this.x, this.a);
Descent.mid(this.x, this.a, this.ia);
this.computeNextPosition(this.ia, this.b);
Descent.mid(this.x, this.b, this.ib);
this.computeNextPosition(this.ib, this.c);
this.computeNextPosition(this.c, this.d);
var disp = 0;
this.matrixApply((i, j) => {
var x = (this.a[i][j] + 2.0 * this.b[i][j] + 2.0 * this.c[i][j] + this.d[i][j]) / 6.0,
d = this.x[i][j] - x;
disp += d * d;
this.x[i][j] = x;
});
return disp;
}
private static mid(a: number[][], b: number[][], m: number[][]): void {
Descent.mApply(a.length, a[0].length, (i, j) =>
m[i][j] = a[i][j] + (b[i][j] - a[i][j]) / 2.0);
}
public takeDescentStep(x: number[], d: number[], stepSize: number): void {
for (var i = 0; i < this.n; ++i) {
x[i] = x[i] - stepSize * d[i];
}
}
public computeStress(): number {
var stress = 0;
for (var u = 0, nMinus1 = this.n - 1; u < nMinus1; ++u) {
for (var v = u + 1, n = this.n; v < n; ++v) {
var l = 0;
for (var i = 0; i < this.k; ++i) {
var dx = this.x[i][u] - this.x[i][v];
l += dx * dx;
}
l = Math.sqrt(l);
var d = this.D[u][v];
if (!isFinite(d)) continue;
var rl = d - l;
var d2 = d * d;
stress += rl * rl / d2;
}
}
return stress;
}
}
// Linear congruential pseudo random number generator
export class PseudoRandom {
private a: number = 214013;
private c: number = 2531011;
private m: number = 2147483648;
private range: number = 32767;
constructor(public seed: number = 1) { }
// random real between 0 and 1
getNext(): number {
this.seed = (this.seed * this.a + this.c) % this.m;
return (this.seed >> 16) / this.range;
}
// random real between min and max
getNextBetween(min: number, max: number) {
return min + this.getNext() * (max - min);
}
}