Updated metar checking

This commit is contained in:
2025-04-20 13:14:40 -04:00
parent 20d5bf26de
commit 4a200b3f94
9 changed files with 301 additions and 212 deletions

View File

@@ -1,7 +1,5 @@
use crate::error::ApiResult; use crate::error::ApiResult;
use redis::{ use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult};
Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult,
};
use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData}; use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::OnceLock; use std::sync::OnceLock;

View File

@@ -0,0 +1,57 @@
use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
use crate::db::redis_async_connection;
use crate::error::ApiResult;
#[derive(Debug, Serialize, Deserialize)]
pub struct MetarCheck {
pub icao: String,
pub status: bool,
pub updated_at: DateTime<Utc>,
}
impl MetarCheck {
pub fn new(icao: String, status: bool) -> Self {
Self {
icao,
status,
updated_at: Utc::now(),
}
}
pub async fn get(icao: &str) -> Option<MetarCheck> {
let mut conn = match redis_async_connection().await {
Ok(conn) => conn,
Err(err) => {
log::error!("{}", err);
return None;
}
};
let result: RedisResult<Option<String>> = conn.get(icao).await;
match result {
Ok(Some(value)) => match serde_json::from_str(&value) {
Ok(result) => Some(result),
Err(err) => {
log::error!("{}", err);
None
}
},
Ok(None) => None,
Err(err) => {
log::error!("{}", err);
None
}
}
}
pub async fn insert(&self, seconds: u64) -> ApiResult<()> {
let mut conn = redis_async_connection().await?;
let value = serde_json::to_string(&self)?;
conn
.set_ex::<_, _, ()>(self.icao.as_str(), value, seconds)
.await?;
Ok(())
}
}

View File

@@ -1,5 +1,7 @@
mod metar_check;
mod model; mod model;
mod routes; mod routes;
pub use model::*; pub use model::*;
pub use metar_check::*;
pub use routes::init_routes; pub use routes::init_routes;

View File

