resources/charts/observable-plot.js (180 lines of code) (raw):

import * as Plot from "@observablehq/plot"; import { csvParse } from "d3-dsv"; import * as d3Array from "d3-array"; import { format as d3Format } from "d3-format"; // 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"; const ROOT = document.getElementById("chart"); const DEFAULT_OPTIONS = { // This width is really a max-width: the plot adjusts using the available // width as well. width: 2000, // The height is especially used for the ratio. height: 1000, }; let preparedData; function prepare() { /** * AirportInformation: { state, iata, name, city, country, latitude, longitude } * airports: Array<AirportInformation> * flights: Array<{ origin, destination, count }> */ const { airports, flights } = parseAirportsInformation(); /** * flightsByAirport: Map<iata: string, { origin: number, destination: number, total: number}> */ const flightsByAirport = groupFlightsByAirports(flights); /** * byAirport: Map<iata: string, AirportInformation> */ const byAirport = d3Array.index(airports, (d) => d.iata); /* Array<[state, AirportInformation[]]> */ const airportsGroupedByStateArray = d3Array.groups(airports, (d) => d.state); /* DescSortedArray<[{ state: string, total: number, mostUsedAirportsInState: AirportInformation[] }]> */ const stateInformationSortedArray = airportsGroupedByStateArray .map(([state, airportsInState]) => { const totalFlightsInState = d3Array.sum(airportsInState, ({ iata }) => flightsByAirport.get(iata)?.total); const sorted = d3Array.sort(airportsInState, ({ iata }) => -flightsByAirport.get(iata)?.total); const mostUsedAirportsInState = sorted.slice(0, airportCountPerGroup()); return { state, total: totalFlightsInState, mostUsedAirports: mostUsedAirportsInState, }; }) .sort((stateA, stateB) => stateB.total - stateA.total); /* Array<state: string> */ const stateSortedArray = stateInformationSortedArray.map(({ state }) => state); // Flatten the information in preparedData.stateInformationSortedArray, so that we // have one item == one airport information. /* Array<{state, iata, name, city, index, origin, destination, total}> */ const plotData = stateInformationSortedArray.flatMap(({ mostUsedAirports, total, state }) => { const enrichedMostUsedAirports = mostUsedAirports.map(({ iata, name, city }, index) => ({ state, iata, name, city, index, // This will be used to have consistent colors. ...flightsByAirport.get(iata), })); const otherTotal = total - d3Array.sum(mostUsedAirports, ({ iata }) => flightsByAirport.get(iata)?.total); if (otherTotal > 0) { enrichedMostUsedAirports.push({ state, iata: "Other", total: otherTotal, index: enrichedMostUsedAirports.length, }); } return enrichedMostUsedAirports; }); preparedData = { airports, flights, flightsByAirport, byAirport, stateInformationSortedArray, stateSortedArray, plotData, }; } function parseAirportsInformation() { return { airports: csvParse(airportsString), flights: csvParse(flightsString), }; } function groupFlightsByAirports(flights) { const flightsByAirport = new Map(); for (const { origin, destination, count } of flights) { const infoForOriginAirport = flightsByAirport.get(origin) ?? { origin: 0, destination: 0, total: 0 }; const intCount = Number(count); if (Number.isNaN(intCount)) { console.error(`Couldn't convert ${count} to number.`); continue; } infoForOriginAirport.origin += intCount; infoForOriginAirport.total += intCount; flightsByAirport.set(origin, infoForOriginAirport); const infoForDestinationAirport = flightsByAirport.get(destination) ?? { origin: 0, destination: 0, total: 0 }; infoForDestinationAirport.destination += intCount; infoForDestinationAirport.total += intCount; flightsByAirport.set(destination, infoForDestinationAirport); } return flightsByAirport; } function isReady() { return !!preparedData; } function addStackedBars() { if (!isReady()) throw new Error("Please prepare the data first."); const options = { ...DEFAULT_OPTIONS, color: { type: "categorical" }, x: { domain: preparedData.stateSortedArray, }, y: { grid: true, tickFormat: "~s" }, marks: [ Plot.barY(preparedData.plotData, { x: "state", y: "total", fill: "index", title: (d) => `${d.iata === "Other" ? "Other" : `${d.name}, ${d.city} (${d.iata})`}\n${d3Format(",")(d.total)} flights`, }), Plot.text(preparedData.stateInformationSortedArray, { x: "state", y: "total", text: (d) => d3Format(".2~s")(d.total), dy: -10 }), Plot.ruleY([0]), ], }; ROOT.append(Plot.plot(options)); } function addDottedBars() { if (!isReady()) throw new Error("Please prepare the data first."); const data = [...preparedData.flightsByAirport] .flatMap(([iata, { origin, destination }]) => { const airportInformation = preparedData.byAirport.get(iata); return [ { ...airportInformation, value: -origin }, { ...airportInformation, value: destination }, ]; }) .filter((d) => d.value); const options = { ...DEFAULT_OPTIONS, color: { type: "threshold", domain: [0] }, x: { domain: preparedData.stateSortedArray, }, y: { grid: true, label: "← outward Number of flights inward →", labelAnchor: "center", tickFormat: (v) => d3Format("~s")(Math.abs(v)), type: "pow", exponent: 0.2, }, marks: [ Plot.dot(data, { x: "state", y: "value", r: 4, stroke: "value", strokeWidth: 3, title: (d) => `${d.iata === "Other" ? "Other" : `${d.name}, ${d.city} (${d.iata})`}\n${d3Format(",")(Math.abs(d.value))} ${d.value > 0 ? "inward" : "outward"} flights`, }), Plot.ruleY([0]), ], }; ROOT.append(Plot.plot(options)); } function reset() { ROOT.textContent = ""; } async function runAllTheThings() { reset(); [ // Force prettier to use a multiline formatting "prepare", "add-stacked-chart-button", "add-dotted-chart-button", ].forEach((id) => document.getElementById(id).click()); } // This is the number of airports we keep for each state in the stacked bar // graph. One additional group will be added, that will sum all airports that // haven't been kept. It's retrieved from the input directly. function airportCountPerGroup() { return document.querySelector("#airport-group-size-input").value; } function onGroupSizeInputChange() { document.querySelector("#airport-group-size").textContent = airportCountPerGroup(); if (import.meta.env.DEV) { // In dev mode, redraw everything runAllTheThings(); } } document.getElementById("prepare").addEventListener("click", prepare); document.getElementById("add-stacked-chart-button").addEventListener("click", addStackedBars); document.getElementById("add-dotted-chart-button").addEventListener("click", addDottedBars); document.getElementById("reset").addEventListener("click", reset); document.getElementById("run-all").addEventListener("click", runAllTheThings); document.getElementById("airport-group-size-input").addEventListener("input", onGroupSizeInputChange); onGroupSizeInputChange(); if (import.meta.env.DEV) runAllTheThings();