This commit is contained in:
2025-07-17 22:22:07 +02:00
parent 2bdb7e78c4
commit e0ee6d11dd
112 changed files with 71 additions and 2820 deletions
+22
View File
@@ -0,0 +1,22 @@
import type { FC } from 'react';
interface LoaderProps {
size?: 0.5 | 1 | 2 | 3;
}
export const Loader: FC<LoaderProps> = ({ size = 2 }) => {
const variants = {
0.5: 'size-6 border-1 after:top-1 after:left-0.5 after:size-1.5 after:border-1',
1: 'size-12 border-2 after:top-2 after:left-1 after:size-3 after:border-2',
2: 'size-24 border-4 after:top-3 after:left-2 after:size-6 after:border-4',
3: 'size-36 border-6 after:top-4 after:left-3 after:size-9 after:border-6',
};
return (
<div className="flex justify-center">
<span
className={`relative inline-block ${variants[size]} animate-spin rounded-[50%] border-red-500 after:absolute after:rounded-[50%] after:border-black after:content-['']`}
></span>
</div>
);
};
+119
View File
@@ -0,0 +1,119 @@
import { default as L } from 'leaflet';
import 'leaflet-svg-shape-markers';
import { createRef, Fragment, useEffect, type FC } from 'react';
import { GeoJSON, ImageOverlay, MapContainer, TileLayer } from 'react-leaflet';
interface MapProps {
airport: Airport | undefined;
chart: Chart | undefined;
procedures: Procedure[];
}
export const Map: FC<MapProps> = ({ airport, chart, procedures }) => {
const mapRef = createRef<L.Map>();
const imageRef = createRef<L.ImageOverlay>();
useEffect(() => {
if (airport) mapRef.current?.flyTo([airport?.Latitude, airport?.Longitude], 10, { animate: false });
}, [airport]);
return (
<MapContainer
center={[0, 0]}
zoom={5}
zoomSnap={0}
className="h-full w-full"
ref={(_mapRef) => {
_mapRef?.attributionControl.setPosition('topright');
_mapRef?.zoomControl.setPosition('topright');
mapRef.current = _mapRef;
}}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{chart && chart.bounds && (
<ImageOverlay
url={chart.data}
bounds={chart.bounds}
opacity={0.75}
ref={(_imageRef) => {
if (_imageRef) {
mapRef.current?.fitBounds(_imageRef.getBounds(), {
padding: [-50, -50],
});
}
imageRef.current = _imageRef;
}}
/>
)}
{procedures.map((procedure) => (
<Fragment key={procedure.name}>
<GeoJSON
key={`${procedure.name}-lines`}
data={procedure.data}
style={({ properties }) => ({
color: properties.isMAP ? '#00ffff' : '#ff00ff',
stroke: true,
weight: properties.isMAP ? 2.5 : 5,
opacity: 1,
dashArray: properties.isManual ? '20, 20' : undefined,
})}
filter={(feature) => feature.geometry.type !== 'Point'}
/>
<GeoJSON
key={`${procedure.name}-points`}
data={procedure.data}
style={{
color: 'black',
fill: true,
fillColor: 'transparent',
stroke: true,
weight: 3,
}}
pointToLayer={({ properties }, latlng) => {
if (properties.isIntersection) return L.circleMarker(latlng, { radius: 6 });
else if (properties.IsMAP)
return L.shapeMarker(latlng, {
shape: 'square',
radius: 6,
});
else if (properties.IsFAF)
return L.shapeMarker(latlng, {
shape: 'diamond',
radius: 6,
});
else if (properties.isFlyOver)
return L.shapeMarker(latlng, {
shape: 'triangle',
radius: 6,
});
return L.shapeMarker(latlng, {
shape: 'star-4',
radius: 10,
rotation: 45,
});
}}
onEachFeature={({ geometry, properties }, layer) => {
if (geometry.type === 'Point') {
layer.bindPopup(
`${properties.name}<br>
${properties.altitude} ft<br>
${properties.speed} kts<br>
CNSTR:
${properties.altitudeConstraint ?? ''}
${properties.speedConstraint ?? ''}<br>`
);
}
}}
filter={(feature) => feature.geometry.type === 'Point'}
/>
</Fragment>
))}
</MapContainer>
);
};
@@ -0,0 +1,190 @@
import { createRef, useMemo, useState, type Dispatch, type FC, type SetStateAction } from 'react';
import Parser from '../parser/parser';
import { Loader } from './Loader';
const parser = await Parser.instance();
interface ProcedureSelectProps {
selectedAirport: Airport | undefined;
selectedRunway: Runway | undefined;
selectedTerminal: Terminal | undefined;
setSelectedAirport: Dispatch<SetStateAction<Airport | undefined>>;
setSelectedRunway: Dispatch<SetStateAction<Runway | undefined>>;
setSelectedTerminal: Dispatch<SetStateAction<Terminal | undefined>>;
handleSelection: (transitions: string[]) => void;
}
export const ProcedureSelect: FC<ProcedureSelectProps> = ({
selectedAirport,
selectedRunway,
selectedTerminal,
setSelectedAirport,
setSelectedRunway,
setSelectedTerminal,
handleSelection,
}) => {
const inputRef = createRef<HTMLInputElement>();
const [error, setError] = useState<string>();
const [inFlight, setInFlight] = useState<number>();
const runways = useMemo(
() => parser.runways.filter(({ AirportID }) => AirportID === selectedAirport?.ID),
[selectedAirport]
);
const terminals = useMemo(() => {
const _terminals = parser.terminals.filter(
({ AirportID, RwyID }) => AirportID === selectedAirport?.ID && (!RwyID || RwyID === selectedRunway?.ID)
);
return {
star: _terminals.filter((terminal) => terminal.Proc === 1),
sid: _terminals.filter((terminal) => terminal.Proc === 2),
iap: _terminals.filter((terminal) => terminal.Proc === 3),
};
}, [selectedAirport, selectedRunway]);
return (
<div className="flex h-full w-[300px] shrink-0 flex-col overflow-hidden">
{selectedAirport && (
<button
className="sticky top-0 m-2 cursor-pointer rounded border border-red-500 bg-red-500 px-2 py-1 font-semibold text-stone-50 focus:outline-2 focus:outline-black focus-visible:outline-2 focus-visible:outline-black"
onClick={() => {
setError(undefined);
setInFlight(undefined);
if (selectedTerminal) {
setSelectedTerminal(undefined);
} else if (selectedRunway) {
setSelectedRunway(undefined);
} else if (selectedAirport) {
setSelectedAirport(undefined);
}
}}
>
Go back
</button>
)}
<div className="flex h-[calc(100%-50px)] flex-col gap-2 overflow-y-auto px-2 pb-2">
{!selectedAirport && (
<div className="flex w-full flex-col gap-2">
<h1 className="text-center text-3xl">Enter Airport ICAO</h1>
<input
ref={inputRef}
className="rounded border border-black px-2 py-1 focus:outline-2 focus-visible:outline-2"
onChange={(e) => {
if (e.target.value.length <= 4) e.target.value = e.target.value.toUpperCase();
else e.target.value = e.target.value.slice(0, 4);
}}
></input>
<button
className="w-full cursor-pointer rounded border border-gray-300 bg-gray-300 px-2 py-1 font-semibold focus:outline-2 focus-visible:outline-2"
onClick={() => {
const airport = parser.airports.find(({ ICAO }) => ICAO === inputRef.current?.value.toUpperCase());
if (!airport) {
setError('Airport not found');
return;
}
setSelectedAirport(airport);
setError(undefined);
}}
>
Select Airport
</button>
{error && <span className="text-center text-red-700">{error}</span>}
</div>
)}
{selectedAirport && !selectedRunway && (
<div className="flex w-full flex-col gap-2">
<h1 className="text-center text-3xl">Select Runway</h1>
{runways.map((runway) => (
<button
key={runway.ID}
className="w-full cursor-pointer rounded border border-gray-300 bg-gray-300 px-2 py-1 font-semibold focus:outline-2 focus-visible:outline-2"
onClick={() => setSelectedRunway(runway)}
>
Runway {runway.Ident}
</button>
))}
</div>
)}
{selectedAirport && selectedRunway && !selectedTerminal && (
<div className="flex w-full flex-col gap-2">
<h1 className="text-center text-3xl">Select Procedure</h1>
<div className="flex flex-col gap-2">
<div className="sticky top-0 -mx-2 bg-gray-500 px-2 text-lg font-semibold text-white">Arrivals</div>
{terminals.star.map((terminal) => (
<button
key={terminal.ID}
className="cursor-pointer rounded border border-gray-300 bg-gray-300 px-2 py-1 font-semibold focus:outline-2 focus-visible:outline-2 disabled:bg-gray-100"
onClick={() => {
setInFlight(terminal.ID);
parser.loadTerminal(terminal.ID).then(() => {
setSelectedTerminal(terminal);
const transitions = new Set(parser.procedures.map((proc) => proc.Transition));
handleSelection(Array.from(transitions));
setInFlight(undefined);
});
}}
disabled={!!inFlight}
>
<div className="flex justify-between">
{terminal.FullName}
{inFlight === terminal.ID && <Loader size={0.5} />}
</div>
</button>
))}
</div>
<div className="flex flex-col gap-2">
<div className="sticky top-0 -mx-2 bg-gray-500 px-2 text-lg font-semibold text-white">Departures</div>
{terminals.sid.map((terminal) => (
<button
key={terminal.ID}
className="cursor-pointer rounded border border-gray-300 bg-gray-300 px-2 py-1 font-semibold focus:outline-2 focus-visible:outline-2 disabled:bg-gray-100"
onClick={() => {
parser.loadTerminal(terminal.ID).then(() => {
setSelectedTerminal(terminal);
const transitions = new Set(parser.procedures.map((proc) => proc.Transition));
handleSelection(Array.from(transitions));
});
}}
disabled={!!inFlight}
>
<div className="flex justify-between">
{terminal.FullName}
{inFlight === terminal.ID && <Loader size={0.5} />}
</div>
</button>
))}
</div>
<div className="flex flex-col gap-2">
<div className="sticky top-0 -mx-2 bg-gray-500 px-2 text-lg font-semibold text-white">Approaches</div>
{terminals.iap.map((terminal) => (
<button
key={terminal.ID}
className="cursor-pointer rounded border border-gray-300 bg-gray-300 px-2 py-1 font-semibold focus:outline-2 focus-visible:outline-2 disabled:bg-gray-100"
onClick={() => {
parser.loadTerminal(terminal.ID).then(() => {
setSelectedTerminal(terminal);
const transitions = new Set(parser.procedures.map((proc) => proc.Transition));
handleSelection(Array.from(transitions));
});
}}
disabled={!!inFlight}
>
<div className="flex justify-between">
{terminal.FullName}
{inFlight === terminal.ID && <Loader size={0.5} />}
</div>
</button>
))}
</div>
</div>
)}
</div>
</div>
);
};
+195
View File
@@ -0,0 +1,195 @@
import BrowserImageManipulation from 'browser-image-manipulation';
import L from 'leaflet';
import type { Chart as NGChart } from 'navigraph/charts';
import { useCallback, useEffect, useState, type Dispatch, type FC, type SetStateAction } from 'react';
import { charts } from '../lib/navigraph';
import { Loader } from './Loader';
interface SidebarProps {
airport: Airport;
runway: Runway;
terminal: Terminal;
transitions: Procedure[];
transition: Procedure | undefined;
chart: Chart | undefined;
setTransition: Dispatch<SetStateAction<SidebarProps['transition']>>;
setChart: Dispatch<SetStateAction<SidebarProps['chart']>>;
backAction: () => void;
}
export const Sidebar: FC<SidebarProps> = ({
airport,
runway,
terminal,
transitions,
transition,
chart,
setTransition,
setChart,
backAction,
}) => {
const [inFlight, setInFlight] = useState(false);
const [chartIndex, setChartIndex] = useState<{
sid: NGChart[];
star: NGChart[];
iap: NGChart[];
}>({ star: [], sid: [], iap: [] });
useEffect(() => {
setInFlight(true);
(async () => {
const _chartIndex = await charts.getChartsIndex({ icao: airport.ICAO, version: 'STD' });
const newChartIndex = {
star:
_chartIndex?.filter(
(_chart) =>
_chart.category === 'ARR' && (_chart.runways.length === 0 || _chart.runways.includes(runway.Ident))
) ?? [],
sid:
_chartIndex?.filter(
(_chart) =>
_chart.category === 'DEP' && (_chart.runways.length === 0 || _chart.runways.includes(runway.Ident))
) ?? [],
iap:
_chartIndex?.filter(
(_chart) =>
_chart.category === 'APP' && (_chart.runways.length === 0 || _chart.runways.includes(runway.Ident))
) ?? [],
};
_chartIndex?.filter((_chart) => ['ARR', 'DEP', 'APP'].includes(_chart.category));
setChartIndex(newChartIndex);
setInFlight(false);
})();
}, [airport.ICAO]);
useEffect(() => {
if (terminal) {
const star = chartIndex.star.find((_chart) => _chart.procedures.includes(terminal.FullName));
const sid = chartIndex.sid.find((_chart) => _chart.procedures.includes(terminal.FullName));
const iap = chartIndex.iap.find((_chart) => _chart.procedures.includes(terminal.FullName));
if (star) findChart(star);
if (sid) findChart(sid);
if (iap) findChart(iap);
}
}, [terminal, chartIndex]);
const findChart = useCallback(
async (_chart: NGChart) => {
if (chart?.index_number === _chart.index_number) return;
if (!_chart.bounding_boxes) return;
const planView = _chart.bounding_boxes.planview;
const chartImage = await charts.getChartImage({ chart: _chart, theme: 'light' });
if (!chartImage) return;
// Crop
const dataURL = await new BrowserImageManipulation()
.loadBlob(chartImage)
.crop(
planView.pixels.x2 - planView.pixels.x1,
planView.pixels.y1 - planView.pixels.y2,
planView.pixels.x1,
planView.pixels.y2
)
.saveAsImage();
const bounds = new L.LatLngBounds(
[planView.latlng.lat1, planView.latlng.lng1],
[planView.latlng.lat2, planView.latlng.lng2]
);
setChart({ data: dataURL, index_number: _chart.index_number, bounds });
},
[chart]
);
return (
<div className="flex h-full w-[300px] shrink-0 flex-col overflow-hidden">
<button
className="sticky top-0 m-2 cursor-pointer rounded border border-red-500 bg-red-500 px-2 py-1 font-semibold text-stone-50 focus:outline-2 focus:outline-black focus-visible:outline-2 focus-visible:outline-black"
onClick={backAction}
>
Go back
</button>
<div className="flex h-[calc(100%-50px)] flex-col gap-2 overflow-y-auto px-2 pb-2">
<div className="flex flex-col gap-2">
<div className="sticky top-0 -mx-2 bg-gray-500 px-2 text-lg font-semibold text-white">
Transitions for <span className="font-bold">{terminal.FullName}</span>
</div>
{transitions.map((_procedure) => (
<button
key={_procedure.name}
className={`cursor-pointer rounded border border-gray-300 bg-gray-300 px-2 py-1 font-semibold focus:outline-2 focus:outline-black focus-visible:outline-2 focus-visible:outline-black ${_procedure.name === transition?.name ? 'outline-2' : ''}`}
onClick={() => {
if (_procedure.name === transition?.name) return;
setTransition(_procedure);
}}
>
{_procedure.name ? _procedure.name : 'NONE'}
</button>
))}
</div>
<div className="flex flex-col gap-2">
<div className="sticky top-0 z-10 -mx-2 bg-gray-500 px-2 text-lg font-semibold text-white">Charts</div>
{inFlight && <Loader size={3} />}
<div className="flex flex-col gap-2">
<div className="sticky top-7 -mx-2 -mt-2 bg-gray-500 px-2 text-lg font-semibold text-white">Arrivals</div>
{chartIndex.star
.filter((_chart) => _chart.is_georeferenced)
.map((_chart) => (
<button
key={_chart.index_number}
className={`cursor-pointer rounded border border-gray-300 bg-gray-300 px-2 py-1 text-left font-semibold focus:outline-2 focus:outline-black focus-visible:outline-2 focus-visible:outline-black ${chart?.index_number === _chart.index_number ? 'outline-2' : ''}`}
onClick={() => findChart(_chart)}
>
<span className="font-bold">{_chart.index_number}</span>
<br />
{_chart.name}
</button>
))}
</div>
<div className="flex flex-col gap-2">
<div className="sticky top-7 -mx-2 bg-gray-500 px-2 text-lg font-semibold text-white">Departures</div>
{chartIndex.sid
.filter((_chart) => _chart.is_georeferenced)
.map((_chart) => (
<button
key={_chart.index_number}
className={`cursor-pointer rounded border border-gray-300 bg-gray-300 px-2 py-1 text-left font-semibold focus:outline-2 focus:outline-black focus-visible:outline-2 focus-visible:outline-black ${chart?.index_number === _chart.index_number ? 'outline-2' : ''}`}
onClick={() => findChart(_chart)}
>
<span className="font-bold">{_chart.index_number}</span>
<br />
{_chart.name}
</button>
))}
</div>
<div className="flex flex-col gap-2">
<div className="sticky top-7 -mx-2 bg-gray-500 px-2 text-lg font-semibold text-white">Approaches</div>
{chartIndex.iap
.filter((_chart) => _chart.is_georeferenced)
.map((_chart) => (
<button
key={_chart.index_number}
className={`cursor-pointer rounded border border-gray-300 bg-gray-300 px-2 py-1 text-left font-semibold focus:outline-2 focus:outline-black focus-visible:outline-2 focus-visible:outline-black ${chart?.index_number === _chart.index_number ? 'outline-2' : ''}`}
onClick={() => findChart(_chart)}
>
<span className="font-bold">{_chart.index_number}</span>
<br />
{_chart.name}
</button>
))}
</div>
</div>
</div>
</div>
);
};