@@ -1,7 +1,7 @@
use crate::error::Error; use crate::error::Error;
use crate::{error::ApiResult, db}; use crate::{error::ApiResult, db};
use chrono::{DateTime, Datelike, NaiveDate, Utc}; use chrono::{DateTime, Datelike, NaiveDate, Utc};
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::env; use std::env;
use std::fmt::Display; use std::fmt::Display;
use std::str::FromStr; use std::str::FromStr;
@@ -10,6 +10,7 @@ use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::airports::{Airport, UpdateAirport}; use crate::airports::{Airport, UpdateAirport};
use crate::db::redis_async_connection; use crate::db::redis_async_connection;
use crate::metars::MetarCheck;
const TABLE_NAME: &str = "metars"; const TABLE_NAME: &str = "metars";
@@ -431,28 +432,19 @@ impl Metar {
let visibility: String = if visibility_str.contains("/") { let visibility: String = if visibility_str.contains("/") {
let visibility_parts: Vec<&str> = visibility_str.split("/").collect(); let visibility_parts: Vec<&str> = visibility_str.split("/").collect();
let visibility_left = visibility_parts[0]; let visibility_left = visibility_parts[0];
let visibility_right = visibility_parts[1].parse::<f64>().unwrap(); let visibility_right = visibility_parts[1].parse::<f64>()?;
if visibility_left.starts_with("M") { if visibility_left.starts_with("M") {
format!( format!(
"M{}", "M{}",
visibility_left[1..visibility_left.len()] visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right
.parse::<f64>()
.unwrap()
/ visibility_right
) )
} else if visibility_left.starts_with("P") { } else if visibility_left.starts_with("P") {
format!( format!(
"P{}", "P{}",
visibility_left[1..visibility_left.len()] visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right
.parse::<f64>()
.unwrap()
/ visibility_right
) )
} else { } else {
format!( format!("{}", visibility_left.parse::<f64>()? / visibility_right)
"{}",
visibility_left.parse::<f64>().unwrap() / visibility_right
)
} }
} else { } else {
visibility_str.to_string() visibility_str.to_string()
@@ -463,22 +455,18 @@ impl Metar {
&& metar_parts.len() > 1 && metar_parts.len() > 1
&& visibility_re.is_match(metar_parts[1]) && visibility_re.is_match(metar_parts[1])
{ {
let visibility_whole = metar_parts[0].parse::<f64>().unwrap(); let visibility_whole = metar_parts[0].parse::<f64>()?;
metar_parts.remove(0); metar_parts.remove(0);
let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect(); let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect();
metar_parts.remove(0); metar_parts.remove(0);
let visibility_left = visibility_parts[0]; let visibility_left = visibility_parts[0];
let visibility_right = visibility_parts[1][0..visibility_parts[1].len() - 2] let visibility_right =
.parse::<f64>() visibility_parts[1][0..visibility_parts[1].len() - 2].parse::<f64>()?;
.unwrap();
let visibility = if visibility_left.starts_with("M") { let visibility = if visibility_left.starts_with("M") {
format!( format!(
"M{}", "M{}",
visibility_whole visibility_whole
+ (visibility_left[1..visibility_left.len()] + (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
.parse::<f64>()
.unwrap()
/ visibility_right)
) )
} else if visibility_left.starts_with("P") { } else if visibility_left.starts_with("P") {
format!( format!(
@@ -909,8 +897,17 @@ impl Metar {
let current_year = current_time.year(); let current_year = current_time.year();
let current_month = current_time.month(); let current_month = current_time.month();
let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day) let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day)
.ok_or_else(|| Error::new(500, format!("Invalid date with day {} for current month", observation_day)))? .ok_or_else(|| {
.and_hms_opt(observation_hour, observation_minute, 0).unwrap(); Error::new(
500,
format!(
"Invalid date with day {} for current month",
observation_day
),
)
})?
.and_hms_opt(observation_hour, observation_minute, 0)
.unwrap();
let obs_datetime = if candidate_date > current_time { let obs_datetime = if candidate_date > current_time {
// Subtract one month. (Handle year rollover carefully.) // Subtract one month. (Handle year rollover carefully.)
@@ -920,8 +917,16 @@ impl Metar {
(current_month - 1, current_year) (current_month - 1, current_year)
}; };
let adjusted_date = NaiveDate::from_ymd_opt(year, month, observation_day) let adjusted_date =
.ok_or_else(|| Error::new(500, format!("Invalid date with day {} for month {}", observation_day, month)))?; NaiveDate::from_ymd_opt(year, month, observation_day).ok_or_else(|| {
Error::new(
500,
format!(
"Invalid date with day {} for month {}",
observation_day, month
),
)
})?;
adjusted_date.and_hms(observation_hour, observation_minute, 0) adjusted_date.and_hms(observation_hour, observation_minute, 0)
} else { } else {
candidate_date candidate_date
@@ -929,32 +934,8 @@ impl Metar {
Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string()) Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string())
} }
async fn get_missing_metar_icaos( async fn get_remote_metars(client: &Client, icaos: &Vec<String>) -> ApiResult<Vec<Metar>> {
db_metars: &Vec<Self>, let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
station_icaos: &Vec<String>,
) -> Vec<String> {
let mut missing_metar_icaos: Vec<String> = vec![];
let current_time = Utc::now().timestamp();
let db_metars_set: HashSet<&str> = db_metars.iter().map(|icao| icao.icao.as_str()).collect();
let station_icaos_set: HashSet<&str> = station_icaos.iter().map(|s| s.as_str()).collect();
for difference in db_metars_set.symmetric_difference(&station_icaos_set) {
missing_metar_icaos.push(difference.to_string());
}
let time_offset = env::var("API_METAR_TIME_OFFSET")
.unwrap_or("3000".to_string())
.parse::<i64>()
.unwrap_or(3000);
for metar in db_metars {
if current_time > (metar.observation_time.timestamp() + time_offset) {
log::trace!("{} METAR data is outdated", metar.icao);
missing_metar_icaos.push(metar.icao.to_string());
}
}
missing_metar_icaos
}
async fn get_remote_metars(client: &Client, icaos: &[&str]) -> ApiResult<Vec<Metar>> {
let base_url = std::env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set");
// Query the remote API for the missing METAR data 10 at a time // Query the remote API for the missing METAR data 10 at a time
let icao_chunks = icaos let icao_chunks = icaos
.chunks(10) .chunks(10)
@@ -962,7 +943,10 @@ impl Metar {
.collect::<Vec<String>>(); .collect::<Vec<String>>();
let mut metars: Vec<Metar> = vec![]; let mut metars: Vec<Metar> = vec![];
for icao_chunk in icao_chunks { for icao_chunk in icao_chunks {
let url = format!("{}/metar?ids={}&hours=0&order=id,-obs", base_url, icao_chunk); let url = format!(
"{}/metar?ids={}&hours=0&order=id,-obs",
base_url, icao_chunk
);
let mut m = match client.get(url).send().await { let mut m = match client.get(url).send().await {
Ok(r) => { Ok(r) => {
// Check if the status code is 200 // Check if the status code is 200
@@ -1012,14 +996,12 @@ impl Metar {
pub async fn find_all( pub async fn find_all(
client: &Client, client: &Client,
icao_list: &Vec<String>, icao_list: &Vec<String>,
force: &bool, _force: &bool,
) -> ApiResult<Vec<Self>> { ) -> ApiResult<Vec<Self>> {
if icao_list.is_empty() { if icao_list.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let mut metars: Vec<Metar> = vec![];
if !*force {
let pool = db::pool(); let pool = db::pool();
let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!( let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!(
r#" r#"
@@ -1032,80 +1014,114 @@ impl Metar {
.bind(icao_list) .bind(icao_list)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
metars = metar_rows
.into_iter() let current_time = Utc::now().timestamp();
.map(|m| Metar::from_db(m).unwrap()) let time_offset = env::var("API_METAR_TIME_OFFSET")
.collect(); .unwrap_or("3000".to_string())
.parse::<i64>()
.unwrap_or(3000);
let short_time_offset: i64 = 300;
// Setup metars and missing metar structures
let mut metars: Vec<Metar> = vec![];
let mut missing_metar_icaos: Vec<String> = vec![];
let mut found_metar_icaos: HashSet<String> = HashSet::new();
let mut requested_icaos: HashSet<String> = HashSet::from_iter(icao_list.clone());
// Iterate over returned database metars
for metar_row in metar_rows {
let icao = metar_row.icao.clone();
// Remove icao from requested icaos
requested_icaos.remove(&icao);
// Handle outdated metars
if current_time > (metar_row.observation_time.timestamp() + time_offset) {
let refresh_seconds = match MetarCheck::get(&icao).await {
Some(c) => current_time - c.updated_at.timestamp(),
None => short_time_offset,
};
// If the metar was cached more than short_time_offset minutes ago, refresh it
if refresh_seconds >= short_time_offset {
log::trace!("{} METAR data is outdated, refreshing now", &icao);
missing_metar_icaos.push(icao);
}
// Otherwise return outdated data and wait
else {
log::trace!(
"{} METAR data is outdated; refreshing in {} seconds",
&icao,
short_time_offset - refresh_seconds
);
metars.push(Metar::from_db(metar_row)?)
}
}
// Otherwise add the metar to the vector
else {
found_metar_icaos.insert(icao.clone());
let metar_check = MetarCheck::new(icao, true);
metar_check.insert(time_offset as u64).await?;
metars.push(Metar::from_db(metar_row)?);
}
} }
let mut conn = redis_async_connection().await?; // Add all metars that were not in the returned database metars
// Check for missing metars for icao in &requested_icaos {
let missing_icao_list = Self::get_missing_metar_icaos(&metars, icao_list).await; match MetarCheck::get(icao).await {
if !missing_icao_list.is_empty() { Some(c) => {
let mut updated_missing_icao_list: Vec<&str> = Vec::new(); if current_time > (c.updated_at.timestamp() + short_time_offset) {
for icao in &missing_icao_list { missing_metar_icaos.push(icao.to_string());
if *force {
updated_missing_icao_list.push(icao);
} else {
let result: RedisResult<Option<bool>> = conn.get(icao).await;
match result {
Ok(Some(value)) => {
if value {
updated_missing_icao_list.push(icao);
} }
} }
Ok(None) => updated_missing_icao_list.push(icao), None => {
Err(err) => { missing_metar_icaos.push(icao.to_string());
log::error!("{}", err);
return Err(err.into());
} }
} }
} }
}
if !updated_missing_icao_list.is_empty() { if !missing_metar_icaos.is_empty() {
log::trace!( log::trace!(
"Retrieving missing METAR data for {:?}", "Retrieving missing METAR data for {:?}",
updated_missing_icao_list missing_metar_icaos
); );
let mut missing_icao_list = Self::get_remote_metars(client, &updated_missing_icao_list) let mut remote_metars = Self::get_remote_metars(client, &missing_metar_icaos)
.await .await
.unwrap_or_else(|err| { .unwrap_or_else(|err| {
log::warn!("Unable to get remote METAR data; {}", err); log::warn!("Unable to get remote METAR data; {}", err);
vec![] vec![]
}); });
if missing_icao_list.len() > 0 { if remote_metars.len() > 0 {
// Insert missing METARs // Insert missing METARs
for missing_metar in &missing_icao_list { for remote_metar in &remote_metars {
let _: RedisResult<()> = conn.set(&missing_metar.icao, true).await; found_metar_icaos.insert(remote_metar.icao.to_string());
missing_metar.insert().await?; let metar_check = MetarCheck::new(remote_metar.icao.clone(), true);
metar_check.insert(time_offset as u64).await?;
remote_metar.insert().await?;
} }
metars.append(&mut missing_icao_list) metars.append(&mut remote_metars)
} }
// Invalidate the still missing icaos // Update still missing metars
let still_missing_icao_list = let mut still_missing_metar_icaos: Vec<String> = vec![];
Self::get_missing_metar_icaos(&missing_icao_list, icao_list).await; for difference in found_metar_icaos.symmetric_difference(&requested_icaos) {
if !still_missing_icao_list.is_empty() { still_missing_metar_icaos.push(difference.to_string());
for icao in still_missing_icao_list { let metar_check = MetarCheck::new(difference.to_string(), false);
// Skip values if they've been set to true in the past metar_check.insert(short_time_offset as u64).await?
let result: RedisResult<Option<bool>> = conn.get(&icao).await;
if let Ok(Some(v)) = result {
if v {
continue;
}
}
let _: RedisResult<()> = conn.set_ex(&icao, false, 3600).await;
}
}
} }
// if !still_missing_metar_icaos.is_empty() {
// log::trace!("Still missing METAR data from {:?}", still_missing_metar_icaos);
// }
} }
Ok(metars) Ok(metars)
} }
pub async fn insert(&self) -> ApiResult<()> { pub async fn insert(&self) -> ApiResult<()> {
log::trace!("Inserting metar {} with observation time {}", self.icao, self.observation_time); log::trace!(
"Inserting metar {} with observation time {}",
self.icao,
self.observation_time
);
let metar: MetarRow = self.to_db()?; let metar: MetarRow = self.to_db()?;
metar.insert().await?; metar.insert().await?;
Ok(()) Ok(())
@@ -1137,8 +1153,7 @@ mod tests {
); );
// Remove the trailing 'Z' and parse // Remove the trailing 'Z' and parse
let trimmed = &datetime_str[..19]; let trimmed = &datetime_str[..19];
NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").unwrap_or_else(|e| {
.unwrap_or_else(|e| {
panic!( panic!(
"Parsing '{}' from input {} failed: {}", "Parsing '{}' from input {} failed: {}",
trimmed, obs_time, e trimmed, obs_time, e

View File

@@ -5,11 +5,11 @@ meta {
} }
get { get {
url: {{API_URL}}/metars?icaos=KHEF,KJYO url: {{API_URL}}/metars?icaos=KIAD
body: none body: none
auth: none auth: none
} }
params:query { params:query {
icaos: KHEF,KJYO icaos: KIAD
} }

View File

@@ -36,8 +36,8 @@ export interface LayerInfo {
const layerMap: LayerInfo[] = [ const layerMap: LayerInfo[] = [
{ url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', name: 'Open Street Map', markerOutline: 'black' }, { 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/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'}, { 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 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 dark2Url = 'https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/tile/2/0/3.pbf';
const defaultZoom = 6; const defaultZoom = 6;
@@ -137,7 +137,7 @@ function App() {
function BaseLayerChangeHandler() { function BaseLayerChangeHandler() {
useMapEvents({ useMapEvents({
baselayerchange: (e) => { baselayerchange: (e) => {
const index = layerMap.findIndex(layer => layer.name === e.name); const index = layerMap.findIndex((layer) => layer.name === e.name);
setSelectedLayerIndex(`${index}`); setSelectedLayerIndex(`${index}`);
Cookies.set('selectedLayer', `${index}`, { expires: 7 }); Cookies.set('selectedLayer', `${index}`, { expires: 7 });
setSelectedLayer(layerMap[index]); setSelectedLayer(layerMap[index]);

View File

@@ -1,7 +1,7 @@
import { Badge, Box, Divider, Drawer, Group, Tabs, TabsList, Text, Tooltip } from '@mantine/core'; import { Badge, Box, Divider, Drawer, Group, Tabs, TabsList, Text, Tooltip } from '@mantine/core';
import { Airport, AirportCategory } from '@lib/airport.types.ts'; import { Airport, AirportCategory } from '@lib/airport.types.ts';
import { getMarkerColor, Metar } from '@lib/metar.types.ts'; import { getMarkerColor, Metar } from '@lib/metar.types.ts';
import { forwardRef, useEffect, useState } from 'react'; import { forwardRef, ReactNode, useEffect, useState } from 'react';
import { getMetars } from '@lib/metar.ts'; import { getMetars } from '@lib/metar.ts';
import { useMediaQuery } from '@mantine/hooks'; import { useMediaQuery } from '@mantine/hooks';
@@ -56,7 +56,9 @@ export default function AirportDrawer({
> >
<Drawer.Content> <Drawer.Content>
<Drawer.Header> <Drawer.Header>
<Drawer.Title><Text size={'xl'}>{airport.name}</Text></Drawer.Title> <Drawer.Title>
<Text size={'xl'}>{airport.name}</Text>
</Drawer.Title>
<Drawer.CloseButton /> <Drawer.CloseButton />
</Drawer.Header> </Drawer.Header>
<Drawer.Body> <Drawer.Body>
@@ -72,7 +74,7 @@ export default function AirportDrawer({
padding: '10px' padding: '10px'
}} }}
> >
<Badge size="lg" color={metarColor}> <Badge size='lg' color={metarColor}>
{metar.flight_category} {metar.flight_category}
</Badge> </Badge>
{/*<Text style={{ color: metarColor }}>{metar.flight_category}</Text>*/} {/*<Text style={{ color: metarColor }}>{metar.flight_category}</Text>*/}
@@ -86,10 +88,12 @@ export default function AirportDrawer({
<Tabs.Tab value={'info'}>Info</Tabs.Tab> <Tabs.Tab value={'info'}>Info</Tabs.Tab>
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab> <Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
</TabsList> </TabsList>
<Tabs.Panel value={'info'}><AirportInfo airport={airport}/></Tabs.Panel> <Tabs.Panel value={'info'}>
{airport.latest_metar && ( <AirportInfo airport={airport} />
<Tabs.Panel value={'weather'}><WeatherInfo metar={airport.latest_metar} /></Tabs.Panel> </Tabs.Panel>
)} <Tabs.Panel value={'weather'}>
<WeatherInfo metar={airport.latest_metar} />
</Tabs.Panel>
</Tabs> </Tabs>
</Box> </Box>
</Drawer.Body> </Drawer.Body>
@@ -98,53 +102,62 @@ export default function AirportDrawer({
); );
} }
function AirportInfo({ airport }: { airport: Airport }) { function AirportInfoSlot({ title, value, units }: { title: string; value: string | number; units?: string }) {
return (<div> return (
<div style={{
display: 'flex',
justifyContent: 'space-between',
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-lg)',
borderTop: '1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))'
}}>
<div> <div>
<Text size="xs" color="dimmed"> <Text size='xs' color='dimmed'>
ICAO {title}
</Text> </Text>
<Text fw={500} size="sm"> <Text fw={500} size='sm'>
{airport.icao} {value}
{units}
</Text> </Text>
</div> </div>
<div> );
<Text size="xs" color="dimmed">
IATA
</Text>
<Text fw={500} size="sm">
{airport.iata}
</Text>
</div>
<div>
<Text size="xs" color="dimmed">
Local
</Text>
<Text fw={500} size="sm">
{airport.local}
</Text>
</div>
<div>
<Text size="xs" color="dimmed">
Category
</Text>
<Text fw={500} size="sm">
{airportCategoryToText(airport.category)}
</Text>
</div>
</div>
<Divider />
</div>);
} }
function WeatherInfo({ metar }: { metar: Metar }) { function AirportInfoRow({ children }: { children: ReactNode }) {
return <>{metar.raw_text}</> 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))'
}}
>
{children}
</div>
);
}
function AirportInfo({ airport }: { airport: Airport }) {
return (
<div>
<AirportInfoRow>
<AirportInfoSlot title={'ICAO'} value={airport.icao} />
<AirportInfoSlot title={'IATA'} value={airport.iata} />
<AirportInfoSlot title={'LOCAL'} value={airport.local} />
<AirportInfoSlot title={'Category'} value={airportCategoryToText(airport.category)} />
</AirportInfoRow>
<AirportInfoRow>
<AirportInfoSlot title={'Latitude'} value={airport.latitude} units={'°'} />
<AirportInfoSlot title={'Longitude'} value={airport.longitude} units={'°'} />
<AirportInfoSlot title={'Elevation'} value={airport.elevation_ft} units={' ft'} />
Zoom To
</AirportInfoRow>
<Divider />
</div>
);
}
function WeatherInfo({ metar }: { metar?: Metar }) {
if (metar) {
return <>{metar.raw_text}</>;
} else {
return <>No METAR observation available</>;
}
} }
function airportCategoryToText(category: AirportCategory): string { function airportCategoryToText(category: AirportCategory): string {
@@ -168,20 +181,26 @@ function airportCategoryToText(category: AirportCategory): string {
} }
} }
const TimeSince = forwardRef<HTMLParagraphElement, { date: string }>( const TimeSince = forwardRef<HTMLParagraphElement, { date: string }>(({ date }, ref) => {
({ date }, ref) => {
const inputDate = new Date(date); const inputDate = new Date(date);
// @ts-expect-error doing arithmetic with dates // @ts-expect-error doing arithmetic with dates
const seconds = Math.floor((new Date() - inputDate) / 1000); const seconds = Math.floor((new Date() - inputDate) / 1000);
if (seconds < 60) { if (seconds < 60) {
const content = seconds + (seconds === 1 ? " second ago" : " seconds ago"); const content = seconds + (seconds === 1 ? ' second ago' : ' seconds ago');
return <Text ref={ref} style={{ userSelect: 'none' }} >{content}</Text>; return (
<Text ref={ref} style={{ userSelect: 'none' }}>
{content}
</Text>
);
} else { } else {
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const content = minutes + (minutes === 1 ? " minute ago" : " minutes ago"); const content = minutes + (minutes === 1 ? ' minute ago' : ' minutes ago');
// If more than 60 minutes have passed, set the text color to yellow // 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>; return (
} <Text ref={ref} style={{ color: minutes >= 60 ? '#fca903' : undefined, userSelect: 'none' }}>
} {content}
</Text>
); );
}
});

View File

@@ -9,7 +9,7 @@ export default function AirportMarker({
index, index,
airport, airport,
setAirport, setAirport,
selectedLayer, selectedLayer
}: { }: {
index: number; index: number;
airport: Airport; airport: Airport;
@@ -28,7 +28,7 @@ export default function AirportMarker({
eventHandlers={{ eventHandlers={{
click: () => setAirport(airport), click: () => setAirport(airport),
mouseover: () => markerRef.current?.openPopup(), mouseover: () => markerRef.current?.openPopup(),
mouseout: () => markerRef.current?.closePopup(), mouseout: () => markerRef.current?.closePopup()
}} }}
> >
<Popup closeButton={false} autoPan={false} interactive={false}> <Popup closeButton={false} autoPan={false} interactive={false}>

View File

@@ -155,9 +155,7 @@ export function Header() {
)} )}
</Group> </Group>
)} )}
{isMobile && ( {isMobile && <Burger opened={opened} onClick={toggle} size='sm' />}
<Burger opened={opened} onClick={toggle} size='sm' />
)}
</Group> </Group>
</header> </header>
</Box> </Box>