Working on metars, updating ui drawer
This commit is contained in:
16
README.md
16
README.md
@@ -6,9 +6,12 @@
|
|||||||
## Makefile
|
## Makefile
|
||||||
* `make` or `make help` to list all commands
|
* `make` or `make help` to list all commands
|
||||||
* `make docker-up` to start all containers
|
* `make docker-up` to start all containers
|
||||||
|
* `make docker-refresh` to start the background services
|
||||||
* `make docker-clean` to stop and delete all containers, volumes, and networks related
|
* `make docker-clean` to stop and delete all containers, volumes, and networks related
|
||||||
to the application
|
to the application
|
||||||
|
|
||||||
|
**WARNING**: Running `make docker-clean` or `make docker-refresh` will wipe the database, redis, and minio data
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Override any environment variables in `.env.local`
|
1. Override any environment variables in `.env.local`
|
||||||
@@ -20,7 +23,16 @@ to the application
|
|||||||
* Running just `make cert` will generate `localhost` certificates
|
* Running just `make cert` will generate `localhost` certificates
|
||||||
4. Run the application with `make up`
|
4. Run the application with `make up`
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
Start background services with `make docker-refresh`
|
||||||
|
* Note: when `ENVIRONMENT` is not set to `production` (i.e., set to `development`),
|
||||||
|
the nginx container will function only as a reverse proxy - the UI must be run through `make run-ui`
|
||||||
|
|
||||||
|
Start the UI through `make run-ui` and the API through `make run-api`
|
||||||
|
|
||||||
### Production Environment
|
### Production Environment
|
||||||
|
Start with `make docker-up`
|
||||||
|
|
||||||
The most likely to change environment variables are the following:
|
The most likely to change environment variables are the following:
|
||||||
* `UI_PORT`
|
* `UI_PORT`
|
||||||
* `API_PORT`
|
* `API_PORT`
|
||||||
@@ -71,3 +83,7 @@ The following resources were used to help decode METARS.
|
|||||||
|
|
||||||
### Other data
|
### Other data
|
||||||
- https://www.faa.gov/air_traffic/weather/asos
|
- https://www.faa.gov/air_traffic/weather/asos
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
`cargo test metars::model::tests::test_parse_time -- --exact --nocapture
|
||||||
|
`
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ async fn validate_session(req: HttpRequest) -> HttpResponse {
|
|||||||
let session_id = cookie.value().to_string();
|
let session_id = cookie.value().to_string();
|
||||||
let session = match Session::replace(&session_id, &ip_address).await {
|
let session = match Session::replace(&session_id, &ip_address).await {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(err) => {
|
Err(_) => {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Invalid session validate attempt [Session: {}] [IP Address: {}]",
|
"Invalid session validate attempt [Session: {}] [IP Address: {}]",
|
||||||
session_id,
|
session_id,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::error::ApiResult;
|
use crate::error::ApiResult;
|
||||||
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult};
|
use redis::{
|
||||||
|
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;
|
||||||
@@ -129,10 +131,10 @@ fn redis() -> &'static RedisClient {
|
|||||||
REDIS.get().unwrap()
|
REDIS.get().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn redis_connection() -> RedisResult<redis::Connection> {
|
// pub fn redis_connection() -> RedisResult<redis::Connection> {
|
||||||
let conn = redis().get_connection()?;
|
// let conn = redis().get_connection()?;
|
||||||
Ok(conn)
|
// Ok(conn)
|
||||||
}
|
// }
|
||||||
|
|
||||||
pub async fn redis_async_connection() -> RedisResult<RedisConnection> {
|
pub async fn redis_async_connection() -> RedisResult<RedisConnection> {
|
||||||
let conn = redis().get_multiplexed_async_connection().await?;
|
let conn = redis().get_multiplexed_async_connection().await?;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::{error::ApiResult, db};
|
use crate::{error::ApiResult, db};
|
||||||
use chrono::{DateTime, Datelike, Utc};
|
use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
@@ -338,49 +338,7 @@ impl Metar {
|
|||||||
// Date/Time
|
// Date/Time
|
||||||
let observation_time = metar_parts[0];
|
let observation_time = metar_parts[0];
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
if observation_time.len() != 7 {
|
let observation_time = Self::parse_time(observation_time)?;
|
||||||
return Err(Error::new(
|
|
||||||
500,
|
|
||||||
format!(
|
|
||||||
"Unable to parse observation time in {}: {}",
|
|
||||||
observation_time, metar_string
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let observation_time_day = match observation_time[0..2].parse::<u32>() {
|
|
||||||
Ok(day) => day,
|
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
};
|
|
||||||
let observation_time_hour = match observation_time[2..4].parse::<u32>() {
|
|
||||||
Ok(hour) => hour,
|
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
};
|
|
||||||
let observation_time_minute = match observation_time[4..6].parse::<u32>() {
|
|
||||||
Ok(minute) => minute,
|
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
};
|
|
||||||
let current_time = Utc::now().naive_utc();
|
|
||||||
|
|
||||||
// Check if the observation time is from the previous month
|
|
||||||
let observation_time_month = if current_time.day() > observation_time_day {
|
|
||||||
current_time.month() - 1
|
|
||||||
} else {
|
|
||||||
current_time.month()
|
|
||||||
};
|
|
||||||
// Check if the observation time is from the previous year
|
|
||||||
let observation_time_year = if current_time.month() > observation_time_month {
|
|
||||||
current_time.year() - 1
|
|
||||||
} else {
|
|
||||||
current_time.year()
|
|
||||||
};
|
|
||||||
let observation_time = format!(
|
|
||||||
"{:04}-{:02}-{:02}T{:02}:{:02}:00Z",
|
|
||||||
observation_time_year,
|
|
||||||
observation_time_month,
|
|
||||||
observation_time_day,
|
|
||||||
observation_time_hour,
|
|
||||||
observation_time_minute
|
|
||||||
);
|
|
||||||
metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) {
|
metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) {
|
||||||
Ok(datetime) => datetime.with_timezone(&Utc),
|
Ok(datetime) => datetime.with_timezone(&Utc),
|
||||||
Err(err) => return Err(err.into()),
|
Err(err) => return Err(err.into()),
|
||||||
@@ -928,6 +886,49 @@ impl Metar {
|
|||||||
Ok(metar)
|
Ok(metar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_time(observation_time: &str) -> ApiResult<String> {
|
||||||
|
if observation_time.len() != 7 {
|
||||||
|
return Err(Error::new(
|
||||||
|
500,
|
||||||
|
format!("Unable to parse observation time in {}", observation_time),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let observation_day = match observation_time[0..2].parse::<u32>() {
|
||||||
|
Ok(day) => day,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let observation_hour = match observation_time[2..4].parse::<u32>() {
|
||||||
|
Ok(hour) => hour,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let observation_minute = match observation_time[4..6].parse::<u32>() {
|
||||||
|
Ok(minute) => minute,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
let current_time = Utc::now().naive_utc();
|
||||||
|
let current_year = current_time.year();
|
||||||
|
let current_month = current_time.month();
|
||||||
|
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)))?
|
||||||
|
.and_hms_opt(observation_hour, observation_minute, 0).unwrap();
|
||||||
|
|
||||||
|
let obs_datetime = if candidate_date > current_time {
|
||||||
|
// Subtract one month. (Handle year rollover carefully.)
|
||||||
|
let (month, year) = if current_month == 1 {
|
||||||
|
(12, current_year - 1)
|
||||||
|
} else {
|
||||||
|
(current_month - 1, current_year)
|
||||||
|
};
|
||||||
|
|
||||||
|
let adjusted_date = 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)
|
||||||
|
} else {
|
||||||
|
candidate_date
|
||||||
|
};
|
||||||
|
Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_missing_metar_icaos(
|
async fn get_missing_metar_icaos(
|
||||||
db_metars: &Vec<Self>,
|
db_metars: &Vec<Self>,
|
||||||
station_icaos: &Vec<String>,
|
station_icaos: &Vec<String>,
|
||||||
@@ -961,7 +962,7 @@ 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={}&order=id", 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
|
||||||
@@ -1049,7 +1050,6 @@ impl Metar {
|
|||||||
let result: RedisResult<Option<bool>> = conn.get(icao).await;
|
let result: RedisResult<Option<bool>> = conn.get(icao).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(Some(value)) => {
|
Ok(Some(value)) => {
|
||||||
log::info!("{}: {}", icao, value);
|
|
||||||
if value {
|
if value {
|
||||||
updated_missing_icao_list.push(icao);
|
updated_missing_icao_list.push(icao);
|
||||||
}
|
}
|
||||||
@@ -1105,6 +1105,7 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert(&self) -> ApiResult<()> {
|
pub async fn insert(&self) -> ApiResult<()> {
|
||||||
|
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(())
|
||||||
@@ -1113,26 +1114,68 @@ impl Metar {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_metar() {
|
fn test_parse_time() {
|
||||||
|
for day in 1..=31 {
|
||||||
|
for hour in 0..24 {
|
||||||
|
for minute in 0..60 {
|
||||||
|
// METAR form "DDHHMMZ"
|
||||||
|
let obs_time = format!("{:02}{:02}{:02}Z", day, hour, minute);
|
||||||
|
let result = Metar::parse_time(&obs_time);
|
||||||
|
match result {
|
||||||
|
Ok(datetime_str) => {
|
||||||
|
// "YYYY-MM-DDTHH:MM:00Z"
|
||||||
|
assert_eq!(
|
||||||
|
datetime_str.len(),
|
||||||
|
20,
|
||||||
|
"Unexpected length for input {} yielded {}",
|
||||||
|
obs_time,
|
||||||
|
datetime_str
|
||||||
|
);
|
||||||
|
// Remove the trailing 'Z' and parse
|
||||||
|
let trimmed = &datetime_str[..19];
|
||||||
|
NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"Parsing '{}' from input {} failed: {}",
|
||||||
|
trimmed, obs_time, e
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_err) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_metar() {
|
||||||
let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 06/04 A2990
|
let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 06/04 A2990
|
||||||
RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR
|
RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR
|
||||||
SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string();
|
SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string();
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
// dbg!(&metar);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 SLP126 T02500217 $".to_string();
|
metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 SLP126 T02500217 $".to_string();
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
// dbg!(&metar);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
metar_string =
|
metar_string =
|
||||||
"KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117"
|
"KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117"
|
||||||
.to_string();
|
.to_string();
|
||||||
let metar = Metar::parse(&metar_string).unwrap();
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
// dbg!(&metar);
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
// metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string();
|
metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string();
|
||||||
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
|
dbg!(&metar.observation_time);
|
||||||
|
|
||||||
|
metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 SLP090 P0001 60004 T00001017 10000 21011 53026".to_string();
|
||||||
|
let metar = Metar::parse(&metar_string).unwrap();
|
||||||
|
dbg!(&metar.observation_time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ meta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
url: {{API_URL}}/metars?icaos=KJYO,KOKV,KMRB,KHEF,KIAD&force=true
|
url: {{API_URL}}/metars?icaos=KHEF,KJYO
|
||||||
body: none
|
body: none
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
|
|
||||||
params:query {
|
params:query {
|
||||||
icaos: KJYO,KOKV,KMRB,KHEF,KIAD
|
icaos: KHEF,KJYO
|
||||||
force: true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,17 @@ L.Icon.Default.mergeOptions({
|
|||||||
shadowUrl: markerShadow
|
shadowUrl: markerShadow
|
||||||
});
|
});
|
||||||
|
|
||||||
const openStreetMapUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
export interface LayerInfo {
|
||||||
const lightLayerUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
|
url: string;
|
||||||
const darkLayerUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
|
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 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;
|
||||||
@@ -38,7 +46,10 @@ function App() {
|
|||||||
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
|
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
|
||||||
const initialRadarValue = Cookies.get('showRadar') === 'true';
|
const initialRadarValue = Cookies.get('showRadar') === 'true';
|
||||||
const [showRadar, setShowRadar] = useState<boolean>(initialRadarValue);
|
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(() => {
|
useEffect(() => {
|
||||||
if (showRadar) {
|
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() {
|
function BaseLayerChangeHandler() {
|
||||||
useMapEvents({
|
useMapEvents({
|
||||||
baselayerchange: (e) => {
|
baselayerchange: (e) => {
|
||||||
setBaseLayer(e.name);
|
const index = layerMap.findIndex(layer => layer.name === e.name);
|
||||||
Cookies.set('selectedBaseLayer', e.name, { expires: 7 });
|
setSelectedLayerIndex(`${index}`);
|
||||||
|
Cookies.set('selectedLayer', `${index}`, { expires: 7 });
|
||||||
|
setSelectedLayer(layerMap[index]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
@@ -86,21 +107,24 @@ function App() {
|
|||||||
zoomControl={false}
|
zoomControl={false}
|
||||||
>
|
>
|
||||||
<LayersControl>
|
<LayersControl>
|
||||||
<LayersControl.BaseLayer checked={baseLayer === 'Open Street Map'} name={'Open Street Map'}>
|
{layerMap.map((layer, index) => (
|
||||||
<TileLayer url={openStreetMapUrl} />
|
<LayersControl.BaseLayer key={index} checked={selectedLayerIndex === `${index}`} name={layer.name}>
|
||||||
</LayersControl.BaseLayer>
|
<TileLayer url={layer.url} />
|
||||||
<LayersControl.BaseLayer checked={baseLayer === 'Carto Light'} name={'Carto Light'}>
|
</LayersControl.BaseLayer>
|
||||||
<TileLayer url={lightLayerUrl} />
|
))}
|
||||||
</LayersControl.BaseLayer>
|
|
||||||
<LayersControl.BaseLayer checked={baseLayer === 'Carto Dark'} name={'Carto Dark'}>
|
|
||||||
<TileLayer url={darkLayerUrl} />
|
|
||||||
</LayersControl.BaseLayer>
|
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
|
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
|
||||||
<ZoomControl position={'bottomright'} />
|
<ZoomControl position={'bottomright'} />
|
||||||
<AirportLayer setAirport={setAirport} />
|
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
|
||||||
<BaseLayerChangeHandler />
|
<BaseLayerChangeHandler />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={toggleShowNoMetar}
|
||||||
|
style={{ bottom: '120px' }}
|
||||||
|
className={`map-button ${showNoMetar ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
U
|
||||||
|
</UnstyledButton>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={toggleRadar}
|
onClick={toggleRadar}
|
||||||
style={{ bottom: '80px' }}
|
style={{ bottom: '80px' }}
|
||||||
|
|||||||
@@ -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 { Airport, AirportCategory } from '@lib/airport.types.ts';
|
||||||
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
|
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getMetars } from '@lib/metar.ts';
|
import { getMetars } from '@lib/metar.ts';
|
||||||
|
import { useMediaQuery } from '@mantine/hooks';
|
||||||
|
|
||||||
export default function AirportDrawer({
|
export default function AirportDrawer({
|
||||||
airport,
|
airport,
|
||||||
@@ -12,15 +13,27 @@ export default function AirportDrawer({
|
|||||||
setAirport: (airport: Airport | null) => void;
|
setAirport: (airport: Airport | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [metar, setMetar] = useState<Metar | undefined>(undefined);
|
const [metar, setMetar] = useState<Metar | undefined>(undefined);
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (airport != null) {
|
if (!airport) return;
|
||||||
|
function updateMetar() {
|
||||||
|
if (!airport) return;
|
||||||
|
console.log(airport.icao);
|
||||||
getMetars({ icaos: [airport.icao] }).then((m) => {
|
getMetars({ icaos: [airport.icao] }).then((m) => {
|
||||||
if (m.length > 0) {
|
if (m.length > 0) {
|
||||||
setMetar(m[0]);
|
setMetar(m[0]);
|
||||||
|
} else {
|
||||||
|
setMetar(undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateMetar();
|
||||||
|
|
||||||
|
const interval = setInterval(updateMetar, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
}, [airport]);
|
}, [airport]);
|
||||||
|
|
||||||
if (!airport) {
|
if (!airport) {
|
||||||
@@ -35,47 +48,58 @@ export default function AirportDrawer({
|
|||||||
onClose={() => setAirport(null)}
|
onClose={() => setAirport(null)}
|
||||||
title={airport.name}
|
title={airport.name}
|
||||||
withinPortal
|
withinPortal
|
||||||
zIndex={10000}
|
zIndex={1000}
|
||||||
styles={{ root: { width: 0, height: 0 } }}
|
styles={{ root: { padding: 0, margin: 0, width: 0, height: 0 } }}
|
||||||
padding='md'
|
padding='md'
|
||||||
size='md'
|
size={isMobile ? '100%' : 'md'}
|
||||||
position='left'
|
position='left'
|
||||||
withOverlay={false}
|
withOverlay={false}
|
||||||
closeOnClickOutside={false}
|
closeOnClickOutside={false}
|
||||||
>
|
>
|
||||||
<Box mb='lg'>
|
<Box mb='lg'>
|
||||||
{metar && metar.flight_category && (
|
{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 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>
|
||||||
)}
|
)}
|
||||||
<Group>
|
<Tabs variant={'outline'} defaultValue={'info'}>
|
||||||
<div>ICAO: {airport.icao}</div>
|
<TabsList grow>
|
||||||
<div>Category: {airportCategoryToText(airport.category)}</div>
|
<Tabs.Tab value={'info'}>Info</Tabs.Tab>
|
||||||
<div>
|
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
|
||||||
Country / Region: {airport.iso_country}, {airport.iso_region}
|
</TabsList>
|
||||||
</div>
|
<Tabs.Panel value={'info'}><AirportInfo airport={airport}/></Tabs.Panel>
|
||||||
<div>Municipality: {airport.municipality || 'N/A'}</div>
|
{airport.latest_metar && (
|
||||||
<div>Local Code: {airport.local || 'N/A'}</div>
|
<Tabs.Panel value={'weather'}><WeatherInfo metar={airport.latest_metar} /></Tabs.Panel>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</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 {
|
function airportCategoryToText(category: AirportCategory): string {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case AirportCategory.SMALL:
|
case AirportCategory.SMALL:
|
||||||
@@ -96,3 +120,19 @@ function airportCategoryToText(category: AirportCategory): string {
|
|||||||
return 'Unknown';
|
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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,13 +4,22 @@ import { useMapEvents } from 'react-leaflet';
|
|||||||
import { getAirports } from '@lib/airport.ts';
|
import { getAirports } from '@lib/airport.ts';
|
||||||
import AirportMarker from '@components/AirportMarker.tsx';
|
import AirportMarker from '@components/AirportMarker.tsx';
|
||||||
import { LeafletEvent } from 'leaflet';
|
import { LeafletEvent } from 'leaflet';
|
||||||
|
import { LayerInfo } from '@/App.tsx';
|
||||||
|
|
||||||
interface Bounds {
|
interface Bounds {
|
||||||
northEast: { lat: number; lon: number };
|
northEast: { lat: number; lon: number };
|
||||||
southWest: { 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[]>([]);
|
const [airports, setAirports] = useState<Airport[]>([]);
|
||||||
|
|
||||||
function loadAirports(event: LeafletEvent) {
|
function loadAirports(event: LeafletEvent) {
|
||||||
@@ -52,40 +61,44 @@ export default function AirportLayer({ setAirport }: { setAirport: (airport: Air
|
|||||||
}
|
}
|
||||||
}, [map]);
|
}, [map]);
|
||||||
|
|
||||||
const categoryOrder: { [key in AirportCategory]?: number } = {
|
// const categoryOrder: { [key in AirportCategory]?: number } = {
|
||||||
[AirportCategory.LARGE]: 3,
|
// [AirportCategory.LARGE]: 3,
|
||||||
[AirportCategory.MEDIUM]: 2,
|
// [AirportCategory.MEDIUM]: 2,
|
||||||
[AirportCategory.SMALL]: 1,
|
// [AirportCategory.SMALL]: 1,
|
||||||
[AirportCategory.HELIPORT]: 0
|
// [AirportCategory.HELIPORT]: 0
|
||||||
};
|
// };
|
||||||
|
|
||||||
const sortedAirports = airports.slice().sort((a, b) => {
|
// const sortedAirports = airports.slice().sort((a, b) => {
|
||||||
// Compare by airport category first.
|
// // Compare by airport category first.
|
||||||
const categoryA = categoryOrder[a.category] ?? 4;
|
// const categoryA = categoryOrder[a.category] ?? 4;
|
||||||
const categoryB = categoryOrder[b.category] ?? 4;
|
// const categoryB = categoryOrder[b.category] ?? 4;
|
||||||
if (categoryA !== categoryB) {
|
// if (categoryA !== categoryB) {
|
||||||
return categoryA - categoryB;
|
// return categoryA - categoryB;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// Then compare by flight category if available.
|
// // Then compare by flight category if available.
|
||||||
// Assuming that latest_metar.flight_category is a string and "UNKN" needs to come last.
|
// // Assuming that latest_metar.flight_category is a string and "UNKN" needs to come last.
|
||||||
const fcA = a.latest_metar?.flight_category ?? 'UNKN';
|
// const fcA = a.latest_metar?.flight_category ?? 'UNKN';
|
||||||
const fcB = b.latest_metar?.flight_category ?? 'UNKN';
|
// const fcB = b.latest_metar?.flight_category ?? 'UNKN';
|
||||||
|
//
|
||||||
if (fcA === 'UNKN' && fcB !== 'UNKN') return 1;
|
// if (fcA === 'UNKN' && fcB !== 'UNKN') return 1;
|
||||||
if (fcB === 'UNKN' && fcA !== 'UNKN') return -1;
|
// if (fcB === 'UNKN' && fcA !== 'UNKN') return -1;
|
||||||
|
//
|
||||||
// If both flight categories are not "UNKN", do a simple alphabetical comparison.
|
// // 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.)
|
// // (You may wish to customize this logic based on the actual flight category values.)
|
||||||
if (fcA < fcB) return -1;
|
// if (fcA < fcB) return -1;
|
||||||
if (fcA > fcB) return 1;
|
// if (fcA > fcB) return 1;
|
||||||
return 0;
|
// return 0;
|
||||||
});
|
// });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedAirports.map((airport, index) => (
|
{airports.map((airport, index) => (
|
||||||
<AirportMarker key={index} airport={airport} index={index} setAirport={setAirport} />
|
<div key={index}>
|
||||||
|
{(showNoMetar || airport.latest_metar != undefined) && (
|
||||||
|
<AirportMarker airport={airport} index={index} setAirport={setAirport} selectedLayer={selectedLayer} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import { Marker, Popup } from 'react-leaflet';
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { getMarkerColor } from '@lib/metar.types.ts';
|
import { getMarkerColor } from '@lib/metar.types.ts';
|
||||||
|
import { LayerInfo } from '@/App.tsx';
|
||||||
|
|
||||||
export default function AirportMarker({
|
export default function AirportMarker({
|
||||||
index,
|
index,
|
||||||
airport,
|
airport,
|
||||||
setAirport
|
setAirport,
|
||||||
|
selectedLayer,
|
||||||
}: {
|
}: {
|
||||||
index: number;
|
index: number;
|
||||||
airport: Airport;
|
airport: Airport;
|
||||||
setAirport: (airport: Airport) => void;
|
setAirport: (airport: Airport) => void;
|
||||||
|
selectedLayer: LayerInfo;
|
||||||
}) {
|
}) {
|
||||||
const icon = createCustomIcon(airport);
|
const icon = createCustomIcon(airport, selectedLayer);
|
||||||
const markerRef = useRef<L.Marker>(null);
|
const markerRef = useRef<L.Marker>(null);
|
||||||
|
|
||||||
return (
|
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) {
|
if (airport.category === AirportCategory.HELIPORT) {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
html: `
|
html: `
|
||||||
@@ -44,7 +47,7 @@ function createCustomIcon(airport: Airport): L.DivIcon {
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid black;
|
border: 2px solid black;
|
||||||
background-color: white;
|
background-color: ${selectedLayer.markerOutline};
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -83,7 +86,7 @@ function createCustomIcon(airport: Airport): L.DivIcon {
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid #fff;
|
border: 2px solid ${selectedLayer.markerOutline};
|
||||||
z-index: {info[1]}">
|
z-index: {info[1]}">
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|||||||
Reference in New Issue
Block a user