All neighbors, map layer settings

This commit is contained in:
ajvpot
2025-09-18 01:40:45 +02:00
parent fe6e44cc80
commit f3ad947b9a
6 changed files with 364 additions and 164 deletions
+3 -115
View File
@@ -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"
+201
View File
@@ -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
View File
@@ -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 &copy; <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);
+48
View File
@@ -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];
}
+41
View File
@@ -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" },
];
+6 -3
View File
@@ -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