mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-08-06 08:32:45 +02:00
All neighbors, map layer settings
This commit is contained in:
@@ -2,39 +2,24 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, useLayoutEffect, ReactNode } from "react";
|
||||
import { getChannelIdFromKey, deriveKeyFromChannelName } from "@/lib/meshcore";
|
||||
import { getRegionFriendlyNames } from "@/lib/regions";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import Modal from "./Modal";
|
||||
|
||||
// Config shape
|
||||
type NodeType = "meshcore" | "meshtastic";
|
||||
export type MeshcoreKey = {
|
||||
channelName: string;
|
||||
privateKey: string;
|
||||
};
|
||||
export type Config = {
|
||||
nodeTypes: NodeType[]; // which node types to show
|
||||
lastSeen: number | null; // seconds, or null for forever
|
||||
tileLayer: string; // add tileLayer selection
|
||||
clustering?: boolean; // add clustering toggle
|
||||
showNodeNames?: boolean; // add show node names toggle
|
||||
meshcoreKeys?: MeshcoreKey[]; // meshcore private keys
|
||||
showMeshcoreCoverageOverlay?: boolean; // meshcore overlay toggle
|
||||
selectedRegion?: string; // selected region for chat messages
|
||||
};
|
||||
|
||||
const TILE_LAYERS = [
|
||||
{ key: "openstreetmap", label: "OpenStreetMap" },
|
||||
{ key: "opentopomap", label: "OpenTopoMap" },
|
||||
{ key: "esri", label: "Esri World Imagery" },
|
||||
];
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
nodeTypes: ["meshcore"],
|
||||
lastSeen: 604800, // 1 week by default
|
||||
tileLayer: "openstreetmap", // default
|
||||
clustering: true, // default to clustering enabled
|
||||
showNodeNames: true, // default to show node names
|
||||
meshcoreKeys: [], // default empty
|
||||
showMeshcoreCoverageOverlay: false, // meshcore overlay default
|
||||
selectedRegion: undefined, // no region selected by default
|
||||
};
|
||||
|
||||
@@ -57,32 +42,10 @@ const PUBLIC_MESHCORE_KEY = {
|
||||
const ConfigContext = createContext<any>(null);
|
||||
|
||||
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||
const [config, setConfig] = useState<Config>(DEFAULT_CONFIG);
|
||||
const [config, setConfig] = useLocalStorage<Config>("meshExplorerConfig", DEFAULT_CONFIG);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyModalOpen, setKeyModalOpen] = useState(false);
|
||||
const configButtonRef = useRef<HTMLElement | null>(null);
|
||||
const firstRender = useRef(true);
|
||||
|
||||
// Load from localStorage
|
||||
// todo: this causes a flash of the default config before the local storage is loaded. use suspense?
|
||||
// also causes race condition on the stats page if the defaults take longer to load than the selected region.
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("meshExplorerConfig");
|
||||
if (stored) {
|
||||
try {
|
||||
setConfig({ ...DEFAULT_CONFIG, ...JSON.parse(stored) });
|
||||
} catch {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save to localStorage
|
||||
useEffect(() => {
|
||||
if (!firstRender.current) {
|
||||
localStorage.setItem("meshExplorerConfig", JSON.stringify(config));
|
||||
} else {
|
||||
firstRender.current = false;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Expose openConfig for header button
|
||||
const openConfig = () => setOpen(true);
|
||||
@@ -148,40 +111,7 @@ function ConfigPopover({ config, setConfig, onClose, anchorRef, onOpenKeyModal }
|
||||
>
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold mb-4">Map Filters</h2>
|
||||
<div className="mb-4">
|
||||
<div className="font-medium mb-2">Node Types</div>
|
||||
<label className="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.nodeTypes.includes("meshcore")}
|
||||
onChange={e => {
|
||||
setConfig({
|
||||
...config,
|
||||
nodeTypes: e.target.checked
|
||||
? Array.from(new Set([...config.nodeTypes, "meshcore"]))
|
||||
: config.nodeTypes.filter(t => t !== "meshcore"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="text-blue-700 dark:text-blue-400">Meshcore</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.nodeTypes.includes("meshtastic")}
|
||||
onChange={e => {
|
||||
setConfig({
|
||||
...config,
|
||||
nodeTypes: e.target.checked
|
||||
? Array.from(new Set([...config.nodeTypes, "meshtastic"]))
|
||||
: config.nodeTypes.filter(t => t !== "meshtastic"),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="text-green-600 dark:text-green-400">Meshtastic</span>
|
||||
</label>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold mb-4">Settings</h2>
|
||||
<div className="mb-2">
|
||||
<div className="font-medium mb-2">Last Seen</div>
|
||||
<select
|
||||
@@ -198,48 +128,6 @@ function ConfigPopover({ config, setConfig, onClose, anchorRef, onOpenKeyModal }
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="font-medium mb-2">Tile Layer</div>
|
||||
<select
|
||||
className="w-full p-2 border rounded"
|
||||
value={config.tileLayer}
|
||||
onChange={e => setConfig({ ...config, tileLayer: e.target.value })}
|
||||
>
|
||||
{TILE_LAYERS.map(opt => (
|
||||
<option key={opt.key} value={opt.key}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.clustering !== false}
|
||||
onChange={e => setConfig({ ...config, clustering: e.target.checked })}
|
||||
/>
|
||||
<span>Enable marker clustering</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showNodeNames !== false}
|
||||
onChange={e => setConfig({ ...config, showNodeNames: e.target.checked })}
|
||||
/>
|
||||
<span>Show node names</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showMeshcoreCoverageOverlay === true}
|
||||
onChange={e => setConfig({ ...config, showMeshcoreCoverageOverlay: e.target.checked })}
|
||||
/>
|
||||
<span>Show meshcore coverage overlay</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<button
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 w-full"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useMapLayerSettings, TILE_LAYERS, NODE_TYPE_OPTIONS, type MapLayerSettings } from '@/hooks/useMapLayerSettings';
|
||||
|
||||
interface MapLayerSettingsProps {
|
||||
onSettingsChange?: (settings: MapLayerSettings) => void;
|
||||
}
|
||||
|
||||
export default function MapLayerSettingsComponent({ onSettingsChange }: MapLayerSettingsProps) {
|
||||
const [settings, setSettings] = useMapLayerSettings();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Notify parent of settings changes
|
||||
useEffect(() => {
|
||||
onSettingsChange?.(settings);
|
||||
}, [settings, onSettingsChange]);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const updateSetting = <K extends keyof MapLayerSettings>(key: K, value: MapLayerSettings[K]) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-2 bg-white text-gray-700 hover:bg-gray-50 border border-gray-300 rounded-lg transition-colors"
|
||||
title="Map layer settings"
|
||||
aria-label="Map layer settings"
|
||||
>
|
||||
{/* Layers icon */}
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute top-full right-0 mt-2 bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-700 rounded-lg shadow-lg p-4 min-w-[250px] z-[1100]"
|
||||
style={{ boxSizing: 'border-box' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-3 text-gray-800 dark:text-gray-200">Map Settings</h3>
|
||||
|
||||
{/* Show nodes */}
|
||||
<label className="flex items-center gap-2 mb-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showNodes}
|
||||
onChange={(e) => updateSetting('showNodes', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Show nodes</span>
|
||||
</label>
|
||||
|
||||
{/* Node types - indented sub-options */}
|
||||
{NODE_TYPE_OPTIONS.map(nodeType => (
|
||||
<label key={nodeType.key} className="flex items-center gap-2 mb-1 ml-6 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.nodeTypes.includes(nodeType.key as "meshcore" | "meshtastic")}
|
||||
onChange={(e) => {
|
||||
const currentTypes = settings.nodeTypes;
|
||||
if (e.target.checked) {
|
||||
updateSetting('nodeTypes', [...currentTypes, nodeType.key as "meshcore" | "meshtastic"]);
|
||||
} else {
|
||||
updateSetting('nodeTypes', currentTypes.filter(t => t !== nodeType.key));
|
||||
}
|
||||
}}
|
||||
disabled={!settings.showNodes}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className={`text-sm ${
|
||||
settings.showNodes
|
||||
? 'text-gray-700 dark:text-gray-300'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
{nodeType.label}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Show node names - indented sub-option */}
|
||||
<label className="flex items-center gap-2 mb-3 ml-6 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showNodeNames}
|
||||
onChange={(e) => updateSetting('showNodeNames', e.target.checked)}
|
||||
disabled={!settings.showNodes}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className={`text-sm ${
|
||||
settings.showNodes
|
||||
? 'text-gray-700 dark:text-gray-300'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
Show node names
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Enable marker clustering - indented sub-option */}
|
||||
<label className="flex items-center gap-2 mb-3 ml-6 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableClustering}
|
||||
onChange={(e) => updateSetting('enableClustering', e.target.checked)}
|
||||
disabled={!settings.showNodes}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className={`text-sm ${
|
||||
settings.showNodes
|
||||
? 'text-gray-700 dark:text-gray-300'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
Enable marker clustering
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Tile layer */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tile layer
|
||||
</label>
|
||||
<select
|
||||
value={settings.tileLayer}
|
||||
onChange={(e) => updateSetting('tileLayer', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 dark:border-neutral-600 rounded text-sm bg-white dark:bg-neutral-800 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{TILE_LAYERS.map(layer => (
|
||||
<option key={layer.key} value={layer.key}>
|
||||
{layer.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Show all neighbors */}
|
||||
<label className="flex items-center gap-2 mb-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showAllNeighbors}
|
||||
onChange={(e) => updateSetting('showAllNeighbors', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Show all neighbors</span>
|
||||
</label>
|
||||
|
||||
{/* Use colors - indented sub-option */}
|
||||
<label className="flex items-center gap-2 ml-6 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.useColors}
|
||||
onChange={(e) => updateSetting('useColors', e.target.checked)}
|
||||
disabled={!settings.showAllNeighbors}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className={`text-sm ${
|
||||
settings.showAllNeighbors
|
||||
? 'text-gray-700 dark:text-gray-300'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
Use colors
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Show meshcore coverage overlay */}
|
||||
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showMeshcoreCoverageOverlay}
|
||||
onChange={(e) => updateSetting('showMeshcoreCoverageOverlay', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Show meshcore coverage overlay</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+65
-46
@@ -9,6 +9,8 @@ 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";
|
||||
@@ -36,6 +38,8 @@ type ClusteredMarkersProps = {
|
||||
onNodeClick: (nodeId: string | null) => void;
|
||||
isLoadingNeighbors?: boolean;
|
||||
target?: '_blank' | '_self' | '_parent' | '_top';
|
||||
showNodeNames?: boolean;
|
||||
enableClustering?: boolean;
|
||||
};
|
||||
|
||||
// Individual marker component
|
||||
@@ -254,12 +258,17 @@ const ClusteredMarkersGroup = React.memo(function ClusteredMarkersGroup({
|
||||
return null;
|
||||
});
|
||||
|
||||
const ClusteredMarkers = React.memo(function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick, isLoadingNeighbors = false, target = '_self' }: ClusteredMarkersProps) {
|
||||
const configResult = useConfig();
|
||||
const config = configResult?.config;
|
||||
const showNodeNames = config?.showNodeNames !== false;
|
||||
const ClusteredMarkers = React.memo(function ClusteredMarkers({
|
||||
nodes,
|
||||
selectedNodeId,
|
||||
onNodeClick,
|
||||
isLoadingNeighbors = false,
|
||||
target = '_self',
|
||||
showNodeNames = true,
|
||||
enableClustering = true
|
||||
}: ClusteredMarkersProps) {
|
||||
|
||||
if (config?.clustering === false) {
|
||||
if (!enableClustering) {
|
||||
// Render individual marker components
|
||||
return (
|
||||
<>
|
||||
@@ -357,10 +366,12 @@ function NeighborLines({
|
||||
// Component to render all neighbor lines for all nodes
|
||||
function AllNeighborLines({
|
||||
connections,
|
||||
nodes
|
||||
nodes,
|
||||
useColors = true
|
||||
}: {
|
||||
connections: AllNeighborsConnection[];
|
||||
nodes: NodePosition[];
|
||||
useColors?: boolean;
|
||||
}) {
|
||||
if (connections.length === 0) return null;
|
||||
|
||||
@@ -418,6 +429,11 @@ function AllNeighborLines({
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -466,6 +482,18 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
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,
|
||||
});
|
||||
|
||||
// Use query params to persist map position
|
||||
const { query: mapQuery, updateQuery: updateMapQuery } = useQueryParams<MapQuery>({
|
||||
lat: DEFAULT.lat,
|
||||
@@ -481,6 +509,11 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
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({
|
||||
@@ -507,7 +540,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
maxZoom: 21,
|
||||
},
|
||||
};
|
||||
const selectedTileLayer = tileLayerOptions[(config?.tileLayer as TileLayerKey) || 'openstreetmap'];
|
||||
const selectedTileLayer = tileLayerOptions[(mapLayerSettings.tileLayer as TileLayerKey) || 'openstreetmap'];
|
||||
|
||||
// Handle node hover
|
||||
const handleNodeClick = useCallback((nodeId: string | null) => {
|
||||
@@ -538,8 +571,8 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
params.push(`minLng=${minLng}`);
|
||||
params.push(`maxLng=${maxLng}`);
|
||||
}
|
||||
if (config?.nodeTypes && config.nodeTypes.length > 0) {
|
||||
for (const type of config.nodeTypes) {
|
||||
if (mapLayerSettings.nodeTypes && mapLayerSettings.nodeTypes.length > 0) {
|
||||
for (const type of mapLayerSettings.nodeTypes) {
|
||||
params.push(`nodeTypes=${encodeURIComponent(type)}`);
|
||||
}
|
||||
}
|
||||
@@ -596,7 +629,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
setAllNeighborsLoading(false);
|
||||
}
|
||||
});
|
||||
}, [config?.nodeTypes, config?.lastSeen]);
|
||||
}, [mapLayerSettings.nodeTypes, config?.lastSeen]);
|
||||
|
||||
function isBoundsInside(inner: [[number, number], [number, number]], outer: [[number, number], [number, number]]) {
|
||||
// inner: [[minLat, minLng], [maxLat, maxLng]]
|
||||
@@ -639,7 +672,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
];
|
||||
// Only always refetch if we have too many nodes depending on clustering setting.
|
||||
if (
|
||||
(lastResultCount > (config?.clustering ? 5000: 1000)) ||
|
||||
(lastResultCount > (mapLayerSettings.enableClustering ? 5000: 1000)) ||
|
||||
!lastRequestedBounds.current ||
|
||||
!isBoundsInside(newBounds, lastRequestedBounds.current)
|
||||
) {
|
||||
@@ -674,7 +707,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
];
|
||||
// Only always refetch if clustering is disabled and lastResultCount > 1000
|
||||
if (
|
||||
(config?.clustering === false && lastResultCount > 1000) ||
|
||||
(!mapLayerSettings.enableClustering && lastResultCount > 1000) ||
|
||||
!lastRequestedBounds.current ||
|
||||
!isBoundsInside(newBounds, lastRequestedBounds.current)
|
||||
) {
|
||||
@@ -715,40 +748,21 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
return () => {
|
||||
fetchController.current?.abort();
|
||||
};
|
||||
}, [bounds, config?.nodeTypes, config?.lastSeen, config?.selectedRegion, fetchNodes, showAllNeighbors]);
|
||||
}, [bounds, mapLayerSettings.nodeTypes, config?.lastSeen, config?.selectedRegion, fetchNodes, showAllNeighbors]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", position: "relative" }}>
|
||||
{/* Button Row */}
|
||||
<div style={{ position: "absolute", top: 16, right: 16, zIndex: 1000, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newShowAllNeighbors = !showAllNeighbors;
|
||||
setShowAllNeighbors(newShowAllNeighbors);
|
||||
if (newShowAllNeighbors && bounds) {
|
||||
// Fetch with neighbors
|
||||
fetchNodes(bounds, true);
|
||||
} else if (!newShowAllNeighbors) {
|
||||
// Clear neighbors when hiding
|
||||
setAllNeighborConnections([]);
|
||||
}
|
||||
}}
|
||||
disabled={allNeighborsLoading}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
showAllNeighbors
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
|
||||
} ${allNeighborsLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={showAllNeighbors ? "Hide all neighbors" : "Show all neighbors"}
|
||||
>
|
||||
{allNeighborsLoading ? 'Loading...' : showAllNeighbors ? 'Hide All Neighbors' : 'Show All Neighbors'}
|
||||
</button>
|
||||
{/* 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}
|
||||
@@ -764,7 +778,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
maxZoom={selectedTileLayer.maxZoom}
|
||||
{...(selectedTileLayer.subdomains ? { subdomains: selectedTileLayer.subdomains } : {})}
|
||||
/>
|
||||
{config?.showMeshcoreCoverageOverlay && (
|
||||
{mapLayerSettings.showMeshcoreCoverageOverlay && (
|
||||
<TileLayer
|
||||
url="https://tiles.w0z.is/tiles/{z}/{x}/{y}.png"
|
||||
attribution="Meshcore Coverage © <a href='https://w0z.is/'>w0z.is</a>"
|
||||
@@ -776,13 +790,17 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
opacity={0.7}
|
||||
/>
|
||||
)}
|
||||
<ClusteredMarkers
|
||||
nodes={nodePositions}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeClick={handleNodeClick}
|
||||
isLoadingNeighbors={neighborsLoading}
|
||||
target={target}
|
||||
/>
|
||||
{mapLayerSettings.showNodes && (
|
||||
<ClusteredMarkers
|
||||
nodes={nodePositions}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeClick={handleNodeClick}
|
||||
isLoadingNeighbors={neighborsLoading}
|
||||
target={target}
|
||||
showNodeNames={mapLayerSettings.showNodeNames}
|
||||
enableClustering={mapLayerSettings.enableClustering}
|
||||
/>
|
||||
)}
|
||||
<NeighborLines
|
||||
selectedNodeId={selectedNodeId}
|
||||
neighbors={neighbors}
|
||||
@@ -792,12 +810,13 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) {
|
||||
<AllNeighborLines
|
||||
connections={allNeighborConnections}
|
||||
nodes={nodePositions}
|
||||
useColors={mapLayerSettings.useColors}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
|
||||
{/* Traffic Legend */}
|
||||
{showAllNeighbors && allNeighborConnections.length > 0 && (() => {
|
||||
{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);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* A hook that provides localStorage persistence for state values
|
||||
* @param key The localStorage key to use
|
||||
* @param defaultValue The default value to use if nothing is stored
|
||||
* @returns A tuple of [value, setValue] similar to useState
|
||||
*/
|
||||
export function useLocalStorage<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const [value, setValue] = useState<T>(defaultValue);
|
||||
const firstRender = useRef(true);
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored !== null) {
|
||||
const parsed = JSON.parse(stored);
|
||||
setValue(typeof defaultValue === 'object' && defaultValue !== null
|
||||
? { ...defaultValue, ...parsed }
|
||||
: parsed
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load from localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, defaultValue]);
|
||||
|
||||
// Save to localStorage when value changes (except on first render)
|
||||
useEffect(() => {
|
||||
if (!firstRender.current) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to save to localStorage key "${key}":`, error);
|
||||
}
|
||||
} else {
|
||||
firstRender.current = false;
|
||||
}
|
||||
}, [key, value]);
|
||||
|
||||
const setStoredValue = (newValue: T | ((prev: T) => T)) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
return [value, setStoredValue];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
type NodeType = "meshcore" | "meshtastic";
|
||||
|
||||
export interface MapLayerSettings {
|
||||
showNodes: boolean;
|
||||
showNodeNames: boolean;
|
||||
enableClustering: boolean;
|
||||
tileLayer: string;
|
||||
showAllNeighbors: boolean;
|
||||
useColors: boolean;
|
||||
nodeTypes: NodeType[];
|
||||
showMeshcoreCoverageOverlay: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MAP_LAYER_SETTINGS: MapLayerSettings = {
|
||||
showNodes: true,
|
||||
showNodeNames: true,
|
||||
enableClustering: true,
|
||||
tileLayer: "openstreetmap",
|
||||
showAllNeighbors: false,
|
||||
useColors: true,
|
||||
nodeTypes: ["meshcore"],
|
||||
showMeshcoreCoverageOverlay: false,
|
||||
};
|
||||
|
||||
export function useMapLayerSettings() {
|
||||
return useLocalStorage<MapLayerSettings>("mapLayerSettings", DEFAULT_MAP_LAYER_SETTINGS);
|
||||
}
|
||||
|
||||
export const TILE_LAYERS = [
|
||||
{ key: "openstreetmap", label: "OpenStreetMap" },
|
||||
{ key: "opentopomap", label: "OpenTopoMap" },
|
||||
{ key: "esri", label: "Esri World Imagery" },
|
||||
];
|
||||
|
||||
export const NODE_TYPE_OPTIONS = [
|
||||
{ key: "meshcore", label: "Meshcore" },
|
||||
{ key: "meshtastic", label: "Meshtastic" },
|
||||
];
|
||||
@@ -388,18 +388,21 @@ export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat
|
||||
${meshcoreWhere}
|
||||
),
|
||||
path_neighbors AS (
|
||||
-- Extract neighbors from routing paths with packet counts
|
||||
-- Extract neighbors from routing paths with unique payload counts
|
||||
-- Group by payload first to avoid double counting same message propagation
|
||||
SELECT
|
||||
source_prefix,
|
||||
target_prefix,
|
||||
'path' as connection_type,
|
||||
count() as packet_count
|
||||
FROM (
|
||||
SELECT
|
||||
SELECT DISTINCT
|
||||
payload,
|
||||
upper(hex(substring(path, i, 1))) as source_prefix,
|
||||
upper(hex(substring(path, i + 1, 1))) as target_prefix
|
||||
FROM (
|
||||
SELECT
|
||||
SELECT DISTINCT
|
||||
payload,
|
||||
path,
|
||||
path_len
|
||||
FROM meshcore_packets
|
||||
|
||||
Reference in New Issue
Block a user