| 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(); |