Card rendering and maps

This commit is contained in:
Daniel Pupius
2025-04-23 10:38:58 -07:00
parent 77ea38ae19
commit 249cecfda2
18 changed files with 787 additions and 135 deletions
-3
View File
@@ -1,3 +0,0 @@
# Development environment variables
VITE_API_BASE_URL="http://localhost:8080"
VITE_APP_ENV="development"
+9
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
# Production environment variables
VITE_API_BASE_URL=""
VITE_APP_ENV="production"
+74
View File
@@ -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>
);
};
+15 -3
View File
@@ -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>
);
+59 -29
View File
@@ -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>
);
};
+44 -14
View File
@@ -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>
);
+34 -17
View File
@@ -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} />;
+55 -31
View File
@@ -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>
);
};
};
+101 -23
View File
@@ -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>
);
};
+56
View File
@@ -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
View File
@@ -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",
},
}}
/>
+9
View File
@@ -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;
}