mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-06-25 04:10:56 +02:00
903 lines
31 KiB
TypeScript
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 © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
maxZoom: 22,
|
|
},
|
|
opentopomap: {
|
|
url: "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
|
|
attribution: 'Tiles © <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 © <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 © <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>
|
|
);
|
|
}
|