Last active
June 12, 2017 19:56
-
-
Save hkjpotato/f88e818b34827451cc1b3f19a622ad49 to your computer and use it in GitHub Desktop.
dynamic force layout with multiple customization methods
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{"nodes":[{"x":444,"y":275},{"x":378,"y":324},{"x":478,"y":278},{"x":471,"y":256},{"x":382,"y":269},{"x":371,"y":247},{"x":359,"y":276},{"x":364,"y":302},{"x":400,"y":330},{"x":388,"y":298},{"x":524,"y":296},{"x":570,"y":243},{"x":552,"y":159},{"x":502,"y":287},{"x":511,"y":313},{"x":513,"y":265},{"x":602,"y":132},{"x":610,"y":90},{"x":592,"y":91},{"x":575,"y":89},{"x":607,"y":73},{"x":591,"y":68},{"x":574,"y":73},{"x":589,"y":149},{"x":620,"y":205},{"x":621,"y":230},{"x":589,"y":234},{"x":602,"y":223},{"x":548,"y":188},{"x":532,"y":196},{"x":548,"y":114},{"x":575,"y":174},{"x":497,"y":250},{"x":576,"y":196},{"x":504,"y":201},{"x":494,"y":186},{"x":482,"y":199},{"x":505,"y":219},{"x":486,"y":216},{"x":590,"y":306},{"x":677,"y":169},{"x":657,"y":258},{"x":667,"y":205},{"x":552,"y":227},{"x":518,"y":173},{"x":473,"y":125},{"x":796,"y":260},{"x":731,"y":272},{"x":642,"y":288},{"x":576,"y":269},{"x":605,"y":187},{"x":559,"y":289},{"x":544,"y":356},{"x":505,"y":365},{"x":579,"y":289},{"x":619,"y":282},{"x":574,"y":329},{"x":664,"y":306},{"x":627,"y":304},{"x":643,"y":327},{"x":664,"y":348},{"x":665,"y":327},{"x":653,"y":317},{"x":650,"y":338},{"x":622,"y":321},{"x":633,"y":338},{"x":647,"y":357},{"x":718,"y":362},{"x":636,"y":240},{"x":640,"y":227},{"x":617,"y":249},{"x":631,"y":254},{"x":566,"y":213},{"x":713,"y":322},{"x":716,"y":298},{"x":666,"y":241},{"x":627,"y":355}],"links":[{"source":1,"target":0},{"source":2,"target":0},{"source":3,"target":0},{"source":3,"target":2},{"source":4,"target":0},{"source":5,"target":0},{"source":6,"target":0},{"source":7,"target":0},{"source":8,"target":0},{"source":9,"target":0},{"source":11,"target":10},{"source":11,"target":3},{"source":11,"target":2},{"source":11,"target":0},{"source":12,"target":11},{"source":13,"target":11},{"source":14,"target":11},{"source":15,"target":11},{"source":17,"target":16},{"source":18,"target":16},{"source":18,"target":17},{"source":19,"target":16},{"source":19,"target":17},{"source":19,"target":18},{"source":20,"target":16},{"source":20,"target":17},{"source":20,"target":18},{"source":20,"target":19},{"source":21,"target":16},{"source":21,"target":17},{"source":21,"target":18},{"source":21,"target":19},{"source":21,"target":20},{"source":22,"target":16},{"source":22,"target":17},{"source":22,"target":18},{"source":22,"target":19},{"source":22,"target":20},{"source":22,"target":21},{"source":23,"target":16},{"source":23,"target":17},{"source":23,"target":18},{"source":23,"target":19},{"source":23,"target":20},{"source":23,"target":21},{"source":23,"target":22},{"source":23,"target":12},{"source":23,"target":11},{"source":24,"target":23},{"source":24,"target":11},{"source":25,"target":24},{"source":25,"target":23},{"source":25,"target":11},{"source":26,"target":24},{"source":26,"target":11},{"source":26,"target":16},{"source":26,"target":25},{"source":27,"target":11},{"source":27,"target":23},{"source":27,"target":25},{"source":27,"target":24},{"source":27,"target":26},{"source":28,"target":11},{"source":28,"target":27},{"source":29,"target":23},{"source":29,"target":27},{"source":29,"target":11},{"source":30,"target":23},{"source":31,"target":30},{"source":31,"target":11},{"source":31,"target":23},{"source":31,"target":27},{"source":32,"target":11},{"source":33,"target":11},{"source":33,"target":27},{"source":34,"target":11},{"source":34,"target":29},{"source":35,"target":11},{"source":35,"target":34},{"source":35,"target":29},{"source":36,"target":34},{"source":36,"target":35},{"source":36,"target":11},{"source":36,"target":29},{"source":37,"target":34},{"source":37,"target":35},{"source":37,"target":36},{"source":37,"target":11},{"source":37,"target":29},{"source":38,"target":34},{"source":38,"target":35},{"source":38,"target":36},{"source":38,"target":37},{"source":38,"target":11},{"source":38,"target":29},{"source":39,"target":25},{"source":40,"target":25},{"source":41,"target":24},{"source":41,"target":25},{"source":42,"target":41},{"source":42,"target":25},{"source":42,"target":24},{"source":43,"target":11},{"source":43,"target":26},{"source":43,"target":27},{"source":44,"target":28},{"source":44,"target":11},{"source":45,"target":28},{"source":47,"target":46},{"source":48,"target":47},{"source":48,"target":25},{"source":48,"target":27},{"source":48,"target":11},{"source":49,"target":26},{"source":49,"target":11},{"source":50,"target":49},{"source":50,"target":24},{"source":51,"target":49},{"source":51,"target":26},{"source":51,"target":11},{"source":52,"target":51},{"source":52,"target":39},{"source":53,"target":51},{"source":54,"target":51},{"source":54,"target":49},{"source":54,"target":26},{"source":55,"target":51},{"source":55,"target":49},{"source":55,"target":39},{"source":55,"target":54},{"source":55,"target":26},{"source":55,"target":11},{"source":55,"target":16},{"source":55,"target":25},{"source":55,"target":41},{"source":55,"target":48},{"source":56,"target":49},{"source":56,"target":55},{"source":57,"target":55},{"source":57,"target":41},{"source":57,"target":48},{"source":58,"target":55},{"source":58,"target":48},{"source":58,"target":27},{"source":58,"target":57},{"source":58,"target":11},{"source":59,"target":58},{"source":59,"target":55},{"source":59,"target":48},{"source":59,"target":57},{"source":60,"target":48},{"source":60,"target":58},{"source":60,"target":59},{"source":61,"target":48},{"source":61,"target":58},{"source":61,"target":60},{"source":61,"target":59},{"source":61,"target":57},{"source":61,"target":55},{"source":62,"target":55},{"source":62,"target":58},{"source":62,"target":59},{"source":62,"target":48},{"source":62,"target":57},{"source":62,"target":41},{"source":62,"target":61},{"source":62,"target":60},{"source":63,"target":59},{"source":63,"target":48},{"source":63,"target":62},{"source":63,"target":57},{"source":63,"target":58},{"source":63,"target":61},{"source":63,"target":60},{"source":63,"target":55},{"source":64,"target":55},{"source":64,"target":62},{"source":64,"target":48},{"source":64,"target":63},{"source":64,"target":58},{"source":64,"target":61},{"source":64,"target":60},{"source":64,"target":59},{"source":64,"target":57},{"source":64,"target":11},{"source":65,"target":63},{"source":65,"target":64},{"source":65,"target":48},{"source":65,"target":62},{"source":65,"target":58},{"source":65,"target":61},{"source":65,"target":60},{"source":65,"target":59},{"source":65,"target":57},{"source":65,"target":55},{"source":66,"target":64},{"source":66,"target":58},{"source":66,"target":59},{"source":66,"target":62},{"source":66,"target":65},{"source":66,"target":48},{"source":66,"target":63},{"source":66,"target":61},{"source":66,"target":60},{"source":67,"target":57},{"source":68,"target":25},{"source":68,"target":11},{"source":68,"target":24},{"source":68,"target":27},{"source":68,"target":48},{"source":68,"target":41},{"source":69,"target":25},{"source":69,"target":68},{"source":69,"target":11},{"source":69,"target":24},{"source":69,"target":27},{"source":69,"target":48},{"source":69,"target":41},{"source":70,"target":25},{"source":70,"target":69},{"source":70,"target":68},{"source":70,"target":11},{"source":70,"target":24},{"source":70,"target":27},{"source":70,"target":41},{"source":70,"target":58},{"source":71,"target":27},{"source":71,"target":69},{"source":71,"target":68},{"source":71,"target":70},{"source":71,"target":11},{"source":71,"target":48},{"source":71,"target":41},{"source":71,"target":25},{"source":72,"target":26},{"source":72,"target":27},{"source":72,"target":11},{"source":73,"target":48},{"source":74,"target":48},{"source":74,"target":73},{"source":75,"target":69},{"source":75,"target":68},{"source":75,"target":25},{"source":75,"target":48},{"source":75,"target":41},{"source":75,"target":70},{"source":75,"target":71},{"source":76,"target":64},{"source":76,"target":65},{"source":76,"target":66},{"source":76,"target":63},{"source":76,"target":62},{"source":76,"target":48},{"source":76,"target":58}]} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<meta charset="utf-8"> | |
<style> | |
.nodesLayer .node-circle { | |
fill: #ff7f7f; | |
stroke: #fff; | |
stroke-width: 1.5px; | |
} | |
.nodesLayer .selected .node-circle{ | |
stroke: #555; | |
} | |
.nodesLayer .highlighted-ring { | |
stroke: none; | |
} | |
.nodesLayer .highlighted .highlighted-ring { | |
stroke: #000; | |
} | |
.linksLayer { | |
stroke: #999; | |
stroke-width: 1px; | |
} | |
.linksLayer .highlighted { | |
stroke: #000; | |
stroke-width: 2px; | |
} | |
.brush .extent { | |
fill-opacity: .1; | |
stroke: #fff; | |
shape-rendering: crispEdges; | |
} | |
.brush .background { | |
fill-opacity: .1; | |
/*visibility: visible;*/ | |
fill: yellow; | |
shape-rendering: crispEdges; | |
} | |
</style> | |
<body> | |
<div id="root"></div> | |
<script src="http://d3js.org/d3.v3.min.js"></script> | |
<script> | |
//---GLOBAL variables--- | |
//fixed the width and height of force layout so that it can be saved back to json and reload | |
var width = 960, | |
height = 500; | |
//shift for brushing, meta for multiSelectable | |
var shiftKey, metaKey; | |
//--D3 Selection-- | |
var node, links; | |
//--Data-- | |
var nodes = [], links = [], key2nodeMap = []; | |
//--UI-- | |
var radius = 4, highlightedRadius = 10; | |
//--the force-- | |
var force = d3.layout.force() | |
.charge(-120) | |
.linkDistance(30) | |
.size([width, height]) | |
.on('tick', tick); | |
function tick() { | |
node.attr('transform', function(d) { | |
return "translate(" + d.x + "," + d.y + ")"; | |
}) | |
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; }); | |
} | |
//we need this scale for brushing with zoom | |
var xScale = d3.scale.linear() | |
.domain([0,width]).range([0,width]); | |
var yScale = d3.scale.linear() | |
.domain([0,height]).range([0, height]); | |
//----the zoomer--- | |
var zoomer = d3.behavior.zoom() | |
.scaleExtent([0.1, 10]) | |
.x(xScale) | |
.y(yScale) | |
.on("zoomstart", function() { | |
//do nothing | |
}) | |
.on("zoom", function() { | |
vis.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")"); | |
}) | |
//---the brusher--- | |
var brusher = d3.svg.brush() | |
.x(xScale) //sync with zoomer | |
.y(yScale) | |
.on("brushstart", function() { | |
node.each(function(d) { d.previouslySelected = metaKey && d.selected; }); | |
}) | |
.on("brush", function() { | |
var extent = d3.event.target.extent(); | |
node.classed("selected", function(d) { | |
return d.selected = d.previouslySelected || | |
(extent[0][0] <= d.x && d.x < extent[1][0] | |
&& extent[0][1] <= d.y && d.y < extent[1][1]); | |
}); | |
}) | |
.on("brushend", function() { | |
//d3 v3 way to clear the 'extent', we clear the extent everytime | |
d3.event.target.clear(); | |
d3.select(this).call(d3.event.target); | |
}); | |
//---set the SVG components--- | |
/* | |
svg { | |
g(zoom event listener) { | |
rect(placeholder to catch zoom event), | |
g(brush), | |
g(vis) | |
} | |
} | |
the order ensure that g(brush) is always behind g(vis) | |
*/ | |
//don't bind keyevent directly to body, bind it to the svg | |
var svg = d3.select("#root") | |
.attr("tabindex", 1) | |
.on("keydown.brush", keydown) | |
.on("keyup.brush", keyup) | |
// .each(function() { this.focus(); }) | |
.append("svg") | |
.attr("width", width) | |
.attr("height", height) | |
.on('click', function() { | |
//either in brushing mode, or a zoom pan event | |
if (d3.event.defaultPrevented) return; | |
//clean selection | |
node.classed('selected', function(d) { | |
return d.previouslySelected = d.selected = false; | |
}); | |
//dehighlight | |
node.classed('highlighted', function(d) { | |
return d.highlighted = false; | |
}); | |
link.classed('highlighted', function(d) { | |
return d.highlighted = false; | |
}); | |
}); | |
var zoomBase = svg.append('g') | |
.attr('class', 'zoomBase'); | |
//this rect is not zoomable, but it extends the g element to a certain width and height. this rect is used to capture the zoom event | |
var rect = zoomBase.append('rect') | |
.attr('class', 'events-catcher') | |
.attr('width', width) | |
.attr('height', height) | |
.style('fill', 'none') | |
.style('pointer-events', 'all'); //important, since fill is none | |
var brush = zoomBase.append('g') | |
.attr("class", "brush"); | |
var vis = zoomBase.append('g') | |
.attr("id", 'vis'); | |
//--Selection-- | |
var link = vis.append("g") | |
.attr("class", "linksLayer") | |
.selectAll(".link"); | |
var node = vis.append("g") | |
.attr("class", "nodesLayer") | |
.selectAll(".node"); | |
//activate zoomer and brusher | |
zoomBase.call(zoomer); | |
brush.call(brusher); | |
brush.selectAll('.background') | |
.style('visibility', 'visible') | |
//we disable the brush at the begining, you can either remove the listeners and set the cursor style manually, or just set it display none, should be safe enough | |
brush.style('display', 'none'); | |
//---dragger--- | |
//here the principle is dragger only mutate the properties related to drag, eventhough dragstart will be triggered whenever there is a mousedown | |
var dragger = d3.behavior.drag() | |
.on('dragstart', function(d) { | |
//prevent trigger the panning of zoomer | |
d3.event.sourceEvent.stopPropagation(); | |
//the idea come from force.drag source code, fixed the drag element | |
//the only difference is that force.drag is applied to one element, here we might have multiple | |
node.filter(function(d) { return d.selected; }) | |
.each(function(d) { | |
// d.fixed |= 2; | |
d.fixed = true; //we set it to be fixed | |
}); | |
}) | |
.on('drag', function(d) { | |
//update px and py, and resume force | |
node.filter(function(d) { return d.selected; }) | |
.each(function(d) { | |
d.px += d3.event.dx; | |
d.py += d3.event.dy; | |
}) | |
force.resume(); | |
}) | |
.on('dragend', function(d) { | |
//release the drag element if it was not a fixed one | |
node.filter(function(d) { return d.selected; }) | |
.each(function(d) { d.fixed &= ~6; }) | |
}); | |
//D3 update pattern: enter + update + exit | |
function updateNodeLinkSelection() { | |
node = vis.select('.nodesLayer').selectAll('.node').data(nodes); | |
var g = node.enter() | |
.append('g') | |
.attr('class', 'node') | |
.style('cursor', 'pointer'); | |
//--event handlers | |
g.on("mousedown", function(d) { | |
//important, 4 cases | |
//mark the previouslySelected property for click action | |
node.each(function(d) { d.previouslySelected = d.selected; }); | |
//deal with unselected case | |
if (!d.selected) { | |
if (!metaKey) node.classed("selected", function(p) { return p.selected = d === p; }); | |
else d3.select(this).classed("selected", d.selected = true); // multiselectable | |
} | |
}) | |
.on("click", function(d) { | |
if (d3.event.defaultPrevented) return; //if it is a drag action, return | |
//do one more thing if it was selected, toggle the selected state in shift mode | |
if (d.previouslySelected) { | |
if (metaKey) { | |
d3.select(this).classed('selected', d.selected = false) | |
} else { | |
//only select itself, de-selected the others | |
node.classed('selected', function(p) { | |
return p.selected = d === p; | |
}); | |
} | |
}; | |
//only highlight itself | |
node.classed('highlighted', function(p) { | |
return p.highlighted = d === p; | |
}); | |
//dehighlight the link | |
link.classed('highlighted', function(p) { | |
return p.highlighted = false; | |
}); | |
//important to prevent svg clicked being fired | |
d3.event.stopPropagation(); | |
}) | |
.on("dblclick", function(d) { | |
d3.event.stopPropagation(); | |
//prevent the zoom 'doubleclick' behavior | |
}) | |
.call(dragger); //call dragger in the end, so that the 'mousedown' happens before 'mousedown.drag' | |
//--UI-- | |
/* | |
g { | |
circle (stroke for selected, fill to indicate type) | |
circle (only when highlighted, may add another one for color coding the output data) | |
} | |
*/ | |
g.append('circle') | |
.attr('class', 'node-circle') | |
.attr('r', radius) | |
.attr('cx', 0) | |
.attr('cy', 0); | |
g.append('circle') | |
.attr('class', 'highlighted-ring') | |
.attr('r', highlightedRadius) | |
.attr('cx', 0) | |
.attr('cy', 0) | |
.style('fill', 'none') | |
.style('pointer-events', 'none') | |
.style('stroke-width', 1.5); | |
//exit | |
node.exit().remove(); | |
link = vis.select('.linksLayer').selectAll('.link').data(links); | |
link.enter().append('line') | |
.attr('class', 'link') | |
.style('cursor', 'pointer') | |
.on('click', function(d) { | |
d3.event.stopPropagation(); | |
link.classed('highlighted', function(p) { | |
return p.highlighted = p === d; | |
}); | |
//dehighlight the node | |
node.classed('highlighted', function(p) { | |
return p.highlighted = false; | |
}); | |
}); | |
link.exit().remove(); | |
//start force here | |
force.start(); | |
} | |
//---Load the Data--- | |
d3.json("graph.json", function(error, graph) { | |
if (error) throw error; | |
nodes = graph.nodes; | |
links = graph.links; | |
force.nodes(nodes) | |
.links(links); | |
updateNodeLinkSelection(); | |
}); | |
function keydown() { | |
d3.event.preventDefault(); | |
shiftKey = d3.event.shiftKey; | |
metaKey = d3.event.metaKey; | |
if (shiftKey) { | |
//in shift mode, disabel zoom behaviors | |
zoomBase | |
.on('click.svg_clean', function() { | |
//must be a click at white space(since click on node stopPropagation!) | |
//prevent svg clean click when brushing | |
d3.event.preventDefault(); //this is a flag for svg click to check if it is in brushing mode, in brushing mode, we do not allow deselect/dehighlight by clicking on white space | |
}) | |
.on("mousedown.zoom", null) | |
.on("touchstart.zoom", null) | |
.on("touchmove.zoom", null) | |
.on("touchend.zoom", null); | |
//enable brush | |
brush.style('display', 'inline'); | |
} | |
//key helper functions | |
if (!d3.event.metaKey || !e.shiftKey) switch (d3.event.keyCode) { | |
case 38: nudge( 0, -1); break; // UP | |
case 40: nudge( 0, +1); break; // DOWN | |
case 37: nudge(-1, 0); break; // LEFT | |
case 39: nudge(+1, 0); break; // RIGHT | |
case 68: toggleFixed(false); break; // 'd', unfixed selected | |
case 70: toggleFixed(true); break; // 'f', fixed selected | |
case 187: partialScale(1.05); break; // '+', scaleup | |
case 189: partialScale(1/1.05); break; // '-', scaledown | |
case 48: partialRotate(2); break; // '(', rotate clockwise | |
case 57: partialRotate(-2); break; // ')', rotate anti-clockwise | |
} | |
} | |
function keyup() { | |
shiftKey = d3.event.shiftKey; | |
metaKey = d3.event.metaKey; | |
//key up, enable zoom, and disable brush by set display:none | |
zoomBase.call(zoomer) | |
.on('click.svg_clean', null); //have to set it to null | |
brush.style('display', 'none'); | |
} | |
/*---KEY EVENTS---*/ | |
//---key helper functions--- | |
function forceNodeMoveStart() { | |
node.filter(function(d) { return d.selected; }) | |
.each(function(d) { | |
d.fixed = true; //we set it to be fixed | |
}); | |
} | |
function forceNodeMoveEnd() { | |
node.filter(function(d) { return d.selected; }) | |
.each(function(d) { d.fixed &= ~6; }) | |
} | |
function nudge(dx, dy) { | |
forceNodeMoveStart(); | |
node.filter(function(d) { return d.selected; }) | |
.each(function(d) { | |
d.px += dx; | |
d.py += dy; | |
}); | |
force.resume(); | |
forceNodeMoveEnd(); | |
} | |
function toggleFixed(fixed) { | |
node | |
.filter(function(d) { return d.selected; }) | |
.each(function(d) { | |
d.fixed = fixed; | |
}); | |
force.resume(); | |
} | |
//d3.geom.polygon can help to find the centroid of the convex boudary | |
function getCentroid(nodesData) { | |
return d3.geom.polygon( | |
d3.geom.hull(nodesData.map( | |
function(d){return [d.x, d.y];} | |
))) | |
.centroid(); | |
} | |
function partialScale(scale) { | |
function getNewPos(centroid, old, scale) { | |
return [ | |
(old.x - centroid[0]) * scale + centroid[0], | |
(old.y - centroid[1]) * scale + centroid[1] | |
] | |
} | |
var selectedNode = node.filter(function(d) { return d.selected; }); | |
var centroid = getCentroid(selectedNode.data()); | |
forceNodeMoveStart(); | |
selectedNode.each(function(d) { | |
var newPos = getNewPos(centroid, d, scale); | |
d.px = newPos[0]; | |
d.py = newPos[1]; | |
}) | |
force.resume(); | |
forceNodeMoveEnd(); | |
} | |
function partialRotate(deg) { | |
function rotateAround(centroid, old, deg) { | |
var cos = Math.cos, sin = Math.sin, r = deg / 180 * Math.PI; | |
var newX = (old.x - centroid[0]) * cos(r) - (old.y - centroid[1]) * sin(r) + centroid[0]; | |
var newY = (old.x - centroid[0]) * sin(r) + (old.y - centroid[1]) * cos(r) + centroid[1]; | |
return [newX, newY] | |
} | |
var selectedNode = node.filter(function(d) { return d.selected; }); | |
var centroid = getCentroid(selectedNode.data()); | |
forceNodeMoveStart(); | |
selectedNode.each(function(d) { | |
var newPos = rotateAround(centroid, d, deg); | |
d.px = newPos[0]; | |
d.py = newPos[1]; | |
}); | |
force.resume(); | |
forceNodeMoveEnd(); | |
} | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment