See SO answer.
You can pan (click background), zoom (mousewheel), drag nodes around. Middle click to add more nodes (at mouse).
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8" /> | |
<title>Navigate and Zoom around graph</title> | |
<style> | |
body, html, svg { | |
padding: 0; | |
margin: 0; | |
width: 100%; | |
height: 100%; | |
} | |
#menu { | |
position: absolute; | |
bottom: 1em; | |
left: 1em; | |
} | |
.node { | |
cursor: pointer; | |
} | |
.node > rect { | |
fill: rgba(255,255,255,.9); | |
stroke-width: 3px; | |
stroke: #000; | |
rx: 4px; | |
ry: 4px; | |
shape-rendering: crispEdges; | |
} | |
.node > text.label { | |
fill: black; | |
text-anchor: middle; | |
alignment-baseline: central; | |
font-size: 13px; | |
font-family: sans-serif; | |
letter-spacing: -1px; | |
} | |
.node:hover > rect { | |
fill: black; | |
stroke: red; | |
} | |
.node:hover > text { | |
fill: white; | |
} | |
.link { | |
stroke: black; | |
stroke-width: 2px; | |
stroke-antialiasing: true; | |
} | |
</style> | |
<script src="http://d3js.org/d3.v3.min.js" charset="utf-8"></script> | |
</head> | |
<body> | |
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" pointer-events="all"> | |
<defs> | |
<radialGradient id="background-gradient" cx="70%" cy="100%" r="90%" fy="60%"> | |
<stop offset="5%" stop-color="#EEFFFF" /> | |
<stop offset="95%" stop-color="#DDEEFF" /> | |
</radialGradient> | |
</defs> | |
<rect id="background" width="100%" height="100%" fill="url(#background-gradient)" pointer-events="all" /> | |
<g id="root"> | |
<g id="links"></g> | |
<g id="nodes"></g> | |
</g> | |
</svg> | |
<div id="menu"> | |
<button onclick="force.stop()">Freeze</button> | |
<button onclick="force.resume()">Thaw</button> | |
<button onclick="force.stop(); zoomFit(0.95, 500)">Fit</button> | |
</div> | |
<script>//<![CDATA[ | |
var zoom = d3.behavior | |
.zoom() | |
.scaleExtent([1/4, 4]) | |
.on('zoom.zoom', function () { | |
console.trace("zoom", d3.event.translate, d3.event.scale); | |
root.attr('transform', | |
'translate(' + d3.event.translate + ')' | |
+ 'scale(' + d3.event.scale + ')'); | |
}) | |
; | |
var svg = d3 | |
.select('svg') | |
.call(zoom) | |
; | |
svg.select('#background') | |
.on('mousedown', function () { | |
if(d3.event.which != 2) return; | |
d3.event.preventDefault(); | |
var point = d3.mouse(node_group.node()); | |
var label = 'user@' + Math.round(point[0]) + ',' + Math.round(point[1]); | |
nodes.push(createNode(label, point[0], point[1])); | |
restart(); | |
}) | |
; | |
var root = svg.select('#root'); | |
var node_group = svg.select('#nodes'); | |
var link_group = svg.select('#links'); | |
var force = d3.layout | |
.force() | |
.gravity(0.03) | |
.linkStrength(0.3) | |
.charge(-400) | |
.on('tick', function tick() { | |
link.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 translate(d) { | |
return 'translate(' + d.x + ',' + d.y + ')'; | |
}) | |
; | |
}) | |
; | |
var graph = {}; | |
var nodes = force.nodes(); | |
var links = force.links(); | |
var node = node_group.selectAll('.node'); | |
var link = link_group.selectAll('.link'); | |
var uiNodes, uiLinks; | |
d3.select(window).on('resize', resize); | |
function resize() { | |
var width = window.innerWidth, height = window.innerHeight; | |
console.trace("Resize", force.size(), [width, height]); | |
force.size([width, height]).resume(); | |
lapsedZoomFit(5, 0); | |
} | |
function restart(first) { | |
link = link.data(links); | |
link.exit().remove(); | |
uiLinks = link | |
.enter() | |
.append('line') | |
.attr('id', function(d) { | |
return 'link_' + d.source.id + '_' + d.target.id; | |
}) | |
.attr('class', 'link') | |
; | |
node = node.data(nodes); | |
node.exit().remove(); | |
uiNodes = createNodes(node); | |
uiNodes | |
.call(force | |
.drag() | |
.on('dragstart', function() { | |
d3.event.sourceEvent.stopPropagation(); | |
}) | |
) | |
force.start(); | |
if (first) { | |
lapsedZoomFit(undefined, 0); | |
} | |
} | |
function lapsedZoomFit(ticks, transitionDuration) { | |
for (var i = ticks || 200; i > 0; --i) force.tick(); | |
force.stop(); | |
zoomFit(undefined, transitionDuration); | |
} | |
function zoomFit(paddingPercent, transitionDuration) { | |
var bounds = root.node().getBBox(); | |
var parent = root.node().parentElement; | |
var fullWidth = parent.clientWidth, | |
fullHeight = parent.clientHeight; | |
var width = bounds.width, | |
height = bounds.height; | |
var midX = bounds.x + width / 2, | |
midY = bounds.y + height / 2; | |
if (width == 0 || height == 0) return; // nothing to fit | |
var scale = (paddingPercent || 0.75) / Math.max(width / fullWidth, height / fullHeight); | |
var translate = [fullWidth / 2 - scale * midX, fullHeight / 2 - scale * midY]; | |
console.trace("zoomFit", translate, scale); | |
root | |
.transition() | |
.duration(transitionDuration || 0) // milliseconds | |
.call(zoom.translate(translate).scale(scale).event); | |
} | |
function createNodes(nodeData) { | |
var uiNodes = nodeData | |
.enter() | |
.append('g') | |
.attr('id', function(d) { return 'node_' + d.id; }) | |
; | |
nodeData | |
.attr("class", 'node'); | |
; | |
var rect = uiNodes.append('rect') | |
; | |
var text = uiNodes.append('text') | |
.each(function(d) { d.label = this; }) | |
.classed('label', true) | |
.text(function(d) { return d.id; }) | |
; | |
var padding = {x: 5, y: 4}; | |
rect | |
.attr('width', function(d) { return d.label.clientWidth + 2.0 * padding.x; }) | |
.attr('height', function(d) { return d.label.clientHeight + 2.0 * padding.y; }) | |
.attr('x', function(d) { return +d3.select(this).attr('width') / -2.0; }) | |
.attr('y', function(d) { return +d3.select(this).attr('height') / -2.0; }) | |
; | |
return nodeData; | |
} | |
function createNode(id, x,y) { | |
return { | |
id: id, | |
x: x, | |
y: y, | |
label: null, | |
toString: function() { | |
return this.id + " @ " + this.x + "," + this.y + " " + this.width + "x" + this.height; | |
} | |
}; | |
} | |
function createLink(fromNode, toNode) { | |
return { | |
source: fromNode, | |
target: toNode, | |
weight: 1, | |
toString: function() { | |
return this.source + " -> " + this.target; | |
} | |
}; | |
} | |
for (var i = 0; i < 20; ++i) { | |
var id = Math.floor(Math.random() * 10000000); | |
nodes.push(createNode(id, i%2? i : 0, i)); | |
} | |
for (var i = 0; i < 10; ++i) { | |
var fromPos = Math.floor(Math.random() * nodes.length); | |
var toPos = Math.floor(Math.random() * nodes.length); | |
links.push(createLink(nodes[fromPos], nodes[toPos])); | |
} | |
// use middle click to add more nodes | |
restart(true); | |
//]]></script> | |
</body> | |
</html> |