From f3ad947b9af9fd37f2d29203fe8cd1b74ff07178 Mon Sep 17 00:00:00 2001 From: ajvpot <553597+ajvpot@users.noreply.github.com> Date: Thu, 18 Sep 2025 01:40:45 +0200 Subject: [PATCH] All neighbors, map layer settings --- src/components/ConfigContext.tsx | 118 +--------------- src/components/MapLayerSettings.tsx | 201 ++++++++++++++++++++++++++++ src/components/MapView.tsx | 111 ++++++++------- src/hooks/useLocalStorage.ts | 48 +++++++ src/hooks/useMapLayerSettings.ts | 41 ++++++ src/lib/clickhouse/actions.ts | 9 +- 6 files changed, 364 insertions(+), 164 deletions(-) create mode 100644 src/components/MapLayerSettings.tsx create mode 100644 src/hooks/useLocalStorage.ts create mode 100644 src/hooks/useMapLayerSettings.ts diff --git a/src/components/ConfigContext.tsx b/src/components/ConfigContext.tsx index 45a31a1..4ea8852 100644 --- a/src/components/ConfigContext.tsx +++ b/src/components/ConfigContext.tsx @@ -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(null); export function ConfigProvider({ children }: { children: ReactNode }) { - const [config, setConfig] = useState(DEFAULT_CONFIG); + const [config, setConfig] = useLocalStorage("meshExplorerConfig", DEFAULT_CONFIG); const [open, setOpen] = useState(false); const [keyModalOpen, setKeyModalOpen] = useState(false); const configButtonRef = useRef(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 } > -

Map Filters

-
-
Node Types
- - -
+

Settings

Last Seen
setConfig({ ...config, tileLayer: e.target.value })} - > - {TILE_LAYERS.map(opt => ( - - ))} - -
-
- -
-
- -
-
- -
+ + {isOpen && ( +
+

Map Settings

+ + {/* Show nodes */} + + + {/* Node types - indented sub-options */} + {NODE_TYPE_OPTIONS.map(nodeType => ( + + ))} + + {/* Show node names - indented sub-option */} + + + {/* Enable marker clustering - indented sub-option */} + + + {/* Tile layer */} +
+ + +
+ + {/* Show all neighbors */} + + + {/* Use colors - indented sub-option */} + + + {/* Show meshcore coverage overlay */} + +
+ )} +
+ ); +} diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index bf3ad03..f929cf6 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -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({ + 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({ lat: DEFAULT.lat, @@ -481,6 +509,11 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { const [showAllNeighbors, setShowAllNeighbors] = useState(false); const [allNeighborConnections, setAllNeighborConnections] = useState([]); const [allNeighborsLoading, setAllNeighborsLoading] = useState(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 (
- {/* Button Row */} -
- + {/* Button Column */} +
bounds && fetchNodes(bounds, showAllNeighbors)} loading={loading || !bounds} title="Refresh map nodes" ariaLabel="Refresh map nodes" /> +
- {config?.showMeshcoreCoverageOverlay && ( + {mapLayerSettings.showMeshcoreCoverageOverlay && ( )} - + {mapLayerSettings.showNodes && ( + + )} )} {/* 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); diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..b00c644 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -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(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] { + const [value, setValue] = useState(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]; +} diff --git a/src/hooks/useMapLayerSettings.ts b/src/hooks/useMapLayerSettings.ts new file mode 100644 index 0000000..cc5b1ab --- /dev/null +++ b/src/hooks/useMapLayerSettings.ts @@ -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", 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" }, +]; diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index a52d1ba..2aab405 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -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