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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,8 +13,8 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-DKdsyLDV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bk7xCS0b.css">
<script type="module" crossorigin src="/assets/index-CEwxDLtB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-8fUZTDSA.css">
</head>
<body>
<div id="root"></div>
+51
View File
@@ -16,11 +16,13 @@
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"meshcore-hashtag-cracker": "^1.5.0",
"nosleep.js": "^0.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
@@ -28,6 +30,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.0.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -1117,6 +1120,17 @@
}
}
},
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"dev": true,
@@ -1262,6 +1276,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "25.0.3",
"dev": true,
@@ -2362,6 +2393,12 @@
"node": ">=6"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"license": "MIT",
@@ -2816,6 +2853,20 @@
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"dev": true,
+3
View File
@@ -19,11 +19,13 @@
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"meshcore-hashtag-cracker": "^1.5.0",
"nosleep.js": "^0.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
@@ -31,6 +33,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.0.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+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)