Files
meshexplorer/src/components/MapView.tsx
T
2025-09-18 01:58:48 +02:00

903 lines
31 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { MapContainer, TileLayer, useMapEvents, Marker, Popup, MapContainerProps, useMap, Polyline } from "react-leaflet";
import { useRouter, useSearchParams } from "next/navigation";
import 'leaflet/dist/leaflet.css';
import L from "leaflet";
import 'leaflet.markercluster/dist/leaflet.markercluster.js';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import { useConfig } from "./ConfigContext";
import RefreshButton from "@/components/RefreshButton";
import MapLayerSettingsComponent from "@/components/MapLayerSettings";
import { type MapLayerSettings } from "@/hooks/useMapLayerSettings";
import { NodeMarker, ClusterMarker, PopupContent } from "./MapIcons";
import { renderToString } from "react-dom/server";
import { buildApiUrl } from "@/lib/api";
import { NodePosition } from "@/types/map";
import { useNeighbors, type Neighbor } from "@/hooks/useNeighbors";
import { type AllNeighborsConnection } from "@/hooks/useAllNeighbors";
import { useQueryParams } from "@/hooks/useQueryParams";
const DEFAULT = {
lat: 46.56, // Center between Seattle and Portland
lng: -122.51,
zoom: 7, // Zoom level to show both cities
};
interface MapQuery {
lat?: number;
lng?: number;
zoom?: number;
}
type ClusteredMarkersProps = {
nodes: NodePosition[];
selectedNodeId: string | null;
onNodeClick: (nodeId: string | null) => void;
isLoadingNeighbors?: boolean;
target?: '_blank' | '_self' | '_parent' | '_top';
showNodeNames?: boolean;
enableClustering?: boolean;
};
// Individual marker component
const IndividualMarker = React.memo(function IndividualMarker({
node,
showNodeNames,
selectedNodeId,
onNodeClick,
isLoadingNeighbors = false,
target = '_self'
}: {
node: NodePosition;
showNodeNames: boolean;
selectedNodeId: string | null;
onNodeClick: (nodeId: string | null) => void;
isLoadingNeighbors?: boolean;
target?: '_blank' | '_self' | '_parent' | '_top';
}) {
const map = useMap();
const markerRef = useRef<L.Marker | null>(null);
const onNodeClickRef = useRef(onNodeClick);
// Keep the callback ref updated
useEffect(() => {
onNodeClickRef.current = onNodeClick;
}, [onNodeClick]);
useEffect(() => {
if (!map) return;
const isSelected = selectedNodeId === node.node_id;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [12, 24],
iconAnchor: [6, 6],
html: renderToString(
<NodeMarker
node={node}
showNodeNames={showNodeNames}
isSelected={isSelected}
isLoadingNeighbors={isSelected && isLoadingNeighbors}
/>
),
});
const marker = L.marker([node.latitude, node.longitude], { icon });
(marker as any).options.nodeData = node;
marker.bindPopup(renderToString(<PopupContent node={node} target={target} />));
// Add hover handler for meshcore nodes
if (node.type === "meshcore") {
marker.on('mouseover', () => {
onNodeClickRef.current(node.node_id);
});
}
marker.addTo(map);
markerRef.current = marker;
return () => {
if (markerRef.current && map.hasLayer(markerRef.current)) {
map.removeLayer(markerRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally omitting selectedNodeId, showNodeNames, isLoadingNeighbors to prevent marker recreation
}, [map, node.node_id, node.latitude, node.longitude, node.type, target]);
// Update marker when visual properties change (but don't recreate marker)
useEffect(() => {
if (markerRef.current) {
// Update icon and popup content only
const isSelected = selectedNodeId === node.node_id;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [12, 24],
iconAnchor: [6, 6],
html: renderToString(
<NodeMarker
node={node}
showNodeNames={showNodeNames}
isSelected={isSelected}
isLoadingNeighbors={isSelected && isLoadingNeighbors}
/>
),
});
markerRef.current.setIcon(icon);
markerRef.current.getPopup()?.setContent(renderToString(<PopupContent node={node} target={target} />));
}
}, [node, showNodeNames, selectedNodeId, isLoadingNeighbors]);
// Handle position updates separately to avoid recreating marker
useEffect(() => {
if (markerRef.current) {
const currentPos = markerRef.current.getLatLng();
if (currentPos.lat !== node.latitude || currentPos.lng !== node.longitude) {
markerRef.current.setLatLng([node.latitude, node.longitude]);
}
}
}, [node.latitude, node.longitude]);
return null;
});
// Clustered markers component
const ClusteredMarkersGroup = React.memo(function ClusteredMarkersGroup({
nodes,
showNodeNames,
selectedNodeId,
onNodeClick,
isLoadingNeighbors = false,
target = '_self'
}: {
nodes: NodePosition[];
showNodeNames: boolean;
selectedNodeId: string | null;
onNodeClick: (nodeId: string | null) => void;
isLoadingNeighbors?: boolean;
target?: '_blank' | '_self' | '_parent' | '_top';
}) {
const map = useMap();
const clusterGroupRef = useRef<any>(null);
const onNodeClickRef = useRef(onNodeClick);
// Keep the callback ref updated
useEffect(() => {
onNodeClickRef.current = onNodeClick;
}, [onNodeClick]);
// Create cluster group only when map or nodes array changes
useEffect(() => {
if (!map) return;
const iconCreateFunction = (cluster: any) => {
const children = cluster.getAllChildMarkers();
return L.divIcon({
html: renderToString(<ClusterMarker>{children}</ClusterMarker>),
className: 'custom-cluster-icon',
iconSize: [30, 30],
iconAnchor: [15, 15],
});
};
const markers = (L as any).markerClusterGroup({
iconCreateFunction,
maxClusterRadius: 40,
});
nodes.forEach((node: NodePosition) => {
const isSelected = selectedNodeId === node.node_id;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [12, 24],
iconAnchor: [6, 6],
html: renderToString(
<NodeMarker
node={node}
showNodeNames={showNodeNames}
isSelected={isSelected}
isLoadingNeighbors={isSelected && isLoadingNeighbors}
/>
),
});
const marker = L.marker([node.latitude, node.longitude], { icon });
(marker as any).options.nodeData = node;
marker.bindPopup(renderToString(<PopupContent node={node} target={target} />));
// Add hover handler for meshcore nodes
if (node.type === "meshcore") {
marker.on('mouseover', () => {
onNodeClickRef.current(node.node_id);
});
}
markers.addLayer(marker);
});
markers._isClusterLayer = true;
map.addLayer(markers);
clusterGroupRef.current = markers;
return () => {
if (clusterGroupRef.current && map.hasLayer(clusterGroupRef.current)) {
map.removeLayer(clusterGroupRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally omitting selectedNodeId, showNodeNames, isLoadingNeighbors to prevent cluster recreation
}, [map, nodes, target]);
// Update marker appearances when visual properties change
useEffect(() => {
if (!clusterGroupRef.current) return;
clusterGroupRef.current.eachLayer((marker: any) => {
const nodeData = marker.options.nodeData;
if (nodeData) {
const isSelected = selectedNodeId === nodeData.node_id;
const icon = L.divIcon({
className: 'custom-node-marker-container',
iconSize: [16, 32],
iconAnchor: [8, 8],
html: renderToString(
<NodeMarker
node={nodeData}
showNodeNames={showNodeNames}
isSelected={isSelected}
isLoadingNeighbors={isSelected && isLoadingNeighbors}
/>
),
});
marker.setIcon(icon);
marker.getPopup()?.setContent(renderToString(<PopupContent node={nodeData} target={target} />));
}
});
}, [showNodeNames, selectedNodeId, isLoadingNeighbors]);
return null;
});
const ClusteredMarkers = React.memo(function ClusteredMarkers({
nodes,
selectedNodeId,
onNodeClick,
isLoadingNeighbors = false,
target = '_self',
showNodeNames = true,
enableClustering = true
}: ClusteredMarkersProps) {
if (!enableClustering) {
// Render individual marker components
return (
<>
{nodes.map((node) => (
<IndividualMarker
key={node.node_id}
node={node}
showNodeNames={showNodeNames}
selectedNodeId={selectedNodeId}
onNodeClick={onNodeClick}
isLoadingNeighbors={isLoadingNeighbors}
target={target}
/>
))}
</>
);
} else {
// Render clustered markers
return (
<ClusteredMarkersGroup
nodes={nodes}
showNodeNames={showNodeNames}
selectedNodeId={selectedNodeId}
onNodeClick={onNodeClick}
isLoadingNeighbors={isLoadingNeighbors}
target={target}
/>
);
}
});
// Component to render neighbor lines with directional arrows
function NeighborLines({
selectedNodeId,
neighbors,
nodes
}: {
selectedNodeId: string | null;
neighbors: Neighbor[];
nodes: NodePosition[];
}) {
if (!selectedNodeId || neighbors.length === 0) return null;
// Find the selected node's position
const selectedNode = nodes.find(node => node.node_id === selectedNodeId);
if (!selectedNode) return null;
// Create lines to neighbors that have location data and are visible on the map
const lines = neighbors
.filter(neighbor => neighbor.has_location && neighbor.latitude && neighbor.longitude)
.map(neighbor => {
// Check if the neighbor is also visible on the map
const neighborOnMap = nodes.find(node => node.node_id === neighbor.public_key);
const hasIncoming = neighbor.directions?.includes('incoming') || false;
const hasOutgoing = neighbor.directions?.includes('outgoing') || false;
const isBidirectional = hasIncoming && hasOutgoing;
return {
neighbor,
positions: [
[selectedNode.latitude, selectedNode.longitude] as [number, number],
[neighbor.latitude!, neighbor.longitude!] as [number, number]
],
isNeighborVisible: !!neighborOnMap,
hasIncoming,
hasOutgoing,
isBidirectional
};
});
return (
<>
{lines.map(({ neighbor, positions, isNeighborVisible, isBidirectional }) => {
const lineColor = isNeighborVisible ? (isBidirectional ? '#10b981' : '#3b82f6') : '#94a3b8';
return (
<Polyline
key={`${selectedNodeId}-${neighbor.public_key}`}
positions={positions}
pathOptions={{
color: lineColor,
weight: isBidirectional ? 3 : 2,
opacity: 0.7,
dashArray: isNeighborVisible ? undefined : '5, 5'
}}
/>
);
})}
</>
);
}
// Component to render all neighbor lines for all nodes
function AllNeighborLines({
connections,
nodes,
useColors = true,
minPacketCount = 1
}: {
connections: AllNeighborsConnection[];
nodes: NodePosition[];
useColors?: boolean;
minPacketCount?: number;
}) {
if (connections.length === 0) return null;
// Create a set of visible node IDs for quick lookup
const visibleNodeIds = new Set(nodes.map(node => node.node_id));
// Filter connections to only show lines between nodes that are visible on the map
// and meet the minimum packet count threshold
const visibleConnections = connections.filter(connection =>
visibleNodeIds.has(connection.source_node) &&
visibleNodeIds.has(connection.target_node) &&
connection.packet_count >= minPacketCount
);
// Calculate logarithmic thresholds based on packet counts for path connections
const pathConnections = visibleConnections.filter(conn => conn.connection_type === 'path');
const packetCounts = pathConnections.map(conn => conn.packet_count).sort((a, b) => a - b);
const getLogThresholds = (counts: number[]) => {
if (counts.length === 0) return { min: 1, t1: 1, t2: 1, t3: 1, t4: 1, max: 1 };
const min = Math.max(1, counts[0]); // Ensure minimum is at least 1 for log calculation
const max = counts[counts.length - 1];
if (min === max) {
return { min, t1: min, t2: min, t3: min, t4: min, max };
}
// Use logarithmic scale to create thresholds
const logMin = Math.log10(min);
const logMax = Math.log10(max);
const logRange = logMax - logMin;
const t1 = Math.pow(10, logMin + logRange * 0.2);
const t2 = Math.pow(10, logMin + logRange * 0.4);
const t3 = Math.pow(10, logMin + logRange * 0.6);
const t4 = Math.pow(10, logMin + logRange * 0.8);
return {
min,
t1: Math.round(t1),
t2: Math.round(t2),
t3: Math.round(t3),
t4: Math.round(t4),
max
};
};
const thresholds = getLogThresholds(packetCounts);
return (
<>
{visibleConnections.map((connection) => {
const positions: [number, number][] = [
[connection.source_latitude, connection.source_longitude],
[connection.target_latitude, connection.target_longitude]
];
// Different colors based on connection type and logarithmic packet count
const getConnectionColor = (connectionType: string, packetCount: number) => {
if (!useColors) {
// If colors are disabled, use consistent colors based on connection type
return connectionType === 'direct' ? '#8b5cf6' : '#6b7280'; // Purple for direct, gray for path
}
if (connectionType === 'direct') {
return '#8b5cf6'; // Purple for direct connections
}
// For path connections, use logarithmic thresholds for color intensity
if (packetCount >= thresholds.t4) return '#dc2626'; // Red for highest log range
if (packetCount >= thresholds.t3) return '#ea580c'; // Dark orange
if (packetCount >= thresholds.t2) return '#f59e0b'; // Orange
if (packetCount >= thresholds.t1) return '#eab308'; // Yellow
if (packetCount > thresholds.min) return '#84cc16'; // Light green for above minimum
return '#6b7280'; // Gray for minimum traffic
};
const lineColor = getConnectionColor(connection.connection_type, connection.packet_count);
// Consistent line weight for all connections
const lineWeight = connection.connection_type === 'direct' ? 2 : 1;
return (
<Polyline
key={`${connection.source_node}-${connection.target_node}-${connection.connection_type}`}
positions={positions}
pathOptions={{
color: lineColor,
weight: lineWeight,
opacity: 0.7,
}}
/>
);
})}
</>
);
}
interface MapViewProps {
target?: '_blank' | '_self' | '_parent' | '_top';
}
export default function MapView({ target = '_self' }: MapViewProps = {}) {
const [nodePositions, setNodePositions] = useState<NodePosition[]>([]);
const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null);
const [loading, setLoading] = useState(false);
const [lastResultCount, setLastResultCount] = useState<number>(0);
const fetchController = useRef<AbortController | null>(null);
const lastRequestedBounds = useRef<[[number, number], [number, number]] | null>(null);
const configResult = useConfig();
const config = configResult?.config;
// Map layer settings state
const [mapLayerSettings, setMapLayerSettings] = useState<MapLayerSettings>({
showNodes: true,
showNodeNames: true,
enableClustering: true,
tileLayer: "openstreetmap",
showAllNeighbors: false,
useColors: true,
nodeTypes: ["meshcore"],
showMeshcoreCoverageOverlay: false,
minPacketCount: 1,
});
// Use query params to persist map position
const { query: mapQuery, updateQuery: updateMapQuery } = useQueryParams<MapQuery>({
lat: DEFAULT.lat,
lng: DEFAULT.lng,
zoom: DEFAULT.zoom,
});
const mapCenter: [number, number] = [mapQuery.lat ?? DEFAULT.lat, mapQuery.lng ?? DEFAULT.lng];
const mapZoom = mapQuery.zoom ?? DEFAULT.zoom;
// Neighbor-related state
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [showAllNeighbors, setShowAllNeighbors] = useState<boolean>(false);
const [allNeighborConnections, setAllNeighborConnections] = useState<AllNeighborsConnection[]>([]);
const [allNeighborsLoading, setAllNeighborsLoading] = useState<boolean>(false);
// Update showAllNeighbors when mapLayerSettings changes
useEffect(() => {
setShowAllNeighbors(mapLayerSettings.showAllNeighbors);
}, [mapLayerSettings.showAllNeighbors]);
// Use TanStack Query for neighbors data
const { data: neighbors = [], isLoading: neighborsLoading } = useNeighbors({
nodeId: selectedNodeId,
lastSeen: config?.lastSeen,
enabled: !!selectedNodeId
});
type TileLayerKey = 'openstreetmap' | 'opentopomap' | 'esri';
const tileLayerOptions: Record<TileLayerKey, { url: string; attribution: string; maxZoom: number; subdomains?: string[] }> = {
openstreetmap: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: 'Tiles &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 22,
},
opentopomap: {
url: "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
attribution: 'Tiles &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 17,
},
esri: {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attribution: 'Tiles &copy; <a href="https://developers.arcgis.com/documentation/mapping-apis-and-services/deployment/basemap-attribution/">Esri</a>',
maxZoom: 21,
},
};
const selectedTileLayer = tileLayerOptions[(mapLayerSettings.tileLayer as TileLayerKey) || 'openstreetmap'];
// Handle node hover
const handleNodeClick = useCallback((nodeId: string | null) => {
if (nodeId !== null && selectedNodeId !== nodeId) {
// Mouse over new node - set new selection (TanStack Query will handle fetching)
setSelectedNodeId(nodeId);
}
// Lines persist on mouseout and when hovering over same node
}, [selectedNodeId]);
const fetchNodes = useCallback((bounds?: [[number, number], [number, number]], includeNeighbors: boolean = false) => {
if (fetchController.current) {
fetchController.current.abort();
}
const controller = new AbortController();
fetchController.current = controller;
setLoading(true);
if (includeNeighbors) {
setAllNeighborsLoading(true);
}
let url = "/api/map";
const params = [];
if (bounds) {
const [[minLat, minLng], [maxLat, maxLng]] = bounds;
params.push(`minLat=${minLat}`);
params.push(`maxLat=${maxLat}`);
params.push(`minLng=${minLng}`);
params.push(`maxLng=${maxLng}`);
}
if (mapLayerSettings.nodeTypes && mapLayerSettings.nodeTypes.length > 0) {
for (const type of mapLayerSettings.nodeTypes) {
params.push(`nodeTypes=${encodeURIComponent(type)}`);
}
}
if (config?.lastSeen !== null && config?.lastSeen !== undefined) {
params.push(`lastSeen=${config.lastSeen}`);
}
if (config?.selectedRegion) {
params.push(`region=${encodeURIComponent(config.selectedRegion)}`);
}
if (includeNeighbors) {
params.push('includeNeighbors=true');
}
if (params.length > 0) {
url += `?${params.join("&")}`;
}
fetch(buildApiUrl(url), { signal: controller.signal })
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) {
// Backward compatibility: just nodes array
setNodePositions(data);
setLastResultCount(data.length);
if (includeNeighbors) {
// If we expected neighbors but got just nodes, clear neighbors
setAllNeighborConnections([]);
}
} else if (data && data.nodes && Array.isArray(data.nodes)) {
// New format: object with nodes and neighbors
setNodePositions(data.nodes);
setLastResultCount(data.nodes.length);
if (data.neighbors && Array.isArray(data.neighbors)) {
setAllNeighborConnections(data.neighbors);
} else {
setAllNeighborConnections([]);
}
} else {
setNodePositions([]);
setAllNeighborConnections([]);
}
if (fetchController.current === controller) {
setLoading(false);
setAllNeighborsLoading(false);
}
})
.catch((err) => {
if (err.name !== "AbortError") {
setNodePositions([]);
setAllNeighborConnections([]);
}
if (fetchController.current === controller) {
setLoading(false);
setAllNeighborsLoading(false);
}
});
}, [mapLayerSettings.nodeTypes, config?.lastSeen]);
function isBoundsInside(inner: [[number, number], [number, number]], outer: [[number, number], [number, number]]) {
// inner: [[minLat, minLng], [maxLat, maxLng]]
// outer: [[minLat, minLng], [maxLat, maxLng]]
return (
inner[0][0] >= outer[0][0] && // minLat
inner[0][1] >= outer[0][1] && // minLng
inner[1][0] <= outer[1][0] && // maxLat
inner[1][1] <= outer[1][1] // maxLng
);
}
function MapEventCatcher() {
useMapEvents({
moveend: (e) => {
const map = e.target;
const center = map.getCenter();
const zoom = map.getZoom();
// Update URL with new map position
updateMapQuery({
lat: Math.round(center.lat * 100000) / 100000, // Round to 5 decimal places
lng: Math.round(center.lng * 100000) / 100000,
zoom: zoom
});
const b = map.getBounds();
const buffer = 0.2; // 20% buffer
const latDiff = b.getNorthEast().lat - b.getSouthWest().lat;
const lngDiff = b.getNorthEast().lng - b.getSouthWest().lng;
const newBounds: [[number, number], [number, number]] = [
[
b.getSouthWest().lat - latDiff * buffer,
b.getSouthWest().lng - lngDiff * buffer,
],
[
b.getNorthEast().lat + latDiff * buffer,
b.getNorthEast().lng + lngDiff * buffer,
],
];
// Only always refetch if we have too many nodes depending on clustering setting.
if (
(lastResultCount > (mapLayerSettings.enableClustering ? 5000: 1000)) ||
!lastRequestedBounds.current ||
!isBoundsInside(newBounds, lastRequestedBounds.current)
) {
setBounds(newBounds);
}
},
zoomend: (e) => {
const map = e.target;
const center = map.getCenter();
const zoom = map.getZoom();
// Update URL with new map position
updateMapQuery({
lat: Math.round(center.lat * 100000) / 100000, // Round to 5 decimal places
lng: Math.round(center.lng * 100000) / 100000,
zoom: zoom
});
const b = map.getBounds();
const buffer = 0.2; // 20% buffer
const latDiff = b.getNorthEast().lat - b.getSouthWest().lat;
const lngDiff = b.getNorthEast().lng - b.getSouthWest().lng;
const newBounds: [[number, number], [number, number]] = [
[
b.getSouthWest().lat - latDiff * buffer,
b.getSouthWest().lng - lngDiff * buffer,
],
[
b.getNorthEast().lat + latDiff * buffer,
b.getNorthEast().lng + lngDiff * buffer,
],
];
// Only always refetch if clustering is disabled and lastResultCount > 1000
if (
(!mapLayerSettings.enableClustering && lastResultCount > 1000) ||
!lastRequestedBounds.current ||
!isBoundsInside(newBounds, lastRequestedBounds.current)
) {
setBounds(newBounds);
}
},
});
return null;
}
// Set initial bounds on first render using the map instance
function InitialBoundsSetter() {
const map = useMap();
useEffect(() => {
if (!bounds && map) {
const b = map.getBounds();
setBounds([
[b.getSouthWest().lat, b.getSouthWest().lng],
[b.getNorthEast().lat, b.getNorthEast().lng],
]);
map.attributionControl.setPrefix('map.w0z.is')
}
}, [map]);
return null;
}
useEffect(() => {
fetchController.current?.abort(); // abort any in-flight request on effect cleanup
if (bounds) {
fetchNodes(bounds, showAllNeighbors);
lastRequestedBounds.current = bounds;
} else {
// Don't fetch until bounds is set
setNodePositions([]);
setAllNeighborConnections([]);
lastRequestedBounds.current = null;
}
return () => {
fetchController.current?.abort();
};
}, [bounds, mapLayerSettings.nodeTypes, config?.lastSeen, config?.selectedRegion, fetchNodes, showAllNeighbors]);
return (
<div style={{ width: "100%", height: "100%", position: "relative" }}>
{/* Button Column */}
<div style={{ position: "absolute", top: 16, right: 16, zIndex: 1000, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
<RefreshButton
onClick={() => bounds && fetchNodes(bounds, showAllNeighbors)}
loading={loading || !bounds}
title="Refresh map nodes"
ariaLabel="Refresh map nodes"
/>
<MapLayerSettingsComponent
onSettingsChange={setMapLayerSettings}
/>
</div>
<MapContainer
center={mapCenter}
zoom={mapZoom}
style={{ width: "100%", height: "100%", zIndex: 1 }}
className="bg-gray-200"
>
<InitialBoundsSetter />
<MapEventCatcher />
<TileLayer
attribution={selectedTileLayer.attribution}
url={selectedTileLayer.url}
maxZoom={selectedTileLayer.maxZoom}
{...(selectedTileLayer.subdomains ? { subdomains: selectedTileLayer.subdomains } : {})}
/>
{mapLayerSettings.showMeshcoreCoverageOverlay && (
<TileLayer
url="https://tiles.w0z.is/tiles/{z}/{x}/{y}.png"
attribution="Meshcore Coverage &copy; <a href='https://w0z.is/'>w0z.is</a>"
minZoom={1}
maxZoom={22}
minNativeZoom={8}
maxNativeZoom={8}
zIndex={1000}
opacity={0.7}
/>
)}
{mapLayerSettings.showNodes && (
<ClusteredMarkers
nodes={nodePositions}
selectedNodeId={selectedNodeId}
onNodeClick={handleNodeClick}
isLoadingNeighbors={neighborsLoading}
target={target}
showNodeNames={mapLayerSettings.showNodeNames}
enableClustering={mapLayerSettings.enableClustering}
/>
)}
<NeighborLines
selectedNodeId={selectedNodeId}
neighbors={neighbors}
nodes={nodePositions}
/>
{showAllNeighbors && (
<AllNeighborLines
connections={allNeighborConnections}
nodes={nodePositions}
useColors={mapLayerSettings.useColors}
minPacketCount={mapLayerSettings.minPacketCount}
/>
)}
</MapContainer>
{/* Traffic Legend */}
{showAllNeighbors && mapLayerSettings.useColors && allNeighborConnections.length > 0 && (() => {
// Calculate logarithmic thresholds for legend display
const pathConnections = allNeighborConnections.filter(conn => conn.connection_type === 'path');
const packetCounts = pathConnections.map(conn => conn.packet_count).sort((a, b) => a - b);
const legendThresholds = packetCounts.length > 0 ? (() => {
const min = Math.max(1, packetCounts[0]);
const max = packetCounts[packetCounts.length - 1];
if (min === max) {
return { min, t1: min, t2: min, t3: min, t4: min, max };
}
const logMin = Math.log10(min);
const logMax = Math.log10(max);
const logRange = logMax - logMin;
return {
min,
t1: Math.round(Math.pow(10, logMin + logRange * 0.2)),
t2: Math.round(Math.pow(10, logMin + logRange * 0.4)),
t3: Math.round(Math.pow(10, logMin + logRange * 0.6)),
t4: Math.round(Math.pow(10, logMin + logRange * 0.8)),
max
};
})() : null;
return legendThresholds && (
<div style={{
position: "absolute",
bottom: 16,
left: 16,
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
padding: '12px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
fontSize: '12px',
fontFamily: 'monospace'
}}>
<div style={{ fontWeight: 'bold', marginBottom: '8px' }}>
Path Traffic
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '2px', backgroundColor: '#dc2626' }}></div>
<span>High: {legendThresholds.t4}+ packets</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '2px', backgroundColor: '#ea580c' }}></div>
<span>Med-High: {legendThresholds.t3}-{legendThresholds.t4 - 1}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '2px', backgroundColor: '#f59e0b' }}></div>
<span>Medium: {legendThresholds.t2}-{legendThresholds.t3 - 1}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '2px', backgroundColor: '#eab308' }}></div>
<span>Low-Med: {legendThresholds.t1}-{legendThresholds.t2 - 1}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '2px', backgroundColor: '#84cc16' }}></div>
<span>Low: {legendThresholds.min + 1}-{legendThresholds.t1 - 1}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '2px', backgroundColor: '#6b7280' }}></div>
<span>Minimal: {legendThresholds.min}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px', paddingTop: '4px', borderTop: '1px solid #e5e7eb' }}>
<div style={{ width: '20px', height: '2px', backgroundColor: '#8b5cf6' }}></div>
<span>MQTT connections</span>
</div>
</div>
</div>
);
})()}
</div>
);
}