dc.graph
Version:
Graph visualizations integrated with crossfilter and dc.js
871 lines (823 loc) • 27.1 kB
JavaScript
function point_on_ellipse(A, B, dx, dy) {
var tansq = Math.tan(Math.atan2(dy, dx));
tansq = tansq*tansq; // why is this not just dy*dy/dx*dx ? ?
var ret = {x: A*B/Math.sqrt(B*B + A*A*tansq), y: A*B/Math.sqrt(A*A + B*B/tansq)};
if(dx<0)
ret.x = -ret.x;
if(dy<0)
ret.y = -ret.y;
return ret;
}
var eps = 0.0000001;
function between(a, b, c) {
return a-eps <= b && b <= c+eps;
}
// Adapted from http://stackoverflow.com/questions/563198/how-do-you-detect-where-two-line-segments-intersect/1968345#1968345
function segment_intersection(x1,y1,x2,y2, x3,y3,x4,y4) {
var x=((x1*y2-y1*x2)*(x3-x4)-(x1-x2)*(x3*y4-y3*x4)) /
((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4));
var y=((x1*y2-y1*x2)*(y3-y4)-(y1-y2)*(x3*y4-y3*x4)) /
((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4));
if (isNaN(x)||isNaN(y)) {
return false;
} else {
if (x1>=x2) {
if (!between(x2, x, x1)) {return false;}
} else {
if (!between(x1, x, x2)) {return false;}
}
if (y1>=y2) {
if (!between(y2, y, y1)) {return false;}
} else {
if (!between(y1, y, y2)) {return false;}
}
if (x3>=x4) {
if (!between(x4, x, x3)) {return false;}
} else {
if (!between(x3, x, x4)) {return false;}
}
if (y3>=y4) {
if (!between(y4, y, y3)) {return false;}
} else {
if (!between(y3, y, y4)) {return false;}
}
}
return {x: x, y: y};
}
function point_on_polygon(points, x0, y0, x1, y1) {
for(var i = 0; i < points.length; ++i) {
var next = i===points.length-1 ? 0 : i+1;
var isect = segment_intersection(points[i].x, points[i].y, points[next].x, points[next].y,
x0, y0, x1, y1);
if(isect)
return isect;
}
return null;
}
// as many as we can get from
// http://www.graphviz.org/doc/info/shapes.html
dc_graph.shape_presets = {
egg: {
// not really: an ovoid should be two half-ellipses stuck together
// https://en.wikipedia.org/wiki/Oval
generator: 'polygon',
preset: function() {
return {sides: 100, distortion: -0.25};
}
},
triangle: {
generator: 'polygon',
preset: function() {
return {sides: 3};
}
},
rectangle: {
generator: 'polygon',
preset: function() {
return {sides: 4};
}
},
diamond: {
generator: 'polygon',
preset: function() {
return {sides: 4, rotation: 45};
}
},
trapezium: {
generator: 'polygon',
preset: function() {
return {sides: 4, distortion: -0.5};
}
},
parallelogram: {
generator: 'polygon',
preset: function() {
return {sides: 4, skew: 0.5};
}
},
pentagon: {
generator: 'polygon',
preset: function() {
return {sides: 5};
}
},
hexagon: {
generator: 'polygon',
preset: function() {
return {sides: 6};
}
},
septagon: {
generator: 'polygon',
preset: function() {
return {sides: 7};
}
},
octagon: {
generator: 'polygon',
preset: function() {
return {sides: 8};
}
},
invtriangle: {
generator: 'polygon',
preset: function() {
return {sides: 3, rotation: 180};
}
},
invtrapezium: {
generator: 'polygon',
preset: function() {
return {sides: 4, distortion: 0.5};
}
},
square: {
generator: 'polygon',
preset: function() {
return {
sides: 4,
regular: true
};
}
},
plain: {
generator: 'rounded-rect',
preset: function() {
return {
noshape: true
};
}
},
house: {
generator: 'elaborated-rect',
preset: function() {
return {
get_points: function(rx, ry) {
return [
{x: rx, y: ry*2/3},
{x: rx, y: -ry/2},
{x: 0, y: -ry},
{x: -rx, y: -ry/2},
{x: -rx, y: ry*2/3}
];
},
minrx: 30
};
}
},
invhouse: {
generator: 'elaborated-rect',
preset: function() {
return {
get_points: function(rx, ry) {
return [
{x: rx, y: ry/2},
{x: rx, y: -ry*2/3},
{x: -rx, y: -ry*2/3},
{x: -rx, y: ry/2},
{x: 0, y: ry}
];
},
minrx: 30
};
}
},
rarrow: {
generator: 'elaborated-rect',
preset: function() {
return {
get_points: function(rx, ry) {
return [
{x: rx, y: ry},
{x: rx, y: ry*1.5},
{x: rx + ry*1.5, y: 0},
{x: rx, y: -ry*1.5},
{x: rx, y: -ry},
{x: -rx, y: -ry},
{x: -rx, y: ry}
];
},
minrx: 30
};
}
},
larrow: {
generator: 'elaborated-rect',
preset: function() {
return {
get_points: function(rx, ry) {
return [
{x: -rx, y: ry},
{x: -rx, y: ry*1.5},
{x: -rx - ry*1.5, y: 0},
{x: -rx, y: -ry*1.5},
{x: -rx, y: -ry},
{x: rx, y: -ry},
{x: rx, y: ry}
];
},
minrx: 30
};
}
},
rpromoter: {
generator: 'elaborated-rect',
preset: function() {
return {
get_points: function(rx, ry) {
return [
{x: rx, y: ry},
{x: rx, y: ry*1.5},
{x: rx + ry*1.5, y: 0},
{x: rx, y: -ry*1.5},
{x: rx, y: -ry},
{x: -rx, y: -ry},
{x: -rx, y: ry*1.5},
{x: 0, y: ry*1.5},
{x: 0, y: ry},
];
},
minrx: 30
};
}
},
lpromoter: {
generator: 'elaborated-rect',
preset: function() {
return {
get_points: function(rx, ry) {
return [
{x: -rx, y: ry},
{x: -rx, y: ry*1.5},
{x: -rx - ry*1.5, y: 0},
{x: -rx, y: -ry*1.5},
{x: -rx, y: -ry},
{x: rx, y: -ry},
{x: rx, y: ry*1.5},
{x: 0, y: ry*1.5},
{x: 0, y: ry}
];
},
minrx: 30
};
}
},
cds: {
generator: 'elaborated-rect',
preset: function() {
return {
get_points: function(rx, ry) {
return [
{x: rx, y: ry},
{x: rx + ry, y: 0},
{x: rx, y: -ry},
{x: -rx, y: -ry},
{x: -rx, y: ry}
];
},
minrx: 30
};
}
},
};
dc_graph.shape_presets.box = dc_graph.shape_presets.rect = dc_graph.shape_presets.rectangle;
dc_graph.available_shapes = function() {
var shapes = Object.keys(dc_graph.shape_presets);
return shapes.slice(0, shapes.length-1); // not including polygon
};
var default_shape = {shape: 'ellipse'};
function normalize_shape_def(diagram, n) {
var def = diagram.nodeShape.eval(n);
if(!def)
return default_shape;
if(typeof def === 'string')
return {shape: def};
return def;
}
function elaborate_shape(diagram, def) {
var shape = def.shape, def2 = Object.assign({}, def);
delete def2.shape;
if(shape === 'random') {
var available = dc_graph.available_shapes(); // could include diagram.shape !== ellipse, polygon
shape = available[Math.floor(Math.random()*available.length)];
}
else if(diagram.shape.enum().indexOf(shape) !== -1)
return diagram.shape(shape).elaborate({shape: shape}, def2);
if(!dc_graph.shape_presets[shape]) {
console.warn('unknown shape ', shape);
return default_shape;
}
var preset = dc_graph.shape_presets[shape].preset(def2);
preset.shape = dc_graph.shape_presets[shape].generator;
return diagram.shape(preset.shape).elaborate(preset, def2);
}
function infer_shape(diagram) {
return function(n) {
var def = normalize_shape_def(diagram, n);
n.dcg_shape = elaborate_shape(diagram, def);
n.dcg_shape.abstract = def;
};
}
function shape_changed(diagram) {
return function(n) {
var def = normalize_shape_def(diagram, n);
var old = n.dcg_shape.abstract;
if(def.shape !== old.shape)
return true;
else if(def.shape === 'polygon') {
return def.shape.sides !== old.sides || def.shape.skew !== old.skew ||
def.shape.distortion !== old.distortion || def.shape.rotation !== old.rotation;
}
else return false;
};
}
function node_label_padding(diagram, n) {
var nlp = diagram.nodeLabelPadding.eval(n);
if(typeof nlp === 'number' || typeof nlp === 'string')
return {x: +nlp, y: +nlp};
else return nlp;
}
function fit_shape(shape, diagram) {
return function(content) {
content.each(function(n) {
var bbox = null;
if((!shape.useTextSize || shape.useTextSize(n.dcg_shape)) && diagram.nodeFitLabel.eval(n)) {
bbox = getBBoxNoThrow(this);
bbox = {x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height};
var padding;
var content = diagram.nodeContent.eval(n);
if(content && diagram.content(content).padding)
padding = diagram.content(content).padding(n);
else {
var padding2 = node_label_padding(diagram, n);
padding = {
x: padding2.x*2,
y: padding2.y*2
};
}
bbox.width += padding.x;
bbox.height += padding.y;
n.bbox = bbox;
}
var r = 0, radii;
if(!shape.useRadius || shape.useRadius(n.dcg_shape))
r = diagram.nodeRadius.eval(n);
if(bbox && bbox.width && bbox.height || shape.useTextSize && !shape.useTextSize(n.dcg_shape))
radii = shape.calc_radii(n, r, bbox);
else
radii = {rx: r, ry: r};
n.dcg_rx = radii.rx;
n.dcg_ry = radii.ry;
var w = radii.rx*2, h = radii.ry*2;
// fixme: this is only consistent if regular || !squeeze
// but we'd need to calculate polygon first in order to find out
// (not a bad idea, just no time right now)
// if(w<h) w = h;
if(!shape.usePaddingAndStroke || shape.usePaddingAndStroke(n.dcg_shape)) {
var pands = diagram.nodePadding.eval(n) + diagram.nodeStrokeWidth.eval(n);
w += pands;
h += pands;
}
n.cola.width = w;
n.cola.height = h;
});
};
}
function ellipse_attrs(diagram) {
return {
rx: function(n) { return n.dcg_rx; },
ry: function(n) { return n.dcg_ry; }
};
}
function polygon_attrs(diagram, n) {
return {
d: function(n) {
var rx = n.dcg_rx, ry = n.dcg_ry,
def = n.dcg_shape,
sides = def.sides || 4,
skew = def.skew || 0,
distortion = def.distortion || 0,
rotation = def.rotation || 0,
align = (sides%2 ? 0 : 0.5), // even-sided horizontal top, odd pointy top
angles = [];
rotation = rotation/360 + 0.25; // start at y axis not x
for(var i = 0; i<sides; ++i) {
var theta = -((i+align)/sides + rotation)*Math.PI*2; // svg is up-negative
angles.push({x: Math.cos(theta), y: Math.sin(theta)});
}
var yext = d3.extent(angles, function(theta) { return theta.y; });
if(def.regular)
rx = ry = Math.max(rx, ry);
else if(rx < ry && !def.squeeze)
rx = ry;
else
ry = ry / Math.min(-yext[0], yext[1]);
n.dcg_points = angles.map(function(theta) {
var x = rx*theta.x,
y = ry*theta.y;
x *= 1 + distortion*((ry-y)/ry - 1);
x -= skew*y/2;
return {x: x, y: y};
});
return generate_path(n.dcg_points, 1, true);
}
};
}
function binary_search(f, a, b) {
var patience = 100;
if(f(a).val >= 0)
throw new Error("f(a) must be less than 0");
if(f(b).val <= 0)
throw new Error("f(b) must be greater than 0");
while(true) {
if(!--patience)
throw new Error("patience ran out");
var c = (a+b)/2,
f_c = f(c), fv = f_c.val;
if(Math.abs(fv) < 0.5)
return f_c;
if(fv > 0)
b = c;
else
a = c;
}
}
function draw_edge_to_shapes(diagram, e, sx, sy, tx, ty,
neighbor, dir, offset, source_padding, target_padding) {
var deltaX, deltaY,
sp, tp, points, bezDegree,
headAng, retPath;
if(!neighbor) {
sp = e.sourcePort.pos;
tp = e.targetPort.pos;
if(!sp) sp = {x: 0, y: 0};
if(!tp) tp = {x: 0, y: 0};
points = [{
x: sx + sp.x,
y: sy + sp.y
}, {
x: tx + tp.x,
y: ty + tp.y
}];
bezDegree = 1;
}
else {
var p_on_s = function(node, ang) {
return diagram.shape(node.dcg_shape.shape).intersect_vec(node, Math.cos(ang)*1000, Math.sin(ang)*1000);
};
var compare_dist = function(node, port0, goal) {
return function(ang) {
var port = p_on_s(node, ang);
if(!port)
return {
port: {x: 0, y: 0},
val: 0,
ang: ang
};
else
return {
port: port,
val: Math.hypot(port.x - port0.x, port.y - port0.y) - goal,
ang: ang
};
};
};
var srcang = Math.atan2(neighbor.sourcePort.y, neighbor.sourcePort.x),
tarang = Math.atan2(neighbor.targetPort.y, neighbor.targetPort.x);
var bss, bst;
// don't like this but throwing is unacceptable
try {
bss = binary_search(compare_dist(e.source, neighbor.sourcePort, offset),
srcang, srcang + 2 * dir * offset / source_padding);
}
catch(x) {
bss = {ang: srcang, port: neighbor.sourcePort};
}
try {
bst = binary_search(compare_dist(e.target, neighbor.targetPort, offset),
tarang, tarang - 2 * dir * offset / source_padding);
}
catch(x) {
bst = {ang: tarang, port: neighbor.targetPort};
}
sp = bss.port;
tp = bst.port;
var sdist = Math.hypot(sp.x, sp.y),
tdist = Math.hypot(tp.x, tp.y),
c1dist = sdist+source_padding/2,
c2dist = tdist+target_padding/2;
var c1X = sx + c1dist * Math.cos(bss.ang),
c1Y = sy + c1dist * Math.sin(bss.ang),
c2X = tx + c2dist * Math.cos(bst.ang),
c2Y = ty + c2dist * Math.sin(bst.ang);
points = [
{x: sx + sp.x, y: sy + sp.y},
{x: c1X, y: c1Y},
{x: c2X, y: c2Y},
{x: tx + tp.x, y: ty + tp.y}
];
bezDegree = 3;
}
return {
sourcePort: sp,
targetPort: tp,
points: points,
bezDegree: bezDegree
};
}
function is_one_segment(path) {
return path.bezDegree === 1 && path.points.length === 2 ||
path.bezDegree === 3 && path.points.length === 4;
}
function as_bezier3(path) {
var p = path.points;
if(path.bezDegree === 3) return p;
else if(path.bezDegree === 1)
return [
{
x: p[0].x,
y: p[0].y
},
{
x: p[0].x + (p[1].x - p[0].x)/3,
y: p[0].y + (p[1].y - p[0].y)/3
},
{
x: p[0].x + 2*(p[1].x - p[0].x)/3,
y: p[0].y + 2*(p[1].y - p[0].y)/3
},
{
x: p[1].x,
y: p[1].y
}
];
else throw new Error('unknown bezDegree ' + path.bezDegree);
}
// from https://www.jasondavies.com/animated-bezier/
function interpolate(d, p) {
var r = [];
for (var i=1; i<d.length; i++) {
var d0 = d[i-1], d1 = d[i];
r.push({x: d0.x + (d1.x - d0.x) * p, y: d0.y + (d1.y - d0.y) * p});
}
return r;
}
function getLevels(points, t_) {
var x = [points];
for (var i=1; i<points.length; i++) {
x.push(interpolate(x[x.length-1], t_));
}
return x;
}
// get a point on a bezier segment, where 0 <= t <= 1
function bezier_point(points, t_) {
var q = getLevels(points, t_);
return q[q.length-1][0];
}
// from https://stackoverflow.com/questions/8369488/splitting-a-bezier-curve#8405756
// somewhat redundant with the above but different objective
function split_bezier(p, t) {
var x1 = p[0].x, y1 = p[0].y,
x2 = p[1].x, y2 = p[1].y,
x3 = p[2].x, y3 = p[2].y,
x4 = p[3].x, y4 = p[3].y,
x12 = (x2-x1)*t+x1,
y12 = (y2-y1)*t+y1,
x23 = (x3-x2)*t+x2,
y23 = (y3-y2)*t+y2,
x34 = (x4-x3)*t+x3,
y34 = (y4-y3)*t+y3,
x123 = (x23-x12)*t+x12,
y123 = (y23-y12)*t+y12,
x234 = (x34-x23)*t+x23,
y234 = (y34-y23)*t+y23,
x1234 = (x234-x123)*t+x123,
y1234 = (y234-y123)*t+y123;
return [
[{x: x1, y: y1}, {x: x12, y: y12}, {x: x123, y: y123}, {x: x1234, y: y1234}],
[{x: x1234, y: y1234}, {x: x234, y: y234}, {x: x34, y: y34}, {x: x4, y: y4}]
];
}
function split_bezier_n(p, n) {
var ret = [];
while(n > 1) {
var parts = split_bezier(p, 1/n);
ret.push(parts[0][0], parts[0][1], parts[0][2]);
p = parts[1];
--n;
}
ret.push.apply(ret, p);
return ret;
}
// binary search for a point along a bezier that is a certain distance from one of the end points
// return the bezier cut at that point.
function chop_bezier(points, end, dist) {
var EPS = 0.1, dist2 = dist*dist;
var ref, dir, segment;
if(end === 'head') {
ref = points[points.length-1];
segment = points.slice(points.length-4);
dir = -1;
} else {
ref = points[0];
segment = points.slice(0, 4);
dir = 1;
}
var parts, d2, t = 0.5, dt = 0.5, dx, dy;
do {
parts = split_bezier(segment, t);
dx = ref.x - parts[1][0].x;
dy = ref.y - parts[1][0].y;
d2 = dx*dx + dy*dy;
dt /= 2;
if(d2 > dist2)
t -= dt*dir;
else
t += dt*dir;
//console.log('dist', dist, 'dir', dir, 'd', d, 't', t, 'dt', dt);
}
while(dt > 0.0000001 && Math.abs(d2 - dist2) > EPS);
points = points.slice();
if(end === 'head')
return points.slice(0, points.length-4).concat(parts[0]);
else
return parts[1].concat(points.slice(4));
}
function angle_between_points(p0, p1) {
return Math.atan2(p1.y - p0.y, p1.x - p0.x);
}
dc_graph.no_shape = function() {
var _shape = {
parent: property(null),
elaborate: function(preset, def) {
return Object.assign(preset, def);
},
useTextSize: function() { return false; },
useRadius: function() { return false; },
usePaddingAndStroke: function() { return false; },
intersect_vec: function(n, deltaX, deltaY) {
return {x: 0, y: 0};
},
calc_radii: function(n, ry, bbox) {
return {rx: 0, ry: 0};
},
create: function(nodeEnter) {
},
replace: function(nodeChanged) {
},
update: function(node) {
}
};
return _shape;
};
dc_graph.ellipse_shape = function() {
var _shape = {
parent: property(null),
elaborate: function(preset, def) {
return Object.assign(preset, def);
},
intersect_vec: function(n, deltaX, deltaY) {
return point_on_ellipse(n.dcg_rx, n.dcg_ry, deltaX, deltaY);
},
calc_radii: function(n, ry, bbox) {
// make sure we can fit height in r
ry = Math.max(ry, bbox.height/2 + 5);
var rx = bbox.width/2;
// solve (x/A)^2 + (y/B)^2) = 1 for A, with B=r, to fit text in ellipse
// http://stackoverflow.com/a/433438/676195
var y_over_B = bbox.height/2/ry;
rx = rx/Math.sqrt(1 - y_over_B*y_over_B);
rx = Math.max(rx, ry);
return {rx: rx, ry: ry};
},
create: function(nodeEnter) {
nodeEnter.insert('ellipse', ':first-child')
.attr('class', 'node-shape');
},
update: function(node) {
node.select('ellipse.node-shape')
.attr(ellipse_attrs(_shape.parent()));
}
};
return _shape;
};
dc_graph.polygon_shape = function() {
var _shape = {
parent: property(null),
elaborate: function(preset, def) {
return Object.assign(preset, def);
},
intersect_vec: function(n, deltaX, deltaY) {
return point_on_polygon(n.dcg_points, 0, 0, deltaX, deltaY);
},
calc_radii: function(n, ry, bbox) {
// make sure we can fit height in r
ry = Math.max(ry, bbox.height/2 + 5);
var rx = bbox.width/2;
// this is cribbed from graphviz but there is much i don't understand
// and any errors are mine
// https://github.com/ellson/graphviz/blob/6acd566eab716c899ef3c4ddc87eceb9b428b627/lib/common/shapes.c#L1996
rx = rx*Math.sqrt(2)/Math.cos(Math.PI/(n.dcg_shape.sides||4));
return {rx: rx, ry: ry};
},
create: function(nodeEnter) {
nodeEnter.insert('path', ':first-child')
.attr('class', 'node-shape');
},
update: function(node) {
node.select('path.node-shape')
.attr(polygon_attrs(_shape.parent()));
}
};
return _shape;
};
dc_graph.rounded_rectangle_shape = function() {
var _shape = {
parent: property(null),
elaborate: function(preset, def) {
preset = Object.assign({rx: 10, ry: 10}, preset);
return Object.assign(preset, def);
},
intersect_vec: function(n, deltaX, deltaY) {
var points = [
{x: n.dcg_rx, y: n.dcg_ry},
{x: n.dcg_rx, y: -n.dcg_ry},
{x: -n.dcg_rx, y: -n.dcg_ry},
{x: -n.dcg_rx, y: n.dcg_ry}
];
return point_on_polygon(points, 0, 0, deltaX, deltaY); // not rounded
},
useRadius: function(shape) {
return !shape.noshape;
},
calc_radii: function(n, ry, bbox) {
var fity = bbox.height/2;
// fixme: fudge to make sure text is not too tall for node
if(!n.dcg_shape.noshape)
fity += 5;
return {
rx: bbox.width / 2,
ry: Math.max(ry, fity)
};
},
create: function(nodeEnter) {
nodeEnter.filter(function(n) {
return !n.dcg_shape.noshape;
}).insert('rect', ':first-child')
.attr('class', 'node-shape');
},
update: function(node) {
node.select('rect.node-shape')
.attr({
x: function(n) {
return -n.dcg_rx;
},
y: function(n) {
return -n.dcg_ry;
},
width: function(n) {
return 2*n.dcg_rx;
},
height: function(n) {
return 2*n.dcg_ry;
},
rx: function(n) {
return n.dcg_shape.rx + 'px';
},
ry: function(n) {
return n.dcg_shape.ry + 'px';
}
});
}
};
return _shape;
};
// this is not all that accurate - idea is that arrows, houses, etc, are rectangles
// in terms of sizing, but elaborated drawing & clipping. refine until done.
dc_graph.elaborated_rectangle_shape = function() {
var _shape = dc_graph.rounded_rectangle_shape();
_shape.intersect_vec = function(n, deltaX, deltaY) {
var points = n.dcg_shape.get_points(n.dcg_rx, n.dcg_ry);
return point_on_polygon(points, 0, 0, deltaX, deltaY);
};
delete _shape.useRadius;
var orig_radii = _shape.calc_radii;
_shape.calc_radii = function(n, ry, bbox) {
var ret = orig_radii(n, ry, bbox);
return {
rx: Math.max(ret.rx, n.dcg_shape.minrx),
ry: ret.ry
};
};
_shape.create = function(nodeEnter) {
nodeEnter.insert('path', ':first-child')
.attr('class', 'node-shape');
};
_shape.update = function(node) {
node.select('path.node-shape')
.attr('d', function(n) {
return generate_path(n.dcg_shape.get_points(n.dcg_rx, n.dcg_ry), 1, true);
});
};
return _shape;
};