Files
aviation/ui/src/components/AirportDrawer/index.tsx

258 lines
7.9 KiB
TypeScript

import {
Accordion,
Badge,
Box,
Divider,
Drawer,
Group,
Tabs,
TabsList,
Text,
Tooltip,
UnstyledButton
} from '@mantine/core';
import { Airport, AirportCategory } from '@lib/airport.types.ts';
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react';
import { getMetars } from '@lib/metar.ts';
import { useMediaQuery } from '@mantine/hooks';
import { IconViewfinder } from '@tabler/icons-react';
import { RunwayTable } from '@components/AirportDrawer/RunwayTable.tsx';
import { CommunicationTable } from '@components/AirportDrawer/CommunicationTable.tsx';
import { useMap } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
export function AirportDrawer({
airport,
setAirport
}: {
airport: Airport | null;
setAirport: (airport: Airport | null) => void;
}) {
const [metar, setMetar] = useState<Metar | undefined>(undefined);
const isMobile = useMediaQuery('(max-width: 768px)');
const map = useMap();
useEffect(() => {
if (!airport) return;
function updateMetar() {
if (!airport) return;
getMetars({ icaos: [airport.icao] }).then((m) => {
if (m.length > 0) {
setMetar(m[0]);
} else {
setMetar(undefined);
}
});
}
updateMetar();
const interval = setInterval(updateMetar, 60000);
return () => clearInterval(interval);
}, [airport]);
if (!airport) {
return null;
}
const metarColor = getMarkerColor(metar?.flight_category || 'UNKN');
return (
<Drawer.Root
opened={true}
onClose={() => setAirport(null)}
withinPortal
zIndex={1000}
styles={{ root: { padding: 0, margin: 0, width: 0, height: 0, backgroundColor: 'red' } }}
padding='md'
size={isMobile ? '100%' : 'md'}
position='left'
closeOnClickOutside={false}
>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>
<Text size={'xl'}>{airport.name}</Text>
</Drawer.Title>
<Drawer.CloseButton />
</Drawer.Header>
<Drawer.Body>
<Box mb='lg'>
{metar && metar.flight_category && (
<Group
justify='space-between'
mb='md'
style={{
backgroundColor: '#32495f',
borderTop: '1px solid #1a242f',
borderBottom: '1px solid #1a242f',
padding: '10px'
}}
>
<Badge size='lg' color={metarColor}>
{metar.flight_category}
</Badge>
{/*<Text style={{ color: metarColor }}>{metar.flight_category}</Text>*/}
<Tooltip zIndex={1001} label={new Date(metar.observation_time).toLocaleString()}>
<TimeSince date={metar.observation_time} />
</Tooltip>
</Group>
)}
<Tabs variant={'outline'} defaultValue={'info'}>
<TabsList grow>
<Tabs.Tab value={'info'}>Info</Tabs.Tab>
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
</TabsList>
<Tabs.Panel value={'info'}>
<AirportInfo map={map} airport={airport} />
</Tabs.Panel>
<Tabs.Panel value={'weather'}>
<WeatherInfo metar={airport.latest_metar} />
</Tabs.Panel>
</Tabs>
</Box>
</Drawer.Body>
</Drawer.Content>
</Drawer.Root>
);
}
function AirportInfoSlot({ title, style, children }: { title?: string; style?: CSSProperties; children?: ReactNode }) {
return (
<div style={{ ...style }}>
{title && (
<Text size='xs' color='dimmed'>
{title}
</Text>
)}
<Box fw={500} size='sm'>
{children}
</Box>
</div>
);
}
function AirportInfoRow({ style, children }: { style?: CSSProperties; children: ReactNode }) {
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignContent: 'center',
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-lg)',
borderTop: '1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))',
...style
}}
>
{children}
</div>
);
}
function AirportInfo({ map, airport }: { map: LeafletMap; airport: Airport }) {
function goToLocation(map: LeafletMap, latitude: number, longitude: number) {
if (!map) return;
map.setView([latitude, longitude], map.getZoom());
}
return (
<div>
<AirportInfoRow>
<AirportInfoSlot title={'ICAO'} children={airport.icao} />
<AirportInfoSlot title={'IATA'} children={airport.iata} />
<AirportInfoSlot title={'LOCAL'} children={airport.local} />
<AirportInfoSlot title={'Category'} children={airportCategoryToText(airport.category)} />
</AirportInfoRow>
<AirportInfoRow style={{ justifyContent: 'flex-start' }}>
<AirportInfoSlot title={'Location'}>
{airport.latitude}°, {airport.longitude}°
</AirportInfoSlot>
<AirportInfoSlot title={'Elevation'} style={{ paddingLeft: '1rem' }} children={`${airport.elevation_ft} ft`} />
<AirportInfoSlot style={{ marginLeft: 'auto', paddingLeft: '1rem', paddingTop: '0.5rem' }}>
<UnstyledButton
onClick={() => {
goToLocation(map, airport.latitude, airport.longitude);
}}
>
<IconViewfinder />
</UnstyledButton>
</AirportInfoSlot>
</AirportInfoRow>
<Accordion chevronPosition={'right'} variant={'contained'}>
{airport.runways != null && airport.runways.length > 0 && (
<Accordion.Item value={'runways'}>
<Accordion.Control>Runways</Accordion.Control>
<Accordion.Panel>
<RunwayTable runways={airport.runways} />
</Accordion.Panel>
</Accordion.Item>
)}
{airport.communications != null && airport.communications.length > 0 && (
<Accordion.Item value={'communication'}>
<Accordion.Control>Communication</Accordion.Control>
<Accordion.Panel>
<CommunicationTable communications={airport.communications} />
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
<Divider />
</div>
);
}
function WeatherInfo({ metar }: { metar?: Metar }) {
if (metar) {
return <>{metar.raw_text}</>;
} else {
return <>No METAR observation available</>;
}
}
function airportCategoryToText(category: AirportCategory): string {
switch (category) {
case AirportCategory.SMALL:
return 'Small';
case AirportCategory.MEDIUM:
return 'Medium';
case AirportCategory.LARGE:
return 'Large';
case AirportCategory.HELIPORT:
return 'Helipad';
case AirportCategory.CLOSED:
return 'Closed';
case AirportCategory.SEAPLANE:
return 'Seaplane Base';
case AirportCategory.BALLOONPORT:
return 'Balloon Port';
default:
return 'Unknown';
}
}
const TimeSince = forwardRef<HTMLParagraphElement, { date: string }>(({ date }, ref) => {
const inputDate = new Date(date);
// @ts-expect-error doing arithmetic with dates
const seconds = Math.floor((new Date() - inputDate) / 1000);
if (seconds < 60) {
const content = seconds + (seconds === 1 ? ' second ago' : ' seconds ago');
return (
<Text ref={ref} style={{ userSelect: 'none' }}>
{content}
</Text>
);
} else {
const minutes = Math.floor(seconds / 60);
const content = minutes + (minutes === 1 ? ' minute ago' : ' minutes ago');
// If more than 60 minutes have passed, set the text color to yellow
return (
<Text ref={ref} style={{ color: minutes >= 60 ? '#fca903' : undefined, userSelect: 'none' }}>
{content}
</Text>
);
}
});