Update frequencies to communications, fixed control icons

This commit is contained in:
2025-05-14 08:28:43 -04:00
parent 019fb77373
commit 1e3c75624a
18 changed files with 20457 additions and 20413 deletions

View File

@@ -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,

View File

@@ -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);
} }
} }

View File

@@ -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(())
} }

View File

@@ -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();

View File

@@ -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::*;

View File

@@ -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),
}, },

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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={[
{ {

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -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;
} }

View File

@@ -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 clickblocking 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;

View 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>
);
}

View File

@@ -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 {