Update frequencies to communications, fixed control icons
This commit is contained in:
@@ -10,11 +10,12 @@ import { Header } from '@components/Header';
|
||||
import AirportLayer from '@components/AirportLayer.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Airport } from '@lib/airport.types.ts';
|
||||
import Index from '@components/AirportDrawer';
|
||||
import { getWeatherMapUrl } from '@lib/rainViewer.ts';
|
||||
import Cookies from 'js-cookie';
|
||||
import { IconBuildingAirport, IconRadar } from '@tabler/icons-react';
|
||||
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
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
@@ -92,7 +93,6 @@ function App() {
|
||||
<div className='App'>
|
||||
<Header />
|
||||
<div className='map-wrapper'>
|
||||
<Index airport={airport} setAirport={setAirport} />
|
||||
<MapContainer
|
||||
className='leaflet-container'
|
||||
attributionControl={false}
|
||||
@@ -107,6 +107,7 @@ function App() {
|
||||
scrollWheelZoom={true}
|
||||
zoomControl={false}
|
||||
>
|
||||
<AirportDrawer airport={airport} setAirport={setAirport} />
|
||||
<LayersControl>
|
||||
{layerMap.map((layer, index) => (
|
||||
<LayersControl.BaseLayer key={index} checked={selectedLayerIndex === `${index}`} name={layer.name}>
|
||||
@@ -119,6 +120,7 @@ function App() {
|
||||
<ZoomControl position={'bottomright'} />
|
||||
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
|
||||
<BaseLayerChangeHandler />
|
||||
<LocateControl />
|
||||
<GroupControl
|
||||
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 { Runway } from '@lib/airport.types.ts';
|
||||
|
||||
export default function RunwayTable({ runways }: { runways: Runway[] }) {
|
||||
export function RunwayTable({ runways }: { runways: Runway[] }) {
|
||||
const rows = runways.map((runway) => (
|
||||
<Table.Tr key={runway.id}>
|
||||
<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 { useMediaQuery } from '@mantine/hooks';
|
||||
import { IconViewfinder } from '@tabler/icons-react';
|
||||
import RunwayTable from '@components/AirportDrawer/RunwayTable.tsx';
|
||||
import FrequencyTable from '@components/AirportDrawer/FrequencyTable.tsx';
|
||||
import { RunwayTable } from '@components/AirportDrawer/RunwayTable.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,
|
||||
setAirport
|
||||
}: {
|
||||
@@ -29,12 +31,12 @@ export default function Index({
|
||||
}) {
|
||||
const [metar, setMetar] = useState<Metar | undefined>(undefined);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (!airport) return;
|
||||
function updateMetar() {
|
||||
if (!airport) return;
|
||||
console.log(airport.icao);
|
||||
getMetars({ icaos: [airport.icao] }).then((m) => {
|
||||
if (m.length > 0) {
|
||||
setMetar(m[0]);
|
||||
@@ -104,7 +106,7 @@ export default function Index({
|
||||
<Tabs.Tab value={'weather'}>Weather</Tabs.Tab>
|
||||
</TabsList>
|
||||
<Tabs.Panel value={'info'}>
|
||||
<AirportInfo airport={airport} />
|
||||
<AirportInfo map={map} airport={airport} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value={'weather'}>
|
||||
<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 (
|
||||
<div>
|
||||
<AirportInfoRow>
|
||||
@@ -164,7 +171,9 @@ function AirportInfo({ airport }: { airport: Airport }) {
|
||||
</AirportInfoSlot>
|
||||
<AirportInfoSlot title={'Elevation'} style={{ paddingLeft: '1rem' }} children={`${airport.elevation_ft} ft`} />
|
||||
<AirportInfoSlot style={{ marginLeft: 'auto', paddingLeft: '1rem', paddingTop: '0.5rem' }}>
|
||||
<UnstyledButton>
|
||||
<UnstyledButton onClick={() => {
|
||||
goToLocation(map, airport.latitude, airport.longitude)
|
||||
}}>
|
||||
<IconViewfinder />
|
||||
</UnstyledButton>
|
||||
</AirportInfoSlot>
|
||||
@@ -180,13 +189,13 @@ function AirportInfo({ airport }: { airport: Airport }) {
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
{airport.frequencies != null && airport.frequencies.length > 0 && (
|
||||
<Accordion.Item value={'frequencies'}>
|
||||
{airport.communications != null && airport.communications.length > 0 && (
|
||||
<Accordion.Item value={'communication'}>
|
||||
<Accordion.Control>
|
||||
Frequencies
|
||||
Communication
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<FrequencyTable frequencies={airport.frequencies} />
|
||||
<CommunicationTable communications={airport.communications} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
|
||||
@@ -13,63 +13,55 @@ interface Props {
|
||||
|
||||
export function CustomControl({ position = 'bottomright', onClick, active = false, title = '', children }: Props) {
|
||||
const map = useMap();
|
||||
|
||||
// Create references
|
||||
const buttonRef = useRef<HTMLAnchorElement | null>(null);
|
||||
const reactRootRef = useRef<Root | null>(null);
|
||||
const controlRef = useRef<L.Control>(null);
|
||||
const rootRef = useRef<Root>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new L.Control({ position });
|
||||
|
||||
ctrl.onAdd = () => {
|
||||
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
|
||||
const button = L.DomUtil.create('a', '', container) as HTMLAnchorElement;
|
||||
button.href = '#';
|
||||
button.title = title;
|
||||
return L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
|
||||
}
|
||||
|
||||
// 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);
|
||||
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 () => {
|
||||
ctrl.remove();
|
||||
if (reactRootRef.current) {
|
||||
reactRootRef.current.unmount();
|
||||
reactRootRef.current = null;
|
||||
if (rootRef.current) {
|
||||
rootRef.current!.unmount();
|
||||
rootRef.current = null;
|
||||
}
|
||||
ctrl.remove();
|
||||
};
|
||||
}, [map, position, onClick, children, active, title]);
|
||||
}, [map, position]);
|
||||
|
||||
useEffect(() => {
|
||||
const btn = buttonRef.current;
|
||||
if (!btn) return;
|
||||
if (active) btn.classList.add('active');
|
||||
else btn.classList.remove('active');
|
||||
}, [active]);
|
||||
if (rootRef.current) {
|
||||
rootRef.current.render(
|
||||
<a
|
||||
href={'#'}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useEffect, useRef } from 'react';
|
||||
import * as L from 'leaflet';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
@@ -19,56 +18,57 @@ interface GroupControlProps {
|
||||
export function GroupControl({ position = 'bottomright', buttons }: GroupControlProps) {
|
||||
const map = useMap();
|
||||
// References
|
||||
const buttonRefs = useRef<HTMLAnchorElement[]>([]);
|
||||
const reactRootRefs = useRef<Root[]>([]);
|
||||
const controlRef = useRef<L.Control>(null);
|
||||
const rootRef = useRef<Root>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new L.Control({ position });
|
||||
const current = reactRootRefs.current;
|
||||
controlRef.current = ctrl;
|
||||
|
||||
ctrl.onAdd = () => {
|
||||
const container = 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;
|
||||
return L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
|
||||
};
|
||||
|
||||
ctrl.addTo(map);
|
||||
|
||||
// @ts-expect-error ctrl is a L.Control
|
||||
const container = (ctrl as unknown)._container as HTMLElement;
|
||||
rootRef.current = createRoot(container);
|
||||
|
||||
return () => {
|
||||
ctrl.remove();
|
||||
// unmount React roots
|
||||
current.forEach((r) => r.unmount());
|
||||
rootRef.current!.unmount();
|
||||
};
|
||||
}, [map, buttons, position]);
|
||||
}, [map, position]);
|
||||
|
||||
// if you want to toggle “.active” live when props change
|
||||
useEffect(() => {
|
||||
buttons.forEach((b, i) => {
|
||||
const btn = buttonRefs.current[i];
|
||||
if (!btn) return;
|
||||
if (b.active) btn.classList.add('active');
|
||||
else btn.classList.remove('active');
|
||||
});
|
||||
if (rootRef.current) {
|
||||
rootRef.current.render(
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{buttons.map((b, i) => (
|
||||
<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]);
|
||||
|
||||
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_beacon: boolean;
|
||||
runways: Runway[];
|
||||
frequencies: Frequency[];
|
||||
communications: Communication[];
|
||||
public: boolean;
|
||||
latest_metar?: Metar;
|
||||
}
|
||||
@@ -48,10 +48,11 @@ export interface Runway {
|
||||
surface: string;
|
||||
}
|
||||
|
||||
export interface Frequency {
|
||||
export interface Communication {
|
||||
id: string;
|
||||
name: string;
|
||||
frequency_mhz: number;
|
||||
frequencies_mhz: number[];
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface GetAirportsResponse {
|
||||
|
||||
Reference in New Issue
Block a user