From d00ad987cc4bc1913313981e95fe8885a740cce6 Mon Sep 17 00:00:00 2001 From: Sacha Weatherstone Date: Mon, 3 Jan 2022 00:39:37 +1100 Subject: [PATCH] New map logic --- src/components/Map/index.tsx | 268 +++++++++++++++++--------------- src/core/slices/mapSlice.ts | 60 +++++++ src/core/store.ts | 2 + src/hooks/mapbox.ts | 28 ++++ src/pages/Nodes/NodeCard.tsx | 79 ++++++++-- src/pages/settings/Position.tsx | 3 +- 6 files changed, 299 insertions(+), 141 deletions(-) create mode 100644 src/core/slices/mapSlice.ts create mode 100644 src/hooks/mapbox.ts diff --git a/src/components/Map/index.tsx b/src/components/Map/index.tsx index 270a1337..6f5a0d88 100644 --- a/src/components/Map/index.tsx +++ b/src/components/Map/index.tsx @@ -1,5 +1,3 @@ -import 'mapbox-gl/dist/mapbox-gl.css'; - import React from 'react'; import mapboxgl from 'mapbox-gl'; @@ -7,185 +5,205 @@ import { renderToString } from 'react-dom/server'; import { FaDirections, FaGlobeAfrica, FaMountain } from 'react-icons/fa'; import { MdFullscreen, MdRadar, MdWbShade } from 'react-icons/md'; -import { useAppSelector } from '@app/hooks/redux'; -import { IconButton } from '@meshtastic/components'; - +import { useMapbox } from '@app/hooks/mapbox'; +import { useAppDispatch, useAppSelector } from '@app/hooks/redux'; +import { + setExaggeration, + setHillShade, + setLatLng, + setMapStyle, + setZoom, +} from '@core/slices/mapSlice'; +import { Card, IconButton } from '@meshtastic/components'; + +import type { MapStyle } from './styles'; import { MapStyles } from './styles'; export const Map = (): JSX.Element => { + const dispatch = useAppDispatch(); const darkMode = useAppSelector((state) => state.app.darkMode); const nodes = useAppSelector((state) => state.meshtastic.nodes); - - mapboxgl.accessToken = - 'pk.eyJ1Ijoic2FjaGF3IiwiYSI6ImNrNW9meXozZjBsdW0zbHBjM2FnNnV6cmsifQ.3E4n8eFGD9ZOFo-XDVeZnQ'; - const mapDiv = React.useRef(null); - const [map, setMap] = React.useState(null as mapboxgl.Map | null); - const [exaggeration, setExaggeration] = React.useState(false); - const [shading, setShading] = React.useState(false); - const [maptype, setMaptype] = React.useState< - 'Streets' | 'Outdoors' | 'Satellite' | 'Default' - >('Default'); - - const PlaceNodes = (): void => { + const mapState = useAppSelector((state) => state.map); + const [markers, setMarkers] = React.useState< + { id: number; marker: mapboxgl.Marker }[] + >([]); + const mapRef = React.useRef(null); + + const map = useMapbox(mapRef, mapState.accessToken, { + center: mapState.latLng, + zoom: mapState.zoom, + style: mapState.style.url, + }); + + const updateNodes = React.useCallback(() => { nodes.map((node) => { - if (node.currentPosition && map) { - new mapboxgl.Marker({}) - .setLngLat({ - lat: node.currentPosition?.latitudeI / 1e7, - lng: node.currentPosition?.longitudeI / 1e7, - }) + if (map?.loaded() && node.currentPosition) { + const existingMarker = markers.find( + (marker) => marker.id === node.number, + )?.marker; + const marker = + existingMarker ?? + new mapboxgl.Marker({}).setLngLat([0, 0]).addTo(map); + + marker + .setLngLat([ + node.currentPosition.longitudeI / 1e7, + node.currentPosition.latitudeI / 1e7, + ]) .setPopup( new mapboxgl.Popup().setHTML( renderToString( -
+
{node.user?.longName}
  • ID: {node.number}
-
, + , ), ), - ) - .addTo(map); + ); + + if (!existingMarker) { + setMarkers((markers) => [ + ...markers, + { + id: node.number, + marker, + }, + ]); + } } }); - }; + }, [markers, map, nodes]); + + const ChangeMapStyle = React.useCallback( + (styleName: string, style: MapStyle) => { + dispatch( + setMapStyle( + mapState.style.title === styleName + ? darkMode + ? MapStyles.Dark + : MapStyles.Light + : style, + ), + ); + }, + [dispatch, darkMode, mapState.style.title], + ); React.useEffect(() => { - PlaceNodes(); - }, [nodes]); + map?.on('load', () => { + updateNodes(); + }); + map?.on('styledata', () => { + if (!map.getSource('mapbox-dem')) { + map.addSource('mapbox-dem', { + type: 'raster-dem', + url: 'mapbox://mapbox.mapbox-terrain-dem-v1', + tileSize: 512, + maxzoom: 14, + }); + } + map.setTerrain({ + source: 'mapbox-dem', + exaggeration: mapState.exaggeration ? 1.5 : 0, + }); + }); + map?.on('dragend', (e) => { + dispatch(setLatLng(e.target.getCenter())); + }); + map?.on('zoomend', (e) => { + dispatch(setZoom(e.target.getZoom())); + }); + }, [dispatch, map, updateNodes, mapState.exaggeration]); React.useEffect(() => { - if (map?.loaded()) { - switch (maptype) { - case 'Outdoors': - map.setStyle(MapStyles.Outdoors.url); - break; - - case 'Satellite': - map.setStyle(MapStyles.Satellite.url); - break; - - case 'Streets': - map.setStyle(MapStyles.Streets.url); - break; - - default: - map.setStyle(darkMode ? MapStyles.Dark.url : MapStyles.Light.url); - break; - } + const center = map?.getCenter(); + if (center !== mapState.latLng) { + map?.setCenter(mapState.latLng); } - }, [maptype, darkMode, map]); + }, [map, mapState.latLng]); + /** + * Hill Shading + */ React.useEffect(() => { if (map?.loaded()) { - if (shading) { - map - .addSource('mapbox-dem', { - type: 'raster-dem', - url: 'mapbox://mapbox.mapbox-terrain-dem-v1', - tileSize: 512, - maxzoom: 14, - }) - .addLayer( - { - id: 'hillshading', - source: 'mapbox-dem', - type: 'hillshade', - // insert below waterway-river-canal-shadow; - // where hillshading sits in the Mapbox Outdoors style - }, - 'waterway-river-canal-shadow', - ); + if (mapState.hillShade) { + map.addLayer( + { + id: 'hillshading', + source: 'mapbox-dem', + type: 'hillshade', + // insert below waterway-river-canal-shadow; + // where hillshading sits in the Mapbox Outdoors style + }, + 'waterway-river-canal-shadow', + ); } else { map.removeLayer('hillshading'); } } - }, [shading, map]); + }, [map, mapState.hillShade]); + /** + * Exaggeration + */ React.useEffect(() => { if (map?.loaded()) { - if (exaggeration) { - map - .addSource('mapbox-dem', { - type: 'raster-dem', - url: 'mapbox://mapbox.mapbox-terrain-dem-v1', - tileSize: 512, - maxzoom: 14, - }) - .setTerrain({ - source: 'mapbox-dem', - exaggeration: 1.5, - }); - } else { - map.setTerrain(); - } + map.setTerrain({ + source: 'mapbox-dem', + exaggeration: mapState.exaggeration ? 1.5 : 0, + }); } - }, [exaggeration, map]); + }, [map, mapState.exaggeration]); + /** + * Map Style + */ React.useEffect(() => { - if (!map && mapDiv.current) { - const map = new mapboxgl.Map({ - container: mapDiv.current, - style: darkMode ? MapStyles.Dark.url : MapStyles.Light.url, - // center: [lng, lat], - // zoom: zoom, - }); - setMap(map); - - map.on('load', () => { - map.addLayer({ - id: 'sky', - type: 'sky', - paint: { - 'sky-type': 'atmosphere', - 'sky-atmosphere-sun': [0.0, 0.0], - 'sky-atmosphere-sun-intensity': 15, - }, - }); - PlaceNodes(); - }); + if (map?.loaded()) { + map.setStyle(mapState.style.url); } - }, [map, darkMode]); + }, [map, mapState.style]); + + /** + * Markers + */ + React.useEffect(() => { + updateNodes(); + }, [nodes, updateNodes]); return (
{ - if (maptype === 'Satellite') { - setMaptype('Default'); - } else { - setMaptype('Satellite'); - } + ChangeMapStyle('Satellite', MapStyles.Satellite); }} icon={} />
{ - if (maptype === 'Outdoors') { - setMaptype('Default'); - } else { - setMaptype('Outdoors'); - } + ChangeMapStyle('Outdoors', MapStyles.Outdoors); }} icon={} /> - {maptype === 'Outdoors' && ( + {mapState.style.title === 'Outdoors' && ( { - setShading(!shading); + dispatch(setHillShade(!mapState.hillShade)); }} icon={} /> @@ -194,9 +212,9 @@ export const Map = (): JSX.Element => {
{ - setExaggeration(!exaggeration); + dispatch(setExaggeration(!mapState.exaggeration)); }} icon={} /> @@ -204,7 +222,7 @@ export const Map = (): JSX.Element => { } />
-
+
); diff --git a/src/core/slices/mapSlice.ts b/src/core/slices/mapSlice.ts new file mode 100644 index 00000000..8f28df5a --- /dev/null +++ b/src/core/slices/mapSlice.ts @@ -0,0 +1,60 @@ +import mapboxgl from 'mapbox-gl'; + +import type { MapStyle } from '@app/components/Map/styles'; +import { MapStyles } from '@app/components/Map/styles'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +interface MapState { + latLng: mapboxgl.LngLat; + zoom: number; + accessToken: string; + style: MapStyle; + hillShade: boolean; + exaggeration: boolean; +} + +const initialState: MapState = { + latLng: new mapboxgl.LngLat(-77.0305, 38.8868), + zoom: 9, + accessToken: + 'pk.eyJ1Ijoic2FjaGF3IiwiYSI6ImNrNW9meXozZjBsdW0zbHBjM2FnNnV6cmsifQ.3E4n8eFGD9ZOFo-XDVeZnQ', + style: + localStorage.getItem('darkMode') === 'true' + ? MapStyles.Dark + : MapStyles.Light, + hillShade: false, + exaggeration: true, +}; + +export const mapSlice = createSlice({ + name: 'map', + initialState, + reducers: { + setLatLng: (state, action: PayloadAction) => { + state.latLng = action.payload; + }, + setZoom: (state, action: PayloadAction) => { + state.zoom = action.payload; + }, + setMapStyle(state, action: PayloadAction) { + state.style = action.payload; + }, + setHillShade(state, action: PayloadAction) { + state.hillShade = action.payload; + }, + setExaggeration(state, action: PayloadAction) { + state.exaggeration = action.payload; + }, + }, +}); + +export const { + setLatLng, + setZoom, + setMapStyle, + setHillShade, + setExaggeration, +} = mapSlice.actions; + +export default mapSlice.reducer; diff --git a/src/core/store.ts b/src/core/store.ts index 5ee38d5a..3e4e394e 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -1,12 +1,14 @@ import { configureStore } from '@reduxjs/toolkit'; import appReducer from './slices/appSlice'; +import mapReducer from './slices/mapSlice'; import meshtasticReducer from './slices/meshtasticSlice'; export const store = configureStore({ reducer: { app: appReducer, meshtastic: meshtasticReducer, + map: mapReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/src/hooks/mapbox.ts b/src/hooks/mapbox.ts new file mode 100644 index 00000000..175c851e --- /dev/null +++ b/src/hooks/mapbox.ts @@ -0,0 +1,28 @@ +import 'mapbox-gl/dist/mapbox-gl.css'; + +import React from 'react'; + +import mapboxgl from 'mapbox-gl'; + +type AccessToken = string; + +export function useMapbox( + ref: React.RefObject, + accessToken: AccessToken, + options?: Partial, +): mapboxgl.Map | undefined { + const [mapInstance, setMapInstance] = React.useState(); + React.useEffect(() => { + const container = ref.current; + if (mapInstance || !container) { + return; + } + + mapboxgl.accessToken = accessToken; + const mergedOptions = { container, ...options }; + const map = new mapboxgl.Map(mergedOptions); + setMapInstance(map); + }, [ref, accessToken, options, mapInstance]); + + return mapInstance; +} diff --git a/src/pages/Nodes/NodeCard.tsx b/src/pages/Nodes/NodeCard.tsx index 9d415c44..54d4df46 100644 --- a/src/pages/Nodes/NodeCard.tsx +++ b/src/pages/Nodes/NodeCard.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import mapboxgl from 'mapbox-gl'; import { FaSatellite } from 'react-icons/fa'; import { FiCode } from 'react-icons/fi'; import { GiLightningFrequency } from 'react-icons/gi'; @@ -15,17 +16,23 @@ import { } from 'react-icons/md'; import TimeAgo from 'timeago-react'; +import { setLatLng } from '@app/core/slices/mapSlice.js'; +import { useAppDispatch } from '@app/hooks/redux.js'; import type { Node } from '@core/slices/meshtasticSlice'; import { Disclosure } from '@headlessui/react'; import { IconButton } from '@meshtastic/components'; import { Protobuf } from '@meshtastic/meshtasticjs'; +type PositionConfidence = 'high' | 'low' | 'none'; +type NodeAge = 'young' | 'aging' | 'old' | 'dead'; + export interface NodeCardProps { node: Node; myNodeInfo?: Protobuf.MyNodeInfo; } export const NodeCard = ({ node, myNodeInfo }: NodeCardProps): JSX.Element => { + const dispatch = useAppDispatch(); const [snrAverage, setSnrAverage] = React.useState(0); const [satsAverage, setSatsAverage] = React.useState(0); React.useEffect(() => { @@ -35,6 +42,32 @@ export const NodeCard = ({ node, myNodeInfo }: NodeCardProps): JSX.Element => { .reduce((a, b) => a + b) / (node.snr.length > 3 ? 3 : node.snr.length), ); }, [node.snr]); + const [PositionConfidence, setPositionConfidence] = + React.useState('none'); + const [age, setAge] = React.useState('young'); + + React.useEffect(() => { + setAge( + node.lastHeard > new Date(Date.now() - 1000 * 60 * 15) + ? 'young' + : node.lastHeard > new Date(Date.now() - 1000 * 60 * 30) + ? 'aging' + : node.lastHeard > new Date(Date.now() - 1000 * 60 * 60) + ? 'old' + : 'dead', + ); + }, [node.lastHeard]); + + React.useEffect(() => { + setPositionConfidence( + node.currentPosition + ? new Date(node.currentPosition.posTimestamp * 1000) > + new Date(new Date().getTime() - 1000 * 60 * 30) + ? 'high' + : 'low' + : 'none', + ); + }, [node.currentPosition]); // React.useEffect(() => { // setSatsAverage( @@ -52,17 +85,20 @@ export const NodeCard = ({ node, myNodeInfo }: NodeCardProps): JSX.Element => { as="div" className="m-2 rounded-md shadow-md bg-gray-50 dark:bg-gray-700" > - + {myNodeInfo ? ( ) : (
new Date(Date.now() - 1000 * 60 * 15) + age === 'young' ? 'bg-green-500' - : node.lastHeard > new Date(Date.now() - 1000 * 60 * 30) + : age === 'aging' ? 'bg-yellow-500' - : node.lastHeard > new Date(Date.now() - 1000 * 60 * 60) + : age === 'old' ? 'bg-red-500' : 'bg-gray-500' }`} @@ -81,17 +117,30 @@ export const NodeCard = ({ node, myNodeInfo }: NodeCardProps): JSX.Element => { )}
- - {node.currentPosition ? ( - new Date(node.positions[0].posTimestamp * 1000) > - new Date(new Date().getTime() - 1000 * 60 * 30) ? ( - } /> - ) : ( - } /> - ) - ) : ( - } /> - )} + { + if (PositionConfidence !== 'none' && node.currentPosition) { + dispatch( + setLatLng( + new mapboxgl.LngLat( + node.currentPosition.longitudeI / 1e7, + node.currentPosition.latitudeI / 1e7, + ), + ), + ); + } + }} + icon={ + PositionConfidence === 'high' ? ( + + ) : PositionConfidence === 'low' ? ( + + ) : ( + + ) + } + />
{myNodeInfo && ( diff --git a/src/pages/settings/Position.tsx b/src/pages/settings/Position.tsx index 8de4eae0..bf3ef23f 100644 --- a/src/pages/settings/Position.tsx +++ b/src/pages/settings/Position.tsx @@ -3,7 +3,8 @@ import React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { FiCode, FiMenu } from 'react-icons/fi'; import JSONPretty from 'react-json-pretty'; -import ReactSelect, { Theme } from 'react-select'; +import type { Theme } from 'react-select'; +import ReactSelect from 'react-select'; import { useAppSelector } from '@app/hooks/redux'; import { FormFooter } from '@components/FormFooter';