Updated airport data format, dataset, and queries

This commit is contained in:
2023-12-18 23:21:36 -05:00
parent 8afc98ed33
commit 97be61e297
19 changed files with 306 additions and 225 deletions

View File

@@ -16,5 +16,7 @@ The following resources were used to help decode METARS.
- [Metar Decode (NPS EDU)](https://met.nps.edu/~bcreasey/mr3222/files/helpful/DecodeMETAR-TAF.html) - [Metar Decode (NPS EDU)](https://met.nps.edu/~bcreasey/mr3222/files/helpful/DecodeMETAR-TAF.html)
- [Weather Phenomena](http://www.moratech.com/aviation/metar-class/metar-pg9-ww.html) - [Weather Phenomena](http://www.moratech.com/aviation/metar-class/metar-pg9-ww.html)
- Airport dataset is based on [mborsetti/airportsdata](https://github.com/mborsetti/airportsdata)
## OpenMapTiles ## OpenMapTiles
[Generate Vector Tiles](https://openmaptiles.org/docs/generate/generate-openmaptiles/) [Generate Vector Tiles](https://openmaptiles.org/docs/generate/generate-openmaptiles/)

63
data/airports.py Normal file
View File

@@ -0,0 +1,63 @@
import pandas as pd
from datetime import date
import json
# Load the airports.csv file from the web
url = 'https://davidmegginson.github.io/ourairports-data/airports.csv'
df = pd.read_csv(url, index_col=0)
# convert the dataframe to a dictionary
airports = df.to_dict('index')
formated_airports = {}
for airport in airports:
category = airports[airport]['type']
if pd.isnull(category) or category == 'nan' or category == 'NAN':
category = 'UNKNOWN'
elevation = airports[airport]['elevation_ft']
if pd.isnull(elevation) or elevation == 'nan' or elevation == 'NAN':
elevation = 0
country = airports[airport]['iso_country']
if pd.isnull(country) or country == 'nan' or country == 'NAN':
country = 'UNKNOWN'
region = airports[airport]['iso_region']
if pd.isnull(region) or region == 'nan' or region == 'NAN':
region = 'UNKNOWN'
municipality = airports[airport]['municipality']
if pd.isnull(municipality) or municipality == 'nan' or municipality == 'NAN':
municipality = 'UNKNOWN'
iata = airports[airport]['iata_code']
if pd.isnull(iata) or iata == 'nan' or iata == 'NAN':
iata = ''
local = airports[airport]['local_code']
if pd.isnull(local) or local == 'nan' or local == 'NAN':
local = ''
formated_airports[airport] = {
'icao': airports[airport]['ident'],
'category': category,
'name': airports[airport]['name'],
'elevation_ft': elevation,
'iso_country': country,
'iso_region': region,
'municipality': municipality,
'iata_code': iata,
'local_code': local,
'latitude': airports[airport]['latitude_deg'],
'longitude': airports[airport]['longitude_deg']
}
# convert the dictionary to a list of dictionaries
formated_airports = list(formated_airports.values())
# convert the list of dictionaries to a json file
today = date.today()
date = today.strftime("%Y-%m-%d")
with open(f'airports_{date}.json', 'wb') as file:
file.write(json.dumps(formated_airports).encode('utf-8'))

File diff suppressed because one or more lines are too long

0
data/requirements.txt Normal file
View File

View File

@@ -2,11 +2,11 @@ CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS airports ( CREATE TABLE IF NOT EXISTS airports (
icao TEXT PRIMARY KEY NOT NULL, icao TEXT PRIMARY KEY NOT NULL,
category TEXT NOT NULL, category TEXT NOT NULL,
full_name TEXT NOT NULL, name TEXT NOT NULL,
elevation_ft REAL NOT NULL,
iso_country TEXT NOT NULL, iso_country TEXT NOT NULL,
iso_region TEXT NOT NULL, iso_region TEXT NOT NULL,
municipality TEXT NOT NULL, municipality TEXT NOT NULL,
gps_code TEXT NOT NULL,
iata_code TEXT NOT NULL, iata_code TEXT NOT NULL,
local_code TEXT NOT NULL, local_code TEXT NOT NULL,
point GEOMETRY(POINT,4326) NOT NULL, point GEOMETRY(POINT,4326) NOT NULL,

View File

@@ -4,24 +4,24 @@ use crate::db;
use crate::error_handler::ServiceError; use crate::error_handler::ServiceError;
use crate::db::schema::airports; use crate::db::schema::airports;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sql_query;
use log::error; use log::error;
use postgis_diesel::types::*; use postgis_diesel::types::*;
use postgis_diesel::functions::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct Airport { pub struct Airport {
pub icao: String, pub icao: String,
pub category: String, pub category: String,
pub full_name: String, pub name: String,
pub elevation_ft: Option<i32>, pub elevation_ft: f32,
pub iso_country: String, pub iso_country: String,
pub iso_region: String, pub iso_region: String,
pub municipality: String, pub municipality: String,
pub gps_code: String,
pub iata_code: String, pub iata_code: String,
pub local_code: String, pub local_code: String,
pub point: Point, pub latitude: f64,
pub longitude: f64,
pub has_tower: Option<bool>, pub has_tower: Option<bool>,
} }
@@ -30,14 +30,14 @@ impl Into<QueryAirport> for Airport {
return QueryAirport { return QueryAirport {
icao: self.icao.clone(), icao: self.icao.clone(),
category: self.category.clone(), category: self.category.clone(),
full_name: self.full_name.clone(), name: self.name.clone(),
elevation_ft: self.elevation_ft,
iso_country: self.iso_country.clone(), iso_country: self.iso_country.clone(),
iso_region: self.iso_region.clone(), iso_region: self.iso_region.clone(),
municipality: self.municipality.clone(), municipality: self.municipality.clone(),
gps_code: self.gps_code.clone(),
iata_code: self.iata_code.clone(), iata_code: self.iata_code.clone(),
local_code: self.local_code.clone(), local_code: self.local_code.clone(),
point: self.point.clone(), point: Point::new(self.longitude, self.latitude, Some(4326)),
data: match serde_json::to_value(&self) { data: match serde_json::to_value(&self) {
Ok(d) => d, Ok(d) => d,
Err(err) => { Err(err) => {
@@ -60,11 +60,11 @@ impl From<QueryAirport> for Airport {
pub struct QueryAirport { pub struct QueryAirport {
pub icao: String, pub icao: String,
pub category: String, pub category: String,
pub full_name: String, pub name: String,
pub elevation_ft: f32,
pub iso_country: String, pub iso_country: String,
pub iso_region: String, pub iso_region: String,
pub municipality: String, pub municipality: String,
pub gps_code: String,
pub iata_code: String, pub iata_code: String,
pub local_code: String, pub local_code: String,
pub point: Point, pub point: Point,
@@ -75,7 +75,7 @@ pub struct QueryAirport {
pub struct QueryFilters { pub struct QueryFilters {
pub search: Option<String>, pub search: Option<String>,
pub bounds: Option<Polygon<Point>>, pub bounds: Option<Polygon<Point>>,
pub category: Option<String>, pub categories: Option<Vec<String>>,
pub order_field: Option<QueryOrderField>, pub order_field: Option<QueryOrderField>,
pub order_by: Option<QueryOrderBy> pub order_by: Option<QueryOrderBy>
} }
@@ -85,7 +85,7 @@ impl Default for QueryFilters {
QueryFilters { QueryFilters {
search: None, search: None,
bounds: None, bounds: None,
category: None, categories: None,
order_field: None, order_field: None,
order_by: None order_by: None
} }
@@ -117,7 +117,6 @@ pub enum QueryOrderField {
Country, Country,
Region, Region,
Municipality, Municipality,
GPS,
Iata, Iata,
Local, Local,
} }
@@ -132,7 +131,6 @@ impl FromStr for QueryOrderField {
"iso_country" => Ok(QueryOrderField::Country), "iso_country" => Ok(QueryOrderField::Country),
"iso_region" => Ok(QueryOrderField::Region), "iso_region" => Ok(QueryOrderField::Region),
"municipality" => Ok(QueryOrderField::Municipality), "municipality" => Ok(QueryOrderField::Municipality),
"gps_code" => Ok(QueryOrderField::GPS),
"iata_code" => Ok(QueryOrderField::Iata), "iata_code" => Ok(QueryOrderField::Iata),
"local_code" => Ok(QueryOrderField::Local), "local_code" => Ok(QueryOrderField::Local),
_ => Err(()) _ => Err(())
@@ -143,94 +141,96 @@ impl FromStr for QueryOrderField {
impl QueryAirport { impl QueryAirport {
pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> { pub fn get_all(filters: &QueryFilters, limit: i32, page: i32) -> Result<Vec<Self>, ServiceError> {
let mut conn = db::connection()?; let mut conn = db::connection()?;
let mut query: String = "SELECT * FROM airports".to_string();
let mut query = airports::table.limit(limit as i64).into_boxed(); query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?);
// Limit query to page and limit
let offset = (page - 1) * limit;
query = query.offset(offset as i64);
if let Some(bounds) = &filters.bounds {
query = query.filter(st_contains(bounds, airports::point));
}
if let Some(category) = &filters.category {
query = query.filter(airports::category.eq(category));
}
if let Some(search) = &filters.search {
query = query.filter(
airports::icao.ilike(format!("%{}%", search))
.or(airports::full_name.ilike(format!("%{}%", search)))
.or(airports::iso_country.ilike(format!("%{}%", search)))
.or(airports::iso_region.ilike(format!("%{}%", search)))
.or(airports::municipality.ilike(format!("%{}%", search)))
.or(airports::gps_code.ilike(format!("%{}%", search)))
.or(airports::iata_code.ilike(format!("%{}%", search)))
.or(airports::local_code.ilike(format!("%{}%", search)))
)
}
if let Some(order_by) = &filters.order_by { if let Some(order_by) = &filters.order_by {
match order_by { match order_by {
QueryOrderBy::Asc => { QueryOrderBy::Asc => {
if let Some(order_field) = &filters.order_field { if let Some(order_field) = &filters.order_field {
query = match order_field { query = match order_field {
QueryOrderField::Icao => query.order(airports::icao.asc()), QueryOrderField::Icao => format!("{} ORDER BY icao ASC", query),
QueryOrderField::Name => query.order(airports::full_name.asc()), QueryOrderField::Name => format!("{} ORDER BY name ASC", query),
QueryOrderField::Category => query.order(airports::category.asc()), QueryOrderField::Category => format!("{} ORDER BY category ASC", query),
QueryOrderField::Country => query.order(airports::iso_country.asc()), QueryOrderField::Country => format!("{} ORDER BY iso_country ASC", query),
QueryOrderField::Region => query.order(airports::iso_region.asc()), QueryOrderField::Region => format!("{} ORDER BY iso_region ASC", query),
QueryOrderField::Municipality => query.order(airports::municipality.asc()), QueryOrderField::Municipality => format!("{} ORDER BY municipality ASC", query),
QueryOrderField::GPS => query.order(airports::gps_code.asc()), QueryOrderField::Iata => format!("{} ORDER BY iata_code ASC", query),
QueryOrderField::Iata => query.order(airports::iata_code.asc()), QueryOrderField::Local => format!("{} ORDER BY local_code ASC", query),
QueryOrderField::Local => query.order(airports::local_code.asc()),
}; };
}; };
}, },
QueryOrderBy::Desc => { QueryOrderBy::Desc => {
if let Some(order_field) = &filters.order_field { if let Some(order_field) = &filters.order_field {
query = match order_field { query = match order_field {
QueryOrderField::Icao => query.order(airports::icao.desc()), QueryOrderField::Icao => format!("{} ORDER BY icao DESC", query),
QueryOrderField::Name => query.order(airports::full_name.desc()), QueryOrderField::Name => format!("{} ORDER BY name DESC", query),
QueryOrderField::Category => query.order(airports::category.desc()), QueryOrderField::Category => format!("{} ORDER BY category DESC", query),
QueryOrderField::Country => query.order(airports::iso_country.desc()), QueryOrderField::Country => format!("{} ORDER BY iso_country DESC", query),
QueryOrderField::Region => query.order(airports::iso_region.desc()), QueryOrderField::Region => format!("{} ORDER BY iso_region DESC", query),
QueryOrderField::Municipality => query.order(airports::municipality.desc()), QueryOrderField::Municipality => format!("{} ORDER BY municipality DESC", query),
QueryOrderField::GPS => query.order(airports::gps_code.desc()), QueryOrderField::Iata => format!("{} ORDER BY iata_code DESC", query),
QueryOrderField::Iata => query.order(airports::iata_code.desc()), QueryOrderField::Local => format!("{} ORDER BY local_code DESC", query),
QueryOrderField::Local => query.order(airports::local_code.desc()),
}; };
}; };
} }
} }
} }
let airports: Vec<QueryAirport> = query.load::<QueryAirport>(&mut conn)?; // Limit query to page and limit
query = format!("{} LIMIT {} OFFSET {}", query, limit, (page - 1) * limit);
let airports: Vec<QueryAirport> = match sql_query(query).load(&mut conn) {
Ok(a) => a,
Err(err) => return Err(ServiceError { status: 500, message: format!("{}", err) })
};
Ok(airports) Ok(airports)
} }
pub fn get_count(filters: &QueryFilters) -> Result<i64, ServiceError> { pub fn get_count(filters: &QueryFilters) -> Result<i64, ServiceError> {
let mut conn = db::connection()?; let mut conn = db::connection()?;
let mut query = airports::table.count().into_boxed(); let mut query = "SELECT COUNT(*) FROM airports".to_string();
query = format!("{} {}", query, QueryAirport::build_filter_query(&filters)?);
let count: i64 = match sql_query(query).execute(&mut conn) {
Ok(c) => c as i64,
Err(err) => return Err(ServiceError { status: 500, message: format!("{}", err) })
};
return Ok(count);
}
// TODO: Unsafe query, need to sanitize inputs
fn build_filter_query(filters: &QueryFilters) -> Result<String, ServiceError> {
let mut query = "".to_string();
let mut parts: Vec<String> = vec![];
if let Some(bounds) = &filters.bounds { if let Some(bounds) = &filters.bounds {
query = query.filter(st_contains(bounds, airports::point)); // convert bounds to a WKT polygon
if bounds.rings.len() > 1 {
return Err(ServiceError { status: 400, message: "Only one polygon is allowed".to_string() })
} else {
let mut points: Vec<String> = vec![];
bounds.rings.iter().for_each(|ring| {
ring.iter().for_each(|point| {
points.push(format!("{} {}", point.get_x(), point.get_y()));
});
});
let bounds = format!("POLYGON(({}))", points.join(","));
parts.push(format!("ST_Contains(ST_GeomFromText('{}', 4326), point)", bounds));
} }
if let Some(category) = &filters.category { }
query = query.filter(airports::category.eq(category)); if let Some(categories) = &filters.categories {
parts.push(format!("({})", categories.iter().map(|category| format!("category = '{}'", category)).collect::<Vec<String>>().join(" OR ")));
} }
if let Some(search) = &filters.search { if let Some(search) = &filters.search {
query = query.filter( let search_strs = vec!["icao", "name", "iso_country", "iso_region", "municipality", "iata_code", "local_code"];
airports::icao.ilike(format!("%{}%", search)) parts.push(format!("({})", search_strs.iter().map(|s| format!("{} ILIKE '%{}%'", s, search)).collect::<Vec<String>>().join(" OR ")));
.or(airports::full_name.ilike(format!("%{}%", search)))
.or(airports::iso_country.ilike(format!("%{}%", search)))
.or(airports::iso_region.ilike(format!("%{}%", search)))
.or(airports::municipality.ilike(format!("%{}%", search)))
.or(airports::gps_code.ilike(format!("%{}%", search)))
.or(airports::iata_code.ilike(format!("%{}%", search)))
.or(airports::local_code.ilike(format!("%{}%", search)))
)
} }
let count: i64 = query.get_result(&mut conn)?; if parts.len() > 0 {
return Ok(count); query = format!("{} WHERE {}", query, parts.join(" AND "));
}
return Ok(query);
} }
pub fn find(icao: String) -> Result<Self, ServiceError> { pub fn find(icao: String) -> Result<Self, ServiceError> {

View File

@@ -12,7 +12,7 @@ use serde::{Serialize, Deserialize};
struct GetAllParameters { struct GetAllParameters {
search: Option<String>, search: Option<String>,
bounds: Option<String>, bounds: Option<String>,
category: Option<String>, categories: Option<String>,
order_field: Option<String>, order_field: Option<String>,
order_by: Option<String>, order_by: Option<String>,
limit: Option<i32>, limit: Option<i32>,
@@ -69,7 +69,10 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
let params = web::Query::<GetAllParameters>::from_query(req.query_string()).unwrap(); let params = web::Query::<GetAllParameters>::from_query(req.query_string()).unwrap();
let mut filters = QueryFilters::default(); let mut filters = QueryFilters::default();
filters.search = params.search.clone(); filters.search = params.search.clone();
filters.category = params.category.clone(); filters.categories = match &params.categories {
Some(c) => Some(c.split(",").map(|s| s.to_string()).collect()),
None => None
};
filters.bounds = match &params.bounds { filters.bounds = match &params.bounds {
Some(b) => { Some(b) => {
let bounds: Vec<&str> = b.split(",").collect(); let bounds: Vec<&str> = b.split(",").collect();

View File

@@ -4,11 +4,11 @@ diesel::table! {
airports (icao) { airports (icao) {
icao -> Text, icao -> Text,
category -> Text, category -> Text,
full_name -> Text, name -> Text,
elevation_ft -> Float,
iso_country -> Text, iso_country -> Text,
iso_region -> Text, iso_region -> Text,
municipality -> Text, municipality -> Text,
gps_code -> Text,
iata_code -> Text, iata_code -> Text,
local_code -> Text, local_code -> Text,
point -> Geometry, point -> Geometry,

View File

@@ -22,7 +22,7 @@ async fn main() -> std::io::Result<()> {
dotenv().ok(); dotenv().ok();
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,service=info")); env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,service=info"));
db::init(); db::init();
scheduler::update_airports(); // scheduler::update_airports();
let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string()); let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string());
let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string()); let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string());

View File

@@ -195,11 +195,11 @@ impl Metar {
break; break;
} }
// Report Modifiers // Report Modifiers
if metar_parts[0] == "AUTO" { if !metar_parts.is_empty() && metar_parts[0] == "AUTO" {
metar.quality_control_flags.auto = Some(true); metar.quality_control_flags.auto = Some(true);
metar_parts.remove(0); metar_parts.remove(0);
} }
if metar_parts[0] == "COR" { if !metar_parts.is_empty() && metar_parts[0] == "COR" {
metar.quality_control_flags.corrected = Some(true); metar.quality_control_flags.corrected = Some(true);
metar_parts.remove(0); metar_parts.remove(0);
} }
@@ -207,14 +207,14 @@ impl Metar {
// Wind Direction and Speed // Wind Direction and Speed
let wind_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}KT$").unwrap(); let wind_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}KT$").unwrap();
let wind_gust_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}G[0-9]{2}KT$").unwrap(); let wind_gust_re = regex::Regex::new(r"^(?:[0-9]{3}|VRB)[0-9]{2}G[0-9]{2}KT$").unwrap();
if wind_re.is_match(metar_parts[0]) { if !metar_parts.is_empty() && wind_re.is_match(metar_parts[0]) {
let wind = metar_parts[0]; let wind = metar_parts[0];
metar_parts.remove(0); metar_parts.remove(0);
let wind_dir_degrees = &wind[0..3]; let wind_dir_degrees = &wind[0..3];
let wind_speed_kt = &wind[3..5]; let wind_speed_kt = &wind[3..5];
metar.wind_dir_degrees = Some(wind_dir_degrees.to_string()); metar.wind_dir_degrees = Some(wind_dir_degrees.to_string());
metar.wind_speed_kt = Some(wind_speed_kt.parse::<i32>().unwrap()); metar.wind_speed_kt = Some(wind_speed_kt.parse::<i32>().unwrap());
} else if wind_gust_re.is_match(metar_parts[0]) { } else if !metar_parts.is_empty() && wind_gust_re.is_match(metar_parts[0]) {
let wind = metar_parts[0]; let wind = metar_parts[0];
metar_parts.remove(0); metar_parts.remove(0);
let wind_dir_degrees = &wind[0..3]; let wind_dir_degrees = &wind[0..3];
@@ -228,14 +228,14 @@ impl Metar {
// Variable Wind Direction // Variable Wind Direction
let variable_wind_re = regex::Regex::new(r"^[0-9]{3}V[0-9]{3}$").unwrap(); let variable_wind_re = regex::Regex::new(r"^[0-9]{3}V[0-9]{3}$").unwrap();
if variable_wind_re.is_match(metar_parts[0]) { if !metar_parts.is_empty() && variable_wind_re.is_match(metar_parts[0]) {
metar.variable_wind_dir_degrees = Some(metar_parts[0].to_string()); metar.variable_wind_dir_degrees = Some(metar_parts[0].to_string());
metar_parts.remove(0); metar_parts.remove(0);
} }
// Visibility // Visibility
let visibility_re = regex::Regex::new(r"^M?(?:[0-9]+|[0-9]+/[0-9]+)SM").unwrap(); let visibility_re = regex::Regex::new(r"^M?(?:[0-9]+|[0-9]+/[0-9]+)SM").unwrap();
if visibility_re.is_match(metar_parts[0]) { if !metar_parts.is_empty() && visibility_re.is_match(metar_parts[0]) {
let visibility_str = &metar_parts[0][0..metar_parts[0].len() - 2]; let visibility_str = &metar_parts[0][0..metar_parts[0].len() - 2];
metar_parts.remove(0); metar_parts.remove(0);
let visibility: String = if visibility_str.contains("/") { let visibility: String = if visibility_str.contains("/") {
@@ -253,7 +253,7 @@ impl Metar {
visibility_str.to_string() visibility_str.to_string()
}; };
metar.visibility_statute_mi = Some(visibility); metar.visibility_statute_mi = Some(visibility);
} else if metar_parts[0].parse::<f64>().is_ok() && metar_parts.len() > 1 && visibility_re.is_match(metar_parts[1]) { } else if !metar_parts.is_empty() && metar_parts[0].parse::<f64>().is_ok() && metar_parts.len() > 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>().unwrap();
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();
@@ -273,7 +273,7 @@ impl Metar {
// Runway Visual Range // Runway Visual Range
let rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}FT$").unwrap(); let rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}FT$").unwrap();
let variable_rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}V[PM]?[0-9]{4}FT$").unwrap(); let variable_rvr_re = regex::Regex::new(r"^R[0-9]{1,3}(?:L|R|C)?/[PM]?[0-9]{4}V[PM]?[0-9]{4}FT$").unwrap();
while rvr_re.is_match(metar_parts[0]) || variable_rvr_re.is_match(metar_parts[0]) { while !metar_parts.is_empty() && rvr_re.is_match(metar_parts[0]) || variable_rvr_re.is_match(metar_parts[0]) {
let rvr_string = metar_parts[0]; let rvr_string = metar_parts[0];
metar_parts.remove(0); metar_parts.remove(0);
let mut rvr = RunwayVisualRange::default(); let mut rvr = RunwayVisualRange::default();
@@ -297,14 +297,14 @@ impl Metar {
let wx_descriptor = "(?:MI|PR|BC|DR|BL|SH|TS|FZ)?"; let wx_descriptor = "(?:MI|PR|BC|DR|BL|SH|TS|FZ)?";
let wx_precipitation = "(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)?"; let wx_precipitation = "(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)?";
let wx_re = regex::Regex::new(&format!(r"^{}{}{}$", wx_intensity, wx_descriptor, wx_precipitation)).unwrap(); let wx_re = regex::Regex::new(&format!(r"^{}{}{}$", wx_intensity, wx_descriptor, wx_precipitation)).unwrap();
while wx_re.is_match(metar_parts[0]) { while !metar_parts.is_empty() && wx_re.is_match(metar_parts[0]) {
metar.weather_phenomena.push(metar_parts[0].to_string()); metar.weather_phenomena.push(metar_parts[0].to_string());
metar_parts.remove(0); metar_parts.remove(0);
} }
// Sky Condition // Sky Condition
let sky_condition_re = regex::Regex::new(r"^(?:CLR|SKC|(?:FEW|SCT|BKN|OVC|VV)([0-9]{3})?)$").unwrap(); let sky_condition_re = regex::Regex::new(r"^(?:CLR|SKC|(?:FEW|SCT|BKN|OVC|VV)([0-9]{3})?)$").unwrap();
while sky_condition_re.is_match(metar_parts[0]) { while !metar_parts.is_empty() && sky_condition_re.is_match(metar_parts[0]) {
let sky_condition_string = metar_parts[0]; let sky_condition_string = metar_parts[0];
metar_parts.remove(0); metar_parts.remove(0);
let mut sky_condition = SkyCondition::default(); let mut sky_condition = SkyCondition::default();
@@ -318,7 +318,7 @@ impl Metar {
// Temperature and Dewpoint // Temperature and Dewpoint
let temp_re = regex::Regex::new(r"^(?:M?[0-9]{2})?/(?:M?[0-9]{2})?$").unwrap(); let temp_re = regex::Regex::new(r"^(?:M?[0-9]{2})?/(?:M?[0-9]{2})?$").unwrap();
if temp_re.is_match(metar_parts[0]) { if !metar_parts.is_empty() && temp_re.is_match(metar_parts[0]) {
let temp_string = metar_parts[0]; let temp_string = metar_parts[0];
metar_parts.remove(0); metar_parts.remove(0);
let temp_parts: Vec<&str> = temp_string.split("/").collect(); let temp_parts: Vec<&str> = temp_string.split("/").collect();
@@ -360,7 +360,7 @@ impl Metar {
// Altimeter // Altimeter
let altim_re = regex::Regex::new(r"^A[0-9]{4}$").unwrap(); let altim_re = regex::Regex::new(r"^A[0-9]{4}$").unwrap();
if altim_re.is_match(metar_parts[0]) { if !metar_parts.is_empty() && altim_re.is_match(metar_parts[0]) {
let altim = metar_parts[0]; let altim = metar_parts[0];
metar_parts.remove(0); metar_parts.remove(0);
metar.altim_in_hg = Some(altim[1..altim.len()].parse::<f64>().unwrap() / 100.0); metar.altim_in_hg = Some(altim[1..altim.len()].parse::<f64>().unwrap() / 100.0);

View File

@@ -12,7 +12,7 @@ export async function getAirport({ icao }: GetAirportProps): Promise<GetAirportR
interface GetAirportsProps { interface GetAirportsProps {
bounds?: Bounds; bounds?: Bounds;
category?: string; categories?: string[];
search?: string; search?: string;
order_field?: AirportOrderField; order_field?: AirportOrderField;
order_by?: 'asc' | 'desc'; order_by?: 'asc' | 'desc';
@@ -22,7 +22,7 @@ interface GetAirportsProps {
export async function getAirports({ export async function getAirports({
bounds, bounds,
category, categories,
search, search,
order_field, order_field,
order_by, order_by,
@@ -33,7 +33,7 @@ export async function getAirports({
bounds: bounds bounds: bounds
? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}` ? `${bounds?.northEast.lat},${bounds?.northEast.lon},${bounds?.southWest.lat},${bounds?.southWest.lon}`
: undefined, : undefined,
category: category ?? undefined, categories: categories ?? undefined,
search: search ?? undefined, search: search ?? undefined,
order_field: order_field ?? undefined, order_field: order_field ?? undefined,
order_by: order_by ?? undefined, order_by: order_by ?? undefined,
@@ -63,11 +63,11 @@ export async function updateAirport({ airport }: { airport: Airport }): Promise<
return response?.json() || { data: undefined }; return response?.json() || { data: undefined };
} }
export async function importAirports(payload: File): Promise<any> { export async function importAirports(payload: File): Promise<boolean> {
const data = new FormData(); const data = new FormData();
data.append('data', payload); data.append('data', payload);
const response = await postRequest('airports/import', data, { const response = await postRequest('airports/import', data, {
type: 'form' type: 'form'
}); });
return response?.status == 200; return response ? response.status === 200 : false;
} }

View File

@@ -26,7 +26,6 @@ export enum AirportOrderField {
ISO_COUNTRY = 'iso_country', ISO_COUNTRY = 'iso_country',
ISO_REGION = 'iso_region', ISO_REGION = 'iso_region',
MUNICIPALITY = 'municipality', MUNICIPALITY = 'municipality',
GPS_CODE = 'gps_code',
IATA_CODE = 'iata_code', IATA_CODE = 'iata_code',
LOCAL_CODE = 'local_code', LOCAL_CODE = 'local_code',
} }
@@ -44,19 +43,15 @@ export interface Coordinate {
export interface Airport { export interface Airport {
icao: string; icao: string;
category: AirportCategory; category: AirportCategory;
full_name: string; name: string;
elevation_ft: number; elevation_ft: number;
iso_country: string; iso_country: string;
iso_region: string; iso_region: string;
municipality: string; municipality: string;
gps_code: string;
iata_code: string; iata_code: string;
local_code: string; local_code: string;
point: { latitude: number;
x: number; longitude: number;
y: number;
srid: number;
};
latest_metar?: Metar; latest_metar?: Metar;
} }

View File

@@ -27,7 +27,7 @@ export default function Page({ params }: { params: { icao: string } }) {
return ( return (
<> <>
<div className=''> <div className=''>
<h3 className=''>{airport.full_name}</h3> <h3 className=''>{airport.name}</h3>
{metar && <SkyConditions metar={metar} />} {metar && <SkyConditions metar={metar} />}
</div> </div>
</> </>

View File

@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import RecoilRootWrapper from '@app/recoil-root-wrapper'; import RecoilRootWrapper from '@app/recoil-root-wrapper';
import Sidebar from '@/components/Sidebar';
import Header from '@/components/Header'; import Header from '@/components/Header';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
@@ -8,6 +7,7 @@ import { ModalsProvider } from '@mantine/modals';
import 'styles/globals.css'; import 'styles/globals.css';
import 'styles/leaflet.css'; import 'styles/leaflet.css';
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import { Notifications } from '@mantine/notifications';
export const metadata = { export const metadata = {
title: 'Aviation Weather', title: 'Aviation Weather',
@@ -23,15 +23,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<title>Aviation Weather</title> <title>Aviation Weather</title>
</head> </head>
<body className={`${inter.className} wrapper h-full`}> <body className={`${inter.className} wrapper h-full`}>
<RecoilRootWrapper>
<MantineProvider> <MantineProvider>
<Notifications />
<ModalsProvider> <ModalsProvider>
<RecoilRootWrapper>
<Header /> <Header />
<Sidebar />
{children} {children}
</RecoilRootWrapper>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
</RecoilRootWrapper>
</body> </body>
</html> </html>
); );

View File

@@ -4,6 +4,7 @@ import { Text, Button, Card, Group, Pagination, Table, TextInput, rem, UnstyledB
import { HiChevronUp, HiChevronDown, HiSelector } from "react-icons/hi"; import { HiChevronUp, HiChevronDown, HiSelector } from "react-icons/hi";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CiSearch } from "react-icons/ci"; import { CiSearch } from "react-icons/ci";
import { notifications } from '@mantine/notifications';
export default function AirportTablePanel({ setAirport }: { setAirport: (airport: Airport) => void }) { export default function AirportTablePanel({ setAirport }: { setAirport: (airport: Airport) => void }) {
@@ -37,12 +38,11 @@ export default function AirportTablePanel({ setAirport }: { setAirport: (airport
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<Table.Td>{airport.icao}</Table.Td> <Table.Td>{airport.icao}</Table.Td>
<Table.Td>{airport.full_name}</Table.Td> <Table.Td>{airport.name}</Table.Td>
<Table.Td>{airportCategoryToText(airport.category)}</Table.Td> <Table.Td>{airportCategoryToText(airport.category)}</Table.Td>
<Table.Td>{airport.iso_country}</Table.Td> <Table.Td>{airport.iso_country}</Table.Td>
<Table.Td>{airport.iso_region}</Table.Td> <Table.Td>{airport.iso_region}</Table.Td>
<Table.Td>{airport.municipality}</Table.Td> <Table.Td>{airport.municipality}</Table.Td>
<Table.Td>{airport.gps_code}</Table.Td>
<Table.Td>{airport.iata_code}</Table.Td> <Table.Td>{airport.iata_code}</Table.Td>
<Table.Td>{airport.local_code}</Table.Td> <Table.Td>{airport.local_code}</Table.Td>
</Table.Tr> </Table.Tr>
@@ -61,12 +61,11 @@ export default function AirportTablePanel({ setAirport }: { setAirport: (airport
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>ICAO</Table.Th> <Table.Th>ICAO</Table.Th>
<Table.Th>Full Name</Table.Th> <Table.Th>Name</Table.Th>
<Table.Th>Category</Table.Th> <Table.Th>Category</Table.Th>
<Table.Th>ISO Country</Table.Th> <Table.Th>ISO Country</Table.Th>
<Table.Th>ISO Region</Table.Th> <Table.Th>ISO Region</Table.Th>
<Table.Th>Municipality</Table.Th> <Table.Th>Municipality</Table.Th>
<Table.Th>GPS Code</Table.Th>
<Table.Th>IATA Code</Table.Th> <Table.Th>IATA Code</Table.Th>
<Table.Th>Local Code</Table.Th> <Table.Th>Local Code</Table.Th>
</Table.Tr> </Table.Tr>
@@ -83,8 +82,17 @@ export default function AirportTablePanel({ setAirport }: { setAirport: (airport
<Space mr={'sm'}> <Space mr={'sm'}>
<PanelFileButton accept={'.json'} onChange={async (payload) => { <PanelFileButton accept={'.json'} onChange={async (payload) => {
if (payload instanceof File) { if (payload instanceof File) {
await importAirports(payload); const response = await importAirports(payload);
if (response) {
await getAirportData(); await getAirportData();
} else {
notifications.show({
title: `Failed to import airports`,
message: `Please try again.`,
color: 'red',
autoClose: 2000
});
}
} }
}}> }}>
Import Import

View File

@@ -3,7 +3,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getAirport, getAirports } from '@/api/airport'; import { getAirport, getAirports } from '@/api/airport';
import { useRouter } from 'next/navigation';
import { Autocomplete, Avatar, Button, Card, FileButton, Grid, Group, Menu, Text, UnstyledButton } from '@mantine/core'; import { Autocomplete, Avatar, Button, Card, FileButton, Grid, Group, Menu, Text, UnstyledButton } from '@mantine/core';
import './header.css'; import './header.css';
import { refresh, refreshLoggedIn, logout } from '@/api/auth'; import { refresh, refreshLoggedIn, logout } from '@/api/auth';
@@ -17,6 +16,7 @@ import { favoritesState } from '@/state/user';
import { coordinatesState, zoomState } from '@/state/map'; import { coordinatesState, zoomState } from '@/state/map';
export default function Header() { export default function Header() {
const [loading, setLoading] = useState(true);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [airports, setAirports] = useState<{ key: string; value: string; label: string }[]>([]); const [airports, setAirports] = useState<{ key: string; value: string; label: string }[]>([]);
const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']); const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
@@ -24,7 +24,6 @@ export default function Header() {
const [favorites, setFavorites] = useRecoilState(favoritesState); const [favorites, setFavorites] = useRecoilState(favoritesState);
const [refreshId, setRefreshId] = useState<NodeJS.Timeout | undefined>(undefined); const [refreshId, setRefreshId] = useState<NodeJS.Timeout | undefined>(undefined);
const [profilePicture, setProfilePicture] = useState<File | null>(null); const [profilePicture, setProfilePicture] = useState<File | null>(null);
const router = useRouter();
const [coordinates, setCoordinates] = useRecoilState(coordinatesState); const [coordinates, setCoordinates] = useRecoilState(coordinatesState);
const [zoom, setZoom] = useRecoilState(zoomState); const [zoom, setZoom] = useRecoilState(zoomState);
@@ -49,6 +48,7 @@ export default function Header() {
} }
}); });
} }
setLoading(false);
}, [user]); }, [user]);
async function onChange(value: string) { async function onChange(value: string) {
@@ -58,7 +58,7 @@ export default function Header() {
airportData.data.map((airport) => ({ airportData.data.map((airport) => ({
key: airport.icao, key: airport.icao,
value: airport.icao, value: airport.icao,
label: `${airport.icao} - ${airport.full_name}` label: `${airport.icao} - ${airport.name}`
})) }))
); );
} }
@@ -66,7 +66,7 @@ export default function Header() {
async function onClick(value: string) { async function onClick(value: string) {
const airport = await getAirport({ icao: value }); const airport = await getAirport({ icao: value });
if (airport) { if (airport) {
setCoordinates({ lat: airport.data.point.y, lon: airport.data.point.x }); setCoordinates({ lat: airport.data.latitude, lon: airport.data.longitude });
} }
} }
@@ -96,6 +96,7 @@ export default function Header() {
toggle={toggle} toggle={toggle}
setFavorites={setFavorites} setFavorites={setFavorites}
refreshId={refreshId} refreshId={refreshId}
loading={loading}
/> />
</nav> </nav>
<HeaderModal <HeaderModal
@@ -128,13 +129,18 @@ interface UserSectionProps {
toggle: (type: string) => void; toggle: (type: string) => void;
setFavorites: (favorites: string[]) => void; setFavorites: (favorites: string[]) => void;
refreshId: NodeJS.Timeout | undefined; refreshId: NodeJS.Timeout | undefined;
loading: boolean;
} }
function UserSection({ profilePicture, setProfilePicture, setFavorites, refreshId, toggle }: UserSectionProps) { function UserSection({ profilePicture, setProfilePicture, setFavorites, refreshId, toggle, loading }: UserSectionProps) {
const [user, setUser] = useRecoilState(userState); const [user, setUser] = useRecoilState(userState);
return ( return (
<div className='user-section'> <div className='user-section'>
{loading ? (
<></>
) : (
<>
{user ? ( {user ? (
<Menu shadow='md' width={200} openDelay={100} closeDelay={400}> <Menu shadow='md' width={200} openDelay={100} closeDelay={400}>
<Menu.Target> <Menu.Target>
@@ -237,6 +243,8 @@ function UserSection({ profilePicture, setProfilePicture, setFavorites, refreshI
</Button> </Button>
</Group> </Group>
)} )}
</>
)}
</div> </div>
) )
} }

View File

@@ -53,6 +53,7 @@ export default function MapTiles() {
northEast: { lat: ne.lat, lon: ne.lng }, northEast: { lat: ne.lat, lon: ne.lng },
southWest: { lat: sw.lat, lon: sw.lng } southWest: { lat: sw.lat, lon: sw.lng }
}, },
categories: ['small_airport', 'medium_airport', 'large_airport'],
order_field: AirportOrderField.CATEGORY, order_field: AirportOrderField.CATEGORY,
order_by: 'asc', order_by: 'asc',
limit: 250, limit: 250,
@@ -111,7 +112,7 @@ export default function MapTiles() {
{airports.map((airport) => ( {airports.map((airport) => (
<Marker <Marker
key={airport.icao} key={airport.icao}
position={[airport.point.y, airport.point.x]} position={[airport.latitude, airport.longitude]}
icon={metarIcon(airport)} icon={metarIcon(airport)}
eventHandlers={{ eventHandlers={{
click: () => handleOpen(airport) click: () => handleOpen(airport)
@@ -119,7 +120,7 @@ export default function MapTiles() {
> >
{!isOpen && ( {!isOpen && (
<Tooltip className='metar-tooltip' direction='top' offset={[5, -5]} opacity={1}> <Tooltip className='metar-tooltip' direction='top' offset={[5, -5]} opacity={1}>
<b>{airport.icao}</b> - {airport.full_name} <b>{airport.icao}</b> - {airport.name}
</Tooltip> </Tooltip>
)} )}
</Marker> </Marker>

View File

@@ -57,7 +57,7 @@ export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps
> >
<span className='title'> <span className='title'>
<Link href={`/airport/${airport.icao}`}> <Link href={`/airport/${airport.icao}`}>
{airport.icao} {airport.full_name} {airport.icao} {airport.name}
</Link> </Link>
{isFavorite ? ( {isFavorite ? (
<AiFillStar size={24} className='star' onClick={() => handleFavorite(false)} /> <AiFillStar size={24} className='star' onClick={() => handleFavorite(false)} />