Browse Source

Add node map + fixes

pull/2/head
Sacha Weatherstone 5 years ago
parent
commit
8d68832c62
  1. 27
      package.json
  2. 690
      pnpm-lock.yaml
  3. 211
      src/components/Map/index.tsx
  4. 25
      src/components/Map/styles.ts
  5. 17
      src/components/generic/StatCard.tsx
  6. 2
      src/components/templates/PageLayout.tsx
  7. 10
      src/core/connection.ts
  8. 53
      src/core/slices/meshtasticSlice.ts
  9. 2
      src/pages/Messages.tsx
  10. 118
      src/pages/Nodes/Index.tsx
  11. 65
      src/pages/Nodes/Node.tsx
  12. 11
      vite.config.ts

27
package.json

@ -13,39 +13,41 @@
},
"dependencies": {
"@headlessui/react": "^1.4.2",
"@meshtastic/meshtasticjs": "^0.6.31",
"@meshtastic/meshtasticjs": "^0.6.32",
"@reduxjs/toolkit": "^1.6.2",
"boring-avatars": "^1.5.8",
"i18next": "^21.5.3",
"i18next": "^21.5.5",
"i18next-browser-languagedetector": "^6.1.2",
"mapbox-gl": "^2.6.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-file-icon": "^1.1.0",
"react-hook-form": "^7.20.4",
"react-hook-form": "^7.21.0",
"react-i18next": "^11.14.3",
"react-icons": "^4.3.1",
"react-json-pretty": "^2.2.0",
"react-qr-code": "^2.0.3",
"react-redux": "^7.2.6",
"rfc4648": "^1.5.0",
"swr": "^1.0.1",
"swr": "^1.1.0",
"timeago-react": "^3.0.4",
"type-route": "^0.6.0",
"use-breakpoint": "^3.0.0"
},
"devDependencies": {
"@types/mapbox-gl": "^2.6.0",
"@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11",
"@types/react-file-icon": "^1.0.1",
"@types/w3c-web-serial": "^1.0.2",
"@types/web-bluetooth": "^0.0.11",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"@verypossible/eslint-config": "^1.6.1",
"@vitejs/plugin-react": "^1.1.0",
"@vitejs/plugin-react": "^1.1.1",
"autoprefixer": "^10.4.0",
"babel-plugin-module-resolver": "^4.1.0",
"eslint": "8.3.0",
"eslint": "8.4.1",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-babel-module": "^5.3.1",
@ -55,12 +57,13 @@
"eslint-plugin-react-hooks": "^4.3.0",
"gzipper": "^6.0.0",
"postcss": "^8.4.4",
"prettier": "^2.5.0",
"prettier": "^2.5.1",
"tailwindcss": "^3.0.0-alpha.2",
"tar": "^6.1.11",
"typescript": "^4.5.2",
"vite": "^2.6.14",
"vite-plugin-pwa": "^0.11.7",
"workbox-window": "^6.4.1"
"vite": "^2.7.0",
"vite-plugin-cdn-import": "^0.3.5",
"vite-plugin-pwa": "^0.11.10",
"workbox-window": "^6.4.2"
}
}

690
pnpm-lock.yaml

File diff suppressed because it is too large

211
src/components/Map/index.tsx

@ -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>
);
};

25
src/components/Map/styles.ts

@ -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,
};

17
src/components/generic/StatCard.tsx

@ -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>
);
};

2
src/components/templates/PageLayout.tsx

@ -37,7 +37,7 @@ export const PageLayout = ({
}}
>
<Tab.List className="flex flex-col border-b border-gray-300 divide-y divide-gray-300 dark:divide-gray-600 dark:border-gray-600">
<div className="flex items-center justify-between m-8 mr-6 md:my-10">
<div className="flex items-center justify-between m-6 mr-6">
<div className="text-4xl font-extrabold leading-none tracking-tight">
{title}
</div>

10
src/core/connection.ts

