Skip to content

Commit a532828

Browse files
Merge pull request #91 from Navigraph/weather
Add Weather SDK module and related examples
2 parents 589b47c + 2b337ba commit a532828

38 files changed

+1184
-34
lines changed

.changeset/lemon-pillows-clap.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@navigraph/weather": major
3+
"playground": minor
4+
"navigraph": minor
5+
---
6+
7+
Added weather API module

examples/playground/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
"@navigraph/charts": "*",
1717
"@navigraph/leaflet": "*",
1818
"@navigraph/packages": "*",
19+
"@navigraph/weather": "*",
1920
"@tanstack/react-query": "^5.52.2",
21+
"@turf/buffer": "^7.1.0",
22+
"@turf/helpers": "^7.1.0",
2023
"clsx": "^2.1.1",
2124
"leaflet": "^1.9.4",
2225
"react": "^18.3.1",

examples/playground/src/Root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Auth from "./pages/Auth"
1010
import Charts from "./pages/Charts"
1111
import Packages from "./pages/Packages"
1212
import Tiles from "./pages/Tiles"
13+
import Weather from "./pages/Weather"
1314

1415
export default function Root() {
1516
const { isInitialized } = useNavigraphAuth()
@@ -33,6 +34,7 @@ export default function Root() {
3334
<Route path="/charts" element={<Charts />} />
3435
<Route path="/amdb/*" element={<Amdb />} />
3536
<Route path="/packages" element={<Packages />} />
37+
<Route path="/weather" element={<Weather />} />
3638
</Routes>
3739
<MainWindow />
3840
</main>

examples/playground/src/components/Button.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@ import { PropsWithChildren } from "react"
44
interface Props {
55
selected?: boolean
66
className?: string
7+
selectedColor?: string
78
disabled?: boolean
89
onClick: () => void
910
}
1011

11-
export default function Button({ selected, onClick, children, className, disabled }: PropsWithChildren<Props>) {
12+
export default function Button({
13+
selected,
14+
onClick,
15+
children,
16+
selectedColor,
17+
className,
18+
disabled,
19+
}: PropsWithChildren<Props>) {
1220
return (
1321
<button
1422
disabled={disabled}
1523
onClick={onClick}
24+
style={{ backgroundColor: selected ? selectedColor : undefined }}
1625
className={clsx(
1726
"p-1 rounded-lg shadow-lg border-blue-gray-400 border-[1px] text-white text-sm font-semibold disabled:text-gray-200",
1827
selected ? "bg-blue-50 enabled:hover:bg-blue-25" : "bg-blue-gray-200 enabled:hover:bg-blue-gray-50",

examples/playground/src/components/JsonView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ interface Props {
1010
*/
1111
export default function JsonView({ content, onClick }: Props) {
1212
return (
13-
<div className={clsx("pane overflow-auto w-full no-scrollbar", onClick && "cursor-pointer")}>
13+
<div className={clsx("pane overflow-auto w-full max-h-96", onClick && "cursor-pointer")}>
1414
<pre className={clsx("text-white text-xs", onClick && "hover:text-gray-50")}>
1515
{JSON.stringify(content, null, 2)}
1616
</pre>

examples/playground/src/components/SideBar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import clsx from "clsx"
33
import { IconType } from "react-icons"
44
import { FaDatabase, FaDownload, FaGlobe, FaMap, FaUser } from "react-icons/fa"
55
import { MdOutlineSettings } from "react-icons/md"
6+
import { TiWeatherPartlySunny } from "react-icons/ti"
67
import { NavLink } from "react-router-dom"
78
import { useNavigraphAuth } from "../hooks/useNavigraphAuth"
89

@@ -61,6 +62,9 @@ export default function SideBar() {
6162
<SideBarLink path="/packages" icon={FaDownload} disabled={!user?.scope.includes(Scope.FMSDATA)}>
6263
Packages
6364
</SideBarLink>
65+
<SideBarLink path="/weather" icon={TiWeatherPartlySunny} disabled={!user}>
66+
Weather
67+
</SideBarLink>
6468
</div>
6569
)
6670
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { AVWXSource, getWeatherApi } from "@navigraph/weather"
2+
import { useQueries } from "@tanstack/react-query"
3+
import { circleMarker, GeoJSON as LeafletGeoJSON } from "leaflet"
4+
import { memo, useRef } from "react"
5+
import { renderToString } from "react-dom/server"
6+
import { GeoJSON } from "react-leaflet"
7+
import { useRecoilValue } from "recoil"
8+
import { avwxState } from "../../state/weather"
9+
import JsonView from "../JsonView"
10+
11+
export const weatherColors: Record<AVWXSource, string> = {
12+
AIREP: "rgb(195, 48, 34)",
13+
AIRMET: "rgb(128, 128, 128)",
14+
CWA: "rgb(0, 80, 160)",
15+
GAIRMET: "rgb(160, 96, 0)",
16+
ISIGMET: "rgb(153, 0, 153)",
17+
METAR: "rgb(50, 185, 243)",
18+
SIGMET: "rgb(27, 136, 136)",
19+
}
20+
21+
/**
22+
* Handles the rendering of Aviation Weather reports to the map
23+
*/
24+
const AviationWeather = memo(() => {
25+
const weatherApi = getWeatherApi()
26+
27+
const sources = useRecoilValue(avwxState)
28+
29+
// Queries reports for all of the selected AVWX layers
30+
const layers = useQueries({
31+
queries: sources.map(source => ({
32+
queryKey: ["avwx", source],
33+
queryFn: async () => [source, await weatherApi.getAviationWeatherReports([source])] as const,
34+
})),
35+
})
36+
37+
// Store refs to the map layers so they can be updated based on selections
38+
const layersRef = useRef<Partial<Record<AVWXSource, LeafletGeoJSON>>>({})
39+
40+
return layers.map(({ data }) => {
41+
if (!data) return null
42+
43+
return (
44+
<GeoJSON
45+
ref={value => {
46+
layersRef.current[data[0]] = value ?? undefined
47+
}}
48+
key={data[0]}
49+
data={data[1]}
50+
style={{
51+
color: weatherColors[data[0]],
52+
}}
53+
pointToLayer={(feature, latlng) => {
54+
const marker = circleMarker(latlng)
55+
56+
marker.feature = feature
57+
58+
return marker
59+
}}
60+
onEachFeature={(feature, _layer) => {
61+
_layer.on("click", e => {
62+
const target = e.target as LeafletGeoJSON
63+
64+
const feature = target.feature
65+
66+
if (feature?.type === "Feature") {
67+
Object.values(layersRef.current).forEach(layer => {
68+
layer?.resetStyle()
69+
})
70+
71+
target.setStyle({ color: "blue" })
72+
}
73+
})
74+
75+
if (feature.properties) {
76+
_layer.bindPopup(
77+
renderToString(
78+
<div className="flex flex-col items-center gap-2">
79+
<span className="text-ng-background-200">{data[0]}</span>
80+
<JsonView content={feature.properties} />
81+
</div>,
82+
),
83+
)
84+
}
85+
}}
86+
/>
87+
)
88+
})
89+
})
90+
91+
export default AviationWeather
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { memo } from "react"
2+
import { Marker, Popup } from "react-leaflet"
3+
import { useRecoilValue } from "recoil"
4+
import { metarTafMarkersState } from "../../state/weather"
5+
import JsonView from "../JsonView"
6+
7+
const MetarTaf = memo(() => {
8+
const markers = useRecoilValue(metarTafMarkersState)
9+
10+
return [
11+
...markers.flatMap(({ latitude, longitude, ...data }) => {
12+
if (!latitude || !longitude) return
13+
14+
return (
15+
<Marker position={{ lat: latitude, lng: longitude }}>
16+
<Popup>
17+
<JsonView content={data} />
18+
</Popup>
19+
</Marker>
20+
)
21+
}),
22+
]
23+
})
24+
25+
export default MetarTaf

examples/playground/src/components/map/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import {
2121
} from "../../state/map"
2222
import Button from "../Button"
2323
import AmdbManager from "./AmdbManager"
24+
import AviationWeather from "./AviationWeather"
25+
import MetarTaf from "./MetarTaf"
26+
import WeatherRouteManager from "./weatherRoute"
2427

2528
/**
2629
* Creates a Navigraph tiles preset config based on the 4 properties needed
@@ -154,7 +157,7 @@ export default function MapPane() {
154157
<MapContainer
155158
center={[51.505, -0.09]}
156159
zoom={13}
157-
className="h-screen bg-black"
160+
className="h-screen bg-black cursor-pointer"
158161
zoomControl={false}
159162
ref={mapRef}
160163
whenReady={() => {
@@ -170,6 +173,9 @@ export default function MapPane() {
170173
/>
171174
))}
172175
{user?.scope.includes(Scope.CHARTS) && <ChartOverlay />}
176+
<WeatherRouteManager />
177+
<AviationWeather />
178+
<MetarTaf />
173179
{user?.scope.includes(Scope.AMDB) && <AmdbManager />}
174180
</MapContainer>
175181
<OverlayControls />
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { buffer } from "@turf/buffer"
2+
import * as helpers from "@turf/helpers"
3+
import { LatLng, LeafletMouseEvent } from "leaflet"
4+
import { useEffect, useMemo, useState } from "react"
5+
import { GeoJSON, Marker, Polyline, useMap } from "react-leaflet"
6+
import { useRecoilState, useRecoilValue } from "recoil"
7+
import { weatherRouteEditState, weatherRouteRangeState, weatherRouteState } from "../../state/weather"
8+
9+
/**
10+
* Handles the creation and rendering of routes for weather queries along routes
11+
*/
12+
export default function WeatherRouteManager() {
13+
const map = useMap()
14+
15+
const [editActive, setEditActive] = useRecoilState(weatherRouteEditState)
16+
17+
const [route, setRoute] = useRecoilState(weatherRouteState)
18+
19+
const [nextPosition, setNextPosition] = useState<LatLng | null>(null)
20+
21+
useEffect(() => {
22+
const clickCallback = (e: LeafletMouseEvent) => {
23+
// If the edit process is active, append the location of any clicks to the route array
24+
if (editActive) {
25+
setRoute([...route, e.latlng])
26+
}
27+
}
28+
29+
const mouseMoveCallback = (e: LeafletMouseEvent) => {
30+
// If the edit process is active, set the nextPosition state to the lat/lng position of the mouse for an indication of where the next line will be
31+
if (editActive) {
32+
setNextPosition(e.latlng)
33+
}
34+
}
35+
36+
// If the edit process is not active, the nextPosition should not be rendered
37+
if (!editActive) {
38+
setNextPosition(null)
39+
}
40+
41+
map.on("click", clickCallback)
42+
map.on("mousemove", mouseMoveCallback)
43+
44+
return () => {
45+
map.off("click", clickCallback)
46+
map.off("mousemove", mouseMoveCallback)
47+
}
48+
}, [editActive, map, route, setRoute])
49+
50+
useEffect(() => {
51+
const callback = (e: KeyboardEvent) => {
52+
// Whenever editing is active, pressing enter should stop it
53+
if (e.key === "Enter" && editActive) {
54+
setEditActive(false)
55+
}
56+
}
57+
58+
document.addEventListener("keydown", callback)
59+
60+
return () => {
61+
document.removeEventListener("keydown", callback)
62+
}
63+
}, [editActive, setEditActive])
64+
65+
const positions = useRecoilValue(weatherRouteState)
66+
const range = useRecoilValue(weatherRouteRangeState)
67+
68+
const area = useMemo(() => {
69+
if (positions.length < 2) {
70+
return null
71+
}
72+
73+
const linestring = helpers.lineString(positions.map(({ lng, lat }) => [lng, lat]))
74+
75+
return buffer(linestring, range * 1852, { units: "meters" })
76+
}, [range, positions])
77+
78+
return (
79+
<>
80+
<Polyline positions={route} />
81+
{nextPosition && route.length >= 1 && (
82+
<Polyline color="red" positions={[route[route.length - 1], nextPosition]} />
83+
)}
84+
{nextPosition && <Marker position={nextPosition} />}
85+
{area && <GeoJSON key={Math.random()} data={area} />}
86+
</>
87+
)
88+
}

0 commit comments

Comments
 (0)