174 lines
5.7 KiB
JavaScript
174 lines
5.7 KiB
JavaScript
import Chart from "chart.js/auto";
|
||
import { csvParse } from "d3-dsv";
|
||
|
||
// These 2 imports use some vite machinery to directly import these files as
|
||
// strings. Then they are constants and this reduces the load on the GC.
|
||
import airportsString from "./datasets/airports.csv?raw";
|
||
import flightsString from "./datasets/flights-airports.csv?raw";
|
||
|
||
/*
|
||
* From https://www.omnicalculator.com/other/latitude-longitude-distance:
|
||
* d = 2R × sin⁻¹(√[sin²((θ₂ - θ₁)/2) + cosθ₁ × cosθ₂ × sin²((φ₂ - φ₁)/2)])
|
||
*
|
||
* where:
|
||
* θ₁, φ₁ – First point latitude and longitude coordinates;
|
||
* θ₂, φ₂ – Second point latitude and longitude coordinates;
|
||
* R – Earth's radius (R = 6371 km); and
|
||
* d – Distance between them along Earth's surface.
|
||
*/
|
||
const R = 6371;
|
||
function computeDistance(coords1, coords2) {
|
||
const long1 = toRadian(coords1.longitude);
|
||
const lat1 = toRadian(coords1.latitude);
|
||
const long2 = toRadian(coords2.longitude);
|
||
const lat2 = toRadian(coords2.latitude);
|
||
const longSquareSin = Math.sin((long2 - long1) / 2) ** 2;
|
||
const latSquareSin = Math.sin((lat2 - lat1) / 2) ** 2;
|
||
const d = 2 * R * Math.asin(Math.sqrt(latSquareSin + Math.cos(lat1) * Math.cos(lat2) * longSquareSin));
|
||
return d;
|
||
}
|
||
|
||
const RAD = Math.PI / 180;
|
||
function toRadian(deg) {
|
||
return deg * RAD;
|
||
}
|
||
|
||
let preparedData = null;
|
||
function prepare() {
|
||
/**
|
||
* AirportInformation: { state, iata, name, city, country, latitude, longitude }
|
||
* airports: Array<AirportInformation>
|
||
* flights: Array<{ origin, destination, count }>
|
||
*/
|
||
const airports = csvParse(airportsString);
|
||
const flights = csvParse(flightsString);
|
||
const airportMap = new Map(airports.map((airport) => [airport.iata, airport]));
|
||
|
||
for (const flight of flights) {
|
||
const origAirport = airportMap.get(flight.origin);
|
||
const destAirport = airportMap.get(flight.destination);
|
||
flight.distance = computeDistance(origAirport, destAirport);
|
||
}
|
||
|
||
preparedData = { flights, airportMap };
|
||
}
|
||
|
||
const ROOT = document.getElementById("chart");
|
||
const opaqueCheckBox = document.getElementById("opaque-color");
|
||
|
||
let currentChart = null;
|
||
function drawScattered(data) {
|
||
if (!preparedData)
|
||
throw new Error("Please prepare the data first.");
|
||
|
||
reset();
|
||
|
||
currentChart = new Chart(ROOT, {
|
||
type: "scatter",
|
||
data: {
|
||
datasets: [
|
||
{
|
||
data: preparedData.flights,
|
||
backgroundColor: opaqueCheckBox.checked ? "rgb(0, 125, 255)" : "rgba(0, 125, 255, .20)",
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
animation: false,
|
||
parsing: {
|
||
xAxisKey: "distance",
|
||
yAxisKey: "count",
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: "Number of flights for a distance",
|
||
position: "bottom",
|
||
},
|
||
tooltip: {
|
||
displayColors: false,
|
||
callbacks: {
|
||
label: (item) => {
|
||
const orig = preparedData.airportMap.get(item.raw.origin);
|
||
const dest = preparedData.airportMap.get(item.raw.destination);
|
||
const result = `✈ ${orig.name} (${orig.iata}) → ${dest.name} (${dest.iata}): ${Math.round(item.raw.distance)} km`;
|
||
return result;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
title: {
|
||
text: "distance →",
|
||
align: "end",
|
||
display: true,
|
||
},
|
||
ticks: {
|
||
format: {
|
||
style: "unit",
|
||
unit: "kilometer",
|
||
},
|
||
},
|
||
},
|
||
y: {
|
||
type: "logarithmic",
|
||
title: {
|
||
text: "# of flights →",
|
||
align: "end",
|
||
display: true,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function openTooltip() {
|
||
if (!currentChart)
|
||
throw new Error("No chart is present, please draw a chart first");
|
||
|
||
const renderedDataset = currentChart.getDatasetMeta(0);
|
||
const node = currentChart.canvas;
|
||
const rect = node.getBoundingClientRect();
|
||
|
||
// Index 2426 is carefully chosen to display a lot of lines (which depends
|
||
// on the zoom level).
|
||
const point = renderedDataset.data[2426];
|
||
const event = new MouseEvent("mousemove", {
|
||
clientX: rect.left + point.x,
|
||
clientY: rect.top + point.y,
|
||
cancelable: true,
|
||
bubbles: true,
|
||
});
|
||
|
||
node.dispatchEvent(event);
|
||
}
|
||
|
||
function reset() {
|
||
if (currentChart) {
|
||
currentChart.destroy();
|
||
currentChart = null;
|
||
}
|
||
}
|
||
|
||
async function runAllTheThings() {
|
||
[
|
||
// Force prettier to use a multiline formatting
|
||
"prepare",
|
||
"add-scatter-chart-button",
|
||
"open-tooltip",
|
||
].forEach((id) => document.getElementById(id).click());
|
||
}
|
||
|
||
document.getElementById("prepare").addEventListener("click", prepare);
|
||
document.getElementById("add-scatter-chart-button").addEventListener("click", drawScattered);
|
||
document.getElementById("open-tooltip").addEventListener("click", openTooltip);
|
||
document.getElementById("reset").addEventListener("click", reset);
|
||
document.getElementById("run-all").addEventListener("click", runAllTheThings);
|
||
|
||
if (import.meta.env.DEV)
|
||
runAllTheThings();
|