Rebuilt Transition adn chart select
This commit is contained in:
parent
f593cbd29a
commit
287ad8859b
6
TODO.md
6
TODO.md
@ -1,6 +0,0 @@
|
||||
Revise list of charts to only show those applicable to the selected procedure (so terminal itself and all transitions accompanied)
|
||||
Revise image overlay
|
||||
- Find center of full page
|
||||
- Find center of georeferenced area
|
||||
- Calculate skew parameters
|
||||
- Skew geobounds
|
||||
@ -3,7 +3,8 @@ import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useState } from 'react';
|
||||
import { ProcedureSelect } from './components//ProcedureSelect';
|
||||
import { Map } from './components/Map';
|
||||
import { useNavigraphAuth } from './hooks/useNavigraphAuth';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { useNavigraphAuth } from './contexts/NavigraphAuth/NavigraphAuthContext';
|
||||
import Parser from './parser/parser';
|
||||
|
||||
const parser = await Parser.instance();
|
||||
@ -12,8 +13,10 @@ function App() {
|
||||
const [selectedAirport, setSelectedAirport] = useState<Airport>();
|
||||
const [selectedRunway, setSelectedRunway] = useState<Runway>();
|
||||
const [selectedTerminal, setSelectedTerminal] = useState<Terminal>();
|
||||
const [procedures, setProcedures] = useState<{ name: string; data: object }[]>([]);
|
||||
const [transitions, setTransitions] = useState<Procedure[]>([]);
|
||||
const [params, setParams] = useState<DeviceFlowParams | null>(null);
|
||||
const [selectedTransition, setSelectedTransition] = useState<Procedure>();
|
||||
const [selectedChart, setSelectedChart] = useState<Chart>();
|
||||
|
||||
const { user, signIn, initialized } = useNavigraphAuth();
|
||||
|
||||
@ -21,7 +24,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{procedures.length === 0 ? (
|
||||
{transitions.length === 0 ? (
|
||||
<div className="flex min-h-dvh w-full">
|
||||
{!initialized && <div>Loading...</div>}
|
||||
|
||||
@ -44,7 +47,7 @@ function App() {
|
||||
setSelectedRunway={setSelectedRunway}
|
||||
setSelectedTerminal={setSelectedTerminal}
|
||||
handleSelection={(selectedTransitions) =>
|
||||
setProcedures(
|
||||
setTransitions(
|
||||
selectedTransitions.map((transition) => ({
|
||||
name: transition,
|
||||
data: parser.parse(selectedRunway!, transition),
|
||||
@ -56,16 +59,23 @@ function App() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-dvh w-dvw">
|
||||
{procedures.length > 0 && selectedAirport && selectedTerminal ? (
|
||||
<Map
|
||||
airport={selectedAirport}
|
||||
terminal={selectedTerminal}
|
||||
procedures={procedures}
|
||||
backAction={() => {
|
||||
setSelectedTerminal(undefined);
|
||||
setProcedures([]);
|
||||
}}
|
||||
/>
|
||||
{transitions.length > 0 && selectedAirport && selectedTerminal ? (
|
||||
<>
|
||||
<Sidebar
|
||||
airport={selectedAirport}
|
||||
terminal={selectedTerminal}
|
||||
transitions={transitions}
|
||||
transition={selectedTransition}
|
||||
chart={selectedChart}
|
||||
setTransition={setSelectedTransition}
|
||||
setChart={setSelectedChart}
|
||||
backAction={() => {
|
||||
setSelectedTerminal(undefined);
|
||||
setTransitions([]);
|
||||
}}
|
||||
/>
|
||||
<Map airport={selectedAirport} chart={selectedChart} procedure={selectedTransition} />
|
||||
</>
|
||||
) : (
|
||||
<h1 className="text-center text-3xl">Error</h1>
|
||||
)}
|
||||
|
||||
@ -1,197 +1,105 @@
|
||||
import BrowserImageManipulation from 'browser-image-manipulation';
|
||||
import { default as L, type LatLngBoundsExpression } from 'leaflet';
|
||||
import { default as L } from 'leaflet';
|
||||
import 'leaflet-svg-shape-markers';
|
||||
import { type Chart } from 'navigraph/charts';
|
||||
import { createRef, useEffect, useState, type FC } from 'react';
|
||||
import { createRef, type FC } from 'react';
|
||||
import { GeoJSON, ImageOverlay, MapContainer, TileLayer } from 'react-leaflet';
|
||||
import { charts } from '../lib/navigraph';
|
||||
|
||||
interface MapProps {
|
||||
airport: Airport;
|
||||
terminal: Terminal;
|
||||
procedures: { name: string; data: object }[];
|
||||
backAction: () => void;
|
||||
chart: Chart | undefined;
|
||||
procedure: Procedure | undefined;
|
||||
}
|
||||
|
||||
export const Map: FC<MapProps> = ({ airport, terminal, procedures, backAction }) => {
|
||||
const [selectedProcedure, setSelectedProcedure] = useState(procedures[0]);
|
||||
const [chartIndex, setChartIndex] = useState<Chart[]>([]);
|
||||
const [selectedChart, setSelectedChart] = useState<{
|
||||
data: string;
|
||||
index_number: string;
|
||||
bounds: LatLngBoundsExpression;
|
||||
}>();
|
||||
|
||||
export const Map: FC<MapProps> = ({ airport, chart, procedure }) => {
|
||||
const mapRef = createRef<L.Map>();
|
||||
const imageRef = createRef<L.ImageOverlay>();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setChartIndex((await charts.getChartsIndex({ icao: airport.ICAO, version: 'STD' })) ?? []);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="fixed top-2 left-2 z-[5000] 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>
|
||||
<MapContainer
|
||||
center={[airport.Latitude, airport.Longitude]}
|
||||
zoom={13}
|
||||
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"
|
||||
/>
|
||||
|
||||
<MapContainer
|
||||
center={[airport.Latitude, airport.Longitude]}
|
||||
zoom={13}
|
||||
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"
|
||||
/>
|
||||
|
||||
{selectedChart && selectedChart.bounds && (
|
||||
<ImageOverlay
|
||||
url={selectedChart.data}
|
||||
bounds={selectedChart.bounds}
|
||||
opacity={0.75}
|
||||
ref={(_imageRef) => {
|
||||
if (_imageRef) {
|
||||
mapRef.current?.fitBounds(_imageRef.getBounds(), {
|
||||
padding: [-50, -50],
|
||||
});
|
||||
}
|
||||
imageRef.current = _imageRef;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GeoJSON
|
||||
key={`${selectedProcedure.name}-lines`}
|
||||
data={selectedProcedure.data}
|
||||
style={({ properties }) => ({
|
||||
color: '#ff00ff',
|
||||
stroke: true,
|
||||
weight: 5,
|
||||
opacity: 1,
|
||||
dashArray: properties.isManual ? '20, 20' : undefined,
|
||||
})}
|
||||
filter={(feature) => feature.geometry.type !== 'Point'}
|
||||
/>
|
||||
<GeoJSON
|
||||
key={`${selectedProcedure.name}-points`}
|
||||
data={selectedProcedure.data}
|
||||
style={{
|
||||
color: 'black',
|
||||
fill: true,
|
||||
fillColor: 'transparent',
|
||||
stroke: true,
|
||||
weight: 3,
|
||||
}}
|
||||
pointToLayer={({ properties }, latlng) => {
|
||||
if (properties.isFlyOver)
|
||||
return L.shapeMarker(latlng, {
|
||||
shape: 'triangle',
|
||||
radius: 6,
|
||||
{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],
|
||||
});
|
||||
if (properties.isIntersection) return L.circleMarker(latlng, { radius: 6 });
|
||||
|
||||
return L.shapeMarker(latlng, {
|
||||
shape: 'star-4',
|
||||
radius: 10,
|
||||
rotation: 45,
|
||||
});
|
||||
}
|
||||
imageRef.current = _imageRef;
|
||||
}}
|
||||
onEachFeature={({ geometry, properties }, layer) => {
|
||||
if (geometry.type === 'Point') {
|
||||
layer.bindPopup(
|
||||
`${properties.name}<br>
|
||||
/>
|
||||
)}
|
||||
|
||||
{procedure && (
|
||||
<>
|
||||
<GeoJSON
|
||||
key={`${procedure.name}-lines`}
|
||||
data={procedure.data}
|
||||
style={({ properties }) => ({
|
||||
color: '#ff00ff',
|
||||
stroke: true,
|
||||
weight: 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.isFlyOver)
|
||||
return L.shapeMarker(latlng, {
|
||||
shape: 'triangle',
|
||||
radius: 6,
|
||||
});
|
||||
if (properties.isIntersection) return L.circleMarker(latlng, { 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'}
|
||||
/>
|
||||
</MapContainer>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 z-[5000] bg-[#ffffff77] bg-blend-color backdrop-blur-xs">
|
||||
<div className="flex items-center gap-2 overflow-x-auto p-2">
|
||||
<span className="text-lg font-semibold">Procedures:</span>
|
||||
{procedures.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 ${selectedProcedure.name === procedure.name ? 'outline-2' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectedProcedure.name === procedure.name) return;
|
||||
|
||||
setSelectedProcedure(procedure);
|
||||
}}
|
||||
>
|
||||
{procedure.name ? procedure.name : 'ZZZZ'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">Charts:</span>
|
||||
<div className="flex items-center gap-2 overflow-x-auto p-2">
|
||||
{chartIndex
|
||||
.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 font-semibold whitespace-nowrap focus:outline-2 focus:outline-black focus-visible:outline-2 focus-visible:outline-black ${selectedChart?.index_number === chart.index_number ? 'outline-2' : ''}`}
|
||||
onClick={async () => {
|
||||
if (selectedChart?.index_number === chart.index_number) return;
|
||||
|
||||
if (!mapRef.current) return;
|
||||
|
||||
if (!chart.bounding_boxes) return;
|
||||
|
||||
const planView = chart.bounding_boxes.planview;
|
||||
|
||||
const chartImage = await charts.getChartImage({ 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]
|
||||
);
|
||||
|
||||
setSelectedChart({ data: dataURL, index_number: chart.index_number, bounds });
|
||||
|
||||
if (imageRef.current) {
|
||||
mapRef.current?.fitBounds(imageRef.current.getBounds(), {
|
||||
padding: [-50, -50],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{chart.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}}
|
||||
filter={(feature) => feature.geometry.type === 'Point'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
|
||||
109
browser/src/components/Sidebar.tsx
Normal file
109
browser/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import BrowserImageManipulation from 'browser-image-manipulation';
|
||||
import L from 'leaflet';
|
||||
import type { Chart as NGChart } from 'navigraph/charts';
|
||||
import { useEffect, useState, type Dispatch, type FC, type SetStateAction } from 'react';
|
||||
import { charts } from '../lib/navigraph';
|
||||
|
||||
interface SidebarProps {
|
||||
airport: Airport;
|
||||
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,
|
||||
terminal,
|
||||
transitions,
|
||||
transition,
|
||||
chart,
|
||||
setTransition,
|
||||
setChart,
|
||||
backAction,
|
||||
}) => {
|
||||
const [chartIndex, setChartIndex] = useState<NGChart[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setChartIndex((await charts.getChartsIndex({ icao: airport.ICAO, version: 'STD' })) ?? []);
|
||||
})();
|
||||
}, [airport.ICAO]);
|
||||
|
||||
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 : 'ZZZZ'}
|
||||
</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">Charts</div>
|
||||
{chartIndex
|
||||
.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={async () => {
|
||||
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 });
|
||||
}}
|
||||
>
|
||||
<span className="font-bold">{_chart.index_number}</span>
|
||||
<br />
|
||||
{_chart.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
browser/src/contexts/NavigraphAuth/NavigraphAuthContext.ts
Normal file
19
browser/src/contexts/NavigraphAuth/NavigraphAuthContext.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { type User } from 'navigraph/auth';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { auth } from '../../lib/navigraph';
|
||||
|
||||
interface NavigraphAuthContext {
|
||||
initialized: boolean;
|
||||
user: User | null;
|
||||
signIn: typeof auth.signInWithDeviceFlow;
|
||||
}
|
||||
|
||||
export const authContext = createContext<NavigraphAuthContext>({
|
||||
initialized: false,
|
||||
user: null,
|
||||
signIn: () => Promise.reject('Not initialized'),
|
||||
});
|
||||
|
||||
export const useNavigraphAuth = () => {
|
||||
return useContext(authContext);
|
||||
};
|
||||
@ -1,18 +1,7 @@
|
||||
import { type User } from 'navigraph/auth';
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { auth } from '../lib/navigraph';
|
||||
|
||||
interface NavigraphAuthContext {
|
||||
initialized: boolean;
|
||||
user: User | null;
|
||||
signIn: typeof auth.signInWithDeviceFlow;
|
||||
}
|
||||
|
||||
const authContext = createContext<NavigraphAuthContext>({
|
||||
initialized: false,
|
||||
user: null,
|
||||
signIn: () => Promise.reject('Not initialized'),
|
||||
});
|
||||
import type { User } from 'navigraph/auth';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { auth } from '../../lib/navigraph';
|
||||
import { authContext } from './NavigraphAuthContext';
|
||||
|
||||
// Provider hook that creates auth object and handles state
|
||||
function useProvideAuth() {
|
||||
@ -42,12 +31,7 @@ function useProvideAuth() {
|
||||
// Provider component that wraps your app and makes auth object
|
||||
// available to any child component that calls useAuth().
|
||||
export function NavigraphAuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const auth = useProvideAuth();
|
||||
return <authContext.Provider value={auth}>{children}</authContext.Provider>;
|
||||
const _auth = useProvideAuth();
|
||||
return <authContext.Provider value={_auth}>{children}</authContext.Provider>;
|
||||
}
|
||||
|
||||
// Hook for child components to get the auth object
|
||||
// and re-render when it changes.
|
||||
export const useNavigraphAuth = () => {
|
||||
return useContext(authContext);
|
||||
};
|
||||
9
browser/src/types/index.d.ts
vendored
Normal file
9
browser/src/types/index.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
export declare global {
|
||||
type Chart = {
|
||||
data: string;
|
||||
index_number: string;
|
||||
bounds: LatLngBoundsExpression;
|
||||
};
|
||||
|
||||
type Procedure = { name: string; data: object };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user