mirror of
https://github.com/meshtastic/web.git
synced 2026-04-29 02:04:08 -04:00
feat: Add Node detail popup in Map view
This commit is contained in:
171
src/components/PageComponents/Map/NodeDetail.tsx
Normal file
171
src/components/PageComponents/Map/NodeDetail.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { H5 } from "@app/components/UI/Typography/H5.tsx";
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import { Separator } from "@app/components/UI/Seperator";
|
||||
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import type { Protobuf as ProtobufType } from "@meshtastic/js";
|
||||
import {
|
||||
BatteryChargingIcon,
|
||||
BatteryFullIcon,
|
||||
BatteryLowIcon,
|
||||
BatteryMediumIcon,
|
||||
Dot,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MountainSnow,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
|
||||
export interface NodeDetailProps {
|
||||
node: ProtobufType.Mesh.NodeInfo;
|
||||
}
|
||||
|
||||
export const NodeDetail = ({ node }: NodeDetailProps): JSX.Element => {
|
||||
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
|
||||
const hardwareType = Protobuf.Mesh.HardwareModel[
|
||||
node.user?.hwModel ?? 0
|
||||
].replaceAll("_", " ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||
<Hashicon value={node.num.toString()} size={22} />
|
||||
|
||||
<div>
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||
<LockIcon
|
||||
className="text-green-600"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label="Public Key Enabled"
|
||||
/>
|
||||
) : (
|
||||
<LockOpenIcon
|
||||
className="text-yellow-500"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label="No Public Key"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Star
|
||||
fill={node.isFavorite ? "black" : "none"}
|
||||
size={15}
|
||||
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<H5>{name}</H5>
|
||||
|
||||
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}
|
||||
|
||||
{!!node.deviceMetrics?.batteryLevel && (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title={`${
|
||||
node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
|
||||
} volts`}
|
||||
>
|
||||
{node.deviceMetrics?.batteryLevel > 100 ? (
|
||||
<BatteryChargingIcon size={22} />
|
||||
) : node.deviceMetrics?.batteryLevel > 80 ? (
|
||||
<BatteryFullIcon size={22} />
|
||||
) : node.deviceMetrics?.batteryLevel > 20 ? (
|
||||
<BatteryMediumIcon size={22} />
|
||||
) : (
|
||||
<BatteryLowIcon size={22} />
|
||||
)}
|
||||
<Subtle aria-label="Battery">
|
||||
{node.deviceMetrics?.batteryLevel > 100
|
||||
? "Charging"
|
||||
: node.deviceMetrics?.batteryLevel + "%"}
|
||||
</Subtle>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{node.user?.shortName && <div>"{node.user?.shortName}"</div>}
|
||||
{node.user?.id && <div>{node.user?.id}</div>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-1"
|
||||
title={new Date(node.lastHeard * 1000).toLocaleString(
|
||||
navigator.language,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{node.lastHeard > 0 && (
|
||||
<div>
|
||||
Heard <TimeAgo timestamp={node.lastHeard * 1000} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{node.viaMqtt && (
|
||||
<div style={{ color: "#660066" }} className="font-medium">
|
||||
MQTT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-1" />
|
||||
|
||||
<div className="flex mt-2 text-sm">
|
||||
<div className="flex items-center flex-grow">
|
||||
<div className="border-2 border-black rounded px-0.5 mr-1">
|
||||
{isNaN(node.hopsAway) ? "?" : node.hopsAway}
|
||||
</div>
|
||||
<div>{node.hopsAway === 1 ? "Hop" : "Hops"}</div>
|
||||
</div>
|
||||
{node.position?.altitude && (
|
||||
<div className="flex items-center flex-grow">
|
||||
<MountainSnow
|
||||
size={15}
|
||||
className="ml-2 mr-1"
|
||||
aria-label="Elevation"
|
||||
/>
|
||||
<div>{node.position?.altitude} ft</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex mt-2">
|
||||
{!!node.deviceMetrics?.channelUtilization && (
|
||||
<div className="flex-grow">
|
||||
<div>Channel Util</div>
|
||||
<Mono>
|
||||
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
{!!node.deviceMetrics?.airUtilTx && (
|
||||
<div className="flex-grow">
|
||||
<div>Airtime Util</div>
|
||||
<Mono>{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{node.snr !== 0 && (
|
||||
<div className="mt-2">
|
||||
<div>SNR</div>
|
||||
<Mono className="flex items-center text-xs">
|
||||
{node.snr}db
|
||||
<Dot />
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%
|
||||
<Dot />
|
||||
{(node.snr + 10) * 5}raw
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
14
src/components/UI/Typography/H5.tsx
Normal file
14
src/components/UI/Typography/H5.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@app/core/utils/cn.ts";
|
||||
|
||||
export interface H5Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const H5 = ({ className, children }: H5Props): JSX.Element => (
|
||||
<h5
|
||||
className={cn("scroll-m-20 text-lg font-medium tracking-tight", className)}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
|
||||
import { NodeDetail } from "@app/components/PageComponents/Map/NodeDetail";
|
||||
import { cn } from "@app/core/utils/cn.ts";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
@@ -16,8 +17,9 @@ import {
|
||||
ZoomOutIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Marker, useMap } from "react-map-gl";
|
||||
import { Marker, useMap, Popup } from "react-map-gl";
|
||||
import MapGl from "react-map-gl/maplibre";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
|
||||
export const MapPage = (): JSX.Element => {
|
||||
const { nodes, waypoints } = useDevice();
|
||||
@@ -25,6 +27,8 @@ export const MapPage = (): JSX.Element => {
|
||||
const { default: map } = useMap();
|
||||
|
||||
const [zoom, setZoom] = useState(0);
|
||||
const [selectedNode, setSelectedNode] =
|
||||
useState<Protobuf.Mesh.NodeInfo | null>(null);
|
||||
|
||||
const allNodes = Array.from(nodes.values());
|
||||
|
||||
@@ -160,7 +164,7 @@ export const MapPage = (): JSX.Element => {
|
||||
</Source>
|
||||
))} */}
|
||||
{allNodes.map((node) => {
|
||||
if (node.position?.latitudeI) {
|
||||
if (node.position?.latitudeI && node.num !== selectedNode?.num) {
|
||||
return (
|
||||
<Marker
|
||||
key={node.num}
|
||||
@@ -169,6 +173,7 @@ export const MapPage = (): JSX.Element => {
|
||||
style={{ filter: darkMode ? "invert(1)" : "" }}
|
||||
anchor="bottom"
|
||||
onClick={() => {
|
||||
setSelectedNode(node);
|
||||
map?.easeTo({
|
||||
zoom: 12,
|
||||
center: [
|
||||
@@ -189,6 +194,17 @@ export const MapPage = (): JSX.Element => {
|
||||
);
|
||||
}
|
||||
})}
|
||||
{selectedNode?.position && (
|
||||
<Popup
|
||||
longitude={(selectedNode.position.longitudeI ?? 0) / 1e7}
|
||||
latitude={(selectedNode.position.latitudeI ?? 0) / 1e7}
|
||||
anchor="left"
|
||||
closeOnClick={false}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
>
|
||||
<NodeDetail node={selectedNode} />
|
||||
</Popup>
|
||||
)}
|
||||
</MapGl>
|
||||
</PageLayout>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user