Skip to content

Instantly share code, notes, and snippets.

@cool-Blue
Last active June 12, 2016 06:12
Show Gist options
  • Save cool-Blue/40e550b1507cca31b0bb to your computer and use it in GitHub Desktop.
Save cool-Blue/40e550b1507cca31b0bb to your computer and use it in GitHub Desktop.
d3 fdg with generalised drag and zoom background.

Force directed Graph

Features

  1. drag and zoom background surface
  2. transition on zoom, instant on drag
  3. static axes in pixel (range) units
  4. stickable nodes shift-drag or hold down one finger and drag with another drag again to release
  5. double click to move a node to the center of the plot surface

Architecture

SVG (background) roles

  • create an svg element on the specified selector or element
    • only one svg element for each base element
    • return a subclassed d3 object with data hooked. This is to ensure that container is a reference to the final UPDATE+ENTER selection after data is bound and the element is created. This is required as long as selection.data() returns a new element.
  • zoom and pan using d3.behaviour.zoom only
    • pan is instant
    • touch zoom is instant
    • mousewheel zoom is transitioned over 450 ms
    • the current transform status of the container are copied to zoom on zoom start
      this needs to be done once but is done everytime for simplicity. This is to eliminate a jump back to zero before transitioning zoom for the first time.
    • inherit extent and doubleclick behaviour from user defined zoom
      (TODO inherit transform and scale from user zoom, maybe accept user defined zoom)
  • methods
    • #.h, #.w inner height and width of the plot surface
    • #.container return the SVG object with and svg element on the specified selector
    • #.zoomTo move the point specified in domain coordinates to the center of the container transition time specified by user
    • #.onZoom, #.onZoomStart user post-hooks for zoom
    • #.translate, #.scale expose zoom transform state

