Updated metar checking
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
57
api/src/metars/metar_check.rs
Normal file
57
api/src/metars/metar_check.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user