|
const urls = { |
|
// source: https://observablehq.com/@mbostock/u-s-airports-voronoi |
|
// source: https://github.com/topojson/us-atlas |
|
map: "states-albers-10m.json", |
|
|
|
// source: https://gist.github.com/mbostock/7608400 |
|
airports: |
|
"https://gist.githubusercontent.com/mbostock/7608400/raw/e5974d9bba45bc9ab272d98dd7427567aafd55bc/airports.csv", |
|
|
|
// source: https://gist.github.com/mbostock/7608400 |
|
flights: |
|
"https://gist.githubusercontent.com/mbostock/7608400/raw/e5974d9bba45bc9ab272d98dd7427567aafd55bc/flights.csv" |
|
}; |
|
|
|
const svg = d3.select("svg"); |
|
|
|
const width = parseInt(svg.attr("width")); |
|
const height = parseInt(svg.attr("height")); |
|
const hypotenuse = Math.sqrt(width * width + height * height); |
|
|
|
// must be hard-coded to match our topojson projection |
|
// source: https://github.com/topojson/us-atlas |
|
const projection = d3.geoAlbers().scale(1280).translate([480, 300]); |
|
|
|
const scales = { |
|
// used to scale airport bubbles |
|
airports: d3.scaleSqrt() |
|
.range([4, 18]), |
|
|
|
// used to scale number of segments per line |
|
segments: d3.scaleLinear() |
|
.domain([0, hypotenuse]) |
|
.range([1, 10]) |
|
}; |
|
|
|
// have these already created for easier drawing |
|
const g = { |
|
basemap: svg.select("g#basemap"), |
|
flights: svg.select("g#flights"), |
|
airports: svg.select("g#airports"), |
|
voronoi: svg.select("g#voronoi") |
|
}; |
|
|
|
console.assert(g.basemap.size() === 1); |
|
console.assert(g.flights.size() === 1); |
|
console.assert(g.airports.size() === 1); |
|
console.assert(g.voronoi.size() === 1); |
|
|
|
const tooltip = d3.select("text#tooltip"); |
|
console.assert(tooltip.size() === 1); |
|
|
|
// load and draw base map |
|
d3.json(urls.map).then(drawMap); |
|
|
|
// load the airport and flight data together |
|
const promises = [ |
|
d3.csv(urls.airports, typeAirport), |
|
d3.csv(urls.flights, typeFlight) |
|
]; |
|
|
|
Promise.all(promises).then(processData); |
|
|
|
// process airport and flight data |
|
function processData(values) { |
|
console.assert(values.length === 2); |
|
|
|
let airports = values[0]; |
|
let flights = values[1]; |
|
|
|
console.log("airports: " + airports.length); |
|
console.log(" flights: " + flights.length); |
|
|
|
// convert airports array (pre filter) into map for fast lookup |
|
let iata = new Map(airports.map(node => [node.iata, node])); |
|
|
|
// calculate incoming and outgoing degree based on flights |
|
// flights are given by airport iata code (not index) |
|
flights.forEach(function(link) { |
|
link.source = iata.get(link.origin); |
|
link.target = iata.get(link.destination); |
|
|
|
link.source.outgoing += link.count; |
|
link.target.incoming += link.count; |
|
}); |
|
|
|
// remove airports out of bounds |
|
let old = airports.length; |
|
airports = airports.filter(airport => airport.x >= 0 && airport.y >= 0); |
|
console.log(" removed: " + (old - airports.length) + " airports out of bounds"); |
|
|
|
// remove airports with NA state |
|
old = airports.length; |
|
airports = airports.filter(airport => airport.state !== "NA"); |
|
console.log(" removed: " + (old - airports.length) + " airports with NA state"); |
|
|
|
// remove airports without any flights |
|
old = airports.length; |
|
airports = airports.filter(airport => airport.outgoing > 0 && airport.incoming > 0); |
|
console.log(" removed: " + (old - airports.length) + " airports without flights"); |
|
|
|
// sort airports by outgoing degree |
|
airports.sort((a, b) => d3.descending(a.outgoing, b.outgoing)); |
|
|
|
// keep only the top airports |
|
old = airports.length; |
|
airports = airports.slice(0, 50); |
|
console.log(" removed: " + (old - airports.length) + " airports with low outgoing degree"); |
|
|
|
// done filtering airports can draw |
|
drawAirports(airports); |
|
drawPolygons(airports); |
|
|
|
// reset map to only include airports post-filter |
|
iata = new Map(airports.map(node => [node.iata, node])); |
|
|
|
// filter out flights that are not between airports we have leftover |
|
old = flights.length; |
|
flights = flights.filter(link => iata.has(link.source.iata) && iata.has(link.target.iata)); |
|
console.log(" removed: " + (old - flights.length) + " flights"); |
|
|
|
// done filtering flights can draw |
|
drawFlights(airports, flights); |
|
|
|
console.log({airports: airports}); |
|
console.log({flights: flights}); |
|
} |
|
|
|
// draws the underlying map |
|
function drawMap(map) { |
|
// remove non-continental states |
|
map.objects.states.geometries = map.objects.states.geometries.filter(isContinental); |
|
|
|
// run topojson on remaining states and adjust projection |
|
let land = topojson.merge(map, map.objects.states.geometries); |
|
|
|
// use null projection; data is already projected |
|
let path = d3.geoPath(); |
|
|
|
// draw base map |
|
g.basemap.append("path") |
|
.datum(land) |
|
.attr("class", "land") |
|
.attr("d", path); |
|
|
|
// draw interior borders |
|
g.basemap.append("path") |
|
.datum(topojson.mesh(map, map.objects.states, (a, b) => a !== b)) |
|
.attr("class", "border interior") |
|
.attr("d", path); |
|
|
|
// draw exterior borders |
|
g.basemap.append("path") |
|
.datum(topojson.mesh(map, map.objects.states, (a, b) => a === b)) |
|
.attr("class", "border exterior") |
|
.attr("d", path); |
|
} |
|
|
|
function drawAirports(airports) { |
|
// adjust scale |
|
const extent = d3.extent(airports, d => d.outgoing); |
|
scales.airports.domain(extent); |
|
|
|
// draw airport bubbles |
|
g.airports.selectAll("circle.airport") |
|
.data(airports, d => d.iata) |
|
.enter() |
|
.append("circle") |
|
.attr("r", d => scales.airports(d.outgoing)) |
|
.attr("cx", d => d.x) // calculated on load |
|
.attr("cy", d => d.y) // calculated on load |
|
.attr("class", "airport") |
|
.each(function(d) { |
|
// adds the circle object to our airport |
|
// makes it fast to select airports on hover |
|
d.bubble = this; |
|
}); |
|
} |
|
|
|
function drawPolygons(airports) { |
|
// convert array of airports into geojson format |
|
const geojson = airports.map(function(airport) { |
|
return { |
|
type: "Feature", |
|
properties: airport, |
|
geometry: { |
|
type: "Point", |
|
coordinates: [airport.longitude, airport.latitude] |
|
} |
|
}; |
|
}); |
|
|
|
// calculate voronoi polygons |
|
const polygons = d3.geoVoronoi().polygons(geojson); |
|
console.log(polygons); |
|
|
|
g.voronoi.selectAll("path") |
|
.data(polygons.features) |
|
.enter() |
|
.append("path") |
|
.attr("d", d3.geoPath(projection)) |
|
.attr("class", "voronoi") |
|
.on("mouseover", function(d) { |
|
let airport = d.properties.site.properties; |
|
|
|
d3.select(airport.bubble) |
|
.classed("highlight", true); |
|
|
|
d3.selectAll(airport.flights) |
|
.classed("highlight", true) |
|
.raise(); |
|
|
|
// make tooltip take up space but keep it invisible |
|
tooltip.style("display", null); |
|
tooltip.style("visibility", "hidden"); |
|
|
|
// set default tooltip positioning |
|
tooltip.attr("text-anchor", "middle"); |
|
tooltip.attr("dy", -scales.airports(airport.outgoing) - 4); |
|
tooltip.attr("x", airport.x); |
|
tooltip.attr("y", airport.y); |
|
|
|
// set the tooltip text |
|
tooltip.text(airport.name + " in " + airport.city + ", " + airport.state); |
|
|
|
// double check if the anchor needs to be changed |
|
let bbox = tooltip.node().getBBox(); |
|
|
|
if (bbox.x <= 0) { |
|
tooltip.attr("text-anchor", "start"); |
|
} |
|
else if (bbox.x + bbox.width >= width) { |
|
tooltip.attr("text-anchor", "end"); |
|
} |
|
|
|
tooltip.style("visibility", "visible"); |
|
}) |
|
.on("mouseout", function(d) { |
|
let airport = d.properties.site.properties; |
|
|
|
d3.select(airport.bubble) |
|
.classed("highlight", false); |
|
|
|
d3.selectAll(airport.flights) |
|
.classed("highlight", false); |
|
|
|
d3.select("text#tooltip").style("visibility", "hidden"); |
|
}) |
|
.on("dblclick", function(d) { |
|
// toggle voronoi outline |
|
let toggle = d3.select(this).classed("highlight"); |
|
d3.select(this).classed("highlight", !toggle); |
|
}); |
|
} |
|
|
|
function drawFlights(airports, flights) { |
|
// break each flight between airports into multiple segments |
|
let bundle = generateSegments(airports, flights); |
|
|
|
// https://github.com/d3/d3-shape#curveBundle |
|
let line = d3.line() |
|
.curve(d3.curveBundle) |
|
.x(airport => airport.x) |
|
.y(airport => airport.y); |
|
|
|
let links = g.flights.selectAll("path.flight") |
|
.data(bundle.paths) |
|
.enter() |
|
.append("path") |
|
.attr("d", line) |
|
.attr("class", "flight") |
|
.each(function(d) { |
|
// adds the path object to our source airport |
|
// makes it fast to select outgoing paths |
|
d[0].flights.push(this); |
|
}); |
|
|
|
// https://github.com/d3/d3-force |
|
let layout = d3.forceSimulation() |
|
// settle at a layout faster |
|
.alphaDecay(0.1) |
|
// nearby nodes attract each other |
|
.force("charge", d3.forceManyBody() |
|
.strength(10) |
|
.distanceMax(scales.airports.range()[1] * 2) |
|
) |
|
// edges want to be as short as possible |
|
// prevents too much stretching |
|
.force("link", d3.forceLink() |
|
.strength(0.7) |
|
.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); |
|
} |
|
|
|
// Turns a single edge into several segments that can |
|
// be used for simple edge bundling. |
|
function generateSegments(nodes, links) { |
|
// 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 |
|
let 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 |
|
let length = distance(d.source, d.target); |
|
|
|
// calculate total number of inner nodes for this link |
|
let total = Math.round(scales.segments(length)); |
|
|
|
// create scales from source to target |
|
let xscale = d3.scaleLinear() |
|
.domain([0, total + 1]) // source, inner nodes, target |
|
.range([d.source.x, d.target.x]); |
|
|
|
let yscale = d3.scaleLinear() |
|
.domain([0, total + 1]) |
|
.range([d.source.y, d.target.y]); |
|
|
|
// initialize source node |
|
let source = d.source; |
|
let target = null; |
|
|
|
// add all points to local path |
|
let local = [source]; |
|
|
|
for (let 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; |
|
} |
|
|
|
// determines which states belong to the continental united states |
|
// https://gist.github.com/mbostock/4090846#file-us-state-names-tsv |
|
function isContinental(state) { |
|
const id = parseInt(state.id); |
|
return id < 60 && id !== 2 && id !== 15; |
|
} |
|
|
|
// see airports.csv |
|
// convert gps coordinates to number and init degree |
|
function typeAirport(airport) { |
|
airport.longitude = parseFloat(airport.longitude); |
|
airport.latitude = parseFloat(airport.latitude); |
|
|
|
// use projection hard-coded to match topojson data |
|
const coords = projection([airport.longitude, airport.latitude]); |
|
airport.x = coords[0]; |
|
airport.y = coords[1]; |
|
|
|
airport.outgoing = 0; // eventually tracks number of outgoing flights |
|
airport.incoming = 0; // eventually tracks number of incoming flights |
|
|
|
airport.flights = []; // eventually tracks outgoing flights |
|
|
|
return airport; |
|
} |
|
|
|
// see flights.csv |
|
// convert count to number |
|
function typeFlight(flight) { |
|
flight.count = parseInt(flight.count); |
|
return flight; |
|
} |
|
|
|
// calculates the distance between two nodes |
|
// sqrt( (x2 - x1)^2 + (y2 - y1)^2 ) |
|
function distance(source, target) { |
|
const dx2 = Math.pow(target.x - source.x, 2); |
|
const dy2 = Math.pow(target.y - source.y, 2); |
|
|
|
return Math.sqrt(dx2 + dy2); |
|
} |
plz react it