mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Add map display
This commit is contained in:
@@ -10,6 +10,7 @@ import { NewMessageModal } from './components/NewMessageModal';
|
||||
import { ConfigModal } from './components/ConfigModal';
|
||||
import { MaintenanceModal } from './components/MaintenanceModal';
|
||||
import { RawPacketList } from './components/RawPacketList';
|
||||
import { MapView } from './components/MapView';
|
||||
import { CrackerPanel } from './components/CrackerPanel';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
|
||||
import { Toaster, toast } from './components/ui/sonner';
|
||||
@@ -540,7 +541,14 @@ export function App() {
|
||||
|
||||
<div className="flex-1 flex flex-col bg-background min-w-0">
|
||||
{activeConversation ? (
|
||||
activeConversation.type === 'raw' ? (
|
||||
activeConversation.type === 'map' ? (
|
||||
<>
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">Node Map</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MapView contacts={contacts} />
|
||||
</div>
|
||||
</>
|
||||
) : activeConversation.type === 'raw' ? (
|
||||
<>
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">Raw Packet Feed</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
|
||||
import type { LatLngBoundsExpression } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Contact } from '../types';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
interface MapViewProps {
|
||||
contacts: Contact[];
|
||||
}
|
||||
|
||||
// Calculate marker color based on how recently the contact was heard
|
||||
function getMarkerColor(lastSeen: number): string {
|
||||
const now = Date.now() / 1000;
|
||||
const age = now - lastSeen;
|
||||
const hour = 3600;
|
||||
const day = 86400;
|
||||
|
||||
if (age < hour) return '#22c55e'; // Bright green - less than 1 hour
|
||||
if (age < day) return '#4ade80'; // Light green - less than 1 day
|
||||
if (age < 3 * day) return '#a3e635'; // Yellow-green - less than 3 days
|
||||
return '#9ca3af'; // Gray - older (up to 7 days)
|
||||
}
|
||||
|
||||
// Component to handle map bounds fitting
|
||||
function MapBoundsHandler({ contacts }: { contacts: Contact[] }) {
|
||||
const map = useMap();
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized) return;
|
||||
|
||||
const fitToContacts = () => {
|
||||
if (contacts.length === 0) {
|
||||
// No contacts with location - show world view
|
||||
map.setView([20, 0], 2);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contacts.length === 1) {
|
||||
// Single contact - center on it
|
||||
map.setView([contacts[0].lat!, contacts[0].lon!], 10);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple contacts - fit bounds
|
||||
const bounds: LatLngBoundsExpression = contacts.map(c => [c.lat!, c.lon!] as [number, number]);
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12 });
|
||||
setHasInitialized(true);
|
||||
};
|
||||
|
||||
// Try geolocation first
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
// Success - center on user location with reasonable zoom
|
||||
map.setView([position.coords.latitude, position.coords.longitude], 8);
|
||||
setHasInitialized(true);
|
||||
},
|
||||
() => {
|
||||
// Geolocation denied/failed - fit to contacts
|
||||
fitToContacts();
|
||||
},
|
||||
{ timeout: 5000, maximumAge: 300000 }
|
||||
);
|
||||
} else {
|
||||
// No geolocation support - fit to contacts
|
||||
fitToContacts();
|
||||
}
|
||||
}, [map, contacts, hasInitialized]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MapView({ contacts }: MapViewProps) {
|
||||
// Filter to contacts with GPS coordinates, heard within the last 7 days
|
||||
const mappableContacts = useMemo(() => {
|
||||
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
|
||||
return contacts.filter(c =>
|
||||
c.lat != null &&
|
||||
c.lon != null &&
|
||||
c.last_seen != null &&
|
||||
c.last_seen > sevenDaysAgo
|
||||
);
|
||||
}, [contacts]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Info bar */}
|
||||
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>
|
||||
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard in the last 7 days with GPS coordinates
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#22c55e]" /> <1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#4ade80]" /> <1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#a3e635]" /> <3d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#9ca3af]" /> older
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="flex-1">
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
className="h-full w-full"
|
||||
style={{ background: '#1a1a2e' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapBoundsHandler contacts={mappableContacts} />
|
||||
|
||||
{mappableContacts.map((contact) => {
|
||||
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
||||
const color = getMarkerColor(contact.last_seen!);
|
||||
const displayName = contact.name || contact.public_key.slice(0, 12);
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={contact.public_key}
|
||||
center={[contact.lat!, contact.lon!]}
|
||||
radius={isRepeater ? 10 : 7}
|
||||
pathOptions={{
|
||||
color: isRepeater ? color : '#000',
|
||||
fillColor: color,
|
||||
fillOpacity: 0.8,
|
||||
weight: isRepeater ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{isRepeater && <span title="Repeater">🛜</span>}
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Last heard: {formatTime(contact.last_seen!)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1 font-mono">
|
||||
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function Sidebar({
|
||||
onSelectConversation(conversation);
|
||||
};
|
||||
|
||||
const isActive = (type: 'contact' | 'channel' | 'raw', id: string) =>
|
||||
const isActive = (type: 'contact' | 'channel' | 'raw' | 'map', id: string) =>
|
||||
activeConversation?.type === type && activeConversation?.id === id;
|
||||
|
||||
// Get unread count for a conversation
|
||||
@@ -227,6 +227,26 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Map */}
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
|
||||
isActive('map', 'map') && "bg-accent border-l-primary"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'map',
|
||||
id: 'map',
|
||||
name: 'Node Map',
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">🗺️</span>
|
||||
<span className="flex-1 truncate">Node Map</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cracker Toggle */}
|
||||
{!query && (
|
||||
<div
|
||||
|
||||
@@ -94,11 +94,11 @@ export interface Message {
|
||||
acked: number;
|
||||
}
|
||||
|
||||
export type ConversationType = 'contact' | 'channel' | 'raw';
|
||||
export type ConversationType = 'contact' | 'channel' | 'raw' | 'map';
|
||||
|
||||
export interface Conversation {
|
||||
type: ConversationType;
|
||||
/** PublicKey for contacts, ChannelKey for channels, 'raw' for raw feed */
|
||||
/** PublicKey for contacts, ChannelKey for channels, 'raw'/'map' for special views */
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Conversation } from '../types';
|
||||
|
||||
export interface ParsedHashConversation {
|
||||
type: 'channel' | 'contact' | 'raw';
|
||||
type: 'channel' | 'contact' | 'raw' | 'map';
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
|
||||
return { type: 'raw', name: 'raw' };
|
||||
}
|
||||
|
||||
if (hash === 'map') {
|
||||
return { type: 'map', name: 'map' };
|
||||
}
|
||||
|
||||
const slashIndex = hash.indexOf('/');
|
||||
if (slashIndex === -1) return null;
|
||||
|
||||
@@ -30,6 +34,7 @@ export function parseHashConversation(): ParsedHashConversation | null {
|
||||
export function getConversationHash(conv: Conversation | null): string {
|
||||
if (!conv) return '';
|
||||
if (conv.type === 'raw') return '#raw';
|
||||
if (conv.type === 'map') return '#map';
|
||||
// Strip leading # from channel names for cleaner URLs
|
||||
const name = conv.type === 'channel' && conv.name.startsWith('#')
|
||||
? conv.name.slice(1)
|
||||
|
||||
Reference in New Issue
Block a user