block by jeremycflin 38d7a625f0fc33d6bb561547dec23f29

Flight Paths Edge Bundling

Full Screen

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.

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.

forked from sjengle‘s block: Flight Paths Edge Bundling

index.html

<!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>

tooltip.js

// 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");
}