@ -10,6 +10,7 @@ import {
setMyNodeInfo,
setPreferences,
setReady,
updateLastInteraction,
} from '@core/slices/meshtasticSlice';
import { store } from '@core/store';
import {
@ -136,6 +137,15 @@ const registerListeners = (): void => {
store.dispatch(setLastMeshInterraction(date.getTime())),
);
connection.onRoutingPacket.subscribe((routingPacket) => {
store.dispatch(
updateLastInteraction({
id: routingPacket.packet.from,
time: new Date(routingPacket.packet.rxTime * 1000),
}),
);
});
connection.onTextPacket.subscribe((message) => {
const myNodeNum = store.getState().meshtastic.radio.hardware.myNodeNum;

53
src/core/slices/meshtasticSlice.ts

@ -2,8 +2,6 @@ import { Protobuf, Types } from '@meshtastic/meshtasticjs';
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
// import { connection } from '../connection';
export interface MessageWithAck {
message: Types.TextPacket;
ack: boolean;
@ -16,11 +14,19 @@ export interface ChannelData {
messages: MessageWithAck[];
}
interface CurrentPosition {
latitudeI: number;
longitudeI: number;
altitude: number;
posTimestamp: number;
}
export interface Node {
number: number;
lastHeard: Date;
snr: number[];
positions: Protobuf.Position[];
currentPosition?: CurrentPosition;
user?: Protobuf.User;
}
@ -79,8 +85,9 @@ export const meshtasticSlice = createSlice({
);
if (node) {
node.user = action.payload.data;
// todo: use rx time
node.lastHeard = new Date();
if (action.payload.packet.rxTime) {
node.lastHeard = new Date(action.payload.packet.rxTime * 1000);
}
} else {
console.log('Node not in DB');
}
@ -90,10 +97,29 @@ export const meshtasticSlice = createSlice({
(node) => node.number === action.payload.packet.from,
);
node?.positions.push(action.payload.data);
if (node) {
// todo: use rx time
node.lastHeard = new Date();
node.positions.push(action.payload.data);
if (
action.payload.data.latitudeI ||
action.payload.data.longitudeI ||
action.payload.data.altitude
) {
node.currentPosition = {
latitudeI:
action.payload.data.latitudeI ?? node.currentPosition?.latitudeI,
longitudeI:
action.payload.data.longitudeI ??
node.currentPosition?.longitudeI,
altitude:
action.payload.data.altitude ?? node.currentPosition?.altitude,
posTimestamp: action.payload.data.posTimestamp, //maybe new date?
};
}
if (action.payload.packet.rxTime) {
node.lastHeard = new Date(action.payload.packet.rxTime * 1000);
}
}
},
addNode: (state, action: PayloadAction<Protobuf.NodeInfo>) => {
@ -156,13 +182,23 @@ export const meshtasticSlice = createSlice({
const channelIndex = state.radio.channels.findIndex(
(channel) => channel.channel.index === action.payload.channel,
);
// todo: update last mesh/user interraction here
state.radio.channels[channelIndex].messages.map((message) => {
if (message.message.packet.id === action.payload.messageId) {
message.ack = true;
}
});
},
updateLastInteraction: (
state,
action: PayloadAction<{ id: number; time: Date }>,
) => {
const node = state.nodes.find(
(node) => node.number === action.payload.id,
);
if (node) {
node.lastHeard = action.payload.time;
}
},
setHostOverrideEnabled: (state, action: PayloadAction<boolean>) => {
state.hostOverrideEnabled = action.payload;
localStorage.setItem('hostOverrideEnabled', String(action.payload));
@ -199,6 +235,7 @@ export const {
setPreferences,
addMessage,
ackMessage,
updateLastInteraction,
setHostOverrideEnabled,
setHostOverride,
resetState,

2
src/pages/Messages.tsx

@ -50,7 +50,7 @@ export const Messages = (): JSX.Element => {
isSender={message.isSender}
message={message.message.data}
ack={message.ack}
rxTime={new Date()}
rxTime={message.received}
senderName={
nodes.find((node) => node.number === message.message.packet.from)
?.user?.longName ?? 'UNK'

118
src/pages/Nodes/Index.tsx

@ -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>
);
};

65
src/pages/Nodes/Node.tsx

@ -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>
);
};

11
vite.config.ts

@ -1,5 +1,6 @@
import path from 'path';
import { defineConfig } from 'vite';
import importToCDN from 'vite-plugin-cdn-import';
import { VitePWA } from 'vite-plugin-pwa';
import react from '@vitejs/plugin-react';
@ -8,6 +9,16 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react(),
importToCDN({
modules: [
{
name: 'mapbox-gl',
var: 'mapboxgl',
path: `dist/mapbox-gl.js`,
},
],
}),
VitePWA({
mode: 'production',

Loading…
Cancel
Save