Skip to content

Commit

Permalink
add map view to main page
Browse files Browse the repository at this point in the history
  • Loading branch information
its-felix committed Oct 5, 2024
1 parent 2fcca78 commit 9383619
Show file tree
Hide file tree
Showing 23 changed files with 550 additions and 39 deletions.
6 changes: 4 additions & 2 deletions cdk/lib/constructs/cloudfront-construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ export class CloudfrontConstruct extends Construct {
contentSecurityPolicy: {
contentSecurityPolicy: [
`default-src 'self'`,
`connect-src 'self' http://127.0.0.1:8090/`,
`connect-src 'self' http://127.0.0.1:8090/ https://api.maptiler.com/`,
`style-src 'self' 'unsafe-inline'`,
`font-src data:`,
`img-src 'self'`,
`img-src 'self' data: blob:`,
`worker-src blob:`,
`child-src blob:`,
].join('; '),
override: true,
},
Expand Down
18 changes: 15 additions & 3 deletions go/api/data/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import (
"github.com/explore-flights/monorepo/go/common/xtime"
"io"
"slices"
"strconv"
"strings"
)

var metroAreaMapping map[string][2]string = map[string][2]string{
var metroAreaMapping = map[string][2]string{
// region Asia
"PEK": {"BJS", "Beijing, China"},
"PKX": {"BJS", "Beijing, China"},
Expand Down Expand Up @@ -140,8 +141,10 @@ type MetropolitanArea struct {
}

type Airport struct {
Code string `json:"code"`
Name string `json:"name"`
Code string `json:"code"`
Name string `json:"name"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}

type Aircraft struct {
Expand Down Expand Up @@ -197,6 +200,15 @@ func (h *Handler) Airports(ctx context.Context) (AirportsResponse, error) {
var airport Airport
airport.Code = strings.TrimSpace(row["iata_code"])
airport.Name = cmp.Or(strings.TrimSpace(row["name"]), airport.Code)
airport.Lat, err = strconv.ParseFloat(row["latitude_deg"], 64)
if err != nil {
return AirportsResponse{}, fmt.Errorf("failed to parse latitude for %q: %w", airport.Name, err)
}

airport.Lng, err = strconv.ParseFloat(row["longitude_deg"], 64)
if err != nil {
return AirportsResponse{}, fmt.Errorf("failed to parse longitude for %q: %w", airport.Name, err)
}

if airport.Code == "" {
continue
Expand Down
1 change: 1 addition & 0 deletions ui/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_MAPTILER_KEY=aHDZ3LmfWtUIoUo1vGvK
4 changes: 3 additions & 1 deletion ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ node/
# System Files
.DS_Store
Thumbs.db
# endregion
# endregion

*.local
Binary file added ui/public/assets/map_consent_background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion ui/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions ui/src/components/common/flight-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';
import { RouterLink, RouterLinkProps } from './router-link';

export interface FlightLinkProps extends Omit<RouterLinkProps, 'to'> {
flightNumber: string;
}

export function FlightLink({ flightNumber, ...props }: FlightLinkProps) {
return <RouterLink {...props} to={`/flight/${encodeURIComponent(flightNumber)}`}>{props.children ?? flightNumber}</RouterLink>;
}
4 changes: 4 additions & 0 deletions ui/src/components/common/router-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export function RouterLink(props: RouterLinkProps) {
{...linkProps}
href={href}
onFollow={(e) => {
if (linkProps.target) {
return;
}

if (linkProps.onFollow) {
linkProps.onFollow(e);
}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/connections/connections-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ function ConnectionsGraphInternal({ connections, aircraftLookup }: ConnectionsGr
}, [getLayoutedElements, connections, aircraftLookup]);

return (
<div style={{ width: '100%', height: '750px' }}>
<div style={{ width: '100%', height: '80vh' }}>
<ReactFlow
nodes={nodes}
edges={edges}
Expand Down
220 changes: 220 additions & 0 deletions ui/src/components/connections/connections-map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import React, { useMemo } from 'react';
import { Aircraft, Airport, Airports, Connection, Connections, Flight } from '../../lib/api/api.model';
import { MaplibreMap, PopupMarker, SmartLine } from '../maplibre/maplibre-map';
import { DateTime } from 'luxon';
import { ColumnLayout, KeyValuePairs, KeyValuePairsProps } from '@cloudscape-design/components';
import { FlightLink } from '../common/flight-link';

export interface ConnectionsMapProps {
connections: Connections;
airports: Airports;
aircraftLookup?: Record<string, Aircraft>;
}

export function ConnectionsMap({ connections, airports, aircraftLookup }: ConnectionsMapProps) {
const [markers, lines] = useMemo(
() => buildMarkersAndLines(connections, airports, aircraftLookup ?? {}),
[connections, airports, aircraftLookup]
);

return (
<MaplibreMap height={'80vh'}>
{...markers}
{...lines}
</MaplibreMap>
);
}

interface ParsedFlight {
flightNumber: string;
departureAirport: AirportNode;
arrivalAirport: AirportNode;
}

interface AirportNode {
airport: Airport;
connections: Array<AirportNode>;
incomingFlights: Array<ParsedFlight>;
outgoingFlights: Array<ParsedFlight>;
}

function buildMarkersAndLines(connections: Connections, airports: Airports, aircraftLookup: Record<string, Aircraft>): [ReadonlyArray<React.ReactNode>, ReadonlyArray<React.ReactNode>] {
const airportNodes = new Map<string, AirportNode>();
processConnections(connections.connections, connections.flights, buildAirportLookup(airports), aircraftLookup, airportNodes);

const markers = new Map<string, React.ReactNode>();
const lines = new Map<string, React.ReactNode>();

for (const node of airportNodes.values()) {
toMarkersAndLines(node, markers, lines);
}

return [
Array.from(markers.values()),
Array.from(lines.values()),
];
}

function toMarkersAndLines(
node: AirportNode,
markers: Map<string, React.ReactNode>,
lines: Map<string, React.ReactNode>,
) {

if (!markers.has(node.airport.code)) {
markers.set(
node.airport.code,
(
<PopupMarker
longitude={node.airport.lng}
latitude={node.airport.lat}
button={{}}
popover={{
size: 'medium',
header: node.airport.name,
renderWithPortal: true,
content: <AirportPopoverContent node={node} />,
}}
>{node.airport.code}</PopupMarker>
),
);
}

for (const connectedNode of node.connections) {
const srcId = `${node.airport.code}-${connectedNode.airport.code}`;

if (!lines.has(srcId)) {
lines.set(
srcId,
(
<SmartLine src={[node.airport.lng, node.airport.lat]} dst={[connectedNode.airport.lng, connectedNode.airport.lat]} />
),
);
}
}
}

function buildAirportLookup(airports: Airports): Record<string, Airport> {
const result: Record<string, Airport> = {};

for (const airport of airports.airports) {
result[airport.code] = airport;
}

for (const metroArea of airports.metropolitanAreas) {
for (const airport of metroArea.airports) {
result[airport.code] = airport;
}
}

return result;
}

function processConnections(
conns: ReadonlyArray<Connection>,
flights: Record<string, Flight>,
airportLookup: Record<string, Airport>,
aircraftLookup: Record<string, Aircraft>,
airportNodes: Map<string, AirportNode>,
) {

for (const conn of conns) {
const flight = flights[conn.flightId];
const departureTime = DateTime.fromISO(flight.departureTime, { setZone: true });
const arrivalTime = DateTime.fromISO(flight.arrivalTime, { setZone: true });
if (!departureTime.isValid || !arrivalTime.isValid) {
throw new Error(`invalid departureTime/arrivalTime: ${flight.departureTime} / ${flight.arrivalTime}`);
}

const departureAirport = airportLookup[flight.departureAirport];
const arrivalAirport = airportLookup[flight.arrivalAirport];

if (departureAirport && arrivalAirport) {
let departureNode = airportNodes.get(flight.departureAirport);
if (!departureNode) {
departureNode = {
airport: departureAirport,
connections: [],
incomingFlights: [],
outgoingFlights: [],
} satisfies AirportNode;

airportNodes.set(flight.departureAirport, departureNode);
}

let arrivalNode = airportNodes.get(flight.arrivalAirport);
if (!arrivalNode) {
arrivalNode = {
airport: arrivalAirport,
connections: [],
incomingFlights: [],
outgoingFlights: [],
} satisfies AirportNode;

airportNodes.set(flight.arrivalAirport, arrivalNode);
}

if (!departureNode.connections.includes(arrivalNode)) {
departureNode.connections.push(arrivalNode);
}

const flightNumber = `${flight.flightNumber.airline}${flight.flightNumber.number}${flight.flightNumber.suffix ?? ''}`;
const parsedFlight = {
flightNumber: flightNumber,
departureAirport: departureNode,
arrivalAirport: arrivalNode,
} satisfies ParsedFlight;

if (departureNode.outgoingFlights.findIndex((v) => v.flightNumber === flightNumber) === -1) {
departureNode.outgoingFlights.push(parsedFlight);
}

if (arrivalNode.incomingFlights.findIndex((v) => v.flightNumber === flightNumber) === -1) {
arrivalNode.incomingFlights.push(parsedFlight);
}
}

processConnections(conn.outgoing, flights, airportLookup, aircraftLookup, airportNodes);
}
}

function AirportPopoverContent({ node }: { node: AirportNode }) {
const items = useMemo(() => {
const result: Array<KeyValuePairsProps.Item> = [];
if (node.incomingFlights.length > 0) {
result.push({
label: `Incoming Flights (${node.incomingFlights.length})`,
value: (
<ColumnLayout columns={Math.min(Math.max(node.incomingFlights.length, 1), 4)} variant={'text-grid'}>
{...node.incomingFlights.map((v) => (
<FlightLink flightNumber={v.flightNumber} target={'_blank'} external={true}>
{v.flightNumber}&nbsp;({v.departureAirport.airport.code})
</FlightLink>
))}
</ColumnLayout>
),
});
}

if (node.outgoingFlights.length > 0) {
result.push({
label: `Outgoing Flights (${node.outgoingFlights.length})`,
value: (
<ColumnLayout columns={Math.min(Math.max(node.outgoingFlights.length, 1), 4)} variant={'text-grid'}>
{...node.outgoingFlights.map((v) => (
<FlightLink flightNumber={v.flightNumber} target={'_blank'} external={true}>
{v.flightNumber}&nbsp;({v.arrivalAirport.airport.code})
</FlightLink>
))}
</ColumnLayout>
),
});
}

return result;
}, [node]);

return (
<KeyValuePairs columns={2} items={items} />
);
}
25 changes: 17 additions & 8 deletions ui/src/components/connections/connections-tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { Aircraft, Connections } from '../../lib/api/api.model';
import { Aircraft, Airports, Connections } from '../../lib/api/api.model';
import React, { useMemo } from 'react';
import { DateTime } from 'luxon';
import { ColumnLayout, Tabs, TabsProps } from '@cloudscape-design/components';
import { ExpandableSection, Tabs, TabsProps } from '@cloudscape-design/components';
import { ConnectionsGraph } from './connections-graph';
import { ConnectionsTable } from './connections-table';
import { ConnectionsMap } from './connections-map';

export interface ConnectionsTabsProps {
connections?: Connections;
airports: Airports;
aircraft?: ReadonlyArray<Aircraft>;
}

export function ConnectionsTabs({ connections, aircraft }: ConnectionsTabsProps) {
export function ConnectionsTabs({ connections, airports, aircraft }: ConnectionsTabsProps) {
if (connections === undefined) {
return undefined;
}
Expand All @@ -35,16 +37,23 @@ export function ConnectionsTabs({ connections, aircraft }: ConnectionsTabsProps)
id: date,
label: DateTime.fromISO(date).toLocaleString(DateTime.DATE_FULL),
content: (
<ColumnLayout columns={1}>
<ConnectionsGraph connections={connections} aircraftLookup={aircraftLookup} />
<ConnectionsTable connections={connections} aircraftLookup={aircraftLookup} />
</ColumnLayout>
<>
<ExpandableSection headerText={'Graph'} defaultExpanded={true} variant={'stacked'} disableContentPaddings={true}>
<ConnectionsGraph connections={connections} aircraftLookup={aircraftLookup} />
</ExpandableSection>
<ExpandableSection headerText={'Map'} defaultExpanded={false} variant={'stacked'} disableContentPaddings={true}>
<ConnectionsMap connections={connections} airports={airports} aircraftLookup={aircraftLookup} />
</ExpandableSection>
<ExpandableSection headerText={'Table'} defaultExpanded={false} variant={'stacked'}>
<ConnectionsTable connections={connections} aircraftLookup={aircraftLookup} />
</ExpandableSection>
</>
),
} satisfies TabsProps.Tab))
}, [connections, aircraftLookup]);

return (
<Tabs variant={'container'} tabs={tabs} />
<Tabs variant={'default'} tabs={tabs} />
);
}

Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/footer/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import classes from './footer.module.scss';
import { RouterLink } from '../common/router-link';

export interface FlightsFooterProps {
onCookiePreferencesClick: (e: CustomEvent<LinkProps.FollowDetail>) => void;
onPrivacyPreferencesClick: (e: CustomEvent<LinkProps.FollowDetail>) => void;
}

export default function FlightsFooter(props: FlightsFooterProps) {
Expand All @@ -18,7 +18,7 @@ export default function FlightsFooter(props: FlightsFooterProps) {
<SpaceBetween size={isMobile ? 'xs' : 'm'} direction={isMobile ? 'vertical' : 'horizontal'}>
<RouterLink to={'/legal'}>Legal</RouterLink>
<RouterLink to={'/privacy-policy'}>Privacy Policy</RouterLink>
<Link variant={'secondary'} href={'#'} onFollow={props.onCookiePreferencesClick}>Cookie Preferences</Link>
<Link variant={'secondary'} href={'#'} onFollow={props.onPrivacyPreferencesClick}>Privacy Preferences</Link>
<Box variant={'span'}>© 2024 Felix</Box>
</SpaceBetween>
</footer>
Expand Down
Loading

0 comments on commit 9383619

Please sign in to comment.