Update frequencies to communications, fixed control icons
This commit is contained in:
@@ -39,20 +39,19 @@ CREATE TABLE IF NOT EXISTS runways (
|
|||||||
|
|
||||||
CREATE INDEX ON runways (icao);
|
CREATE INDEX ON runways (icao);
|
||||||
CREATE INDEX ON runways (runway_id);
|
CREATE INDEX ON runways (runway_id);
|
||||||
CREATE INDEX ON runways (surface);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS frequencies (
|
CREATE TABLE IF NOT EXISTS communications (
|
||||||
id UUID PRIMARY KEY NOT NULL,
|
id UUID PRIMARY KEY NOT NULL,
|
||||||
icao TEXT NOT NULL,
|
icao TEXT NOT NULL,
|
||||||
frequency_id TEXT NOT NULL,
|
frequency_id TEXT NOT NULL,
|
||||||
frequency_name TEXT,
|
name TEXT,
|
||||||
frequency_mhz REAL NOT NULL
|
frequencies_mhz REAL[] NOT NULL,
|
||||||
|
phone TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX ON frequencies (icao);
|
CREATE INDEX ON communications (icao);
|
||||||
CREATE INDEX ON frequencies (frequency_id);
|
CREATE INDEX ON communications (frequency_id);
|
||||||
CREATE INDEX ON frequencies (frequency_name);
|
CREATE INDEX ON communications (name);
|
||||||
CREATE INDEX ON frequencies (frequency_mhz);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS metars (
|
CREATE TABLE IF NOT EXISTS metars (
|
||||||
icao TEXT NOT NULL,
|
icao TEXT NOT NULL,
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ impl Session {
|
|||||||
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
||||||
if environment == "development" || environment == "dev" {
|
if environment == "development" || environment == "dev" {
|
||||||
log::trace!(
|
log::trace!(
|
||||||
"Development cookie [ID: {}]: {}",
|
"Session cookie [ID: {}]: {}",
|
||||||
self.id,
|
self.id,
|
||||||
self.session_id
|
self.session_id
|
||||||
);
|
);
|
||||||
@@ -147,6 +147,11 @@ impl Session {
|
|||||||
|
|
||||||
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
||||||
if environment == "development" || environment == "dev" {
|
if environment == "development" || environment == "dev" {
|
||||||
|
log::trace!(
|
||||||
|
"Session expiration cookie [ID: {}]: {}",
|
||||||
|
self.id,
|
||||||
|
self.session_id
|
||||||
|
);
|
||||||
cookie.set_secure(false);
|
cookie.set_secure(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use reqwest::Client;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Postgres, QueryBuilder};
|
use sqlx::{Postgres, QueryBuilder};
|
||||||
use crate::airports::{
|
use crate::airports::{
|
||||||
AirportCategory, Frequency, FrequencyRow, Runway, RunwayRow, UpdateFrequency, UpdateRunway,
|
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication, UpdateRunway,
|
||||||
};
|
};
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::error::{ApiResult, Error};
|
use crate::error::{ApiResult, Error};
|
||||||
@@ -34,7 +34,7 @@ pub struct Airport {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub has_beacon: Option<bool>,
|
pub has_beacon: Option<bool>,
|
||||||
pub runways: Vec<Runway>,
|
pub runways: Vec<Runway>,
|
||||||
pub frequencies: Vec<Frequency>,
|
pub communications: Vec<Communication>,
|
||||||
pub public: bool,
|
pub public: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub latest_metar: Option<Metar>,
|
pub latest_metar: Option<Metar>,
|
||||||
@@ -141,7 +141,7 @@ pub struct UpdateAirport {
|
|||||||
pub has_tower: Option<bool>,
|
pub has_tower: Option<bool>,
|
||||||
pub has_beacon: Option<bool>,
|
pub has_beacon: Option<bool>,
|
||||||
pub runways: Option<Vec<UpdateRunway>>,
|
pub runways: Option<Vec<UpdateRunway>>,
|
||||||
pub frequencies: Option<Vec<UpdateFrequency>>,
|
pub communications: Option<Vec<UpdateCommunication>>,
|
||||||
pub public: Option<bool>,
|
pub public: Option<bool>,
|
||||||
pub latest_metar_observation: Option<DateTime<Utc>>,
|
pub latest_metar_observation: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ impl From<AirportRow> for Airport {
|
|||||||
has_tower: airport.has_tower,
|
has_tower: airport.has_tower,
|
||||||
has_beacon: airport.has_beacon,
|
has_beacon: airport.has_beacon,
|
||||||
runways: vec![],
|
runways: vec![],
|
||||||
frequencies: vec![],
|
communications: vec![],
|
||||||
public: airport.public,
|
public: airport.public,
|
||||||
latest_metar: None,
|
latest_metar: None,
|
||||||
}
|
}
|
||||||
@@ -227,10 +227,10 @@ impl Airport {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let runways_fut = Runway::select_all(icao);
|
let runways_fut = Runway::select_all(icao);
|
||||||
let frequencies_fut = Frequency::select_all(icao);
|
let communications_fut = Communication::select_all(icao);
|
||||||
|
|
||||||
let (airport_result, runways_result, frequencies_result, metar_result) =
|
let (airport_result, runways_result, communications_result, metar_result) =
|
||||||
tokio::join!(airport_fut, runways_fut, frequencies_fut, metar_fut);
|
tokio::join!(airport_fut, runways_fut, communications_fut, metar_fut);
|
||||||
|
|
||||||
let airport_row: Option<AirportRow> = match airport_result {
|
let airport_row: Option<AirportRow> = match airport_result {
|
||||||
Ok(opt) => opt,
|
Ok(opt) => opt,
|
||||||
@@ -248,11 +248,11 @@ impl Airport {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let frequencies: Vec<Frequency> = match frequencies_result {
|
let communications: Vec<Communication> = match communications_result {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Error retrieving frequencies for airport '{}': {}",
|
"Error retrieving communications for airport '{}': {}",
|
||||||
icao,
|
icao,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
@@ -271,7 +271,7 @@ impl Airport {
|
|||||||
airport_row.map(|row| {
|
airport_row.map(|row| {
|
||||||
let mut airport: Airport = row.into();
|
let mut airport: Airport = row.into();
|
||||||
airport.runways = runways;
|
airport.runways = runways;
|
||||||
airport.frequencies = frequencies;
|
airport.communications = communications;
|
||||||
airport.latest_metar = metar;
|
airport.latest_metar = metar;
|
||||||
airport
|
airport
|
||||||
})
|
})
|
||||||
@@ -343,7 +343,7 @@ impl Airport {
|
|||||||
let icaos: Vec<String> = airports.iter().map(|a| a.icao.clone()).collect();
|
let icaos: Vec<String> = airports.iter().map(|a| a.icao.clone()).collect();
|
||||||
|
|
||||||
let runway_future = Runway::select_all_map(icaos.clone());
|
let runway_future = Runway::select_all_map(icaos.clone());
|
||||||
let frequency_future = Frequency::select_all_map(icaos.clone());
|
let frequency_future = Communication::select_all_map(icaos.clone());
|
||||||
let metar_future = if query.metars.unwrap_or(false) {
|
let metar_future = if query.metars.unwrap_or(false) {
|
||||||
Some(Metar::find_all_distinct(client, &icaos))
|
Some(Metar::find_all_distinct(client, &icaos))
|
||||||
} else {
|
} else {
|
||||||
@@ -373,7 +373,7 @@ impl Airport {
|
|||||||
|
|
||||||
for airport in airports.iter_mut() {
|
for airport in airports.iter_mut() {
|
||||||
airport.runways = runway_map.get(&airport.icao).cloned().unwrap_or_default();
|
airport.runways = runway_map.get(&airport.icao).cloned().unwrap_or_default();
|
||||||
airport.frequencies = frequency_map
|
airport.communications = frequency_map
|
||||||
.get(&airport.icao)
|
.get(&airport.icao)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -428,15 +428,15 @@ impl Airport {
|
|||||||
let pool = db::pool();
|
let pool = db::pool();
|
||||||
|
|
||||||
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
|
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
|
||||||
let mut all_frequency_rows: Vec<FrequencyRow> = Vec::new();
|
let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
|
||||||
for runway in &self.runways {
|
for runway in &self.runways {
|
||||||
all_runway_rows.push(Runway::into(runway, &self.icao));
|
all_runway_rows.push(Runway::into(runway, &self.icao));
|
||||||
}
|
}
|
||||||
for frequency in &self.frequencies {
|
for frequency in &self.communications {
|
||||||
all_frequency_rows.push(Frequency::into(frequency, &self.icao));
|
all_frequency_rows.push(Communication::into(frequency, &self.icao));
|
||||||
}
|
}
|
||||||
Runway::insert_all(&all_runway_rows).await?;
|
Runway::insert_all(&all_runway_rows).await?;
|
||||||
Frequency::insert_all(&all_frequency_rows).await?;
|
Communication::insert_all(&all_frequency_rows).await?;
|
||||||
|
|
||||||
let airport: AirportRow = sqlx::query_as(&format!(
|
let airport: AirportRow = sqlx::query_as(&format!(
|
||||||
r#"
|
r#"
|
||||||
@@ -476,15 +476,15 @@ impl Airport {
|
|||||||
let pool = db::pool();
|
let pool = db::pool();
|
||||||
let chunk_size = 1000;
|
let chunk_size = 1000;
|
||||||
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
|
let mut all_runway_rows: Vec<RunwayRow> = Vec::new();
|
||||||
let mut all_frequency_rows: Vec<FrequencyRow> = Vec::new();
|
let mut all_frequency_rows: Vec<CommunicationRow> = Vec::new();
|
||||||
let airport_rows: Vec<AirportRow> = airports
|
let airport_rows: Vec<AirportRow> = airports
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|airport| {
|
.map(|airport| {
|
||||||
for runway in &airport.runways {
|
for runway in &airport.runways {
|
||||||
all_runway_rows.push(Runway::into(runway, &airport.icao));
|
all_runway_rows.push(Runway::into(runway, &airport.icao));
|
||||||
}
|
}
|
||||||
for frequency in &airport.frequencies {
|
for frequency in &airport.communications {
|
||||||
all_frequency_rows.push(Frequency::into(frequency, &airport.icao));
|
all_frequency_rows.push(Communication::into(frequency, &airport.icao));
|
||||||
}
|
}
|
||||||
airport.into()
|
airport.into()
|
||||||
})
|
})
|
||||||
@@ -518,7 +518,7 @@ impl Airport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Runway::insert_all(&all_runway_rows).await?;
|
Runway::insert_all(&all_runway_rows).await?;
|
||||||
Frequency::insert_all(&all_frequency_rows).await?;
|
Communication::insert_all(&all_frequency_rows).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,63 +5,69 @@ use uuid::Uuid;
|
|||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::error::ApiResult;
|
use crate::error::ApiResult;
|
||||||
|
|
||||||
const TABLE_NAME: &str = "frequencies";
|
const TABLE_NAME: &str = "communications";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Frequency {
|
pub struct Communication {
|
||||||
#[serde(rename = "id")]
|
pub id: String,
|
||||||
pub frequency_id: String,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "name")]
|
pub name: Option<String>,
|
||||||
pub frequency_name: Option<String>,
|
pub frequencies_mhz: Vec<f32>,
|
||||||
pub frequency_mhz: f32,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Deserialize, sqlx::FromRow)]
|
||||||
pub struct FrequencyRow {
|
pub struct CommunicationRow {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub icao: String,
|
pub icao: String,
|
||||||
pub frequency_id: String,
|
pub frequency_id: String,
|
||||||
pub frequency_name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub frequency_mhz: f32,
|
pub frequencies_mhz: Vec<f32>,
|
||||||
|
pub phone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct UpdateFrequency {
|
pub struct UpdateCommunication {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub icao: Option<String>,
|
pub icao: Option<String>,
|
||||||
#[serde(rename = "id", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub frequency_id: Option<String>,
|
|
||||||
#[serde(rename = "name", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub frequency_name: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub frequency_mhz: Option<f32>,
|
pub id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub frequencies_mhz: Option<Vec<f32>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phone: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<FrequencyRow> for Frequency {
|
impl From<CommunicationRow> for Communication {
|
||||||
fn from(frequency: FrequencyRow) -> Self {
|
fn from(frequency: CommunicationRow) -> Self {
|
||||||
Self {
|
Self {
|
||||||
frequency_id: frequency.frequency_id.clone(),
|
id: frequency.frequency_id.clone(),
|
||||||
frequency_name: frequency.frequency_name.clone(),
|
name: frequency.name.clone(),
|
||||||
frequency_mhz: frequency.frequency_mhz,
|
frequencies_mhz: frequency.frequencies_mhz,
|
||||||
|
phone: frequency.phone.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Frequency {
|
impl Communication {
|
||||||
pub fn into(frequency: &Frequency, icao: &str) -> FrequencyRow {
|
pub fn into(frequency: &Communication, icao: &str) -> CommunicationRow {
|
||||||
FrequencyRow {
|
CommunicationRow {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
icao: icao.to_string(),
|
icao: icao.to_string(),
|
||||||
frequency_id: frequency.frequency_id.clone(),
|
frequency_id: frequency.id.clone(),
|
||||||
frequency_name: frequency.frequency_name.clone(),
|
name: frequency.name.clone(),
|
||||||
frequency_mhz: frequency.frequency_mhz.clone(),
|
frequencies_mhz: frequency.frequencies_mhz.clone(),
|
||||||
|
phone: frequency.phone.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
|
pub async fn select_all_map(icaos: Vec<String>) -> ApiResult<HashMap<String, Vec<Self>>> {
|
||||||
let pool = db::pool();
|
let pool = db::pool();
|
||||||
|
|
||||||
let frequency_rows: Vec<FrequencyRow> = sqlx::query_as(&format!(
|
let frequency_rows: Vec<CommunicationRow> = sqlx::query_as(&format!(
|
||||||
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
|
r#"SELECT * FROM {} WHERE icao = ANY($1)"#,
|
||||||
TABLE_NAME
|
TABLE_NAME
|
||||||
))
|
))
|
||||||
@@ -85,7 +91,7 @@ impl Frequency {
|
|||||||
pub async fn select_all(icao: &str) -> ApiResult<Vec<Self>> {
|
pub async fn select_all(icao: &str) -> ApiResult<Vec<Self>> {
|
||||||
let pool = db::pool();
|
let pool = db::pool();
|
||||||
|
|
||||||
let frequency_row: Vec<FrequencyRow> = sqlx::query_as(&format!(
|
let frequency_row: Vec<CommunicationRow> = sqlx::query_as(&format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT * FROM {} WHERE icao = $1
|
SELECT * FROM {} WHERE icao = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -97,21 +103,22 @@ impl Frequency {
|
|||||||
Ok(frequency_row.into_iter().map(From::from).collect())
|
Ok(frequency_row.into_iter().map(From::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_all(frequencies: &Vec<FrequencyRow>) -> ApiResult<()> {
|
pub async fn insert_all(communications: &Vec<CommunicationRow>) -> ApiResult<()> {
|
||||||
let pool = db::pool();
|
let pool = db::pool();
|
||||||
let chunk_size = 1000;
|
let chunk_size = 1000;
|
||||||
|
|
||||||
for chunk in frequencies.chunks(chunk_size) {
|
for chunk in communications.chunks(chunk_size) {
|
||||||
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
|
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
|
||||||
"INSERT INTO {} (id, icao, frequency_id, frequency_name, frequency_mhz) ",
|
"INSERT INTO {} (id, icao, frequency_id, name, frequencies_mhz, phone) ",
|
||||||
TABLE_NAME
|
TABLE_NAME
|
||||||
));
|
));
|
||||||
query_builder.push_values(chunk, |mut b, row| {
|
query_builder.push_values(chunk, |mut b, row| {
|
||||||
b.push_bind(&row.id)
|
b.push_bind(&row.id)
|
||||||
.push_bind(&row.icao)
|
.push_bind(&row.icao)
|
||||||
.push_bind(&row.frequency_id)
|
.push_bind(&row.frequency_id)
|
||||||
.push_bind(&row.frequency_name)
|
.push_bind(&row.name)
|
||||||
.push_bind(&row.frequency_mhz);
|
.push_bind(&row.frequencies_mhz)
|
||||||
|
.push_bind(&row.phone);
|
||||||
});
|
});
|
||||||
|
|
||||||
let query = query_builder.build();
|
let query = query_builder.build();
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
mod airport;
|
mod airport;
|
||||||
mod airport_category;
|
mod airport_category;
|
||||||
mod frequency;
|
mod communication;
|
||||||
mod runway;
|
mod runway;
|
||||||
|
|
||||||
pub use airport::*;
|
pub use airport::*;
|
||||||
pub use airport_category::*;
|
pub use airport_category::*;
|
||||||
pub use frequency::*;
|
pub use communication::*;
|
||||||
pub use runway::*;
|
pub use runway::*;
|
||||||
|
|||||||
@@ -855,7 +855,7 @@ impl Metar {
|
|||||||
has_tower: None,
|
has_tower: None,
|
||||||
has_beacon: None,
|
has_beacon: None,
|
||||||
runways: None,
|
runways: None,
|
||||||
frequencies: None,
|
communications: None,
|
||||||
public: None,
|
public: None,
|
||||||
latest_metar_observation: Some(observation_time),
|
latest_metar_observation: Some(observation_time),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ meta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
url: {{API_URL}}/airports?page=1&limit=1000&metars=true
|
url: {{API_URL}}/airports?page=1&limit=1000
|
||||||
body: none
|
body: none
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ get {
|
|||||||
params:query {
|
params:query {
|
||||||
page: 1
|
page: 1
|
||||||
limit: 1000
|
limit: 1000
|
||||||
metars: true
|
~metars: true
|
||||||
~icaos: 00AA
|
~icaos: 00AA
|
||||||
~icaos: KHEF,KJYO,KMRB,KOKV
|
~icaos: KHEF,KJYO,KMRB,KOKV
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ body:json {
|
|||||||
"latitude": 0,
|
"latitude": 0,
|
||||||
"longitude": 0,
|
"longitude": 0,
|
||||||
"runways": [],
|
"runways": [],
|
||||||
"frequencies": [],
|
"communications": [],
|
||||||
"public": true
|
"public": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,12 @@ import { Header } from '@components/Header';
|
|||||||
import AirportLayer from '@components/AirportLayer.tsx';
|
import AirportLayer from '@components/AirportLayer.tsx';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Airport } from '@lib/airport.types.ts';
|
import { Airport } from '@lib/airport.types.ts';
|
||||||
import Index from '@components/AirportDrawer';
|
|
||||||
import { getWeatherMapUrl } from '@lib/rainViewer.ts';
|
import { getWeatherMapUrl } from '@lib/rainViewer.ts';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
import { IconBuildingAirport, IconRadar } from '@tabler/icons-react';
|
import { IconBuildingAirport, IconRadar } from '@tabler/icons-react';
|
||||||
import { GroupControl } from '@components/GroupControl.tsx';
|
import { GroupControl } from '@components/GroupControl.tsx';
|
||||||
|
import { AirportDrawer } from '@components/AirportDrawer';
|
||||||
|
import { LocateControl } from '@components/LocateControl.tsx';
|
||||||
// Fix Leaflet's default icon path issues with Webpack
|
// Fix Leaflet's default icon path issues with Webpack
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -92,7 +93,6 @@ function App() {
|
|||||||
<div className='App'>
|
<div className='App'>
|
||||||
<Header />
|
<Header />
|
||||||
<div className='map-wrapper'>
|
<div className='map-wrapper'>
|
||||||
<Index airport={airport} setAirport={setAirport} />
|
|
||||||
<MapContainer
|
<MapContainer
|
||||||
className='leaflet-container'
|
className='leaflet-container'
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
@@ -107,6 +107,7 @@ function App() {
|
|||||||
scrollWheelZoom={true}
|
scrollWheelZoom={true}
|
||||||
zoomControl={false}
|
zoomControl={false}
|
||||||
>
|
>
|
||||||
|
<AirportDrawer airport={airport} setAirport={setAirport} />
|
||||||
<LayersControl>
|
<LayersControl>
|
||||||
{layerMap.map((layer, index) => (
|
{layerMap.map((layer, index) => (
|
||||||
<LayersControl.BaseLayer key={index} checked={selectedLayerIndex === `${index}`} name={layer.name}>
|
<LayersControl.BaseLayer key={index} checked={selectedLayerIndex === `${index}`} name={layer.name}>
|
||||||
@@ -119,6 +120,7 @@ function App() {
|
|||||||
<ZoomControl position={'bottomright'} />
|
<ZoomControl position={'bottomright'} />
|
||||||
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
|
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
|
||||||
<BaseLayerChangeHandler />
|
<BaseLayerChangeHandler />
|
||||||
|
<LocateControl />
|
||||||
<GroupControl
|
<GroupControl
|
||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
|
|||||||
27
ui/src/components/AirportDrawer/CommunicationTable.tsx
Normal file
27
ui/src/components/AirportDrawer/CommunicationTable.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Table } from '@mantine/core';
|
||||||
|
import { Communication } from '@lib/airport.types.ts';
|
||||||
|
|
||||||
|
export function CommunicationTable({ communications }: { communications: Communication[] }) {
|
||||||
|
const rows = communications.map((communication) => (
|
||||||
|
<Table.Tr key={communication.id}>
|
||||||
|
<Table.Td>{communication.id}</Table.Td>
|
||||||
|
<Table.Td>{communication.name}</Table.Td>
|
||||||
|
<Table.Td>{communication.frequencies_mhz}</Table.Td>
|
||||||
|
<Table.Td>{communication.phone}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>ID</Table.Th>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Frequencies (MHz)</Table.Th>
|
||||||
|
<Table.Th>Phone</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{rows}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Table } from '@mantine/core';
|
|
||||||
import { Frequency } from '@lib/airport.types.ts';
|
|
||||||
|
|
||||||
export default function FrequencyTable({ frequencies }: { frequencies: Frequency[] }) {
|
|
||||||
const rows = frequencies.map((frequency) => (
|
|
||||||
<Table.Tr key={frequency.id}>
|
|
||||||
<Table.Td>{frequency.id}</Table.Td>
|
|
||||||
<Table.Td>{frequency.name}</Table.Td>
|
|
||||||
<Table.Td>{frequency.frequency_mhz}</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>ID</Table.Th>
|
|
||||||
<Table.Th>Name</Table.Th>
|
|
||||||
<Table.Th>MHz</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>{rows}</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Table } from '@mantine/core';
|
import { Table } from '@mantine/core';
|
||||||
import { Runway } from '@lib/airport.types.ts';
|
import { Runway } from '@lib/airport.types.ts';
|
||||||
|
|
||||||
export default function RunwayTable({ runways }: { runways: Runway[] }) {
|
export function RunwayTable({ runways }: { runways: Runway[] }) {
|
||||||
const rows = runways.map((runway) => (
|
const rows = runways.map((runway) => (
|
||||||
<Table.Tr key={runway.id}>
|
<Table.Tr key={runway.id}>
|
||||||
<Table.Td>{runway.id}</Table.Td>
|
<Table.Td>{runway.id}</Table.Td>
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import { CSSProperties, 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';
|
||||||
import { IconViewfinder } from '@tabler/icons-react';
|
import { IconViewfinder } from '@tabler/icons-react';
|
||||||
import RunwayTable from '@components/AirportDrawer/RunwayTable.tsx';
|
import { RunwayTable } from '@components/AirportDrawer/RunwayTable.tsx';
|
||||||
import FrequencyTable from '@components/AirportDrawer/FrequencyTable.tsx';
|
import { CommunicationTable } from '@components/AirportDrawer/CommunicationTable.tsx';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
|
|
||||||
export default function Index({
|
export function AirportDrawer({
|
||||||
airport,
|
airport,
|
||||||
setAirport
|
setAirport
|
||||||
}: {
|
}: {
|
||||||
@@ -29,12 +31,12 @@ export default function Index({
|
|||||||
}) {
|
}) {
|
||||||
const [metar, setMetar] = useState<Metar | undefined>(undefined);
|
const [metar, setMetar] = useState<Metar | undefined>(undefined);
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!airport) return;
|
if (!airport) return;
|
||||||
function updateMetar() {
|
function updateMetar() {
|
||||||
if (!airport) return;
|
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]);
|
||||||
@@ -104,7 +106,7 @@ export default function Index({
|
|||||||
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
|
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<Tabs.Panel value={'info'}>
|
<Tabs.Panel value={'info'}>
|
||||||
<AirportInfo airport={airport} />
|
<AirportInfo map={map} airport={airport} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value={'weather'}>
|
<Tabs.Panel value={'weather'}>
|
||||||
<WeatherInfo metar={airport.latest_metar} />
|
<WeatherInfo metar={airport.latest_metar} />
|
||||||
@@ -149,7 +151,12 @@ function AirportInfoRow({ style, children }: { style?: CSSProperties; children:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AirportInfo({ airport }: { airport: Airport }) {
|
function AirportInfo({ map, airport }: { map: LeafletMap, airport: Airport }) {
|
||||||
|
function goToLocation(map: LeafletMap, latitude: number, longitude: number) {
|
||||||
|
if (!map) return
|
||||||
|
map.setView([latitude, longitude], map.getZoom())
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AirportInfoRow>
|
<AirportInfoRow>
|
||||||
@@ -164,7 +171,9 @@ function AirportInfo({ airport }: { airport: Airport }) {
|
|||||||
</AirportInfoSlot>
|
</AirportInfoSlot>
|
||||||
<AirportInfoSlot title={'Elevation'} style={{ paddingLeft: '1rem' }} children={`${airport.elevation_ft} ft`} />
|
<AirportInfoSlot title={'Elevation'} style={{ paddingLeft: '1rem' }} children={`${airport.elevation_ft} ft`} />
|
||||||
<AirportInfoSlot style={{ marginLeft: 'auto', paddingLeft: '1rem', paddingTop: '0.5rem' }}>
|
<AirportInfoSlot style={{ marginLeft: 'auto', paddingLeft: '1rem', paddingTop: '0.5rem' }}>
|
||||||
<UnstyledButton>
|
<UnstyledButton onClick={() => {
|
||||||
|
goToLocation(map, airport.latitude, airport.longitude)
|
||||||
|
}}>
|
||||||
<IconViewfinder />
|
<IconViewfinder />
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</AirportInfoSlot>
|
</AirportInfoSlot>
|
||||||
@@ -180,13 +189,13 @@ function AirportInfo({ airport }: { airport: Airport }) {
|
|||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
)}
|
)}
|
||||||
{airport.frequencies != null && airport.frequencies.length > 0 && (
|
{airport.communications != null && airport.communications.length > 0 && (
|
||||||
<Accordion.Item value={'frequencies'}>
|
<Accordion.Item value={'communication'}>
|
||||||
<Accordion.Control>
|
<Accordion.Control>
|
||||||
Frequencies
|
Communication
|
||||||
</Accordion.Control>
|
</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<FrequencyTable frequencies={airport.frequencies} />
|
<CommunicationTable communications={airport.communications} />
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,63 +13,55 @@ interface Props {
|
|||||||
|
|
||||||
export function CustomControl({ position = 'bottomright', onClick, active = false, title = '', children }: Props) {
|
export function CustomControl({ position = 'bottomright', onClick, active = false, title = '', children }: Props) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
const controlRef = useRef<L.Control>(null);
|
||||||
// Create references
|
const rootRef = useRef<Root>(null);
|
||||||
const buttonRef = useRef<HTMLAnchorElement | null>(null);
|
|
||||||
const reactRootRef = useRef<Root | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ctrl = new L.Control({ position });
|
const ctrl = new L.Control({ position });
|
||||||
|
|
||||||
ctrl.onAdd = () => {
|
ctrl.onAdd = () => {
|
||||||
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
|
return L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
|
||||||
const button = L.DomUtil.create('a', '', container) as HTMLAnchorElement;
|
|
||||||
button.href = '#';
|
|
||||||
button.title = title;
|
|
||||||
|
|
||||||
// Prevent clicks/scrolls on the control from hitting the map
|
|
||||||
L.DomEvent.disableClickPropagation(container);
|
|
||||||
L.DomEvent.disableScrollPropagation(container);
|
|
||||||
|
|
||||||
// Wire up the handler
|
|
||||||
L.DomEvent.on(button, 'click', L.DomEvent.stop);
|
|
||||||
L.DomEvent.on(button, 'click', L.DomEvent.preventDefault);
|
|
||||||
L.DomEvent.on(button, 'click', () => onClick());
|
|
||||||
|
|
||||||
buttonRef.current = button;
|
|
||||||
|
|
||||||
// Initial active status
|
|
||||||
if (active) {
|
|
||||||
button.classList.add('active');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render children
|
|
||||||
if (children) {
|
|
||||||
reactRootRef.current = createRoot(button);
|
|
||||||
reactRootRef.current.render(children);
|
|
||||||
}
|
|
||||||
return container;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add component to the map
|
|
||||||
ctrl.addTo(map);
|
ctrl.addTo(map);
|
||||||
|
controlRef.current = ctrl;
|
||||||
|
|
||||||
|
// @ts-expect-error ctrl is a L.Control
|
||||||
|
const container = (ctrl as unknown)._container as HTMLElement;
|
||||||
|
rootRef.current = createRoot(container);
|
||||||
|
|
||||||
// On unmount, remove component
|
|
||||||
return () => {
|
return () => {
|
||||||
ctrl.remove();
|
if (rootRef.current) {
|
||||||
if (reactRootRef.current) {
|
rootRef.current!.unmount();
|
||||||
reactRootRef.current.unmount();
|
rootRef.current = null;
|
||||||
reactRootRef.current = null;
|
|
||||||
}
|
}
|
||||||
|
ctrl.remove();
|
||||||
};
|
};
|
||||||
}, [map, position, onClick, children, active, title]);
|
}, [map, position]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const btn = buttonRef.current;
|
if (rootRef.current) {
|
||||||
if (!btn) return;
|
rootRef.current.render(
|
||||||
if (active) btn.classList.add('active');
|
<a
|
||||||
else btn.classList.remove('active');
|
href={'#'}
|
||||||
}, [active]);
|
title={title}
|
||||||
|
className={active ? 'active' : ''}
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [onClick, active, title, children]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { ReactNode, useEffect, useRef } from 'react';
|
||||||
import { ReactNode } from 'react';
|
|
||||||
import * as L from 'leaflet';
|
import * as L from 'leaflet';
|
||||||
import { useMap } from 'react-leaflet';
|
import { useMap } from 'react-leaflet';
|
||||||
import { createRoot, Root } from 'react-dom/client';
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
@@ -19,56 +18,57 @@ interface GroupControlProps {
|
|||||||
export function GroupControl({ position = 'bottomright', buttons }: GroupControlProps) {
|
export function GroupControl({ position = 'bottomright', buttons }: GroupControlProps) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
// References
|
// References
|
||||||
const buttonRefs = useRef<HTMLAnchorElement[]>([]);
|
const controlRef = useRef<L.Control>(null);
|
||||||
const reactRootRefs = useRef<Root[]>([]);
|
const rootRef = useRef<Root>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ctrl = new L.Control({ position });
|
const ctrl = new L.Control({ position });
|
||||||
const current = reactRootRefs.current;
|
controlRef.current = ctrl;
|
||||||
|
|
||||||
ctrl.onAdd = () => {
|
ctrl.onAdd = () => {
|
||||||
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
|
return L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
|
||||||
buttons.forEach((btnDef, i) => {
|
|
||||||
const btn = L.DomUtil.create('a', '', container) as HTMLAnchorElement;
|
|
||||||
btn.href = '#';
|
|
||||||
btn.title = btnDef.title;
|
|
||||||
|
|
||||||
// standard leaflet click‐blocking magic
|
|
||||||
L.DomEvent.disableClickPropagation(btn);
|
|
||||||
L.DomEvent.disableScrollPropagation(btn);
|
|
||||||
L.DomEvent.on(btn, 'click', L.DomEvent.stop)
|
|
||||||
.on(btn, 'click', L.DomEvent.preventDefault)
|
|
||||||
.on(btn, 'click', btnDef.onClick);
|
|
||||||
|
|
||||||
// Initial active status
|
|
||||||
if (btnDef.active) btn.classList.add('active');
|
|
||||||
|
|
||||||
// Render root
|
|
||||||
const rootRef = createRoot(btn);
|
|
||||||
rootRef.render(btnDef.icon);
|
|
||||||
reactRootRefs.current[i] = rootRef;
|
|
||||||
buttonRefs.current[i] = btn;
|
|
||||||
});
|
|
||||||
return container;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ctrl.addTo(map);
|
ctrl.addTo(map);
|
||||||
|
|
||||||
|
// @ts-expect-error ctrl is a L.Control
|
||||||
|
const container = (ctrl as unknown)._container as HTMLElement;
|
||||||
|
rootRef.current = createRoot(container);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ctrl.remove();
|
ctrl.remove();
|
||||||
// unmount React roots
|
rootRef.current!.unmount();
|
||||||
current.forEach((r) => r.unmount());
|
|
||||||
};
|
};
|
||||||
}, [map, buttons, position]);
|
}, [map, position]);
|
||||||
|
|
||||||
// if you want to toggle “.active” live when props change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
buttons.forEach((b, i) => {
|
if (rootRef.current) {
|
||||||
const btn = buttonRefs.current[i];
|
rootRef.current.render(
|
||||||
if (!btn) return;
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
if (b.active) btn.classList.add('active');
|
{buttons.map((b, i) => (
|
||||||
else btn.classList.remove('active');
|
<a
|
||||||
});
|
key={i}
|
||||||
|
href="#"
|
||||||
|
title={b.title}
|
||||||
|
className={b.active ? 'active' : ''}
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
b.onClick();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.icon}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
}, [buttons]);
|
}, [buttons]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
31
ui/src/components/LocateControl.tsx
Normal file
31
ui/src/components/LocateControl.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import { CustomControl } from '@components/CustomControl.tsx';
|
||||||
|
import { IconCurrentLocation } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export function LocateControl() {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
alert('Geolocation is not supported by your browser');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const { latitude, longitude } = pos.coords;
|
||||||
|
// you can use setView or flyTo
|
||||||
|
map.setView([latitude, longitude], map.getZoom());
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.error(err);
|
||||||
|
alert('Unable to retrieve your location');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomControl onClick={handleClick} title="Go to my location">
|
||||||
|
<IconCurrentLocation />
|
||||||
|
</CustomControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export interface Airport {
|
|||||||
has_tower: boolean;
|
has_tower: boolean;
|
||||||
has_beacon: boolean;
|
has_beacon: boolean;
|
||||||
runways: Runway[];
|
runways: Runway[];
|
||||||
frequencies: Frequency[];
|
communications: Communication[];
|
||||||
public: boolean;
|
public: boolean;
|
||||||
latest_metar?: Metar;
|
latest_metar?: Metar;
|
||||||
}
|
}
|
||||||
@@ -48,10 +48,11 @@ export interface Runway {
|
|||||||
surface: string;
|
surface: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Frequency {
|
export interface Communication {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
frequency_mhz: number;
|
frequencies_mhz: number[];
|
||||||
|
phone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetAirportsResponse {
|
export interface GetAirportsResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user