mirror of
https://github.com/dpup/meshstream.git
synced 2026-07-03 08:22:06 +02:00
Card rendering and maps
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
# Development environment variables
|
||||
VITE_API_BASE_URL="http://localhost:8080"
|
||||
VITE_APP_ENV="development"
|
||||
@@ -0,0 +1,9 @@
|
||||
# Example environment variables for the Meshstream web client
|
||||
# Copy this file to .env.local and fill in your values
|
||||
|
||||
# Development environment variables
|
||||
VITE_API_BASE_URL="http://localhost:8080"
|
||||
VITE_APP_ENV="development"
|
||||
|
||||
# Get one at: https://developers.google.com/maps/documentation/javascript/get-api-key
|
||||
VITE_GOOGLE_MAPS_API_KEY=OVERRIDE_IN_LOCAL_ENV
|
||||
@@ -1,3 +0,0 @@
|
||||
# Production environment variables
|
||||
VITE_API_BASE_URL=""
|
||||
VITE_APP_ENV="production"
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import { getStaticMapUrl, getGoogleMapsUrl } from "../lib/mapUtils";
|
||||
|
||||
interface MapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
caption?: string;
|
||||
className?: string;
|
||||
flush?: boolean;
|
||||
nightMode?: boolean;
|
||||
}
|
||||
|
||||
export const Map: React.FC<MapProps> = ({
|
||||
latitude,
|
||||
longitude,
|
||||
zoom = 14,
|
||||
width = 300,
|
||||
height = 200,
|
||||
caption,
|
||||
className = "",
|
||||
flush = false,
|
||||
nightMode = true
|
||||
}) => {
|
||||
const mapUrl = getStaticMapUrl(latitude, longitude, zoom, width, height, nightMode);
|
||||
const googleMapsUrl = getGoogleMapsUrl(latitude, longitude);
|
||||
|
||||
// Check if Google Maps API key is available
|
||||
const apiKeyAvailable = Boolean(import.meta.env.VITE_GOOGLE_MAPS_API_KEY);
|
||||
|
||||
const mapContainerClasses = flush
|
||||
? `w-full h-full overflow-hidden relative ${className}`
|
||||
: `${className} relative overflow-hidden rounded-lg border border-neutral-700 bg-neutral-800/50`;
|
||||
|
||||
if (!apiKeyAvailable) {
|
||||
return (
|
||||
<div className={flush ? "p-4 bg-neutral-800/50" : mapContainerClasses}>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Map display requires a Google Maps API key.
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Add VITE_GOOGLE_MAPS_API_KEY to your environment.
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-neutral-300">
|
||||
{latitude.toFixed(6)}, {longitude.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={mapContainerClasses}>
|
||||
<a
|
||||
href={googleMapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={mapUrl}
|
||||
alt={`Map of ${latitude},${longitude}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-3 py-1 text-xs text-white">
|
||||
{caption}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,12 +17,24 @@ export const ErrorPacket: React.FC<ErrorPacketProps> = ({ packet }) => {
|
||||
return (
|
||||
<PacketCard
|
||||
packet={packet}
|
||||
icon={<AlertTriangle className="h-4 w-4 text-neutral-100" />}
|
||||
icon={<AlertTriangle />}
|
||||
iconBgColor="bg-red-500"
|
||||
label="Error"
|
||||
backgroundColor="bg-red-950/5"
|
||||
>
|
||||
<div className="text-red-400">
|
||||
{data.decodeError}
|
||||
<div className="max-w-md">
|
||||
<div className="text-red-400 mb-2 font-medium">
|
||||
{data.decodeError}
|
||||
</div>
|
||||
|
||||
{data.binaryData && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-neutral-400 mb-1">Raw Data</div>
|
||||
<div className="font-mono text-neutral-300 text-sm bg-neutral-800/50 p-2 rounded overflow-auto">
|
||||
{data.binaryData}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PacketCard>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Packet, PortNum, PortNumByName } from "../../lib/types";
|
||||
import { Package } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
|
||||
interface GenericPacketProps {
|
||||
packet: Packet;
|
||||
@@ -25,44 +26,73 @@ export const GenericPacket: React.FC<GenericPacketProps> = ({ packet }) => {
|
||||
}
|
||||
|
||||
// Determine what type of payload is present
|
||||
if (data.binaryData) return "Binary data";
|
||||
if (data.waypoint) return "Waypoint";
|
||||
if (data.compressedText) return "Compressed text";
|
||||
if (data.mapReport) return "Map report";
|
||||
if (data.remoteHardware) return "Remote hardware";
|
||||
if (data.routing) return "Routing";
|
||||
if (data.admin) return "Admin";
|
||||
if (data.audioData) return "Audio";
|
||||
if (data.binaryData) return "Binary Data";
|
||||
if (data.compressedText) return "Compressed Text";
|
||||
if (data.remoteHardware) return "Remote Hardware Control";
|
||||
if (data.routing) return "Routing Information";
|
||||
if (data.admin) return "Admin Command";
|
||||
if (data.audioData) return "Audio Data";
|
||||
if (data.alert) return `Alert: ${data.alert}`;
|
||||
if (data.reply) return `Reply: ${data.reply}`;
|
||||
|
||||
return "Unknown data";
|
||||
return "Unknown Data Format";
|
||||
};
|
||||
|
||||
const portName = getPortName(data.portNum);
|
||||
|
||||
return (
|
||||
<PacketCard
|
||||
packet={packet}
|
||||
icon={<Package className="h-4 w-4 text-neutral-100" />}
|
||||
iconBgColor="bg-neutral-500"
|
||||
label={getPortName(data.portNum)}
|
||||
icon={<Package />}
|
||||
iconBgColor="bg-slate-500"
|
||||
label={portName.replace("_APP", "")}
|
||||
backgroundColor="bg-slate-950/5"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Port</div>
|
||||
<div>{getPortName(data.portNum)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Payload</div>
|
||||
<div>{getPayloadDescription()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">To</div>
|
||||
<div>{data.to || "Broadcast"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Hop Limit</div>
|
||||
<div>{data.hopLimit}</div>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<KeyValueGrid>
|
||||
<KeyValuePair
|
||||
label="Port"
|
||||
value={portName}
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="Payload"
|
||||
value={getPayloadDescription()}
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="To"
|
||||
value={data.to === 4294967295 ? "Broadcast" : data.to.toString()}
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="Hop Limit"
|
||||
value={data.hopLimit}
|
||||
/>
|
||||
</KeyValueGrid>
|
||||
|
||||
{data.binaryData && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-neutral-400 mb-1">Binary Data</div>
|
||||
<div className="font-mono text-neutral-300 text-sm bg-neutral-800/50 p-2 rounded overflow-auto">
|
||||
{data.binaryData}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.routing && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-neutral-400 mb-1">Routing Information</div>
|
||||
<div className="bg-neutral-800/50 p-2 rounded">
|
||||
{data.routing.errorReason !== undefined && (
|
||||
<div>Error: {PortNum[data.routing.errorReason] || data.routing.errorReason}</div>
|
||||
)}
|
||||
{data.routing.routeRequest && (
|
||||
<div>Route Request: {data.routing.routeRequest.route?.join(' → ')}</div>
|
||||
)}
|
||||
{data.routing.routeReply && (
|
||||
<div>Route Reply: {data.routing.routeReply.route?.join(' → ')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PacketCard>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 text-neutral-400 ${large ? 'mb-1' : ''}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={large ? "font-medium text-base" : ""}>
|
||||
{value || "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface KeyValueGridProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const KeyValueGrid: React.FC<KeyValueGridProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 max-w-md">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from "react";
|
||||
import { Packet } from "../../lib/types";
|
||||
import { Map as MapIcon } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
import { Map } from "../Map";
|
||||
|
||||
interface MapReportPacketProps {
|
||||
packet: Packet;
|
||||
}
|
||||
|
||||
export const MapReportPacket: React.FC<MapReportPacketProps> = ({ packet }) => {
|
||||
const { data } = packet;
|
||||
const mapReport = data.mapReport;
|
||||
|
||||
if (!mapReport || !mapReport.nodes || mapReport.nodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the center point for the map (average of all node positions)
|
||||
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) {
|
||||
return {
|
||||
latitude: sumLat / validPositions,
|
||||
longitude: sumLng / validPositions,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const center = getMapCenter();
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
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)
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{center && (
|
||||
<div className="h-[300px] w-full rounded-lg overflow-hidden">
|
||||
<Map
|
||||
latitude={center.latitude}
|
||||
longitude={center.longitude}
|
||||
zoom={12}
|
||||
width={400}
|
||||
height={300}
|
||||
flush={true}
|
||||
caption="Network Overview"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PacketCard>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Packet } from "../../lib/types";
|
||||
import { User } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
|
||||
interface NodeInfoPacketProps {
|
||||
packet: Packet;
|
||||
@@ -18,23 +19,52 @@ export const NodeInfoPacket: React.FC<NodeInfoPacketProps> = ({ packet }) => {
|
||||
return (
|
||||
<PacketCard
|
||||
packet={packet}
|
||||
icon={<User className="h-4 w-4 text-neutral-100" />}
|
||||
icon={<User />}
|
||||
iconBgColor="bg-purple-500"
|
||||
label="Node Info"
|
||||
backgroundColor="bg-purple-950/5"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Long Name</div>
|
||||
<div>{nodeInfo.longName || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Short Name</div>
|
||||
<div>{nodeInfo.shortName || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">ID</div>
|
||||
<div className="font-mono">{nodeInfo.id || "—"}</div>
|
||||
</div>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<KeyValuePair
|
||||
label="Long Name"
|
||||
value={nodeInfo.longName}
|
||||
large={true}
|
||||
/>
|
||||
|
||||
<KeyValueGrid>
|
||||
<KeyValuePair
|
||||
label="Short Name"
|
||||
value={nodeInfo.shortName}
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="ID"
|
||||
value={<span className="font-mono">{nodeInfo.id || "—"}</span>}
|
||||
/>
|
||||
{nodeInfo.hwModel && (
|
||||
<KeyValuePair
|
||||
label="Hardware"
|
||||
value={nodeInfo.hwModel}
|
||||
/>
|
||||
)}
|
||||
{nodeInfo.role && (
|
||||
<KeyValuePair
|
||||
label="Role"
|
||||
value={nodeInfo.role}
|
||||
/>
|
||||
)}
|
||||
{nodeInfo.batteryLevel !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Battery"
|
||||
value={`${nodeInfo.batteryLevel}%`}
|
||||
/>
|
||||
)}
|
||||
{nodeInfo.lastHeard && (
|
||||
<KeyValuePair
|
||||
label="Last Heard"
|
||||
value={new Date(nodeInfo.lastHeard * 1000).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
/>
|
||||
)}
|
||||
</KeyValueGrid>
|
||||
</div>
|
||||
</PacketCard>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ interface PacketCardProps {
|
||||
iconBgColor: string;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export const PacketCard: React.FC<PacketCardProps> = ({
|
||||
@@ -15,30 +16,46 @@ export const PacketCard: React.FC<PacketCardProps> = ({
|
||||
iconBgColor,
|
||||
label,
|
||||
children,
|
||||
backgroundColor = "bg-neutral-500/5",
|
||||
}) => {
|
||||
const { data } = packet;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl p-4 effect-inset bg-neutral-500/5 rounded-lg border border-neutral-950/60">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex items-center">
|
||||
<div className={`rounded-md ${iconBgColor} p-1.5 mr-3`}>{icon}</div>
|
||||
<span className="font-medium text-neutral-200">
|
||||
From:{" "}
|
||||
{data.from ? `!${data.from.toString(16).toLowerCase()}` : "Unknown"}
|
||||
</span>
|
||||
<div className={`max-w-4xl effect-inset ${backgroundColor} rounded-lg border border-neutral-950/60 hover:shadow-md transition-shadow duration-200 overflow-hidden`}>
|
||||
{/* Card Header with all metadata */}
|
||||
<div className="px-5 py-3 border-b border-neutral-800/50">
|
||||
<div className="flex flex-wrap justify-between items-center gap-2">
|
||||
{/* Left side: Icon, From, Channel */}
|
||||
<div className="flex items-center text-xs">
|
||||
<div className={`${iconBgColor} p-1.5 rounded-full mr-3 shadow-sm flex-shrink-0`}>
|
||||
{React.cloneElement(icon as React.ReactElement, {
|
||||
className: "h-3.5 w-3.5 text-white"
|
||||
})}
|
||||
</div>
|
||||
<span className="font-semibold text-neutral-200 tracking-wide mr-3">
|
||||
{data.from ? `!${data.from.toString(16).toLowerCase()}` : "Unknown"}
|
||||
</span>
|
||||
<span className="text-neutral-400">
|
||||
Channel: {packet.info.channel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right side: ID and Type */}
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-neutral-400">
|
||||
ID: {data.id || "None"}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-neutral-700/50 text-neutral-300 rounded-full text-xs">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-neutral-400 text-sm">
|
||||
ID: {data.id || "No ID"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-neutral-300 pl-9">{children}</div>
|
||||
|
||||
<div className="mt-3 flex justify-between items-center text-xs text-neutral-500">
|
||||
<span>Channel: {packet.info.channel}</span>
|
||||
<span>{label}</span>
|
||||
{/* Card Content */}
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -5,6 +5,8 @@ import { PositionPacket } from "./PositionPacket";
|
||||
import { NodeInfoPacket } from "./NodeInfoPacket";
|
||||
import { TelemetryPacket } from "./TelemetryPacket";
|
||||
import { ErrorPacket } from "./ErrorPacket";
|
||||
import { WaypointPacket } from "./WaypointPacket";
|
||||
import { MapReportPacket } from "./MapReportPacket";
|
||||
import { GenericPacket } from "./GenericPacket";
|
||||
|
||||
interface PacketRendererProps {
|
||||
@@ -37,6 +39,12 @@ export const PacketRenderer: React.FC<PacketRendererProps> = ({ packet }) => {
|
||||
|
||||
case PortNum.TELEMETRY_APP:
|
||||
return <TelemetryPacket packet={packet} />;
|
||||
|
||||
case PortNum.WAYPOINT_APP:
|
||||
return <WaypointPacket packet={packet} />;
|
||||
|
||||
case PortNum.MAP_REPORT_APP:
|
||||
return <MapReportPacket packet={packet} />;
|
||||
|
||||
default:
|
||||
return <GenericPacket packet={packet} />;
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from "react";
|
||||
import { Packet } from "../../lib/types";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
import { Map } from "../Map";
|
||||
|
||||
interface PositionPacketProps {
|
||||
packet: Packet;
|
||||
@@ -19,47 +21,69 @@ export const PositionPacket: React.FC<PositionPacketProps> = ({ packet }) => {
|
||||
const latitude = position.latitudeI ? position.latitudeI * 1e-7 : undefined;
|
||||
const longitude = position.longitudeI ? position.longitudeI * 1e-7 : undefined;
|
||||
|
||||
// Format time without seconds
|
||||
const formattedTime = position.time
|
||||
? new Date(position.time * 1000).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})
|
||||
: 'N/A';
|
||||
|
||||
return (
|
||||
<PacketCard
|
||||
packet={packet}
|
||||
icon={<MapPin className="h-4 w-4 text-neutral-100" />}
|
||||
icon={<MapPin />}
|
||||
iconBgColor="bg-emerald-500"
|
||||
label="Position"
|
||||
backgroundColor="bg-emerald-950/5"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Latitude</div>
|
||||
<div>{latitude !== undefined ? latitude.toFixed(6) : 'N/A'}</div>
|
||||
<KeyValueGrid>
|
||||
<KeyValuePair
|
||||
label="Latitude"
|
||||
value={latitude !== undefined ? latitude.toFixed(6) : 'N/A'}
|
||||
/>
|
||||
<KeyValuePair
|
||||
label="Longitude"
|
||||
value={longitude !== undefined ? longitude.toFixed(6) : 'N/A'}
|
||||
/>
|
||||
{position.altitude && (
|
||||
<KeyValuePair
|
||||
label="Altitude"
|
||||
value={`${position.altitude.toFixed(1)}m`}
|
||||
/>
|
||||
)}
|
||||
{position.time && (
|
||||
<KeyValuePair
|
||||
label="Time"
|
||||
value={formattedTime}
|
||||
/>
|
||||
)}
|
||||
{position.locationSource && (
|
||||
<KeyValuePair
|
||||
label="Source"
|
||||
value={position.locationSource.replace('LOC_', '')}
|
||||
/>
|
||||
)}
|
||||
{position.satsInView && (
|
||||
<KeyValuePair
|
||||
label="Satellites"
|
||||
value={position.satsInView}
|
||||
/>
|
||||
)}
|
||||
</KeyValueGrid>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Longitude</div>
|
||||
<div>{longitude !== undefined ? longitude.toFixed(6) : 'N/A'}</div>
|
||||
</div>
|
||||
{position.altitude && (
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Altitude</div>
|
||||
<div>{position.altitude.toFixed(1)}m</div>
|
||||
</div>
|
||||
)}
|
||||
{position.time && (
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Time</div>
|
||||
<div>{new Date(position.time * 1000).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{position.locationSource && (
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Source</div>
|
||||
<div>{position.locationSource.replace('LOC_', '')}</div>
|
||||
</div>
|
||||
)}
|
||||
{position.satsInView && (
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400">Satellites</div>
|
||||
<div>{position.satsInView}</div>
|
||||
|
||||
{latitude !== undefined && longitude !== undefined && (
|
||||
<div className="h-[240px] w-full rounded-lg overflow-hidden">
|
||||
<Map
|
||||
latitude={latitude}
|
||||
longitude={longitude}
|
||||
width={400}
|
||||
height={240}
|
||||
flush={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PacketCard>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Packet } from "../../lib/types";
|
||||
import { BarChart } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
|
||||
interface TelemetryPacketProps {
|
||||
packet: Packet;
|
||||
@@ -15,41 +16,118 @@ export const TelemetryPacket: React.FC<TelemetryPacketProps> = ({ packet }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to display telemetry fields
|
||||
const renderTelemetryFields = () => {
|
||||
const entries = Object.entries(telemetry).filter(([key]) => key !== 'time');
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <div>No telemetry data available</div>;
|
||||
}
|
||||
// Helper function to render device metrics
|
||||
const renderDeviceMetrics = () => {
|
||||
if (!telemetry.deviceMetrics) return null;
|
||||
|
||||
const metrics = telemetry.deviceMetrics;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<div className="text-xs text-neutral-400">{key.charAt(0).toUpperCase() + key.slice(1)}</div>
|
||||
<div>{typeof value === 'number' ? value.toFixed(2) : String(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{telemetry.time && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-xs text-neutral-400">Time</div>
|
||||
<div>{new Date(telemetry.time * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-neutral-300 mb-2">Device</h4>
|
||||
<KeyValueGrid>
|
||||
{metrics.batteryLevel !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Battery"
|
||||
value={`${metrics.batteryLevel}%`}
|
||||
/>
|
||||
)}
|
||||
{metrics.voltage !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Voltage"
|
||||
value={`${metrics.voltage.toFixed(2)}V`}
|
||||
/>
|
||||
)}
|
||||
{metrics.channelUtilization !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Channel Util."
|
||||
value={`${metrics.channelUtilization.toFixed(1)}%`}
|
||||
/>
|
||||
)}
|
||||
{metrics.uptimeSeconds !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Uptime"
|
||||
value={formatUptime(metrics.uptimeSeconds)}
|
||||
/>
|
||||
)}
|
||||
</KeyValueGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to render environment metrics
|
||||
const renderEnvironmentMetrics = () => {
|
||||
if (!telemetry.environmentMetrics) return null;
|
||||
|
||||
const metrics = telemetry.environmentMetrics;
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-neutral-300 mb-2">Environment</h4>
|
||||
<KeyValueGrid>
|
||||
{metrics.temperature !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Temperature"
|
||||
value={`${metrics.temperature.toFixed(1)}°C`}
|
||||
/>
|
||||
)}
|
||||
{metrics.relativeHumidity !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Humidity"
|
||||
value={`${metrics.relativeHumidity.toFixed(1)}%`}
|
||||
/>
|
||||
)}
|
||||
{metrics.barometricPressure !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Pressure"
|
||||
value={`${(metrics.barometricPressure/100).toFixed(1)} hPa`}
|
||||
/>
|
||||
)}
|
||||
{metrics.lux !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Light"
|
||||
value={`${metrics.lux.toFixed(0)} lux`}
|
||||
/>
|
||||
)}
|
||||
</KeyValueGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Format uptime in a readable way
|
||||
const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PacketCard
|
||||
packet={packet}
|
||||
icon={<BarChart className="h-4 w-4 text-neutral-100" />}
|
||||
icon={<BarChart />}
|
||||
iconBgColor="bg-amber-500"
|
||||
label="Telemetry"
|
||||
backgroundColor="bg-amber-950/5"
|
||||
>
|
||||
{renderTelemetryFields()}
|
||||
<div className="max-w-md">
|
||||
{telemetry.time && (
|
||||
<div className="mb-3">
|
||||
<KeyValuePair
|
||||
label="Time"
|
||||
value={new Date(telemetry.time * 1000).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderDeviceMetrics()}
|
||||
{renderEnvironmentMetrics()}
|
||||
</div>
|
||||
</PacketCard>
|
||||
);
|
||||
};
|
||||
@@ -13,11 +13,14 @@ export const TextMessagePacket: React.FC<TextMessagePacketProps> = ({ packet })
|
||||
return (
|
||||
<PacketCard
|
||||
packet={packet}
|
||||
icon={<MessageSquareText className="h-4 w-4 text-neutral-100" />}
|
||||
icon={<MessageSquareText />}
|
||||
iconBgColor="bg-blue-500"
|
||||
label="Text Message"
|
||||
backgroundColor="bg-blue-950/5"
|
||||
>
|
||||
{data.textMessage || "Empty message"}
|
||||
<div className="max-w-md">
|
||||
{data.textMessage || "Empty message"}
|
||||
</div>
|
||||
</PacketCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { Packet } from "../../lib/types";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
|
||||
import { Map } from "../Map";
|
||||
|
||||
interface WaypointPacketProps {
|
||||
packet: Packet;
|
||||
}
|
||||
|
||||
export const WaypointPacket: React.FC<WaypointPacketProps> = ({ packet }) => {
|
||||
const { data } = packet;
|
||||
const waypoint = data.waypoint;
|
||||
|
||||
if (!waypoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert coordinates
|
||||
const latitude = waypoint.latitudeI ? waypoint.latitudeI * 1e-7 : undefined;
|
||||
const longitude = waypoint.longitudeI ? waypoint.longitudeI * 1e-7 : undefined;
|
||||
|
||||
// Format expire time if available
|
||||
const expireTime = waypoint.expire && waypoint.expire > 0
|
||||
? new Date(waypoint.expire * 1000).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PacketCard
|
||||
packet={packet}
|
||||
icon={<MapPin />}
|
||||
iconBgColor="bg-violet-500"
|
||||
label="Waypoint"
|
||||
backgroundColor="bg-violet-950/5"
|
||||
>
|
||||
<div className="space-y-4 max-w-full">
|
||||
{waypoint.name && (
|
||||
<KeyValuePair
|
||||
label="Waypoint Name"
|
||||
value={waypoint.name}
|
||||
large={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<KeyValueGrid>
|
||||
{waypoint.id !== undefined && (
|
||||
<KeyValuePair
|
||||
label="ID"
|
||||
value={waypoint.id}
|
||||
/>
|
||||
)}
|
||||
{latitude !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Latitude"
|
||||
value={latitude.toFixed(6)}
|
||||
/>
|
||||
)}
|
||||
{longitude !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Longitude"
|
||||
value={longitude.toFixed(6)}
|
||||
/>
|
||||
)}
|
||||
{expireTime && (
|
||||
<KeyValuePair
|
||||
label="Expires"
|
||||
value={expireTime}
|
||||
/>
|
||||
)}
|
||||
{waypoint.lockedTo !== undefined && waypoint.lockedTo > 0 && (
|
||||
<KeyValuePair
|
||||
label="Locked To"
|
||||
value={`!${waypoint.lockedTo.toString(16)}`}
|
||||
/>
|
||||
)}
|
||||
</KeyValueGrid>
|
||||
|
||||
{waypoint.description && (
|
||||
<div className="mt-4">
|
||||
<div className="text-xs text-neutral-400 mb-1">Description</div>
|
||||
<div className="text-neutral-300">{waypoint.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latitude !== undefined && longitude !== undefined && (
|
||||
<div className="h-[240px] w-full rounded-lg overflow-hidden">
|
||||
<Map
|
||||
latitude={latitude}
|
||||
longitude={longitude}
|
||||
caption={waypoint.name}
|
||||
width={400}
|
||||
height={240}
|
||||
flush={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PacketCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Utility functions for working with maps and coordinates
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a Google Maps Static API URL for a given latitude and longitude
|
||||
* @param latitude The latitude in decimal degrees
|
||||
* @param longitude The longitude in decimal degrees
|
||||
* @param zoom The zoom level (1-20)
|
||||
* @param width The image width in pixels
|
||||
* @param height The image height in pixels
|
||||
* @param nightMode Whether to use dark styling for the map
|
||||
* @returns A URL string for the Google Maps Static API
|
||||
*/
|
||||
export const getStaticMapUrl = (
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
zoom: number = 15,
|
||||
width: number = 300,
|
||||
height: number = 200,
|
||||
nightMode: boolean = true
|
||||
): string => {
|
||||
// Get API key from environment variable
|
||||
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "";
|
||||
|
||||
// Build the URL
|
||||
const mapUrl = new URL("https://maps.googleapis.com/maps/api/staticmap");
|
||||
|
||||
// Add parameters
|
||||
mapUrl.searchParams.append("center", `${latitude},${longitude}`);
|
||||
mapUrl.searchParams.append("zoom", zoom.toString());
|
||||
mapUrl.searchParams.append("size", `${width}x${height}`);
|
||||
mapUrl.searchParams.append("key", apiKey);
|
||||
mapUrl.searchParams.append("format", "png");
|
||||
mapUrl.searchParams.append("scale", "2"); // Retina display support
|
||||
|
||||
// Add marker
|
||||
mapUrl.searchParams.append("markers", `color:red|${latitude},${longitude}`);
|
||||
|
||||
// Apply night mode styling using the simpler approach
|
||||
if (nightMode) {
|
||||
mapUrl.searchParams.append("style", "invert_lightness:true");
|
||||
}
|
||||
|
||||
return mapUrl.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Google Maps URL to open the location in Google Maps
|
||||
*/
|
||||
export const getGoogleMapsUrl = (
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): string => {
|
||||
return `https://www.google.com/maps?q=${latitude},${longitude}`;
|
||||
};
|
||||
+27
-10
@@ -1,9 +1,13 @@
|
||||
import React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { PageWrapper } from "../components/PageWrapper";
|
||||
import { TextMessagePacket } from "../components/packets/TextMessagePacket";
|
||||
import { PositionPacket } from "../components/packets/PositionPacket";
|
||||
import { NodeInfoPacket } from "../components/packets/NodeInfoPacket";
|
||||
import { TelemetryPacket } from "../components/packets/TelemetryPacket";
|
||||
import { ErrorPacket } from "../components/packets/ErrorPacket";
|
||||
import { WaypointPacket } from "../components/packets/WaypointPacket";
|
||||
import { MapReportPacket } from "../components/packets/MapReportPacket";
|
||||
import { GenericPacket } from "../components/packets/GenericPacket";
|
||||
|
||||
// Import sample data
|
||||
@@ -12,6 +16,12 @@ import positionData from "../../fixtures/position.json";
|
||||
import nodeInfoData from "../../fixtures/nodeinfo.json";
|
||||
import telemetryData from "../../fixtures/telemetry.json";
|
||||
import decodeErrorData from "../../fixtures/decode_error.json";
|
||||
import waypointData from "../../fixtures/waypoint.json";
|
||||
import mapReportData from "../../fixtures/map_report.json";
|
||||
|
||||
export const Route = createFileRoute("/demo")({
|
||||
component: DemoPage,
|
||||
});
|
||||
|
||||
export function DemoPage() {
|
||||
return (
|
||||
@@ -54,6 +64,20 @@ export function DemoPage() {
|
||||
</h3>
|
||||
<TelemetryPacket packet={telemetryData} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Waypoint Packet
|
||||
</h3>
|
||||
<WaypointPacket packet={waypointData} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Map Report Packet
|
||||
</h3>
|
||||
<MapReportPacket packet={mapReportData} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
@@ -64,23 +88,16 @@ export function DemoPage() {
|
||||
|
||||
<section>
|
||||
<h3 className="text-md font-medium mb-4 text-neutral-300">
|
||||
Generic Packet
|
||||
Generic Packet (Unknown Type)
|
||||
</h3>
|
||||
<GenericPacket
|
||||
packet={{
|
||||
...textMessageData,
|
||||
data: {
|
||||
...textMessageData.data,
|
||||
portNum: "WAYPOINT_APP",
|
||||
portNum: "UNKNOWN_APP",
|
||||
textMessage: undefined,
|
||||
waypoint: {
|
||||
id: 12345,
|
||||
latitudeI: 373066340,
|
||||
longitudeI: -1220381680,
|
||||
name: "Trailhead",
|
||||
description: "Mountain trail entrance point",
|
||||
icon: 128204,
|
||||
},
|
||||
binaryData: "SGVsbG8gV29ybGQh",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
Vendored
+9
@@ -1 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GOOGLE_MAPS_API_KEY: string;
|
||||
// Add other environment variables as needed
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user