FDG roles

  • provide an interface for adding a force directed graph on the provided SVG plot surface
  • TBC
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FDG with zoom(/drag) on background</title>
<style>
.axis path,
.axis line {
fill: none;
stroke: #fff;
}
.paxis path,
.paxis line {
fill: none;
stroke: #ccc;
}
.paxis text {
fill: #ccc;
}
svg {
outline: 1px solid #282f51;
pointer-events: all;
overflow: visible;
}
g.outline {
outline: 1px solid red;
}
#panel div {
display: inline-block;
margin: 0 .25em 3px 0;
}
#panel div div {
white-space: pre;
margin: 0 .25em 3px 0;
}
div#inputDiv {
white-space: normal;
display: inline-block;
}
.node {
cursor: default;
}
text {
font-size: 8px;
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.3.0/css/font-awesome.min.css">
</head>
<body>
<div id="panel">
<div id="inputDiv">
<input id="update" type="button" value="update">
</div>
<div id="wrapAlpha">alpha:
<div id="alpha"></div>
</div>
<div id="fdg">
</div>
</div>
<div id="viz"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script>
<!--<script src="d3 CB.js"></script>-->
<script src="https://gitcdn.xyz/repo/cool-Blue/d3-lib/master/elapsedTime/elapsedTime/elapsed%20time%201.0.js"></script>
<script src="script.js"></script>
</body>
</html>
//debug panel/////////////////////////////////////////////////////////////////////////////
var alpha = d3.select("#alpha").text("waiting..."),
cog = d3.select("#wrapAlpha").insert("i", "#fdg").classed("fa fa-cog fa-spin", true).datum({instID: null}),
fdgInst = d3.select("#fdg");
elapsedTime = ElapsedTime("#panel", {margin: 0, padding: 0})
.message(function (id) {
return 'fps : ' + d3.format(" >8.3f")(1/this.aveLap())
});
elapsedTime.consoleOn = false;
alpha.log = function(e, instID) {
elapsedTime.mark().timestamp();
alpha.text(d3.format(" >8.4f")(e.alpha));
fdgInst.text("fdg instance: " + instID);
};
d3.select("#update").on("click", (function() {
var dataSet = false;
return function() {
fdg.data(dataSets[(dataSet = !dataSet, +dataSet)])
}
})());
d3.select("#jump").on("click", function() {
var jumpXY = 50,
p = d3.transform(fdg.attr("transform"))
.translate.map(function(d){return d+jumpXY});
fdg.zoomTo({x: p[0], y: p[1]});
});
//////////////////////////////////////////////////////////////////////////////////////////
var dataSets = [{
"nodes" : [
{"name": "node1", "r": 10},
{"name": "node2", "r": 10},
{"name": "node3", "r": 30},
{"name": "node4", "r": 15}
],
"edges": [
{"source": 2, "target": 0},
{"source": 2, "target": 1},
{"source": 2, "target": 3}
]
},
{
"nodes":[
{"name": "node1", "r": 20},
{"name": "node2", "r": 10},
{"name": "node3", "r": 30},
{"name": "node4", "r": 15},
{"name": "node5", "r": 10},
{"name": "node6", "r": 10}
],
"edges":[
{"source": 2, "target": 0},
{"source": 2, "target": 1},
{"source": 2, "target": 3},
{"source": 2, "target": 4},
{"source": 2, "target": 5}
]
}
],
svg = SVG({width: 600, height: 200-34, margin: {top: 25, right: 5, bottom: 15, left: 15}}, "#viz", {dblclik: null}),
fdg = FDG(svg, alpha.log).zoomTime(1000)
.on("dblclick", function(d){
d3.event.stopPropagation();
fdg.zoomTo(d);
});
fdg.data(dataSets[0]);
function SVG (size, selector, z){
//delivers an svg background with zoom/drag context in the selector element
//if height or width is NaN, assume it is a valid length but ignore margin
var margin = size.margin || {top: 0, right: 0, bottom: 0, left: 0},
unitW = isNaN(size.width), unitH = isNaN(size.height),
w = unitW ? size.width : size.width - margin.left - margin.right,
h = unitH ? size.height : size.height - margin.top - margin.bottom,
x, y, xAxis, yAxis,
px = d3.scale.linear()
.domain([-w/2, w/2])
.range([0, w]),
py = d3.scale.linear()
.domain([-h/2, h/2])
.range([0, h]),
pxAxis = d3.svg.axis()
.scale(px)
.orient("bottom")
.tickSize(3),
pyAxis = d3.svg.axis()
.scale(py)
.orient("right")
.tickSize(3),
zoomStart = function() {return this},
zoomed = function(){return this},
container,
zoom = d3.behavior.zoom().scaleExtent(z && z.extent || [0.4, 4])
.on("zoom", function(d, i, j){
onZoom.call(this, d, i, j);
zoomed.call(this, d, i, j);
})
.on("zoomstart", function(d, i, j){
onZoomStart.call(this, d, i, j);
zoomStart.call(this, d, i, j);
}),
svg = d3.select(selector).selectAll("svg").data([["transform root"]]);
svg.enter().append("svg");
svg.attr({width: size.width, height: size.height});
var g = svg.selectAll("#zoom").data(id),
gEnter = g.enter().append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.style("cursor", "move")
.call(zoom)
.attr({class: "outline", id: "zoom"}),
zoomText = gEnter.append("text")
.text("g#zoom: transform = translate ( margin.left , margin.top ); .call(zoom)")
.style("fill", "#5c5c5c")
.attr("dy", "-.35em"),
surface = gEnter.append("rect")
.attr({width: w, height: h})
.style({"pointer-events": "all", fill: "#ccc", "stroke-width": 3, "stroke": "#fff"}),
surfaceText = gEnter.append("text")
.text("event capture surface: style='pointer-events: none'")
.style("fill", "#5c5c5c")
.attr({"dy": "1em", "dx": ".2em"}),
gx = gEnter.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + h + ")")
.call(xAxis || noop),
gy = gEnter.append("g")
.attr("class", "y axis")
.call(yAxis || noop),
gpx = gEnter.append("g")
.attr("class", "x paxis")
.attr("transform", "translate(0," + h + ")")
.call(pxAxis),
gpy = gEnter.append("g")
.attr("class", "y paxis")
.attr("transform", "translate(" + w + ",0)")
.call(pyAxis);
if(z && (typeof z.dblclik != "undefined")) gEnter.on("dblclick.zoom", z.dblclik);
function onZoomStart(){
// zoom translate and scale are initially [0,0] and 1
// this needs to be aligned with the container to stop
// jump back to zero before first jump transition
var t = d3.transform(container.attr("transform"));
zoom.translate(t.translate); zoom.scale(t.scale[0]);
}
function onZoom(){
var e = d3.event.sourceEvent,
isWheel = e && ((e.type == "mousewheel") || (e.type == "wheel")),
t = d3.transform(container.attr("transform"));
t.translate = d3.event.translate; t.scale = [d3.event.scale, d3.event.scale];
return isWheel ? zoomWheel.call(this, t) : zoomInst.call(this, t)
}
function zoomInst(t){
container.attr("transform", t.toString());
gx.call(xAxis || noop);
gy.call(yAxis || noop);
}
function zoomWheel(t){
container.transition().duration(450).attr("transform", t.toString());
gx.transition().duration(450).call(xAxis || noop);
gy.transition().duration(450).call(yAxis || noop);
}
g.h = h;
g.w = w;
g.container = function(selector){
var d3_data, d3_datum;
if(selector) {
container = g.selectAll(selector);
// temporarily subclass container
d3_data = container.data;
d3_datum = container.datum;
// need a reference to the update selection
// so force data methods back to here
container.data = function() {
delete container.data; // remove the sub-classing
return container = d3_data.apply(container, arguments)
}
}
return container;
};
g.xScale = function(_){
var y;
if(!_) return (y = subclass(x, function() {
zoom.x(x); // set the base value for the zoom's understanding of x.domain
gx.call(xAxis || noop);
}), y);
zoom.x(x = _);
gx.call(xAxis || noop);
return this;
};
g.yScale = function(_){
if(!_) return subclass(y, function() {
zoom.y(y); // set the base value for the zoom's understanding of y.domain
gy.call(xAxis || noop);
});
zoom.y(y = _);
gy.call(yAxis || noop);
return this;
};
g.xAxis = function(_){
if(!_) return subclass(xAxis, function() {
gx.call(xAxis);
});
gx.call(xAxis = _);
return this;
};
g.yAxis = function(_){
if(!_) return subclass(yAxis, function() {
gy.call(yAxis);
});
gy.call(yAxis = _)
return this;
};
g.zoomTo = function(t, p){
// map p to the center of the plot surface
var s = zoom.scale(),
p1 = [w/2 - p.x * s, h/2 - p.y * s];
container.transition().duration(t).call(zoom.translate(p1).event);
};
g.onZoom = function(cb){zoomed = cb;};
g.onZoomStart = function(cb) {zoomStart = cb;};
d3.rebind(g, zoom, "translate");
d3.rebind(g, zoom, "scale");
return g;
function noop(){};
function subclass(x, post){
// hook a post-processor to all methods of x
return Object.keys(x).reduce(function(s, k) {
return (s[k] = function() {
var ret = x[k].apply(x, arguments);
post();
return ret;
}, s)
}, {})
}
}
function FDG (svg, tickLog) {
var instID = Date.now(),
size = [svg.w, svg.h],
events = ["mouseover", "mouseout", "click", "dblclick", "contextmenu"],
dispatch = d3.dispatch.apply(null, events),
force = d3.layout.force()
.size(size)
.charge(-1000)
.linkDistance(50)
.on("end", function() {
// manage dead instances of force
// only stop if this instance is the current owner
if(cog.datum().instID != instID) return true;
cog.classed("fa-spin", false);
elapsedTime.stop();
})
.on("start", function() {
// mark as active and brand the insID to establish ownership
cog.classed("fa-spin", true).datum().instID = instID;
elapsedTime.start();
}),
fdg = {};
function data(data) {
force
.nodes(data.nodes)
.links(data.edges)
.on("tick", (function(instID) {
return function(e) {
if(tickLog) tickLog.call(this, e, instID);
contentText.text(function(d){return d()})
lines.attr("x1", function(d) {
return d.source.x;
}).attr("y1", function(d) {
return d.source.y;
}).attr("x2", function(d) {
return d.target.x;
}).attr("y2", function(d) {
return d.target.y;
});
node.attr("transform", function(d) {
return "translate(" + [d.x, d.y] + ")"
});
var nodesBB = nodes.node().getBBox(),
textBB = nodeText.node().getBBox();
nodeText
.attr({
x: nodesBB.x + nodesBB.width/2 - textBB.width/2,
y: nodesBB.y + nodesBB.height,
dy: "-0.35em"
});
}
})(instID));
hookDrag(force.drag(), "dragstart.force", function(d) {
// prevent dragging on the nodes from dragging the canvas
var e = d3.event.sourceEvent;
e.stopPropagation();
d.fixed = e.shiftKey || e.touches && (e.touches.length > 1);
});
hookDrag(force.drag(), "dragend.force", function(d) {
// prevent dragging on the nodes from dragging the canvas
var e = d3.event.sourceEvent;
d.fixed = e.shiftKey || d.fixed;
});
var x = d3.scale.linear()
.domain([-svg.w/2, svg.w/2])
.range([0, svg.w]),
y = d3.scale.linear()
.domain([-svg.h/2, svg.h/2])
.range([0, svg.h]),
xAxis = d3.svg.axis()
.scale(x)
.orient("top")
.tickSize(svg.h),
yAxis = d3.svg.axis()
.scale(y)
.orient("left")
.tickSize(-svg.w);
svg
.xScale(x)
.yScale(y)
.xAxis(xAxis)
.yAxis(yAxis)
.onZoom(zoomed);
var content = svg.container("g#fdg").data([data]);
content.enter().append("g").attr({"id": "fdg", class: "outline"});
var contentText = content.selectAll(".contentText")
.data([
function() {
var t = d3.transform(content.attr("transform"));
return "content: transform = translate (" + f(0)(t.translate) + ") scale (" + f(1)(t.scale) + ")";
function f(p) {
return function _f(x) {
return Array.isArray(x) ? x.map(_f) : d3.format("." + p + "f")(x);
}
}
}])
.enter().append("text").classed("contentText", true)
.text(function(d){return d()})
.style("fill", "#5c5c5c")
.attr({"dy": 20, "dx": 20});
var lines = content.selectAll(".links")
.data(linksData),
linesEnter = lines.enter()
.insert("line", d3.select("#nodes") ? "#nodes" : null)
.attr("class", "links")
.attr({stroke: "steelblue", "stroke-width": 3});
var nodes = content.selectAll("#nodes")
.data(nodesData),
nodesEnter = nodes.enter().append("g")
.attr("id", "nodes")
.style("outline", "1px solid black"),
nodeText = content.selectAll(".Nodetext").data(["nodes"]),
nodeTextEnter = nodeText.enter().append("text").classed("Nodetext", true)
.text(id)
.style("fill", "#5c5c5c"),
node = nodes.selectAll(".node")
.data(id),
newNode = node.enter().append("g")
.attr("class", "node")
.style("cursor", "pointer")
.call(force.drag)
.on("mouseover", dispatch.mouseover)
.on("mouseout", dispatch.mouseout)
.on("dblclick", dispatch.dblclick)
.on('contextmenu', function(d, i) {
d3.event.preventDefault();
dispatch.contextmenu(d, i);
}),
circles = newNode.append("circle")
.attr({class: "content"})
.attr("r", function(d) {
return d.r
})
.style({"fill": "red", opacity: 0.8}),
labels = newNode.append("text")
.text(function(d, i) {return i});
lines.exit().remove();
node.exit().remove();
//svg.xScale().domain([-svg.w/2, svg.w/2]);
function nodesData(d) {
return [d.nodes];
}
function linksData(d) {
return d.edges;
}
function hookDrag(target, event, hook) {
//hook force.drag behaviour
var stdDragStart = target.on(event);
target.on(event, function(d) {
hook.call(this, d);
stdDragStart.call(this, d);
});
}
function zoomed() {
force.alpha(0.01);
};
// zoom context services
// content is the target for zoom movements in zoomed
d3.rebind(fdg, content, "attr");
// access the current transform state in zoom listener coordinates
d3.rebind(fdg, svg, "translate");
fdg.nodes = data.nodes;
force.start();
return fdg;
};
// events
d3.rebind.bind(null, fdg, dispatch, "on").apply(null, events);
fdg.zoomTime = (function() {
var _t;
return function(t) {
if(t == undefined) return _t;
if(t == null) return (fdg.zoomTo = svg.zoomTo, this);
fdg.zoomTo = fdg.zoomTo.bind(null, _t = t);
return this;
}
})();
d3.rebind(fdg, svg, "zoomTo")
fdg.zoomTo = function(t, p){
// tell svg which point in the domain space maps to the center of the range
svg.zoomTo(t, p);
return this;
};
fdg.data = data;
return fdg;
}
function id(d){return d;}
function myName(args) {
return /function\s+(\w*)\(/.exec(args.callee)[1];
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment