Rename
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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='© <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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user