Add map display

This commit is contained in:
Jack Kingsman
2026-01-13 14:38:19 -08:00
parent f2a0fb75e5
commit b705f89a09
14 changed files with 802 additions and 547 deletions
+9 -1
View File
@@ -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">
+165
View File
@@ -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]" /> &lt;1h
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#4ade80]" /> &lt;1d
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#a3e635]" /> &lt;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='&copy; <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>
);
}
+21 -1
View File
@@ -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
+2 -2
View File
@@ -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;
}
+6 -1
View File
@@ -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)