Working on metars, updating ui drawer

This commit is contained in:
2025-04-16 22:43:03 -04:00
parent 81335f1b48
commit 3aa8954626
9 changed files with 279 additions and 139 deletions

View File

@@ -25,9 +25,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: markerShadow
});
const openStreetMapUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const lightLayerUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
const darkLayerUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
export interface LayerInfo {
url: string;
name: string;
markerOutline: string;
}
const layerMap: LayerInfo[] = [
{ url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', name: 'Open Street Map', markerOutline: 'black' },
{ url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', name: 'Carto Light', markerOutline: 'black' },
{ url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', name: 'Carto Dark', markerOutline: 'white'},
]
// const dark1Url = 'https://maps.rainviewer.com/data/v3/5/10/11.pbf';
// const dark2Url = 'https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/tile/2/0/3.pbf';
const defaultZoom = 6;
@@ -38,7 +46,10 @@ function App() {
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
const initialRadarValue = Cookies.get('showRadar') === 'true';
const [showRadar, setShowRadar] = useState<boolean>(initialRadarValue);
const [baseLayer, setBaseLayer] = useState<string>(Cookies.get('selectedBaseLayer') || 'Open Street Map');
const initialShowNoMetarValue = Cookies.get('showNoMetar') === 'true';
const [showNoMetar, setShowNoMetar] = useState<boolean>(initialShowNoMetarValue);
const [selectedLayerIndex, setSelectedLayerIndex] = useState<string>(Cookies.get('selectedLayer') || '0');
const [selectedLayer, setSelectedLayer] = useState<LayerInfo>(layerMap[Number(selectedLayerIndex)]);
useEffect(() => {
if (showRadar) {
@@ -56,11 +67,21 @@ function App() {
});
}
function toggleShowNoMetar() {
setShowNoMetar((prev) => {
const newValue = !prev;
Cookies.set('showNoMetar', newValue.toString(), { expires: 7 });
return newValue;
});
}
function BaseLayerChangeHandler() {
useMapEvents({
baselayerchange: (e) => {
setBaseLayer(e.name);
Cookies.set('selectedBaseLayer', e.name, { expires: 7 });
const index = layerMap.findIndex(layer => layer.name === e.name);
setSelectedLayerIndex(`${index}`);
Cookies.set('selectedLayer', `${index}`, { expires: 7 });
setSelectedLayer(layerMap[index]);
}
});
return null;
@@ -86,21 +107,24 @@ function App() {
zoomControl={false}
>
<LayersControl>
<LayersControl.BaseLayer checked={baseLayer === 'Open Street Map'} name={'Open Street Map'}>
<TileLayer url={openStreetMapUrl} />
</LayersControl.BaseLayer>
<LayersControl.BaseLayer checked={baseLayer === 'Carto Light'} name={'Carto Light'}>
<TileLayer url={lightLayerUrl} />
</LayersControl.BaseLayer>
<LayersControl.BaseLayer checked={baseLayer === 'Carto Dark'} name={'Carto Dark'}>
<TileLayer url={darkLayerUrl} />
</LayersControl.BaseLayer>
{layerMap.map((layer, index) => (
<LayersControl.BaseLayer key={index} checked={selectedLayerIndex === `${index}`} name={layer.name}>
<TileLayer url={layer.url} />
</LayersControl.BaseLayer>
))}
</LayersControl>
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
<ZoomControl position={'bottomright'} />
<AirportLayer setAirport={setAirport} />
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
<BaseLayerChangeHandler />
</MapContainer>
<UnstyledButton
onClick={toggleShowNoMetar}
style={{ bottom: '120px' }}
className={`map-button ${showNoMetar ? 'active' : ''}`}
>
U
</UnstyledButton>
<UnstyledButton
onClick={toggleRadar}
style={{ bottom: '80px' }}

View File

@@ -1,8 +1,9 @@
import { Box, Divider, Drawer, Group, Text } from '@mantine/core';
import { Box, Drawer, Group, Tabs, TabsList, Text, Tooltip } from '@mantine/core';
import { Airport, AirportCategory } from '@lib/airport.types.ts';
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
import { useEffect, useState } from 'react';
import { getMetars } from '@lib/metar.ts';
import { useMediaQuery } from '@mantine/hooks';
export default function AirportDrawer({
airport,
@@ -12,15 +13,27 @@ export default function AirportDrawer({
setAirport: (airport: Airport | null) => void;
}) {
const [metar, setMetar] = useState<Metar | undefined>(undefined);
const isMobile = useMediaQuery('(max-width: 768px)');
useEffect(() => {
if (airport != null) {
if (!airport) return;
function updateMetar() {
if (!airport) return;
console.log(airport.icao);
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) {
@@ -35,47 +48,58 @@ export default function AirportDrawer({
onClose={() => setAirport(null)}
title={airport.name}
withinPortal
zIndex={10000}
styles={{ root: { width: 0, height: 0 } }}
zIndex={1000}
styles={{ root: { padding: 0, margin: 0, width: 0, height: 0 } }}
padding='md'
size='md'
size={isMobile ? '100%' : 'md'}
position='left'
withOverlay={false}
closeOnClickOutside={false}
>
<Box mb='lg'>
{metar && metar.flight_category && (
<Group justify='space-between' mb='md'>
<Group
justify='space-between'
mb='md'
style={{
backgroundColor: '#272f38',
borderTop: '1px solid #1a242f',
borderBottom: '1px solid #1a242f',
padding: '10px'
}}
>
<Text style={{ color: metarColor }}>{metar.flight_category}</Text>
<Text size='sm'>{metar.observation_time ? new Date(metar.observation_time).toLocaleString() : 'N/A'}</Text>
<Tooltip zIndex={1001} label={new Date(metar.observation_time).toLocaleString()}>
<TimeSince date={metar.observation_time} />
</Tooltip>
</Group>
)}
<Group>
<div>ICAO: {airport.icao}</div>
<div>Category: {airportCategoryToText(airport.category)}</div>
<div>
Country / Region: {airport.iso_country}, {airport.iso_region}
</div>
<div>Municipality: {airport.municipality || 'N/A'}</div>
<div>Local Code: {airport.local || 'N/A'}</div>
<div>Elevation: {airport.elevation_ft}</div>
<div>
Coordinates: {airport.latitude.toFixed(4)}, {airport.longitude.toFixed(4)}
</div>
<div>Control Tower: {airport.has_tower ? 'Yes' : 'No'}</div>
<div>Beacon: {airport.has_beacon ? 'Yes' : 'No'}</div>
{metar && metar.flight_category && (
<>
<Divider my='sm' />
<div>Flight Category: {metar.flight_category}</div>
</>
<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 airport={airport}/></Tabs.Panel>
{airport.latest_metar && (
<Tabs.Panel value={'weather'}><WeatherInfo metar={airport.latest_metar} /></Tabs.Panel>
)}
</Group>
</Tabs>
</Box>
</Drawer>
);
}
function AirportInfo({ airport }: { airport: Airport }) {
return (<div>
<Text>ICAO: {airport.icao}</Text>
<Text>Category: {airportCategoryToText(airport.category)}</Text>
</div>);
}
function WeatherInfo({ metar }: { metar: Metar }) {
return <>{metar.raw_text}</>
}
function airportCategoryToText(category: AirportCategory): string {
switch (category) {
case AirportCategory.SMALL:
@@ -96,3 +120,19 @@ function airportCategoryToText(category: AirportCategory): string {
return 'Unknown';
}
}
function TimeSince({ date }: { date: string }) {
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>{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 style={{ color: minutes >= 60 ? '#fca903' : undefined }}>{content}</Text>;
}
}

View File

@@ -4,13 +4,22 @@ import { useMapEvents } from 'react-leaflet';
import { getAirports } from '@lib/airport.ts';
import AirportMarker from '@components/AirportMarker.tsx';
import { LeafletEvent } from 'leaflet';
import { LayerInfo } from '@/App.tsx';
interface Bounds {
northEast: { lat: number; lon: number };
southWest: { lat: number; lon: number };
}
export default function AirportLayer({ setAirport }: { setAirport: (airport: Airport) => void }) {
export default function AirportLayer({
setAirport,
showNoMetar,
selectedLayer
}: {
setAirport: (airport: Airport) => void;
showNoMetar: boolean;
selectedLayer: LayerInfo;
}) {
const [airports, setAirports] = useState<Airport[]>([]);
function loadAirports(event: LeafletEvent) {
@@ -52,40 +61,44 @@ export default function AirportLayer({ setAirport }: { setAirport: (airport: Air
}
}, [map]);
const categoryOrder: { [key in AirportCategory]?: number } = {
[AirportCategory.LARGE]: 3,
[AirportCategory.MEDIUM]: 2,
[AirportCategory.SMALL]: 1,
[AirportCategory.HELIPORT]: 0
};
// const categoryOrder: { [key in AirportCategory]?: number } = {
// [AirportCategory.LARGE]: 3,
// [AirportCategory.MEDIUM]: 2,
// [AirportCategory.SMALL]: 1,
// [AirportCategory.HELIPORT]: 0
// };
const sortedAirports = airports.slice().sort((a, b) => {
// Compare by airport category first.
const categoryA = categoryOrder[a.category] ?? 4;
const categoryB = categoryOrder[b.category] ?? 4;
if (categoryA !== categoryB) {
return categoryA - categoryB;
}
// Then compare by flight category if available.
// Assuming that latest_metar.flight_category is a string and "UNKN" needs to come last.
const fcA = a.latest_metar?.flight_category ?? 'UNKN';
const fcB = b.latest_metar?.flight_category ?? 'UNKN';
if (fcA === 'UNKN' && fcB !== 'UNKN') return 1;
if (fcB === 'UNKN' && fcA !== 'UNKN') return -1;
// If both flight categories are not "UNKN", do a simple alphabetical comparison.
// (You may wish to customize this logic based on the actual flight category values.)
if (fcA < fcB) return -1;
if (fcA > fcB) return 1;
return 0;
});
// const sortedAirports = airports.slice().sort((a, b) => {
// // Compare by airport category first.
// const categoryA = categoryOrder[a.category] ?? 4;
// const categoryB = categoryOrder[b.category] ?? 4;
// if (categoryA !== categoryB) {
// return categoryA - categoryB;
// }
//
// // Then compare by flight category if available.
// // Assuming that latest_metar.flight_category is a string and "UNKN" needs to come last.
// const fcA = a.latest_metar?.flight_category ?? 'UNKN';
// const fcB = b.latest_metar?.flight_category ?? 'UNKN';
//
// if (fcA === 'UNKN' && fcB !== 'UNKN') return 1;
// if (fcB === 'UNKN' && fcA !== 'UNKN') return -1;
//
// // If both flight categories are not "UNKN", do a simple alphabetical comparison.
// // (You may wish to customize this logic based on the actual flight category values.)
// if (fcA < fcB) return -1;
// if (fcA > fcB) return 1;
// return 0;
// });
return (
<>
{sortedAirports.map((airport, index) => (
<AirportMarker key={index} airport={airport} index={index} setAirport={setAirport} />
{airports.map((airport, index) => (
<div key={index}>
{(showNoMetar || airport.latest_metar != undefined) && (
<AirportMarker airport={airport} index={index} setAirport={setAirport} selectedLayer={selectedLayer} />
)}
</div>
))}
</>
);

View File

@@ -3,17 +3,20 @@ import { Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
import { useRef } from 'react';
import { getMarkerColor } from '@lib/metar.types.ts';
import { LayerInfo } from '@/App.tsx';
export default function AirportMarker({
index,
airport,
setAirport
setAirport,
selectedLayer,
}: {
index: number;
airport: Airport;
setAirport: (airport: Airport) => void;
selectedLayer: LayerInfo;
}) {
const icon = createCustomIcon(airport);
const icon = createCustomIcon(airport, selectedLayer);
const markerRef = useRef<L.Marker>(null);
return (
@@ -35,7 +38,7 @@ export default function AirportMarker({
);
}
function createCustomIcon(airport: Airport): L.DivIcon {
function createCustomIcon(airport: Airport, selectedLayer: LayerInfo): L.DivIcon {
if (airport.category === AirportCategory.HELIPORT) {
return L.divIcon({
html: `
@@ -44,7 +47,7 @@ function createCustomIcon(airport: Airport): L.DivIcon {
height: 14px;
border-radius: 50%;
border: 2px solid black;
background-color: white;
background-color: ${selectedLayer.markerOutline};
display: flex;
align-items: center;
justify-content: center;
@@ -83,7 +86,7 @@ function createCustomIcon(airport: Airport): L.DivIcon {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #fff;
border: 2px solid ${selectedLayer.markerOutline};
z-index: {info[1]}">
</div>
`,