Browse Source

New map logic

pull/21/head
Sacha Weatherstone 5 years ago
parent
commit
d00ad987cc
  1. 268
      src/components/Map/index.tsx
  2. 60
      src/core/slices/mapSlice.ts
  3. 2
      src/core/store.ts
  4. 28
      src/hooks/mapbox.ts
  5. 79
      src/pages/Nodes/NodeCard.tsx
  6. 3
      src/pages/settings/Position.tsx

268
src/components/Map/index.tsx

@ -1,5 +1,3 @@
import 'mapbox-gl/dist/mapbox-gl.css';
import React from 'react'; import React from 'react';
import mapboxgl from 'mapbox-gl'; import mapboxgl from 'mapbox-gl';
@ -7,185 +5,205 @@ import { renderToString } from 'react-dom/server';
import { FaDirections, FaGlobeAfrica, FaMountain } from 'react-icons/fa'; import { FaDirections, FaGlobeAfrica, FaMountain } from 'react-icons/fa';
import { MdFullscreen, MdRadar, MdWbShade } from 'react-icons/md'; import { MdFullscreen, MdRadar, MdWbShade } from 'react-icons/md';
import { useAppSelector } from '@app/hooks/redux'; import { useMapbox } from '@app/hooks/mapbox';
import { IconButton } from '@meshtastic/components'; 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'; import { MapStyles } from './styles';
export const Map = (): JSX.Element => { export const Map = (): JSX.Element => {
const dispatch = useAppDispatch();
const darkMode = useAppSelector((state) => state.app.darkMode); const darkMode = useAppSelector((state) => state.app.darkMode);
const nodes = useAppSelector((state) => state.meshtastic.nodes); const nodes = useAppSelector((state) => state.meshtastic.nodes);
const mapState = useAppSelector((state) => state.map);
mapboxgl.accessToken = const [markers, setMarkers] = React.useState<
'pk.eyJ1Ijoic2FjaGF3IiwiYSI6ImNrNW9meXozZjBsdW0zbHBjM2FnNnV6cmsifQ.3E4n8eFGD9ZOFo-XDVeZnQ'; { id: number; marker: mapboxgl.Marker }[]
const mapDiv = React.useRef<HTMLDivElement>(null); >([]);
const [map, setMap] = React.useState(null as mapboxgl.Map | null); const mapRef = React.useRef<HTMLDivElement>(null);
const [exaggeration, setExaggeration] = React.useState(false);
const [shading, setShading] = React.useState(false); const map = useMapbox(mapRef, mapState.accessToken, {
const [maptype, setMaptype] = React.useState< center: mapState.latLng,
'Streets' | 'Outdoors' | 'Satellite' | 'Default' zoom: mapState.zoom,
>('Default'); style: mapState.style.url,
});
const PlaceNodes = (): void => {
const updateNodes = React.useCallback(() => {
nodes.map((node) => { nodes.map((node) => {
if (node.currentPosition && map) { if (map?.loaded() && node.currentPosition) {
new mapboxgl.Marker({}) const existingMarker = markers.find(
.setLngLat({ (marker) => marker.id === node.number,
lat: node.currentPosition?.latitudeI / 1e7, )?.marker;
lng: node.currentPosition?.longitudeI / 1e7, const marker =
}) existingMarker ??
new mapboxgl.Marker({}).setLngLat([0, 0]).addTo(map);
marker
.setLngLat([
node.currentPosition.longitudeI / 1e7,
node.currentPosition.latitudeI / 1e7,
])
.setPopup( .setPopup(
new mapboxgl.Popup().setHTML( new mapboxgl.Popup().setHTML(
renderToString( renderToString(
<div> <Card>
<div className="text-xl font-medium"> <div className="text-xl font-medium">
{node.user?.longName} {node.user?.longName}
</div> </div>
<ul> <ul>
<li>ID: {node.number}</li> <li>ID: {node.number}</li>
</ul> </ul>
</div>, </Card>,
), ),
), ),
) );
.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(() => { React.useEffect(() => {
PlaceNodes(); map?.on('load', () => {
}, [nodes]); 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(() => { React.useEffect(() => {
if (map?.loaded()) { const center = map?.getCenter();
switch (maptype) { if (center !== mapState.latLng) {
case 'Outdoors': map?.setCenter(mapState.latLng);
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;
}
} }
}, [maptype, darkMode, map]); }, [map, mapState.latLng]);
/**
* Hill Shading
*/
React.useEffect(() => { React.useEffect(() => {
if (map?.loaded()) { if (map?.loaded()) {
if (shading) { if (mapState.hillShade) {
map map.addLayer(
.addSource('mapbox-dem', { {
type: 'raster-dem', id: 'hillshading',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1', source: 'mapbox-dem',
tileSize: 512, type: 'hillshade',
maxzoom: 14, // insert below waterway-river-canal-shadow;
}) // where hillshading sits in the Mapbox Outdoors style
.addLayer( },
{ 'waterway-river-canal-shadow',
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 { } else {
map.removeLayer('hillshading'); map.removeLayer('hillshading');
} }
} }
}, [shading, map]); }, [map, mapState.hillShade]);
/**
* Exaggeration
*/
React.useEffect(() => { React.useEffect(() => {
if (map?.loaded()) { if (map?.loaded()) {
if (exaggeration) { map.setTerrain({
map source: 'mapbox-dem',
.addSource('mapbox-dem', { exaggeration: mapState.exaggeration ? 1.5 : 0,
type: 'raster-dem', });
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512,
maxzoom: 14,
})
.setTerrain({
source: 'mapbox-dem',
exaggeration: 1.5,
});
} else {
map.setTerrain();
}
} }
}, [exaggeration, map]); }, [map, mapState.exaggeration]);
/**
* Map Style
*/
React.useEffect(() => { React.useEffect(() => {
if (!map && mapDiv.current) { if (map?.loaded()) {
const map = new mapboxgl.Map({ map.setStyle(mapState.style.url);
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();
});
} }
}, [map, darkMode]); }, [map, mapState.style]);
/**
* Markers
*/
React.useEffect(() => {
updateNodes();
}, [nodes, updateNodes]);
return ( return (
<div className="relative flex w-full h-full"> <div className="relative flex w-full h-full">
<div className="fixed right-0 z-20 p-2 m-4 space-y-2 bg-white rounded-md shadow-md md:mx-10 dark:bg-primaryDark"> <div className="fixed right-0 z-20 p-2 m-4 space-y-2 bg-white rounded-md shadow-md md:mx-10 dark:bg-primaryDark">
<IconButton <IconButton
active={maptype === 'Satellite'} active={mapState.style.title === 'Satellite'}
onClick={(): void => { onClick={(): void => {
if (maptype === 'Satellite') { ChangeMapStyle('Satellite', MapStyles.Satellite);
setMaptype('Default');
} else {
setMaptype('Satellite');
}
}} }}
icon={<FaGlobeAfrica />} icon={<FaGlobeAfrica />}
/> />
<div <div
className={`p-1 -m-1 space-y-2 border-gray-400 rounded-md dark:border-gray-200 ${ className={`p-1 -m-1 space-y-2 border-gray-400 rounded-md dark:border-gray-200 ${
maptype === 'Outdoors' ? 'border' : '' mapState.style.title === 'Outdoors' ? 'border' : ''
}`} }`}
> >
<IconButton <IconButton
active={maptype === 'Outdoors'} active={mapState.style.title === 'Outdoors'}
onClick={(): void => { onClick={(): void => {
if (maptype === 'Outdoors') { ChangeMapStyle('Outdoors', MapStyles.Outdoors);
setMaptype('Default');
} else {
setMaptype('Outdoors');
}
}} }}
icon={<FaDirections />} icon={<FaDirections />}
/> />
{maptype === 'Outdoors' && ( {mapState.style.title === 'Outdoors' && (
<IconButton <IconButton
active={shading} active={mapState.hillShade}
onClick={(): void => { onClick={(): void => {
setShading(!shading); dispatch(setHillShade(!mapState.hillShade));
}} }}
icon={<MdWbShade />} icon={<MdWbShade />}
/> />
@ -194,9 +212,9 @@ export const Map = (): JSX.Element => {
<hr className="text-gray-400 dark:text-gray-200" /> <hr className="text-gray-400 dark:text-gray-200" />
<IconButton <IconButton
active={exaggeration} active={mapState.exaggeration}
onClick={(): void => { onClick={(): void => {
setExaggeration(!exaggeration); dispatch(setExaggeration(!mapState.exaggeration));
}} }}
icon={<FaMountain />} icon={<FaMountain />}
/> />
@ -204,7 +222,7 @@ export const Map = (): JSX.Element => {
<IconButton icon={<MdRadar />} /> <IconButton icon={<MdRadar />} />
</div> </div>
<div className="flex w-full h-full"> <div className="flex w-full h-full">
<div className="flex-grow w-full h-full" ref={mapDiv} /> <div className="flex-grow w-full h-full" ref={mapRef} />
</div> </div>
</div> </div>
); );

60
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<mapboxgl.LngLat>) => {
state.latLng = action.payload;
},
setZoom: (state, action: PayloadAction<number>) => {
state.zoom = action.payload;
},
setMapStyle(state, action: PayloadAction<MapStyle>) {
state.style = action.payload;
},
setHillShade(state, action: PayloadAction<boolean>) {
state.hillShade = action.payload;
},
setExaggeration(state, action: PayloadAction<boolean>) {
state.exaggeration = action.payload;
},
},
});
export const {
setLatLng,
setZoom,
setMapStyle,
setHillShade,
setExaggeration,
} = mapSlice.actions;
export default mapSlice.reducer;

2
src/core/store.ts

@ -1,12 +1,14 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import appReducer from './slices/appSlice'; import appReducer from './slices/appSlice';
import mapReducer from './slices/mapSlice';
import meshtasticReducer from './slices/meshtasticSlice'; import meshtasticReducer from './slices/meshtasticSlice';
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
app: appReducer, app: appReducer,
meshtastic: meshtasticReducer, meshtastic: meshtasticReducer,
map: mapReducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ getDefaultMiddleware({

28
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<HTMLDivElement>,
accessToken: AccessToken,
options?: Partial<mapboxgl.MapboxOptions>,
): mapboxgl.Map | undefined {
const [mapInstance, setMapInstance] = React.useState<mapboxgl.Map>();
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;
}

79
src/pages/Nodes/NodeCard.tsx

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import mapboxgl from 'mapbox-gl';
import { FaSatellite } from 'react-icons/fa'; import { FaSatellite } from 'react-icons/fa';
import { FiCode } from 'react-icons/fi'; import { FiCode } from 'react-icons/fi';
import { GiLightningFrequency } from 'react-icons/gi'; import { GiLightningFrequency } from 'react-icons/gi';
@ -15,17 +16,23 @@ import {
} from 'react-icons/md'; } from 'react-icons/md';
import TimeAgo from 'timeago-react'; 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 type { Node } from '@core/slices/meshtasticSlice';
import { Disclosure } from '@headlessui/react'; import { Disclosure } from '@headlessui/react';
import { IconButton } from '@meshtastic/components'; import { IconButton } from '@meshtastic/components';
import { Protobuf } from '@meshtastic/meshtasticjs'; import { Protobuf } from '@meshtastic/meshtasticjs';
type PositionConfidence = 'high' | 'low' | 'none';
type NodeAge = 'young' | 'aging' | 'old' | 'dead';
export interface NodeCardProps { export interface NodeCardProps {
node: Node; node: Node;
myNodeInfo?: Protobuf.MyNodeInfo; myNodeInfo?: Protobuf.MyNodeInfo;
} }
export const NodeCard = ({ node, myNodeInfo }: NodeCardProps): JSX.Element => { export const NodeCard = ({ node, myNodeInfo }: NodeCardProps): JSX.Element => {
const dispatch = useAppDispatch();
const [snrAverage, setSnrAverage] = React.useState(0); const [snrAverage, setSnrAverage] = React.useState(0);
const [satsAverage, setSatsAverage] = React.useState(0); const [satsAverage, setSatsAverage] = React.useState(0);
React.useEffect(() => { 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), .reduce((a, b) => a + b) / (node.snr.length > 3 ? 3 : node.snr.length),
); );
}, [node.snr]); }, [node.snr]);
const [PositionConfidence, setPositionConfidence] =
React.useState<PositionConfidence>('none');
const [age, setAge] = React.useState<NodeAge>('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(() => { // React.useEffect(() => {
// setSatsAverage( // setSatsAverage(
@ -52,17 +85,20 @@ export const NodeCard = ({ node, myNodeInfo }: NodeCardProps): JSX.Element => {
as="div" as="div"
className="m-2 rounded-md shadow-md bg-gray-50 dark:bg-gray-700" className="m-2 rounded-md shadow-md bg-gray-50 dark:bg-gray-700"
> >
<Disclosure.Button className="flex w-full gap-2 p-2 bg-gray-100 rounded-md shadow-md dark:bg-primaryDark"> <Disclosure.Button
as="div"
className="flex w-full gap-2 p-2 bg-gray-100 rounded-md shadow-md dark:bg-primaryDark"
>
{myNodeInfo ? ( {myNodeInfo ? (
<MdAccountCircle className="my-auto" /> <MdAccountCircle className="my-auto" />
) : ( ) : (
<div <div
className={`my-auto w-3 h-3 rounded-full ${ className={`my-auto w-3 h-3 rounded-full ${
node.lastHeard > new Date(Date.now() - 1000 * 60 * 15) age === 'young'
? 'bg-green-500' ? 'bg-green-500'
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 30) : age === 'aging'
? 'bg-yellow-500' ? 'bg-yellow-500'
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 60) : age === 'old'
? 'bg-red-500' ? 'bg-red-500'
: 'bg-gray-500' : 'bg-gray-500'
}`} }`}
@ -81,17 +117,30 @@ export const NodeCard = ({ node, myNodeInfo }: NodeCardProps): JSX.Element => {
</span> </span>
)} )}
</div> </div>
<IconButton
{node.currentPosition ? ( disabled={PositionConfidence === 'none'}
new Date(node.positions[0].posTimestamp * 1000) > onClick={() => {
new Date(new Date().getTime() - 1000 * 60 * 30) ? ( if (PositionConfidence !== 'none' && node.currentPosition) {
<IconButton icon={<MdGpsFixed />} /> dispatch(
) : ( setLatLng(
<IconButton icon={<MdGpsNotFixed />} /> new mapboxgl.LngLat(
) node.currentPosition.longitudeI / 1e7,
) : ( node.currentPosition.latitudeI / 1e7,
<IconButton disabled icon={<MdGpsOff />} /> ),
)} ),
);
}
}}
icon={
PositionConfidence === 'high' ? (
<MdGpsFixed />
) : PositionConfidence === 'low' ? (
<MdGpsNotFixed />
) : (
<MdGpsOff />
)
}
/>
</Disclosure.Button> </Disclosure.Button>
<Disclosure.Panel className="p-2"> <Disclosure.Panel className="p-2">
{myNodeInfo && ( {myNodeInfo && (

3
src/pages/settings/Position.tsx

@ -3,7 +3,8 @@ import React from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { FiCode, FiMenu } from 'react-icons/fi'; import { FiCode, FiMenu } from 'react-icons/fi';
import JSONPretty from 'react-json-pretty'; 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 { useAppSelector } from '@app/hooks/redux';
import { FormFooter } from '@components/FormFooter'; import { FormFooter } from '@components/FormFooter';

Loading…
Cancel
Save