Skip to content

Instantly share code, notes, and snippets.

@hkjpotato
Last active June 12, 2017 19:56
Show Gist options
  • Save hkjpotato/f88e818b34827451cc1b3f19a622ad49 to your computer and use it in GitHub Desktop.
Save hkjpotato/f88e818b34827451cc1b3f19a622ad49 to your computer and use it in GitHub Desktop.
dynamic force layout with multiple customization methods
{"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}]}
<!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