Skip to content

Instantly share code, notes, and snippets.

@e-n-f
Forked from sjengle/.block
Created May 6, 2017 18:47
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save e-n-f/dafc36a3d212da4619dde2d392553c7a to your computer and use it in GitHub Desktop.
Save e-n-f/dafc36a3d212da4619dde2d392553c7a to your computer and use it in GitHub Desktop.
Flight Paths Edge Bundling
license: gpl-3.0
height: 500
border: no

Visualizes flights between airports in the continental United States using edge bundling. The code can be easily modified to either show the top 50 airports by degree or the highest degree airport in each state.

This example combines our map and graph visualizations together in a single visualization. It demonstrates map projections, topojson, force-directed layouts, and edge bundling.

API Links

The following links may be useful for understanding this example:

Inspirations

The original data for this example is from the Voronoi Arc Map bl.ock and the Airports example by Mike Bostock.

The Flight Patterns work by Aaron Koblin and Force Directed Edge Bundling for Graph Visualization paper by Danny Holten and Jarke J. van Wijk are also inspirations for this example.

<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
background-color: white;
text-align: center;
font-family: sans-serif;
}
.land {
fill: #dddddd;
}
.border {
fill: none;
stroke-width: 1px;
}
.interior {
stroke: white;
}
.exterior {
stroke: #bbbbbb;
}
/* shadow trick from bl.ocks.org */
#tooltip {
font-size: 10pt;
font-weight: 900;
fill: white;
text-shadow: 1px 1px 0 #252525, 1px -1px 0 #252525, -1px 1px 0 #252525, -1px -1px 0 #252525;
}
</style>
<svg width="960" height="500"></svg>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/topojson.v1.min.js"></script>
<script src="tooltip.js"></script>
<script>
// adjust links to original data sources
var urls = {
air: "https://gist.githubusercontent.com/mbostock/7608400/raw/airports.csv",
fly: "https://gist.githubusercontent.com/mbostock/7608400/raw/flights.csv",
usa: "https://gist.githubusercontent.com/mbostock/4090846/raw/us.json"
};
var svg = d3.select("svg");
var plot = svg.append("g").attr("id", "plot");
var width = +svg.attr("width");
var height = +svg.attr("height");
var radius = {min: 6, max: 12};
// placeholder for state data once loaded
var states = null;
// only focus on the continental united states
// https://github.com/d3/d3-geo#geoAlbers
var projection = d3.geoAlbers();
// trigger map drawing
d3.json(urls.usa, drawMap);
/*
* draw the continental united states
*/
function drawMap(error, map) {
// determines which ids belong to the continental united states
// https://gist.github.com/mbostock/4090846#file-us-state-names-tsv
var isContinental = function(d) {
var id = +d.id;
return id < 60 && id !== 2 && id !== 15;
};
// filter out non-continental united states
var old = map.objects.states.geometries.length;
map.objects.states.geometries = map.objects.states.geometries.filter(isContinental);
console.log("Filtered out " + (old - map.objects.states.geometries.length) + " states from base map.");
// size projection to fit continental united states
// https://github.com/topojson/topojson-client/blob/master/README.md#feature
states = topojson.feature(map, map.objects.states);
projection.fitSize([width, height], states);
// draw base map with state borders
var base = plot.append("g").attr("id", "basemap");
var path = d3.geoPath(projection);
base.append("path")
.datum(states)
.attr("class", "land")
.attr("d", path);
// draw interior and exterior borders differently
// https://github.com/topojson/topojson-client/blob/master/README.md#mesh
// used to filter only interior borders
var isInterior = function(a, b) { return a !== b; };
// used to filter only exterior borders
var isExterior = function(a, b) { return a === b; };
base.append("path")
.datum(topojson.mesh(map, map.objects.states, isInterior))
.attr("class", "border interior")
.attr("d", path);
base.append("path")
.datum(topojson.mesh(map, map.objects.states, isExterior))
.attr("class", "border exterior")
.attr("d", path);
// trigger data drawing
d3.queue()
.defer(d3.csv, urls.air, typeAirport)
.defer(d3.csv, urls.fly, typeFlight)
.await(filterData);
}
/*
* see airports.csv
* convert gps coordinates to number and init degree
*/
function typeAirport(d) {
d.longitude = +d.longitude;
d.latitude = +d.latitude;
d.degree = 0;
return d;
}
/*
* see flights.csv
* convert count to number and init segments
*/
function typeFlight(d) {
d.count = +d.count;
return d;
}
/*
* we need a much smaller subgraph for edge bundling
*/
function filterData(error, airports, flights) {
if(error) throw error;
// get map of airport objects by iata value
// international air transport association (iata)
var byiata = d3.map(airports, function(d) { return d.iata; });
console.log("Loaded " + byiata.size() + " airports.");
// convert links into better format and track node degree
flights.forEach(function(d) {
d.source = byiata.get(d.origin);
d.target = byiata.get(d.destination);
d.source.degree = d.source.degree + 1;
d.target.degree = d.target.degree + 1;
});
// filter out airports outside of projection
// or those without a valid state (i.e. d.state === "NA")
// https://github.com/d3/d3-geo#geoContains
var old = airports.length;
airports = airports.filter(function(d) {
return d3.geoContains(states, [d.longitude, d.latitude]) && d.state !== "NA";
});
console.log("Filtered " + (old - airports.length) + " out of bounds airports.");
// function to sort airports by degree
var bydegree = function(a, b) {
return d3.descending(a.degree, b.degree);
};
// uncomment nest to show airport with highest degree per state
// uncomment slice to show top 50 airports by degree
// uncomment both to see everything break because there are too many airports
// nest airports by state and reduce to maximum degree airport per state
// https://github.com/d3/d3-collection#nests
// airports = d3.nest()
// .key(function(d) { return d.state; })
// .rollup(function(leaves) {
// leaves.sort(bydegree);
// return leaves[0];
// })
// .map(airports)
// .values();
// sort remaining airports by degree
airports.sort(bydegree);
airports = airports.slice(0, 50);
// calculate projected x, y pixel locations
airports.forEach(function(d) {
var coords = projection([d.longitude, d.latitude]);
d.x = coords[0];
d.y = coords[1];
});
// reset map to only contain airports post filter
byiata = d3.map(airports, function(d) { return d.iata; });
// filter out flights that do not go between remaining airports
old = flights.length;
flights = flights.filter(function(d) {
return byiata.has(d.source.iata) && byiata.has(d.target.iata);
});
console.log("Removed " + (old - flights.length) + " flights.");
console.log("Currently " + airports.length + " airports remaining.");
console.log("Currently " + flights.length + " flights remaining.");
// start drawing everything
drawData(byiata.values(), flights);
}
/*
* draw airports and flights using edge bundling
*/
function drawData(airports, flights) {
// setup and start edge bundling
var bundle = generateSegments(airports, flights);
// https://github.com/d3/d3-shape#curveBundle
var line = d3.line()
.curve(d3.curveBundle)
.x(function(d) { return d.x; })
.y(function(d) { return d.y; });
var links = plot.append("g").attr("id", "flights")
.selectAll("path.flight")
.data(bundle.paths)
.enter()
.append("path")
.attr("d", line)
.style("fill", "none")
.style("stroke", "#252525")
.style("stroke-width", 0.5)
.style("stroke-opacity", 0.2);
// https://github.com/d3/d3-force
var layout = d3.forceSimulation()
// settle at a layout faster
.alphaDecay(0.1)
// nearby nodes attract each other
.force("charge", d3.forceManyBody()
.strength(10)
.distanceMax(radius.max * 2)
)
// edges want to be as short as possible
// prevents too much stretching
.force("link", d3.forceLink()
.strength(0.8)
.distance(0)
)
.on("tick", function(d) {
links.attr("d", line);
})
.on("end", function(d) {
console.log("Layout complete!");
});
layout.nodes(bundle.nodes).force("link").links(bundle.links);
// draw airports
var scale = d3.scaleSqrt()
.domain(d3.extent(airports, function(d) { return d.degree; }))
.range([radius.min, radius.max]);
plot.append("g").attr("id", "airports")
.selectAll("circle.airport")
.data(airports)
.enter()
.append("circle")
.attr("r", function(d) { return scale(d.degree); })
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.style("fill", "white")
.style("opacity", 0.6)
.style("stroke", "#252525")
.on("mouseover", onMouseOver)
.on("mousemove", onMouseMove)
.on("mouseout", onMouseOut);
}
/*
* Turns a single edge into several segments that can
* be used for simple edge bundling.
*/
function generateSegments(nodes, links) {
// calculate distance between two nodes
var distance = function(source, target) {
// sqrt( (x2 - x1)^2 + (y2 - y1)^2 )
var dx2 = Math.pow(target.x - source.x, 2);
var dy2 = Math.pow(target.y - source.y, 2);
return Math.sqrt(dx2 + dy2);
};
// max distance any two nodes can be apart is the hypotenuse!
var hypotenuse = Math.sqrt(width * width + height * height);
// number of inner nodes depends on how far nodes are apart
var inner = d3.scaleLinear()
.domain([0, hypotenuse])
.range([1, 15]);
// generate separate graph for edge bundling
// nodes: all nodes including control nodes
// links: all individual segments (source to target)
// paths: all segments combined into single path for drawing
var bundle = {nodes: [], links: [], paths: []};
// make existing nodes fixed
bundle.nodes = nodes.map(function(d, i) {
d.fx = d.x;
d.fy = d.y;
return d;
});
links.forEach(function(d, i) {
// calculate the distance between the source and target
var length = distance(d.source, d.target);
// calculate total number of inner nodes for this link
var total = Math.round(inner(length));
// create scales from source to target
var xscale = d3.scaleLinear()
.domain([0, total + 1]) // source, inner nodes, target
.range([d.source.x, d.target.x]);
var yscale = d3.scaleLinear()
.domain([0, total + 1])
.range([d.source.y, d.target.y]);
// initialize source node
var source = d.source;
var target = null;
// add all points to local path
var local = [source];
for (var j = 1; j <= total; j++) {
// calculate target node
target = {
x: xscale(j),
y: yscale(j)
};
local.push(target);
bundle.nodes.push(target);
bundle.links.push({
source: source,
target: target
});
source = target;
}
local.push(d.target);
// add last link to target node
bundle.links.push({
source: target,
target: d.target
});
bundle.paths.push(local);
});
return bundle;
}
</script>
// tailored for this example (assumes g#plot, d.id)
function onMouseOver(d) {
var tooltip = d3.select("text#tooltip");
// initialize if missing
if (tooltip.size() < 1) {
tooltip = d3.select("g#plot").append("text").attr("id", "tooltip");
}
// calculate bounding box of plot WITHOUT tooltip
tooltip.style("display", "none");
onMouseMove(d);
var bbox = {
plot: d3.select("g#plot").node().getBBox()
}
// restore tooltip display but keep it invisible
tooltip.style("display", null);
tooltip.style("visibility", "hidden");
// now set tooltip text and attributes
tooltip.text(d.name + " in " + d.city + ", " + d.state);
tooltip.attr("text-anchor", "end");
tooltip.attr("dx", -5);
tooltip.attr("dy", -5);
// calculate resulting bounding box of text
bbox.text = tooltip.node().getBBox();
// determine if need to show right of pointer
if (bbox.text.x < bbox.plot.x) {
tooltip.attr("text-anchor", "start");
tooltip.attr("dx", 5);
}
// determine if need to show below pointer
if (bbox.text.y < bbox.plot.y) {
tooltip.attr("dy", bbox.text.height / 2);
// also need to fix dx in this case
// so it doesn't overlap the mouse pointer
if (bbox.text.x < bbox.plot.x) {
tooltip.attr("dx", 15);
}
}
tooltip.style("visibility", "visible");
d3.select(this).classed("selected", true);
}
function onMouseMove(d) {
var coords = d3.mouse(d3.select("g#plot").node());
d3.select("text#tooltip")
.attr("x", coords[0])
.attr("y", coords[1]);
}
function onMouseOut(d) {
d3.select(this).classed("selected", false);
d3.select("text#tooltip").style("visibility", "hidden");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment