mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
More cleanup to the node details page
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
<title>%VITE_SITE_TITLE%</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;300;400;500;600;700&display=swap"
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;300;400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Roboto+Mono:wght@400;500&family=Share+Tech+Mono&family=Space+Mono:wght@400;700&family=VT323&display=swap"
|
||||
rel="stylesheet">
|
||||
<!-- Google Maps API with environment variable API key -->
|
||||
<script async defer src="https://maps.googleapis.com/maps/api/js?key=%VITE_GOOGLE_MAPS_API_KEY%"></script>
|
||||
|
||||
@@ -11,20 +11,65 @@ interface MapProps {
|
||||
className?: string;
|
||||
flush?: boolean;
|
||||
nightMode?: boolean;
|
||||
precisionBits?: number; // Added for position precision
|
||||
}
|
||||
|
||||
// Helper function to calculate zoom level based on precision bits
|
||||
const calculateZoomFromPrecisionBits = (precisionBits?: number): number => {
|
||||
if (!precisionBits) return 14; // Default zoom
|
||||
|
||||
// Each precision bit roughly halves the area, so we can map bits to zoom level
|
||||
// Starting with Earth at zoom 0, each bit roughly adds 1 zoom level
|
||||
// Typical values: 21 bits ~= zoom 13-14, 24 bits ~= zoom 16-17
|
||||
const baseZoom = 8; // Start with a basic zoom level
|
||||
const additionalZoom = Math.max(0, precisionBits - 16); // Each 2 bits above 16 adds ~1 zoom level
|
||||
|
||||
return Math.min(18, baseZoom + (additionalZoom / 2)); // Cap at zoom 18
|
||||
};
|
||||
|
||||
// Function to calculate accuracy in meters from precision bits
|
||||
const calculateAccuracyFromPrecisionBits = (precisionBits?: number): number => {
|
||||
if (!precisionBits) return 300; // Default accuracy of 300m
|
||||
|
||||
// Each precision bit halves the accuracy radius
|
||||
// Starting with Earth's circumference (~40075km), calculate the precision
|
||||
const earthCircumference = 40075000; // in meters
|
||||
const accuracy = earthCircumference / (2 ** precisionBits) / 2;
|
||||
|
||||
// Limit to reasonable values
|
||||
return Math.max(1, Math.min(accuracy, 10000));
|
||||
};
|
||||
|
||||
export const Map: React.FC<MapProps> = ({
|
||||
latitude,
|
||||
longitude,
|
||||
zoom = 14,
|
||||
zoom,
|
||||
width = 300,
|
||||
height = 200,
|
||||
caption,
|
||||
className = "",
|
||||
flush = false,
|
||||
nightMode = true
|
||||
nightMode = true,
|
||||
precisionBits
|
||||
}) => {
|
||||
const mapUrl = getStaticMapUrl(latitude, longitude, zoom, width, height, nightMode);
|
||||
// Calculate zoom level based on precision bits if zoom is not provided
|
||||
const effectiveZoom = zoom || calculateZoomFromPrecisionBits(precisionBits);
|
||||
|
||||
// Calculate accuracy in meters if we have precision bits
|
||||
const accuracyMeters = precisionBits !== undefined
|
||||
? calculateAccuracyFromPrecisionBits(precisionBits)
|
||||
: undefined;
|
||||
|
||||
const mapUrl = getStaticMapUrl(
|
||||
latitude,
|
||||
longitude,
|
||||
effectiveZoom,
|
||||
width,
|
||||
height,
|
||||
nightMode,
|
||||
precisionBits,
|
||||
accuracyMeters
|
||||
);
|
||||
const googleMapsUrl = getGoogleMapsUrl(latitude, longitude);
|
||||
|
||||
// Check if Google Maps API key is available
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { Packet, PortNum } from "../../lib/types";
|
||||
import { Package } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair";
|
||||
|
||||
interface GenericPacketProps {
|
||||
packet: Packet;
|
||||
@@ -53,18 +53,24 @@ export const GenericPacket: React.FC<GenericPacketProps> = ({ packet }) => {
|
||||
<KeyValuePair
|
||||
label="Port"
|
||||
value={portName}
|
||||
vertical={true}
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="Payload"
|
||||
value={getPayloadDescription()}
|
||||
vertical={true}
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="To"
|
||||
value={data.to === 4294967295 ? "Broadcast" : data.to.toString()}
|
||||
vertical={true}
|
||||
monospace={true}
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="Hop Limit"
|
||||
value={data.hopLimit}
|
||||
vertical={true}
|
||||
monospace={true}
|
||||
/>
|
||||
</KeyValueGrid>
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface KeyValuePairProps {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
large?: boolean;
|
||||
}
|
||||
|
||||
export const KeyValuePair: React.FC<KeyValuePairProps> = ({
|
||||
label,
|
||||
value,
|
||||
large = false
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className={`text-xs tracking-wide text-neutral-400 uppercase ${large ? 'mb-1' : ''}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`${large ? "text-base font-medium tracking-wide" : "tracking-tight"}`}>
|
||||
{value || "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface KeyValueGridProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const KeyValueGrid: React.FC<KeyValueGridProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 max-w-xl">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,145 +1,337 @@
|
||||
import React from "react";
|
||||
import { Packet } from "../../lib/types";
|
||||
import { Map as MapIcon } from "lucide-react";
|
||||
import {
|
||||
Packet,
|
||||
HardwareModel,
|
||||
DeviceRole,
|
||||
RegionCode,
|
||||
ModemPreset,
|
||||
} from "../../lib/types";
|
||||
import { Map as MapIcon, Signal, MapPin } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair";
|
||||
import { Map } from "../Map";
|
||||
|
||||
interface MapReportPacketProps {
|
||||
packet: Packet;
|
||||
}
|
||||
|
||||
// Helper function to get hardware model name
|
||||
const getHardwareModelName = (
|
||||
model: string | HardwareModel | undefined
|
||||
): string => {
|
||||
if (model === undefined) return "Unknown";
|
||||
|
||||
// If it's a string, return it directly
|
||||
if (typeof model === "string") return model;
|
||||
|
||||
// If it's an enum value, return the name
|
||||
switch (model) {
|
||||
case HardwareModel.TBEAM:
|
||||
return "T-BEAM";
|
||||
case HardwareModel.TBEAM_V0P7:
|
||||
return "T-BEAM v0.7";
|
||||
case HardwareModel.LILYGO_TBEAM_S3_CORE:
|
||||
return "T-BEAM S3";
|
||||
case HardwareModel.TLORA_V1:
|
||||
return "T-LORA v1";
|
||||
case HardwareModel.TLORA_V1_1P3:
|
||||
return "T-LORA v1.1.3";
|
||||
case HardwareModel.TLORA_V2:
|
||||
return "T-LORA v2";
|
||||
case HardwareModel.TLORA_V2_1_1P6:
|
||||
return "T-LORA v2.1.6";
|
||||
case HardwareModel.TLORA_V2_1_1P8:
|
||||
return "T-LORA v2.1.8";
|
||||
case HardwareModel.TLORA_T3_S3:
|
||||
return "T-LORA T3-S3";
|
||||
case HardwareModel.T_ECHO:
|
||||
return "T-ECHO";
|
||||
case HardwareModel.HELTEC_V1:
|
||||
return "Heltec v1";
|
||||
case HardwareModel.HELTEC_V2_0:
|
||||
return "Heltec v2.0";
|
||||
case HardwareModel.HELTEC_V2_1:
|
||||
return "Heltec v2.1";
|
||||
case HardwareModel.RAK4631:
|
||||
return "RAK4631";
|
||||
case HardwareModel.RAK11200:
|
||||
return "RAK11200";
|
||||
case HardwareModel.NANO_G1:
|
||||
return "Nano G1";
|
||||
case HardwareModel.NANO_G1_EXPLORER:
|
||||
return "Nano G1 Explorer";
|
||||
case HardwareModel.NANO_G2_ULTRA:
|
||||
return "Nano G2 Ultra";
|
||||
default:
|
||||
return `Unknown (${model})`;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get role name
|
||||
const getRoleName = (role: string | DeviceRole | undefined): string => {
|
||||
if (role === undefined) return "Unknown";
|
||||
|
||||
// If it's a string, return it directly
|
||||
if (typeof role === "string") return role;
|
||||
|
||||
// If it's an enum value, return the name
|
||||
switch (role) {
|
||||
case DeviceRole.CLIENT:
|
||||
return "Client";
|
||||
case DeviceRole.ROUTER:
|
||||
return "Router";
|
||||
case DeviceRole.ROUTER_CLIENT:
|
||||
return "Router+Client";
|
||||
case DeviceRole.TRACKER:
|
||||
return "Tracker";
|
||||
case DeviceRole.TAK_TRACKER:
|
||||
return "TAK Tracker";
|
||||
case DeviceRole.SENSOR:
|
||||
return "Sensor";
|
||||
case DeviceRole.REPEATER:
|
||||
return "Repeater";
|
||||
default:
|
||||
return `Unknown (${role})`;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get region name
|
||||
const getRegionName = (region: RegionCode | string | undefined): string => {
|
||||
if (region === undefined) return "Unknown";
|
||||
|
||||
// Map of region codes to display names
|
||||
const regionNames: Record<string, string> = {
|
||||
"UNSET": "Unset",
|
||||
"US": "US",
|
||||
"EU_433": "EU 433MHz",
|
||||
"EU_868": "EU 868MHz",
|
||||
"CN": "China",
|
||||
"JP": "Japan",
|
||||
"ANZ": "Australia/NZ",
|
||||
"KR": "Korea",
|
||||
"TW": "Taiwan",
|
||||
"RU": "Russia",
|
||||
"IN": "India",
|
||||
"NZ_865": "New Zealand 865MHz",
|
||||
"TH": "Thailand",
|
||||
"LORA_24": "LoRa 2.4GHz",
|
||||
"UA_433": "Ukraine 433MHz",
|
||||
"UA_868": "Ukraine 868MHz",
|
||||
"MY_433": "Malaysia 433MHz"
|
||||
};
|
||||
|
||||
// Get the name from the map, or return unknown with the value
|
||||
return regionNames[region] || `Unknown (${region})`;
|
||||
};
|
||||
|
||||
// Helper function to get modem preset name
|
||||
const getModemPresetName = (preset: ModemPreset | string | undefined): string => {
|
||||
if (preset === undefined) return "Unknown";
|
||||
|
||||
// Map of modem presets to display names
|
||||
const presetNames: Record<string, string> = {
|
||||
"UNSET": "Unset",
|
||||
"LONG_FAST": "Long Fast",
|
||||
"LONG_SLOW": "Long Slow",
|
||||
"VERY_LONG_SLOW": "Very Long Slow",
|
||||
"MEDIUM_SLOW": "Medium Slow",
|
||||
"MEDIUM_FAST": "Medium Fast",
|
||||
"SHORT_SLOW": "Short Slow",
|
||||
"SHORT_FAST": "Short Fast",
|
||||
"ULTRA_FAST": "Ultra Fast"
|
||||
};
|
||||
|
||||
// Get the name from the map, or return unknown with the value
|
||||
return presetNames[preset] || `Unknown (${preset})`;
|
||||
};
|
||||
|
||||
// Helper function to calculate position accuracy in meters from precision bits
|
||||
const calculateAccuracyFromPrecisionBits = (precisionBits?: number): number => {
|
||||
if (!precisionBits) return 300; // Default accuracy of 300m
|
||||
|
||||
// Each precision bit halves the accuracy radius
|
||||
// Starting with Earth's circumference (~40075km), calculate the precision
|
||||
const earthCircumference = 40075000; // in meters
|
||||
const accuracy = earthCircumference / (2 ** precisionBits) / 2;
|
||||
|
||||
// Limit to reasonable values
|
||||
return Math.max(1, Math.min(accuracy, 10000));
|
||||
};
|
||||
|
||||
export const MapReportPacket: React.FC<MapReportPacketProps> = ({ packet }) => {
|
||||
const { data } = packet;
|
||||
const mapReport = data.mapReport;
|
||||
|
||||
if (!mapReport || !mapReport.nodes || mapReport.nodes.length === 0) {
|
||||
if (!mapReport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the center point for the map (average of all node positions)
|
||||
|
||||
// Get the center point for the map based on the MapReport position
|
||||
const getMapCenter = () => {
|
||||
let validPositions = 0;
|
||||
let sumLat = 0;
|
||||
let sumLng = 0;
|
||||
|
||||
mapReport.nodes.forEach(node => {
|
||||
if (node.position && node.position.latitudeI && node.position.longitudeI) {
|
||||
sumLat += node.position.latitudeI * 1e-7;
|
||||
sumLng += node.position.longitudeI * 1e-7;
|
||||
validPositions++;
|
||||
}
|
||||
});
|
||||
|
||||
if (validPositions > 0) {
|
||||
// Check if the report has a position
|
||||
if (mapReport.latitudeI && mapReport.longitudeI) {
|
||||
return {
|
||||
latitude: sumLat / validPositions,
|
||||
longitude: sumLng / validPositions,
|
||||
latitude: mapReport.latitudeI * 1e-7,
|
||||
longitude: mapReport.longitudeI * 1e-7,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
const center = getMapCenter();
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
// Get the position precision bits if available
|
||||
const precisionBits = mapReport.positionPrecision;
|
||||
|
||||
// Calculate position accuracy in meters
|
||||
const positionAccuracy = calculateAccuracyFromPrecisionBits(precisionBits);
|
||||
|
||||
return (
|
||||
<PacketCard
|
||||
packet={packet}
|
||||
icon={<MapIcon />}
|
||||
iconBgColor="bg-cyan-500"
|
||||
label="Map Report"
|
||||
backgroundColor="bg-cyan-950/5"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">
|
||||
Network Map ({mapReport.nodes.length} Nodes)
|
||||
{/* MapReport properties */}
|
||||
{(mapReport.longName ||
|
||||
mapReport.shortName ||
|
||||
mapReport.hwModel ||
|
||||
mapReport.region ||
|
||||
mapReport.modemPreset) && (
|
||||
<div className="p-3 bg-neutral-800/50 rounded border border-neutral-700">
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3 flex items-center">
|
||||
<Signal className="w-4 h-4 mr-2 text-cyan-400" />
|
||||
Report Source
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 max-h-[450px] overflow-y-auto pr-2">
|
||||
{mapReport.nodes.map((node, index) => (
|
||||
<div
|
||||
key={node.num || index}
|
||||
className="p-3 bg-neutral-800/50 rounded border border-neutral-700"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="font-medium text-neutral-200">
|
||||
{node.user?.longName || `Node ${node.num?.toString(16)}`}
|
||||
</div>
|
||||
{node.lastHeard && (
|
||||
<div className="text-xs text-neutral-400">
|
||||
{formatTimestamp(node.lastHeard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<KeyValueGrid>
|
||||
{node.user?.shortName && (
|
||||
<KeyValuePair
|
||||
label="Short Name"
|
||||
value={node.user.shortName}
|
||||
/>
|
||||
)}
|
||||
{node.num !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Node ID"
|
||||
value={`!${node.num.toString(16)}`}
|
||||
/>
|
||||
)}
|
||||
{node.user?.hwModel && (
|
||||
<KeyValuePair
|
||||
label="Hardware"
|
||||
value={node.user.hwModel}
|
||||
/>
|
||||
)}
|
||||
{node.snr !== undefined && (
|
||||
<KeyValuePair
|
||||
label="SNR"
|
||||
value={`${node.snr.toFixed(1)} dB`}
|
||||
/>
|
||||
)}
|
||||
{node.position?.latitudeI && node.position?.longitudeI && (
|
||||
<>
|
||||
<KeyValuePair
|
||||
label="Latitude"
|
||||
value={(node.position.latitudeI * 1e-7).toFixed(6)}
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="Longitude"
|
||||
value={(node.position.longitudeI * 1e-7).toFixed(6)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</KeyValueGrid>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<KeyValueGrid>
|
||||
{mapReport.longName && (
|
||||
<KeyValuePair label="Name" value={mapReport.longName} vertical />
|
||||
)}
|
||||
{mapReport.shortName && (
|
||||
<KeyValuePair label="Short Name" value={mapReport.shortName} vertical />
|
||||
)}
|
||||
{mapReport.hwModel !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Hardware"
|
||||
value={getHardwareModelName(mapReport.hwModel)}
|
||||
vertical
|
||||
/>
|
||||
)}
|
||||
{mapReport.role !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Role"
|
||||
value={getRoleName(mapReport.role)}
|
||||
vertical
|
||||
/>
|
||||
)}
|
||||
{mapReport.firmwareVersion && (
|
||||
<KeyValuePair
|
||||
label="Firmware"
|
||||
value={mapReport.firmwareVersion}
|
||||
vertical
|
||||
monospace
|
||||
/>
|
||||
)}
|
||||
{mapReport.region !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Region"
|
||||
value={getRegionName(mapReport.region)}
|
||||
vertical
|
||||
/>
|
||||
)}
|
||||
{mapReport.modemPreset !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Modem Preset"
|
||||
value={getModemPresetName(mapReport.modemPreset)}
|
||||
vertical
|
||||
/>
|
||||
)}
|
||||
{mapReport.numOnlineLocalNodes !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Online Nodes"
|
||||
value={mapReport.numOnlineLocalNodes.toString()}
|
||||
vertical
|
||||
monospace
|
||||
/>
|
||||
)}
|
||||
{mapReport.hasDefaultChannel !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Default Channel"
|
||||
value={mapReport.hasDefaultChannel ? "Yes" : "No"}
|
||||
vertical
|
||||
/>
|
||||
)}
|
||||
</KeyValueGrid>
|
||||
</div>
|
||||
|
||||
{center && (
|
||||
<div className="h-[300px] w-full rounded-lg overflow-hidden">
|
||||
<Map
|
||||
latitude={center.latitude}
|
||||
)}
|
||||
|
||||
{center && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-3 flex items-center">
|
||||
<MapPin className="w-4 h-4 mr-2 text-cyan-400" />
|
||||
Gateway Location
|
||||
</h3>
|
||||
<div className="h-[300px] rounded-lg overflow-hidden relative">
|
||||
<Map
|
||||
latitude={center.latitude}
|
||||
longitude={center.longitude}
|
||||
zoom={12}
|
||||
width={400}
|
||||
height={300}
|
||||
flush={true}
|
||||
caption="Network Overview"
|
||||
caption="Gateway Location"
|
||||
precisionBits={precisionBits}
|
||||
/>
|
||||
{precisionBits !== undefined && (
|
||||
<div className="bg-black/60 px-3 py-1 text-xs text-white absolute bottom-0 left-0 right-0">
|
||||
{positionAccuracy < 1000
|
||||
? `Location Accuracy: ±${positionAccuracy.toFixed(0)}m`
|
||||
: `Location Accuracy: ±${(positionAccuracy / 1000).toFixed(1)}km`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Position information */}
|
||||
<div className="grid grid-cols-2 gap-3 mt-3 text-sm">
|
||||
<KeyValuePair
|
||||
label="Coordinates"
|
||||
value={`${center.latitude.toFixed(6)}, ${center.longitude.toFixed(6)}`}
|
||||
monospace={true}
|
||||
inset={true}
|
||||
/>
|
||||
|
||||
{/* Position accuracy (always show, even with default value) */}
|
||||
<KeyValuePair
|
||||
label="Accuracy"
|
||||
value={positionAccuracy < 1000
|
||||
? `±${positionAccuracy.toFixed(0)} m`
|
||||
: `±${(positionAccuracy / 1000).toFixed(1)} km`}
|
||||
monospace={true}
|
||||
inset={true}
|
||||
/>
|
||||
|
||||
{mapReport.positionPrecision !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Precision"
|
||||
value={`${mapReport.positionPrecision} bits`}
|
||||
monospace={true}
|
||||
inset={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mapReport.altitude !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Altitude"
|
||||
value={`${mapReport.altitude} m`}
|
||||
monospace={true}
|
||||
inset={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PacketCard>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { Packet } from "../../lib/types";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair";
|
||||
import { Map } from "../Map";
|
||||
|
||||
interface PositionPacketProps {
|
||||
@@ -40,33 +40,44 @@ export const PositionPacket: React.FC<PositionPacketProps> = ({ packet }) => {
|
||||
<KeyValuePair
|
||||
label="Latitude"
|
||||
value={latitude !== undefined ? latitude.toFixed(6) : 'N/A'}
|
||||
vertical
|
||||
monospace
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="Longitude"
|
||||
value={longitude !== undefined ? longitude.toFixed(6) : 'N/A'}
|
||||
vertical
|
||||
monospace
|
||||
/>
|
||||
{position.altitude && (
|
||||
<KeyValuePair
|
||||
label="Altitude"
|
||||
value={`${position.altitude.toFixed(1)}m`}
|
||||
vertical
|
||||
monospace
|
||||
/>
|
||||
)}
|
||||
{position.time && (
|
||||
<KeyValuePair
|
||||
label="Time"
|
||||
value={formattedTime}
|
||||
vertical
|
||||
/>
|
||||
)}
|
||||
{position.locationSource && (
|
||||
<KeyValuePair
|
||||
label="Source"
|
||||
value={position.locationSource.replace('LOC_', '')}
|
||||
vertical
|
||||
/>
|
||||
)}
|
||||
{position.satsInView && (
|
||||
<KeyValuePair
|
||||
label="Satellites"
|
||||
value={position.satsInView}
|
||||
vertical
|
||||
monospace
|
||||
highlight={position.satsInView > 6}
|
||||
/>
|
||||
)}
|
||||
</KeyValueGrid>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { Packet } from "../../lib/types";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair";
|
||||
import { Map } from "../Map";
|
||||
|
||||
interface WaypointPacketProps {
|
||||
@@ -52,19 +52,21 @@ export const WaypointPacket: React.FC<WaypointPacketProps> = ({ packet }) => {
|
||||
)}
|
||||
<KeyValueGrid>
|
||||
{latitude !== undefined && (
|
||||
<KeyValuePair label="Latitude" value={latitude.toFixed(6)} />
|
||||
<KeyValuePair label="Latitude" value={latitude.toFixed(6)} vertical monospace />
|
||||
)}
|
||||
{longitude !== undefined && (
|
||||
<KeyValuePair label="Longitude" value={longitude.toFixed(6)} />
|
||||
<KeyValuePair label="Longitude" value={longitude.toFixed(6)} vertical monospace />
|
||||
)}
|
||||
{waypoint.id !== undefined && (
|
||||
<KeyValuePair label="Waypoint ID" value={waypoint.id} />
|
||||
<KeyValuePair label="Waypoint ID" value={waypoint.id} vertical monospace />
|
||||
)}
|
||||
{expireTime && <KeyValuePair label="Expires" value={expireTime} />}
|
||||
{expireTime && <KeyValuePair label="Expires" value={expireTime} vertical />}
|
||||
{waypoint.lockedTo !== undefined && waypoint.lockedTo > 0 && (
|
||||
<KeyValuePair
|
||||
label="Locked To"
|
||||
value={`!${waypoint.lockedTo.toString(16)}`}
|
||||
vertical
|
||||
monospace
|
||||
/>
|
||||
)}
|
||||
</KeyValueGrid>
|
||||
|
||||
102
web/src/components/ui/KeyValuePair.tsx
Normal file
102
web/src/components/ui/KeyValuePair.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/cn";
|
||||
|
||||
interface KeyValuePairProps {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
monospace?: boolean;
|
||||
highlight?: boolean;
|
||||
large?: boolean;
|
||||
vertical?: boolean;
|
||||
inset?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyValuePair component displays a label-value pair with consistent styling
|
||||
*
|
||||
* @param label - The label or key
|
||||
* @param value - The value (can be any React node)
|
||||
* @param icon - Optional icon to display next to the label
|
||||
* @param monospace - Whether to use monospace font for the value
|
||||
* @param highlight - Whether to highlight the value with a distinctive color
|
||||
* @param large - Whether to use larger text sizing
|
||||
* @param vertical - Whether to stack label and value vertically instead of side by side
|
||||
* @param inset - Whether to use inset effect styling for the container
|
||||
*/
|
||||
export const KeyValuePair: React.FC<KeyValuePairProps> = ({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
monospace = true,
|
||||
highlight = false,
|
||||
large = false,
|
||||
vertical = false,
|
||||
inset = false,
|
||||
}) => {
|
||||
if (vertical) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs tracking-wide text-neutral-400 uppercase",
|
||||
{ "mb-1": large },
|
||||
{ "flex items-center": icon }
|
||||
)}
|
||||
>
|
||||
{icon && <span className="mr-1.5 text-neutral-300">{icon}</span>}
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
{
|
||||
"text-base font-medium tracking-wide": large,
|
||||
"tracking-tight": !large,
|
||||
},
|
||||
{ "font-mono text-sm": monospace },
|
||||
{ "text-blue-400": highlight, "text-neutral-200": !highlight }
|
||||
)}
|
||||
>
|
||||
{value || "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between", {
|
||||
"bg-neutral-700/50 p-2 rounded effect-inset": inset,
|
||||
})}
|
||||
>
|
||||
<span className="text-neutral-400 flex items-center text-sm">
|
||||
{icon && <span className="mr-2 text-neutral-300">{icon}</span>}
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
{ "text-blue-400": highlight, "text-neutral-200": !highlight },
|
||||
{ "font-mono text-sm": monospace },
|
||||
{ "font-medium": large }
|
||||
)}
|
||||
>
|
||||
{value || "—"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* KeyValueGrid provides a consistent grid layout for KeyValuePair components
|
||||
*/
|
||||
export const KeyValueGrid: React.FC<{
|
||||
children: React.ReactNode;
|
||||
columns?: number;
|
||||
className?: string;
|
||||
}> = ({ children, columns = 2, className }) => {
|
||||
return (
|
||||
<div className={cn(`grid grid-cols-${columns} gap-x-6 gap-y-3`, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,8 @@
|
||||
* @param width The image width in pixels
|
||||
* @param height The image height in pixels
|
||||
* @param nightMode Whether to use dark styling for the map
|
||||
* @param precisionBits Optional precision bits to determine how to display the marker
|
||||
* @param accuracyMeters Optional accuracy in meters (used when precisionBits is provided)
|
||||
* @returns A URL string for the Google Maps Static API
|
||||
*/
|
||||
export const getStaticMapUrl = (
|
||||
@@ -18,7 +20,9 @@ export const getStaticMapUrl = (
|
||||
zoom: number = 15,
|
||||
width: number = 300,
|
||||
height: number = 200,
|
||||
nightMode: boolean = true
|
||||
nightMode: boolean = true,
|
||||
precisionBits?: number,
|
||||
accuracyMeters?: number
|
||||
): string => {
|
||||
// Get API key from environment variable
|
||||
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "";
|
||||
@@ -34,8 +38,15 @@ export const getStaticMapUrl = (
|
||||
mapUrl.searchParams.append("format", "png");
|
||||
mapUrl.searchParams.append("scale", "2"); // Retina display support
|
||||
|
||||
// Add marker
|
||||
mapUrl.searchParams.append("markers", `color:red|${latitude},${longitude}`);
|
||||
// Only add marker if we don't have precision information
|
||||
if (precisionBits === undefined) {
|
||||
mapUrl.searchParams.append("markers", `color:green|${latitude},${longitude}`);
|
||||
}
|
||||
// With static maps we can't draw circles directly, so we use a marker with different color
|
||||
// even when we have precision information, but we'll show it differently in the interactive map
|
||||
else {
|
||||
mapUrl.searchParams.append("markers", `color:green|${latitude},${longitude}`);
|
||||
}
|
||||
|
||||
// Apply night mode styling using the simpler approach
|
||||
if (nightMode) {
|
||||
|
||||
@@ -186,10 +186,90 @@ export interface NeighborInfo {
|
||||
neighbors?: Neighbor[]; // The list of out edges from this node
|
||||
}
|
||||
|
||||
// HardwareModel enum based on mesh.proto
|
||||
export enum HardwareModel {
|
||||
UNSET = 0,
|
||||
TLORA_V2 = 1,
|
||||
TLORA_V1 = 2,
|
||||
TLORA_V2_1_1P6 = 3,
|
||||
TBEAM = 4,
|
||||
HELTEC_V2_0 = 5,
|
||||
TBEAM_V0P7 = 6,
|
||||
T_ECHO = 7,
|
||||
TLORA_V1_1P3 = 8,
|
||||
RAK4631 = 9,
|
||||
HELTEC_V2_1 = 10,
|
||||
HELTEC_V1 = 11,
|
||||
LILYGO_TBEAM_S3_CORE = 12,
|
||||
RAK11200 = 13,
|
||||
NANO_G1 = 14,
|
||||
TLORA_V2_1_1P8 = 15,
|
||||
TLORA_T3_S3 = 16,
|
||||
NANO_G1_EXPLORER = 17,
|
||||
NANO_G2_ULTRA = 18
|
||||
}
|
||||
|
||||
// Roles from config.proto
|
||||
export enum DeviceRole {
|
||||
CLIENT = 0,
|
||||
ROUTER = 1,
|
||||
ROUTER_CLIENT = 2,
|
||||
TRACKER = 3,
|
||||
TAK_TRACKER = 4,
|
||||
SENSOR = 5,
|
||||
REPEATER = 6
|
||||
}
|
||||
|
||||
// Region codes as string literals for the wire format
|
||||
export enum RegionCode {
|
||||
UNSET = "UNSET",
|
||||
US = "US",
|
||||
EU_433 = "EU_433",
|
||||
EU_868 = "EU_868",
|
||||
CN = "CN",
|
||||
JP = "JP",
|
||||
ANZ = "ANZ",
|
||||
KR = "KR",
|
||||
TW = "TW",
|
||||
RU = "RU",
|
||||
IN = "IN",
|
||||
NZ_865 = "NZ_865",
|
||||
TH = "TH",
|
||||
LORA_24 = "LORA_24",
|
||||
UA_433 = "UA_433",
|
||||
UA_868 = "UA_868",
|
||||
MY_433 = "MY_433"
|
||||
}
|
||||
|
||||
// Modem presets as string literals for the wire format
|
||||
export enum ModemPreset {
|
||||
UNSET = "UNSET",
|
||||
LONG_FAST = "LONG_FAST",
|
||||
LONG_SLOW = "LONG_SLOW",
|
||||
VERY_LONG_SLOW = "VERY_LONG_SLOW",
|
||||
MEDIUM_SLOW = "MEDIUM_SLOW",
|
||||
MEDIUM_FAST = "MEDIUM_FAST",
|
||||
SHORT_SLOW = "SHORT_SLOW",
|
||||
SHORT_FAST = "SHORT_FAST",
|
||||
ULTRA_FAST = "ULTRA_FAST"
|
||||
}
|
||||
|
||||
// MapReport interface based on mqtt.proto definition
|
||||
export interface MapReport {
|
||||
// This would need to be defined based on the actual data structure
|
||||
// Currently not defined in the proto files
|
||||
[key: string]: unknown;
|
||||
// Fields from the mqtt.proto MapReport message
|
||||
longName?: string;
|
||||
shortName?: string;
|
||||
role?: DeviceRole;
|
||||
hwModel?: HardwareModel;
|
||||
firmwareVersion?: string;
|
||||
region?: RegionCode;
|
||||
modemPreset?: ModemPreset;
|
||||
hasDefaultChannel?: boolean;
|
||||
latitudeI?: number;
|
||||
longitudeI?: number;
|
||||
altitude?: number;
|
||||
positionPrecision?: number;
|
||||
numOnlineLocalNodes?: number;
|
||||
}
|
||||
|
||||
export interface HardwareMessage {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Position,
|
||||
User,
|
||||
Telemetry,
|
||||
MapReport,
|
||||
} from "../../lib/types";
|
||||
|
||||
// Types for aggregated data
|
||||
@@ -28,6 +29,8 @@ export interface NodeData {
|
||||
// Fields for gateway nodes
|
||||
isGateway?: boolean;
|
||||
observedNodeCount?: number;
|
||||
// MapReport payload for this node
|
||||
mapReport?: MapReport;
|
||||
}
|
||||
|
||||
export interface TextMessage {
|
||||
@@ -107,13 +110,14 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
|
||||
// Always mark this packet as processed
|
||||
state.processedPackets[packetKey] = true;
|
||||
|
||||
// Update gateway data, but only if it's reporting packets from a different nodeId
|
||||
// (a true gateway is relaying data from other nodes, not just its own data)
|
||||
if (
|
||||
gatewayId &&
|
||||
nodeId !== undefined &&
|
||||
gatewayId !== `!${nodeId.toString(16)}`
|
||||
) {
|
||||
// Update gateway data
|
||||
// Handle both cases:
|
||||
// 1. Gateway relaying data from other nodes (gatewayId != nodeId)
|
||||
// 2. Gateway reporting its own data (gatewayId = nodeId)
|
||||
if (gatewayId && nodeId !== undefined) {
|
||||
const gatewayNodeHex = `!${nodeId.toString(16).toLowerCase()}`;
|
||||
const isSelfReport = gatewayId === gatewayNodeHex;
|
||||
|
||||
if (!state.gateways[gatewayId]) {
|
||||
state.gateways[gatewayId] = {
|
||||
gatewayId,
|
||||
@@ -137,10 +141,55 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
|
||||
gateway.channelIds.push(channelId);
|
||||
}
|
||||
|
||||
// Record node in observed nodes
|
||||
if (!gateway.observedNodes.includes(nodeId)) {
|
||||
// Only record as observed if it's not the gateway itself reporting
|
||||
if (!isSelfReport && !gateway.observedNodes.includes(nodeId)) {
|
||||
gateway.observedNodes.push(nodeId);
|
||||
}
|
||||
|
||||
// If this is a gateway reporting its own data via MAP_REPORT, create a node entry for it
|
||||
if (isSelfReport && data.mapReport) {
|
||||
// Extract gateway's node ID from the gateway ID
|
||||
const gatewayNodeId = parseInt(gatewayId.substring(1), 16);
|
||||
|
||||
// Make sure we have a node entry for this gateway
|
||||
if (!state.nodes[gatewayNodeId]) {
|
||||
state.nodes[gatewayNodeId] = {
|
||||
nodeId: gatewayNodeId,
|
||||
lastHeard: timestamp,
|
||||
messageCount: 1,
|
||||
textMessageCount: 0,
|
||||
isGateway: true,
|
||||
gatewayId: gatewayId
|
||||
};
|
||||
}
|
||||
|
||||
// Update the node with map report data
|
||||
const gatewayNode = state.nodes[gatewayNodeId];
|
||||
gatewayNode.mapReport = { ...data.mapReport };
|
||||
gatewayNode.lastHeard = Math.max(gatewayNode.lastHeard, timestamp);
|
||||
|
||||
// Copy relevant fields from MapReport to the node object
|
||||
if (data.mapReport.longName) {
|
||||
gatewayNode.longName = data.mapReport.longName;
|
||||
}
|
||||
if (data.mapReport.shortName) {
|
||||
gatewayNode.shortName = data.mapReport.shortName;
|
||||
}
|
||||
if (data.mapReport.hwModel !== undefined) {
|
||||
gatewayNode.hwModel = data.mapReport.hwModel.toString();
|
||||
}
|
||||
|
||||
// Create position info from MapReport
|
||||
if (data.mapReport.latitudeI !== undefined && data.mapReport.longitudeI !== undefined) {
|
||||
gatewayNode.position = {
|
||||
latitudeI: data.mapReport.latitudeI,
|
||||
longitudeI: data.mapReport.longitudeI,
|
||||
altitude: data.mapReport.altitude,
|
||||
time: timestamp,
|
||||
precisionBits: data.mapReport.positionPrecision
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNewPacket) {
|
||||
@@ -220,6 +269,35 @@ const processPacket = (state: AggregatorState, packet: Packet) => {
|
||||
if (data.telemetry) {
|
||||
updateTelemetry(node, data.telemetry);
|
||||
}
|
||||
|
||||
// Update MapReport if available
|
||||
if (data.mapReport) {
|
||||
node.mapReport = { ...data.mapReport };
|
||||
|
||||
// Also update node fields from MapReport if they're missing
|
||||
if (!node.longName && data.mapReport.longName) {
|
||||
node.longName = data.mapReport.longName;
|
||||
}
|
||||
if (!node.shortName && data.mapReport.shortName) {
|
||||
node.shortName = data.mapReport.shortName;
|
||||
}
|
||||
if (!node.hwModel && data.mapReport.hwModel !== undefined) {
|
||||
node.hwModel =
|
||||
data.mapReport.hwModel.toString(); // Store as string to match User.hwModel format
|
||||
}
|
||||
|
||||
// Update position from MapReport if node doesn't have position
|
||||
if (!node.position && data.mapReport.latitudeI !== undefined && data.mapReport.longitudeI !== undefined) {
|
||||
node.position = {
|
||||
latitudeI: data.mapReport.latitudeI,
|
||||
longitudeI: data.mapReport.longitudeI,
|
||||
altitude: data.mapReport.altitude,
|
||||
time: timestamp, // Use packet time
|
||||
// Set precisionBits from positionPrecision if available
|
||||
precisionBits: data.mapReport.positionPrecision
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process text messages - only for new packets to avoid duplicates
|
||||
|
||||
@@ -19,6 +19,15 @@ export default {
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
mono: [
|
||||
'"JetBrains Mono"',
|
||||
'"Share Tech Mono"',
|
||||
'"Space Mono"',
|
||||
'"Roboto Mono"',
|
||||
'"VT323"',
|
||||
'"Courier New"',
|
||||
'monospace',
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user