12 changed files with 850 additions and 381 deletions
File diff suppressed because it is too large
@ -0,0 +1,211 @@ |
|||
import 'mapbox-gl/dist/mapbox-gl.css'; |
|||
|
|||
import React from 'react'; |
|||
|
|||
import mapboxgl from 'mapbox-gl'; |
|||
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 '@components/generic/IconButton'; |
|||
|
|||
import { MapStyles } from './styles'; |
|||
|
|||
export const Map = (): JSX.Element => { |
|||
const darkMode = useAppSelector((state) => state.app.darkMode); |
|||
const nodes = useAppSelector((state) => state.meshtastic.nodes); |
|||
|
|||
mapboxgl.accessToken = |
|||
'pk.eyJ1Ijoic2FjaGF3IiwiYSI6ImNrNW9meXozZjBsdW0zbHBjM2FnNnV6cmsifQ.3E4n8eFGD9ZOFo-XDVeZnQ'; |
|||
const mapDiv = React.useRef<HTMLDivElement>(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 => { |
|||
nodes.map((node) => { |
|||
if (node.currentPosition && map) { |
|||
new mapboxgl.Marker({}) |
|||
.setLngLat({ |
|||
lat: node.currentPosition?.latitudeI / 1e7, |
|||
lng: node.currentPosition?.longitudeI / 1e7, |
|||
}) |
|||
.setPopup( |
|||
new mapboxgl.Popup().setHTML( |
|||
renderToString( |
|||
<div> |
|||
<div className="text-xl font-medium"> |
|||
{node.user?.longName} |
|||
</div> |
|||
<ul> |
|||
<li>ID: {node.number}</li> |
|||
</ul> |
|||
</div>, |
|||
), |
|||
), |
|||
) |
|||
.addTo(map); |
|||
} |
|||
}); |
|||
}; |
|||
|
|||
React.useEffect(() => { |
|||
PlaceNodes(); |
|||
}, [nodes]); |
|||
|
|||
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; |
|||
} |
|||
} |
|||
}, [maptype, darkMode, map]); |
|||
|
|||
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', |
|||
); |
|||
} else { |
|||
map.removeLayer('hillshading'); |
|||
} |
|||
} |
|||
}, [shading, map]); |
|||
|
|||
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(); |
|||
} |
|||
} |
|||
}, [exaggeration, map]); |
|||
|
|||
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(); |
|||
}); |
|||
} |
|||
}, [map, darkMode]); |
|||
|
|||
return ( |
|||
<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"> |
|||
<IconButton |
|||
active={maptype === 'Satellite'} |
|||
onClick={(): void => { |
|||
if (maptype === 'Satellite') { |
|||
setMaptype('Default'); |
|||
} else { |
|||
setMaptype('Satellite'); |
|||
} |
|||
}} |
|||
icon={<FaGlobeAfrica />} |
|||
/> |
|||
|
|||
<div |
|||
className={`p-1 -m-1 space-y-2 border-gray-400 rounded-md dark:border-gray-200 ${ |
|||
maptype === 'Outdoors' ? 'border' : '' |
|||
}`}
|
|||
> |
|||
<IconButton |
|||
active={maptype === 'Outdoors'} |
|||
onClick={(): void => { |
|||
if (maptype === 'Outdoors') { |
|||
setMaptype('Default'); |
|||
} else { |
|||
setMaptype('Outdoors'); |
|||
} |
|||
}} |
|||
icon={<FaDirections />} |
|||
/> |
|||
{maptype === 'Outdoors' && ( |
|||
<IconButton |
|||
active={shading} |
|||
onClick={(): void => { |
|||
setShading(!shading); |
|||
}} |
|||
icon={<MdWbShade />} |
|||
/> |
|||
)} |
|||
</div> |
|||
|
|||
<hr className="text-gray-400 dark:text-gray-200" /> |
|||
<IconButton |
|||
active={exaggeration} |
|||
onClick={(): void => { |
|||
setExaggeration(!exaggeration); |
|||
}} |
|||
icon={<FaMountain />} |
|||
/> |
|||
<IconButton icon={<MdFullscreen />} /> |
|||
<IconButton icon={<MdRadar />} /> |
|||
</div> |
|||
<div className="flex w-full h-full"> |
|||
<div className="flex-grow w-full h-full" ref={mapDiv} /> |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,25 @@ |
|||
export interface MapStyle { |
|||
title: string; |
|||
url: string; |
|||
} |
|||
|
|||
export const MapStyles = { |
|||
Streets: { |
|||
title: 'Streets', |
|||
url: 'mapbox://styles/mapbox/streets-v11', |
|||
} as MapStyle, |
|||
Outdoors: { |
|||
title: 'Outdoors', |
|||
url: 'mapbox://styles/mapbox/outdoors-v11', |
|||
} as MapStyle, |
|||
|
|||
Light: { |
|||
title: 'Light', |
|||
url: 'mapbox://styles/mapbox/light-v10', |
|||
} as MapStyle, |
|||
Dark: { title: 'Dark', url: 'mapbox://styles/mapbox/dark-v10' } as MapStyle, |
|||
Satellite: { |
|||
title: 'Satellite', |
|||
url: 'mapbox://styles/mapbox/satellite-v9', |
|||
} as MapStyle, |
|||
}; |
|||
@ -1,17 +0,0 @@ |
|||
import type React from 'react'; |
|||
|
|||
export interface StatCardProps { |
|||
title: string; |
|||
value: string | JSX.Element; |
|||
} |
|||
|
|||
export const StatCard = ({ title, value }: StatCardProps): JSX.Element => { |
|||
return ( |
|||
<div className="w-full bg-white border-gray-300 shadow-md select-none dark:text-white border-y md:border dark:bg-primaryDark dark:border-gray-600 md:rounded-md "> |
|||
<div className="m-4"> |
|||
<div className="text-lg font-light">{title}</div> |
|||
<div className="text-3xl font-bold">{value}</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -1,43 +1,101 @@ |
|||
import type React from 'react'; |
|||
import React from 'react'; |
|||
|
|||
import Avatar from 'boring-avatars'; |
|||
import { FiCode, FiXCircle } from 'react-icons/fi'; |
|||
import { MdGpsFixed, MdGpsNotFixed, MdGpsOff } from 'react-icons/md'; |
|||
import TimeAgo from 'timeago-react'; |
|||
|
|||
import { useBreakpoint } from '@app/hooks/breakpoint'; |
|||
import { useAppSelector } from '@app/hooks/redux'; |
|||
import { PageLayout } from '@components/templates/PageLayout'; |
|||
import { Drawer } from '@components/generic/Drawer'; |
|||
import { IconButton } from '@components/generic/IconButton'; |
|||
import { Map } from '@components/Map'; |
|||
import { Protobuf } from '@meshtastic/meshtasticjs'; |
|||
|
|||
import { Node } from './Node'; |
|||
|
|||
export const Nodes = (): JSX.Element => { |
|||
const myNodeNum = useAppSelector( |
|||
(state) => state.meshtastic.radio.hardware, |
|||
).myNodeNum; |
|||
const nodes = useAppSelector((state) => state.meshtastic.nodes).filter( |
|||
(node) => node.number !== myNodeNum, |
|||
); |
|||
const nodes = useAppSelector((state) => state.meshtastic.nodes); |
|||
// .filter(
|
|||
// (node) => node.number !== myNodeNum,
|
|||
// );
|
|||
const [navOpen, setNavOpen] = React.useState(false); |
|||
|
|||
const { breakpoint } = useBreakpoint(); |
|||
|
|||
return ( |
|||
<PageLayout |
|||
title="Nodes" |
|||
emptyMessage="No nodes discovered yet..." |
|||
sidebarItems={nodes.map((node) => { |
|||
return { |
|||
title: node.user?.longName ?? node.number.toString(), |
|||
description: node.user |
|||
? Protobuf.HardwareModel[node.user.hwModel] |
|||
: 'Unknown Hardware', |
|||
icon: ( |
|||
<Avatar |
|||
size={30} |
|||
name={node.user?.longName ?? node.number.toString()} |
|||
variant="beam" |
|||
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']} |
|||
<div className="relative flex w-full dark:text-white"> |
|||
<Drawer |
|||
open={breakpoint === 'sm' ? navOpen : true} |
|||
permenant={breakpoint !== 'sm'} |
|||
onClose={(): void => { |
|||
setNavOpen(!navOpen); |
|||
}} |
|||
> |
|||
<div className="flex items-center justify-between m-6 mr-6"> |
|||
<div className="text-4xl font-extrabold leading-none tracking-tight"> |
|||
Nodes |
|||
</div> |
|||
<div className="md:hidden"> |
|||
<IconButton |
|||
icon={<FiXCircle className="w-5 h-5" />} |
|||
onClick={(): void => { |
|||
setNavOpen(false); |
|||
}} |
|||
/> |
|||
), |
|||
}; |
|||
})} |
|||
panels={nodes.map((node, index) => ( |
|||
<Node key={index} node={node} /> |
|||
))} |
|||
/> |
|||
</div> |
|||
</div> |
|||
{!nodes.length && ( |
|||
<span className="p-4 text-sm text-gray-400 dark:text-gray-600"> |
|||
No nodes found. |
|||
</span> |
|||
)} |
|||
{nodes.map((node) => ( |
|||
<div |
|||
key={node.number} |
|||
className="m-2 rounded-md shadow-md bg-gray-50 dark:bg-gray-700" |
|||
> |
|||
<div className="flex gap-2 p-2 bg-gray-100 shadow-md rounded-t-md dark:bg-primaryDark"> |
|||
<div |
|||
className={`my-auto w-3 h-3 rounded-full ${ |
|||
node.lastHeard > new Date(Date.now() - 1000 * 60 * 15) |
|||
? 'bg-green-500' |
|||
: node.lastHeard > new Date(Date.now() - 1000 * 60 * 30) |
|||
? 'bg-yellow-500' |
|||
: 'bg-red-500' |
|||
}`}
|
|||
/> |
|||
<div className="my-auto">{node.user?.longName}</div> |
|||
<div className="my-auto ml-auto text-xs font-semibold"> |
|||
{node.lastHeard.getTime() ? ( |
|||
<TimeAgo datetime={node.lastHeard} /> |
|||
) : ( |
|||
'Never' |
|||
)} |
|||
</div> |
|||
{node.currentPosition ? ( |
|||
new Date(node.positions[0].posTimestamp * 1000) > |
|||
new Date(new Date().getTime() - 1000 * 60 * 30) ? ( |
|||
<IconButton icon={<MdGpsFixed />} /> |
|||
) : ( |
|||
<IconButton icon={<MdGpsNotFixed />} /> |
|||
) |
|||
) : ( |
|||
<IconButton disabled icon={<MdGpsOff />} /> |
|||
)} |
|||
</div> |
|||
<div className="flex p-2"> |
|||
<div className="my-auto"> |
|||
{Protobuf.HardwareModel[node.user?.hwModel ?? 0]} |
|||
</div> |
|||
<div className="ml-auto"> |
|||
<IconButton icon={<FiCode className="w-5 h-5" />} /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
))} |
|||
</Drawer> |
|||
<Map /> |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
@ -1,65 +0,0 @@ |
|||
import 'react-json-pretty/themes/acai.css'; |
|||
|
|||
import React from 'react'; |
|||
|
|||
import { FiCode, FiMenu } from 'react-icons/fi'; |
|||
import JSONPretty from 'react-json-pretty'; |
|||
import TimeAgo from 'timeago-react'; |
|||
|
|||
import { Card } from '@components/generic/Card'; |
|||
import { Cover } from '@components/generic/Cover'; |
|||
import { IconButton } from '@components/generic/IconButton'; |
|||
import { StatCard } from '@components/generic/StatCard'; |
|||
import { PrimaryTemplate } from '@components/templates/PrimaryTemplate'; |
|||
import type { Node as NodeType } from '@core/slices/meshtasticSlice'; |
|||
|
|||
export interface NodeProps { |
|||
navOpen?: boolean; |
|||
setNavOpen?: React.Dispatch<React.SetStateAction<boolean>>; |
|||
node: NodeType; |
|||
} |
|||
|
|||
export const Node = ({ navOpen, setNavOpen, node }: NodeProps): JSX.Element => { |
|||
const [debug, setDebug] = React.useState(false); |
|||
return ( |
|||
<PrimaryTemplate |
|||
title={node.user?.longName ?? node.number.toString()} |
|||
tagline="Node" |
|||
leftButton={ |
|||
<IconButton |
|||
icon={<FiMenu className="w-5 h-5" />} |
|||
onClick={(): void => { |
|||
setNavOpen && setNavOpen(!navOpen); |
|||
}} |
|||
/> |
|||
} |
|||
rightButton={ |
|||
<IconButton |
|||
icon={<FiCode className="w-5 h-5" />} |
|||
active={debug} |
|||
onClick={(): void => { |
|||
setDebug(!debug); |
|||
}} |
|||
/> |
|||
} |
|||
> |
|||
<div className="w-full space-y-4"> |
|||
<div className="flex flex-col justify-between gap-4 md:flex-row"> |
|||
<StatCard |
|||
title="Last heard" |
|||
value={ |
|||
node.lastHeard ? <TimeAgo datetime={node.lastHeard} /> : 'Never' |
|||
} |
|||
/> |
|||
<StatCard title="SNR" value={node.snr.toString()} /> |
|||
</div> |
|||
<Card title="Position" description={node.lastHeard.toLocaleString()}> |
|||
<Cover enabled={debug} content={<JSONPretty data={node} />} /> |
|||
<div className="p-10"> |
|||
<JSONPretty data={node.positions[node.positions.length - 1]} /> |
|||
</div> |
|||
</Card> |
|||
</div> |
|||
</PrimaryTemplate> |
|||
); |
|||
}; |
|||
Loading…
Reference in new issue