Created by Christopher Manning
Draws a force directed graph using svg:path and geo projections onto a sphere.
- Make links in rectangular projection wraparound
- Fix node replusion in azimuthal projections
- Fix node dragging in azimuthal projections
Created by Christopher Manning
Draws a force directed graph using svg:path and geo projections onto a sphere.
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Spherical Force-Directed Layout</title> | |
<script src="http://d3js.org/d3.v3.min.js"></script> | |
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/dat-gui/0.5/dat.gui.min.js"></script> | |
<!--<script src="/js/d3.v3.min.js"></script>--> | |
<!--<script src="/js/dat-gui/build/dat.gui.js"></script> --> | |
<style type="text/css"> | |
body { | |
padding: 0; | |
margin: 0; | |
} | |
path.node { | |
stroke-width: 1.5px; | |
} | |
path.link { | |
stroke: #999; | |
fill-opacity: 0 | |
} | |
</style> | |
</head> | |
<body> | |
<script type="text/javascript"> | |
var projections = { | |
"Albers": d3.geo.albers(), | |
"Azimuthal Equal Area": d3.geo.azimuthalEqualArea(), | |
"Azimuthal Eqidistant": d3.geo.azimuthalEquidistant(), | |
"Conic Conformal": d3.geo.conicConformal(), | |
"Conic Equal Area": d3.geo.conicEqualArea(), | |
"Conic Equidistant": d3.geo.conicEquidistant(), | |
"Eqirectangular": d3.geo.equirectangular(), | |
"Gnomonic": d3.geo.gnomonic(), | |
"Mercator": d3.geo.mercator(), | |
"Orthographic": d3.geo.orthographic(), | |
"Stereographic": d3.geo.stereographic(), | |
"Transverse Mercator": d3.geo.transverseMercator(), | |
}; | |
var config = { "projection": "Orthographic", "clip": true, "friction": .9, "linkStrength": 1, "linkDistance": 20, "charge": 30, "gravity": .1, "theta": .8 }; | |
var gui = new dat.GUI(); | |
//var projectionChanger = gui.add(config, "projection", ['equalarea', 'equidistant', 'gnomonic', 'orthographic', 'stereographic', 'rectangular']); | |
var projectionChanger = gui.add(config, "projection", Object.keys(projections)); | |
//http://stackoverflow.com/a/3417242 | |
function wrapIndex(i, i_max) { | |
return ((i % i_max) + i_max) % i_max; | |
} | |
projectionChanger.onChange(function(value) { | |
projection = projections[value] | |
.scale(height/2) | |
.translate([(width/2)-125, height/2]) | |
.clipAngle(config["clip"] ? 90 : null) | |
path.projection(projections[value]) | |
return | |
if(value == 'rectangular') { | |
path = d3.geo.path().projection(function(coordinates){ | |
console.log(coordinates[0], coordinates[1]) | |
return [ | |
wrapIndex(coordinates[0], width), | |
wrapIndex(coordinates[1], height), | |
]; | |
}); | |
config['clip'] = false | |
} else { | |
projection.mode(value) | |
path = d3.geo.path().projection(projection) | |
} | |
force.start() | |
}); | |
var clipChanger = gui.add(config, "clip").listen(); | |
clipChanger.onChange(function(value) { | |
projection.clipAngle(value ? 90 : null) | |
force.start() | |
}); | |
var fl = gui.addFolder('Force Layout'); | |
fl.open() | |
var frictionChanger = fl.add(config, "friction", 0, 1); | |
frictionChanger.onChange(function(value) { | |
force.friction(value) | |
force.start() | |
}); | |
var linkDistanceChanger = fl.add(config, "linkDistance", 0, 400); | |
linkDistanceChanger.onChange(function(value) { | |
force.linkDistance(value) | |
force.start() | |
}); | |
var linkStrengthChanger = fl.add(config, "linkStrength", 0, 1); | |
linkStrengthChanger.onChange(function(value) { | |
force.linkStrength(value) | |
force.start() | |
}); | |
var chargeChanger = fl.add(config,"charge", 0, 500); | |
chargeChanger.onChange(function(value) { | |
force.charge(-value) | |
force.start() | |
}); | |
var gravityChanger = fl.add(config,"gravity", 0, 1); | |
gravityChanger.onChange(function(value) { | |
force.gravity(value) | |
force.start() | |
}); | |
var thetaChanger = fl.add(config,"theta", 0, 1); | |
thetaChanger.onChange(function(value) { | |
force.theta(value) | |
force.start() | |
}); | |
var width = window.innerWidth, | |
height = window.innerHeight - 5, | |
fill = d3.scale.category20(), | |
nodes = [{x: width/2, y: height/2}], | |
links = []; | |
var projection = projections[config["projection"]] | |
.scale(height/2) | |
.translate([(width/2)-125, height/2]) | |
.clipAngle(config["clip"] ? 90 : null) | |
var path = d3.geo.path() | |
.projection(projection) | |
var force = d3.layout.force() | |
.linkDistance(config["linkDistance"]) | |
.linkStrength(config["linkStrength"]) | |
.gravity(config["gravity"]) | |
.size([width, height]) | |
.charge(-config["charge"]); | |
var svg = d3.select("body").append("svg") | |
.attr("width", width) | |
.attr("height", height) | |
.call(d3.behavior.drag() | |
.origin(function() { var r = projection.rotate(); return {x: 2 * r[0], y: -2 * r[1]}; }) | |
.on("drag", function() { force.start(); var r = [d3.event.x / 2, -d3.event.y / 2, projection.rotate()[2]]; t0 = Date.now(); origin = r; projection.rotate(r); })) | |
for(x=0;x<100;x++){ | |
source = nodes[~~(Math.random() * nodes.length)] | |
target = {x: source.x + Math.random(), y: source.y + Math.random(), group: Math.random()} | |
links.push({source: source, target: target}) | |
nodes.push(target) | |
} | |
var link = svg.selectAll("path.link") | |
.data(links) | |
.enter().append("path").attr("class", "link") | |
var node = svg.selectAll("path.node") | |
.data(nodes) | |
.enter().append("path").attr("class", "node") | |
.style("fill", function(d) { return fill(d.group); }) | |
.style("stroke", function(d) { return d3.rgb(fill(d.group)).darker(); }) | |
.call(force.drag); | |
force | |
.nodes(nodes) | |
.links(links) | |
.on("tick", tick) | |
.start(); | |
function tick() { | |
node.attr("d", function(d) { var p = path({"type":"Feature","geometry":{"type":"Point","coordinates":[d.x, d.y]}}); return p ? p : 'M 0 0' }); | |
link.attr("d", function(d) { var p = path({"type":"Feature","geometry":{"type":"LineString","coordinates":[[d.source.x, d.source.y],[d.target.x, d.target.y]]}}); return p ? p : 'M 0 0' }); | |
} | |
</script> | |
</body> | |
</html> |