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 { 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 { Protobuf } from '@meshtastic/meshtasticjs'; |
||||
|
|
||||
import { Node } from './Node'; |
|
||||
|
|
||||
export const Nodes = (): JSX.Element => { |
export const Nodes = (): JSX.Element => { |
||||
const myNodeNum = useAppSelector( |
const myNodeNum = useAppSelector( |
||||
(state) => state.meshtastic.radio.hardware, |
(state) => state.meshtastic.radio.hardware, |
||||
).myNodeNum; |
).myNodeNum; |
||||
const nodes = useAppSelector((state) => state.meshtastic.nodes).filter( |
const nodes = useAppSelector((state) => state.meshtastic.nodes); |
||||
(node) => node.number !== myNodeNum, |
// .filter(
|
||||
); |
// (node) => node.number !== myNodeNum,
|
||||
|
// );
|
||||
|
const [navOpen, setNavOpen] = React.useState(false); |
||||
|
|
||||
|
const { breakpoint } = useBreakpoint(); |
||||
|
|
||||
return ( |
return ( |
||||
<PageLayout |
<div className="relative flex w-full dark:text-white"> |
||||
title="Nodes" |
<Drawer |
||||
emptyMessage="No nodes discovered yet..." |
open={breakpoint === 'sm' ? navOpen : true} |
||||
sidebarItems={nodes.map((node) => { |
permenant={breakpoint !== 'sm'} |
||||
return { |
onClose={(): void => { |
||||
title: node.user?.longName ?? node.number.toString(), |
setNavOpen(!navOpen); |
||||
description: node.user |
}} |
||||
? Protobuf.HardwareModel[node.user.hwModel] |
> |
||||
: 'Unknown Hardware', |
<div className="flex items-center justify-between m-6 mr-6"> |
||||
icon: ( |
<div className="text-4xl font-extrabold leading-none tracking-tight"> |
||||
<Avatar |
Nodes |
||||
size={30} |
</div> |
||||
name={node.user?.longName ?? node.number.toString()} |
<div className="md:hidden"> |
||||
variant="beam" |
<IconButton |
||||
colors={['#213435', '#46685B', '#648A64', '#A6B985', '#E1E3AC']} |
icon={<FiXCircle className="w-5 h-5" />} |
||||
|
onClick={(): void => { |
||||
|
setNavOpen(false); |
||||
|
}} |
||||
/> |
/> |
||||
), |
</div> |
||||
}; |
</div> |
||||
})} |
{!nodes.length && ( |
||||
panels={nodes.map((node, index) => ( |
<span className="p-4 text-sm text-gray-400 dark:text-gray-600"> |
||||
<Node key={index} node={node} /> |
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