Added airport data to map
This commit is contained in:
@@ -6,22 +6,26 @@ body,
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Set up Flexbox layout */
|
||||
.App {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh; /* Full viewport height */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Make the map container fill the remaining space */
|
||||
.leaflet-container {
|
||||
flex: 1 1 auto; /* Allow the map to grow and fill space */
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*.leaflet-container {*/
|
||||
/* width: 100%;*/
|
||||
/* height: 100vh;*/
|
||||
/*}*/
|
||||
|
||||
@@ -5,10 +5,11 @@ import './App.css';
|
||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
// import { Header } from '@components/Header';
|
||||
|
||||
// Fix for default marker icon issues in React-Leaflet
|
||||
import L from 'leaflet';
|
||||
import { Header } from '@components/Header';
|
||||
import AirportLayer from '@components/AirportLayer.tsx';
|
||||
|
||||
// Fix Leaflet's default icon path issues with Webpack
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -26,21 +27,24 @@ const tileLayerUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
function App() {
|
||||
return (
|
||||
<div className='App'>
|
||||
{/*<Header />*/}
|
||||
<MapContainer
|
||||
className='leaflet-container'
|
||||
center={[38.944444, -77.455833]}
|
||||
zoom={6}
|
||||
minZoom={3}
|
||||
maxZoom={19}
|
||||
maxBounds={[
|
||||
[-85.06, -180],
|
||||
[85.06, 180]
|
||||
]}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer url={tileLayerUrl} />
|
||||
</MapContainer>
|
||||
<Header />
|
||||
<div className='map-wrapper'>
|
||||
<MapContainer
|
||||
className='leaflet-container'
|
||||
center={[38.944444, -77.455833]}
|
||||
zoom={6}
|
||||
minZoom={3}
|
||||
maxZoom={19}
|
||||
maxBounds={[
|
||||
[-85.06, -180],
|
||||
[85.06, 180]
|
||||
]}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer url={tileLayerUrl} />
|
||||
<AirportLayer />
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
104
ui/src/components/AirportLayer.tsx
Normal file
104
ui/src/components/AirportLayer.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
||||
import { Marker, Popup, useMapEvents } from 'react-leaflet';
|
||||
import { getAirports } from '@lib/airport.ts';
|
||||
import L from 'leaflet';
|
||||
|
||||
interface Bounds {
|
||||
northEast: { lat: number; lon: number };
|
||||
southWest: { lat: number; lon: number };
|
||||
}
|
||||
|
||||
export default function AirportLayer() {
|
||||
const [airports, setAirports] = useState<Airport[]>([]);
|
||||
|
||||
useMapEvents({
|
||||
moveend: (event) => {
|
||||
const map = event.target;
|
||||
const bounds = map.getBounds();
|
||||
|
||||
const boundsParam: Bounds = {
|
||||
northEast: {
|
||||
lat: bounds.getNorth(),
|
||||
lon: bounds.getEast()
|
||||
},
|
||||
southWest: {
|
||||
lat: bounds.getSouth(),
|
||||
lon: bounds.getWest()
|
||||
}
|
||||
};
|
||||
|
||||
// Call getAirports with the current map bounds and desired parameters.
|
||||
getAirports({
|
||||
bounds: boundsParam,
|
||||
metars: true,
|
||||
categories: [AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE],
|
||||
limit: 200
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
setAirports(response.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching airports:', error);
|
||||
setAirports([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{airports.map((airport, index) => {
|
||||
const markerColor = getMarkerColor(airport);
|
||||
const icon = createCustomIcon(markerColor);
|
||||
return (
|
||||
<Marker key={index} position={[airport.latitude, airport.longitude]} icon={icon}>
|
||||
<Popup>
|
||||
<div>
|
||||
<h3>{airport.name || 'Unnamed Airport'}</h3>
|
||||
<p>ICAO: {airport.icao || 'N/A'}</p>
|
||||
<p>Flight Category: {airport.latest_metar ? airport.latest_metar.flight_category : 'No METAR Data'}</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkerColor(airport: Airport): string {
|
||||
if (airport.latest_metar) {
|
||||
switch (airport.latest_metar.flight_category.toUpperCase()) {
|
||||
case 'IFR':
|
||||
return '#ff0100';
|
||||
case 'LIFR':
|
||||
return '#7f007f';
|
||||
case 'MVFR':
|
||||
return '#00f';
|
||||
case 'VFR':
|
||||
return '#018000';
|
||||
case 'UNKNOWN':
|
||||
return '#3e3e3e';
|
||||
default:
|
||||
return '#3e3e3e';
|
||||
}
|
||||
} else {
|
||||
return '#696969';
|
||||
}
|
||||
}
|
||||
|
||||
function createCustomIcon(color: string): L.DivIcon {
|
||||
return L.divIcon({
|
||||
html: `<div style="
|
||||
background-color: ${color};
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
"></div>`,
|
||||
className: '',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
.header {
|
||||
height: 56px;
|
||||
margin-bottom: 120px;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Burger, Container, Group, Text } from '@mantine/core';
|
||||
import { Avatar, Burger, Container, Group, Text } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
// import { ReactComponent as Logo } from '../../../public/logo.svg';
|
||||
import classes from './Header.module.css';
|
||||
|
||||
const links = [
|
||||
{ link: '/', label: 'Map' },
|
||||
{ link: '/airports', label: 'Airports' },
|
||||
{ link: '/metars', label: 'METARs' }
|
||||
{ link: '/metars', label: 'Metars' }
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
@@ -31,7 +32,11 @@ export function Header() {
|
||||
return (
|
||||
<header className={classes.header}>
|
||||
<Container size='md' className={classes.inner}>
|
||||
<Text>Aviation Weather</Text>
|
||||
<span style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<Text>Aviation Weather</Text>
|
||||
<Avatar src='../../../public/logo.svg' alt="it's me" />
|
||||
</span>
|
||||
{/*<Logo />*/}
|
||||
<Group gap={5} visibleFrom='xs'>
|
||||
{items}
|
||||
</Group>
|
||||
|
||||
40
ui/src/lib/airport.ts
Normal file
40
ui/src/lib/airport.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Airport, AirportCategory, Bounds, GetAirportsResponse } from '@lib/airport.types.ts';
|
||||
import { getRequest } from '@lib/index.ts';
|
||||
|
||||
export async function getAirport({ icao }: { icao: string }): Promise<Airport> {
|
||||
const response = await getRequest(`airports/${icao}`);
|
||||
return response?.json() || {};
|
||||
}
|
||||
|
||||
interface GetAirportsParameters {
|
||||
icaos?: string[];
|
||||
name?: string;
|
||||
categories?: AirportCategory[];
|
||||
bounds?: Bounds;
|
||||
metars?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function getAirports({
|
||||
icaos,
|
||||
name,
|
||||
categories,
|
||||
bounds,
|
||||
metars = false,
|
||||
limit = 1000,
|
||||
page = 1
|
||||
}: GetAirportsParameters): Promise<GetAirportsResponse> {
|
||||
const response = await getRequest('airports', {
|
||||
bounds: bounds
|
||||
? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}`
|
||||
: undefined,
|
||||
categories: categories ?? undefined,
|
||||
icaos: icaos ?? undefined,
|
||||
name: name ?? undefined,
|
||||
metars: metars ?? undefined,
|
||||
limit,
|
||||
page
|
||||
});
|
||||
return response?.json() || { data: [] };
|
||||
}
|
||||
93
ui/src/lib/airport.types.ts
Normal file
93
ui/src/lib/airport.types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Metar } from './metar.types';
|
||||
|
||||
export enum AirportCategory {
|
||||
SMALL = 'small_airport',
|
||||
MEDIUM = 'medium_airport',
|
||||
LARGE = 'large_airport',
|
||||
HELIPORT = 'heliport',
|
||||
BALLOONPORT = 'balloon_port',
|
||||
CLOSED = 'closed',
|
||||
SEAPLANE = 'seaplane_base',
|
||||
UNKNOWN = 'unknown'
|
||||
}
|
||||
|
||||
export 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';
|
||||
}
|
||||
}
|
||||
|
||||
export enum AirportOrderField {
|
||||
ICAO = 'icao',
|
||||
NAME = 'name',
|
||||
CATEGORY = 'category',
|
||||
CONTINENT = 'continent',
|
||||
ISO_COUNTRY = 'iso_country',
|
||||
ISO_REGION = 'iso_region',
|
||||
MUNICIPALITY = 'municipality'
|
||||
}
|
||||
|
||||
export interface Bounds {
|
||||
northEast: Coordinate;
|
||||
southWest: Coordinate;
|
||||
}
|
||||
|
||||
export interface Coordinate {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export interface Airport {
|
||||
icao: string;
|
||||
iata: string;
|
||||
local: string;
|
||||
name: string;
|
||||
category: AirportCategory;
|
||||
iso_country: string;
|
||||
iso_region: string;
|
||||
municipality: string;
|
||||
elevation_ft: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
has_tower: boolean;
|
||||
has_beacon: boolean;
|
||||
has_metar: boolean;
|
||||
public: boolean;
|
||||
runways: Runway[];
|
||||
frequencies: Frequency[];
|
||||
latest_metar?: Metar;
|
||||
}
|
||||
|
||||
export interface Runway {
|
||||
id: string;
|
||||
length_ft: number;
|
||||
width_ft: number;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
export interface Frequency {
|
||||
id: string;
|
||||
frequency_mhz: number;
|
||||
}
|
||||
|
||||
export interface GetAirportsResponse {
|
||||
data: Airport[];
|
||||
limit: number;
|
||||
page: number;
|
||||
total: number;
|
||||
}
|
||||
72
ui/src/lib/index.ts
Normal file
72
ui/src/lib/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
|
||||
// const servicePort = process.env.SERVICE_PORT || 5000;'
|
||||
// const baseURL = `${serviceHost}:${servicePort}`;
|
||||
const baseUrl = 'http://localhost:5000';
|
||||
|
||||
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
|
||||
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
||||
const urlParams = new URLSearchParams(params);
|
||||
const url = urlParams && urlParams.size > 0 ? `${baseUrl}/${endpoint}?${urlParams}` : `${baseUrl}/${endpoint}`;
|
||||
return await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
}
|
||||
|
||||
interface PostOptions {
|
||||
headers?: Record<string, any>;
|
||||
type?: 'json' | 'form';
|
||||
}
|
||||
|
||||
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
|
||||
const url = `${baseUrl}/${endpoint}`;
|
||||
let response;
|
||||
if (body && (!options?.type || options.type === 'json')) {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} else {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function putRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
|
||||
const url = `${baseUrl}/${endpoint}`;
|
||||
let response;
|
||||
if (body && (!options?.type || options.type === 'json')) {
|
||||
response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
} else {
|
||||
response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
body
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function deleteRequest(endpoint: string): Promise<Response> {
|
||||
const url = `${baseUrl}/${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
return response;
|
||||
}
|
||||
43
ui/src/lib/metar.types.ts
Normal file
43
ui/src/lib/metar.types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface SkyCondition {
|
||||
sky_cover: string;
|
||||
cloud_base_ft_agl: number;
|
||||
}
|
||||
|
||||
export interface QualityControlFlags {
|
||||
auto: boolean;
|
||||
auto_station_without_precipitation: boolean;
|
||||
auto_station_with_precipication: boolean;
|
||||
maintenance_indicator_on: boolean;
|
||||
corrected: boolean;
|
||||
}
|
||||
|
||||
export interface RunwayVisualRange {
|
||||
runway: string;
|
||||
visibility_ft: string;
|
||||
variable_visibility_high_ft: string;
|
||||
variable_visibility_low_ft: string;
|
||||
}
|
||||
|
||||
export interface Metar {
|
||||
raw_text: string;
|
||||
station_id: string;
|
||||
observation_time: string;
|
||||
temp_c: number;
|
||||
dewpoint_c: number;
|
||||
wind_dir_degrees: string;
|
||||
wind_speed_kt: number;
|
||||
wind_gust_kt: number;
|
||||
variable_wind_dir_degrees: string;
|
||||
visibility_statute_mi: string;
|
||||
runway_visual_range: RunwayVisualRange[];
|
||||
altim_in_hg: number;
|
||||
sea_level_pressure_mb: number;
|
||||
quality_control_flags: QualityControlFlags;
|
||||
weather_phenomena: string[];
|
||||
sky_condition: SkyCondition[];
|
||||
flight_category: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN';
|
||||
three_hr_pressure_tendency_mb: number;
|
||||
max_t_c: number;
|
||||
min_t_c: number;
|
||||
precip_in: number;
|
||||
}
|
||||
Reference in New Issue
Block a user