diff --git a/src/app/api/map/route.ts b/src/app/api/map/route.ts index 03ea42d..f031a33 100644 --- a/src/app/api/map/route.ts +++ b/src/app/api/map/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getNodePositions } from "@/lib/clickhouse/actions"; +import { getNodePositions, getAllNodeNeighbors } from "@/lib/clickhouse/actions"; export async function GET(req: Request) { try { @@ -10,9 +10,34 @@ export async function GET(req: Request) { const maxLng = searchParams.get("maxLng"); const nodeTypes = searchParams.getAll("nodeTypes"); const lastSeen = searchParams.get("lastSeen"); + const includeNeighbors = searchParams.get("includeNeighbors") === "true"; + const positions = await getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen }); + + if (includeNeighbors) { + const neighbors = await getAllNodeNeighbors(lastSeen, minLat, maxLat, minLng, maxLng, nodeTypes); + return NextResponse.json({ + nodes: positions, + neighbors: neighbors + }); + } + + // Return just the positions array for backward compatibility return NextResponse.json(positions); } catch (error) { - return NextResponse.json({ error: "Failed to fetch node positions" }, { status: 500 }); + console.error("Error fetching map data:", error); + + // Check if it's a ClickHouse connection error + if (error instanceof Error && error.message.includes('ClickHouse')) { + return NextResponse.json({ + error: "Database temporarily unavailable", + code: "DATABASE_ERROR" + }, { status: 503 }); + } + + return NextResponse.json({ + error: "Failed to fetch map data", + code: "INTERNAL_ERROR" + }, { status: 500 }); } } \ No newline at end of file diff --git a/src/app/api/neighbors/all/route.ts b/src/app/api/neighbors/all/route.ts new file mode 100644 index 0000000..90e3d10 --- /dev/null +++ b/src/app/api/neighbors/all/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getAllNodeNeighbors } from "@/lib/clickhouse/actions"; + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const minLat = searchParams.get("minLat"); + const maxLat = searchParams.get("maxLat"); + const minLng = searchParams.get("minLng"); + const maxLng = searchParams.get("maxLng"); + const nodeTypes = searchParams.getAll("nodeTypes"); + const lastSeen = searchParams.get("lastSeen"); + + const neighbors = await getAllNodeNeighbors(lastSeen, minLat, maxLat, minLng, maxLng, nodeTypes); + + return NextResponse.json(neighbors); + } catch (error) { + console.error("Error fetching all node neighbors:", error); + + // Check if it's a ClickHouse connection error + if (error instanceof Error && error.message.includes('ClickHouse')) { + return NextResponse.json({ + error: "Database temporarily unavailable", + code: "DATABASE_ERROR" + }, { status: 503 }); + } + + return NextResponse.json({ + error: "Failed to fetch all neighbors", + code: "INTERNAL_ERROR" + }, { status: 500 }); + } +} + diff --git a/src/app/globals.css b/src/app/globals.css index d74507a..8e770ff 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -130,8 +130,8 @@ html { } .custom-node-marker { - width: 16px; - height: 16px; + width: 12px; + height: 12px; background: #2563eb; /* blue-600 */ border: 2px solid #fff; border-radius: 50%; @@ -160,10 +160,10 @@ html { .custom-node-marker--loading::after { content: ''; position: absolute; - top: -4px; - left: -4px; - right: -4px; - bottom: -4px; + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; border: 2px solid transparent; border-top: 2px solid #2563eb; /* blue-600 */ border-radius: 50%; @@ -194,13 +194,13 @@ html { .custom-node-label { position: absolute; - top: 18px; + top: 14px; left: 50%; transform: translateX(-50%); - font-size: 12px; + font-size: 11px; color: #222; background: rgba(255,255,255,0.85); - padding: 0 4px; + padding: 0 3px; border-radius: 3px; white-space: nowrap; pointer-events: none; diff --git a/src/components/MapIcons.tsx b/src/components/MapIcons.tsx index 910e294..c3ce91f 100644 --- a/src/components/MapIcons.tsx +++ b/src/components/MapIcons.tsx @@ -70,7 +70,7 @@ export function ClusterMarker({ children }: ClusterMarkerProps) { const percentMeshtastic = total ? meshtasticCount / total : 0; // Pie chart SVG calculations - const r = 18; + const r = 13.5; const c = 2 * Math.PI * r; const meshcoreArc = percentMeshcore * c; const meshtasticArc = percentMeshtastic * c; @@ -78,49 +78,49 @@ export function ClusterMarker({ children }: ClusterMarkerProps) { return (
- @@ -131,7 +131,7 @@ export function ClusterMarker({ children }: ClusterMarkerProps) { transform: "translate(-50%, -50%)", color: "#111", fontWeight: "bold", - fontSize: "15px", + fontSize: "11px", lineHeight: "1", textShadow: "0 0 2px #fff, 0 0 2px #fff, 0 0 2px #fff, 0 0 2px #fff", background: "none", diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 0def24a..de014b6 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -14,6 +14,7 @@ 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 = { @@ -68,8 +69,8 @@ const IndividualMarker = React.memo(function IndividualMarker({ const isSelected = selectedNodeId === node.node_id; const icon = L.divIcon({ className: 'custom-node-marker-container', - iconSize: [16, 32], - iconAnchor: [8, 8], + iconSize: [12, 24], + iconAnchor: [6, 6], html: renderToString( {children}), className: 'custom-cluster-icon', - iconSize: [40, 40], - iconAnchor: [20, 20], + iconSize: [30, 30], + iconAnchor: [15, 15], }); }; @@ -186,8 +187,8 @@ const ClusteredMarkersGroup = React.memo(function ClusteredMarkersGroup({ const isSelected = selectedNodeId === node.node_id; const icon = L.divIcon({ className: 'custom-node-marker-container', - iconSize: [16, 32], - iconAnchor: [8, 8], + iconSize: [12, 24], + iconAnchor: [6, 6], html: renderToString( node.node_id)); + + // Filter connections to only show lines between nodes that are visible on the map + const visibleConnections = connections.filter(connection => + visibleNodeIds.has(connection.source_node) && visibleNodeIds.has(connection.target_node) + ); + + return ( + <> + {visibleConnections.map((connection) => { + const positions: [number, number][] = [ + [connection.source_latitude, connection.source_longitude], + [connection.target_latitude, connection.target_longitude] + ]; + + return ( + + ); + })} + + ); +} + interface MapViewProps { target?: '_blank' | '_self' | '_parent' | '_top'; } @@ -379,6 +422,9 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { // Neighbor-related state const [selectedNodeId, setSelectedNodeId] = useState(null); + const [showAllNeighbors, setShowAllNeighbors] = useState(false); + const [allNeighborConnections, setAllNeighborConnections] = useState([]); + const [allNeighborsLoading, setAllNeighborsLoading] = useState(false); // Use TanStack Query for neighbors data const { data: neighbors = [], isLoading: neighborsLoading } = useNeighbors({ @@ -416,13 +462,17 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { // Lines persist on mouseout and when hovering over same node }, [selectedNodeId]); - const fetchNodes = useCallback((bounds?: [[number, number], [number, number]]) => { + 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) { @@ -440,21 +490,52 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { if (config?.lastSeen !== null && config?.lastSeen !== undefined) { params.push(`lastSeen=${config.lastSeen}`); } + 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); } - if (fetchController.current === controller) setLoading(false); }) .catch((err) => { - if (err.name !== "AbortError") setNodePositions([]); - if (fetchController.current === controller) setLoading(false); + if (err.name !== "AbortError") { + setNodePositions([]); + setAllNeighborConnections([]); + } + if (fetchController.current === controller) { + setLoading(false); + setAllNeighborsLoading(false); + } }); }, [config?.nodeTypes, config?.lastSeen]); @@ -564,24 +645,47 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { useEffect(() => { fetchController.current?.abort(); // abort any in-flight request on effect cleanup if (bounds) { - fetchNodes(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, config?.nodeTypes, config?.lastSeen, fetchNodes]); + }, [bounds, config?.nodeTypes, config?.lastSeen, fetchNodes, showAllNeighbors]); return (
- {/* Only Refresh Button Row */} -
+ {/* Button Row */} +
+ bounds && fetchNodes(bounds)} + onClick={() => bounds && fetchNodes(bounds, showAllNeighbors)} loading={loading || !bounds} title="Refresh map nodes" ariaLabel="Refresh map nodes" @@ -625,6 +729,12 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { neighbors={neighbors} nodes={nodePositions} /> + {showAllNeighbors && ( + + )}
); diff --git a/src/hooks/useAllNeighbors.ts b/src/hooks/useAllNeighbors.ts new file mode 100644 index 0000000..5543389 --- /dev/null +++ b/src/hooks/useAllNeighbors.ts @@ -0,0 +1,74 @@ +import { useQuery } from '@tanstack/react-query'; +import { buildApiUrl } from '@/lib/api'; + +export interface AllNeighborsConnection { + source_node: string; + target_node: string; + source_name: string; + source_latitude: number; + source_longitude: number; + source_has_location: number; + target_name: string; + target_latitude: number; + target_longitude: number; + target_has_location: number; +} + +interface UseAllNeighborsParams { + minLat?: number | null; + maxLat?: number | null; + minLng?: number | null; + maxLng?: number | null; + nodeTypes?: string[]; + lastSeen?: number | null; + enabled?: boolean; +} + +export function useAllNeighbors({ + minLat, + maxLat, + minLng, + maxLng, + nodeTypes, + lastSeen, + enabled = true +}: UseAllNeighborsParams) { + return useQuery({ + queryKey: ['allNeighbors', minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen], + queryFn: async (): Promise => { + const params = new URLSearchParams(); + + if (minLat !== null && minLat !== undefined) { + params.append('minLat', minLat.toString()); + } + if (maxLat !== null && maxLat !== undefined) { + params.append('maxLat', maxLat.toString()); + } + if (minLng !== null && minLng !== undefined) { + params.append('minLng', minLng.toString()); + } + if (maxLng !== null && maxLng !== undefined) { + params.append('maxLng', maxLng.toString()); + } + if (nodeTypes && nodeTypes.length > 0) { + nodeTypes.forEach(type => params.append('nodeTypes', type)); + } + if (lastSeen !== null && lastSeen !== undefined) { + params.append('lastSeen', lastSeen.toString()); + } + + const url = `/api/neighbors/all${params.toString() ? `?${params.toString()}` : ''}`; + + const response = await fetch(buildApiUrl(url)); + if (!response.ok) { + throw new Error(`Failed to fetch all neighbors: ${response.statusText}`); + } + + return response.json(); + }, + enabled: enabled, + staleTime: 5 * 60 * 1000, // 5 minutes (shorter than individual neighbors since this is more expensive) + gcTime: 15 * 60 * 1000, // 15 minutes + }); +} + diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index 6563b54..886ab04 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -278,6 +278,144 @@ export async function getMeshcoreNodeInfo(publicKey: string, limit: number = 50) } } +export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat?: string | null, maxLat?: string | null, minLng?: string | null, maxLng?: string | null, nodeTypes?: string[]) { + try { + // Build where conditions for visible nodes + let visibleNodeWhereConditions = [ + "latitude IS NOT NULL", + "longitude IS NOT NULL" + ]; + const params: Record = {}; + + // Add location bounds for visible nodes + if (minLat !== null && minLat !== undefined && minLat !== "") { + visibleNodeWhereConditions.push("latitude >= {minLat:Float64}"); + params.minLat = Number(minLat); + } + if (maxLat !== null && maxLat !== undefined && maxLat !== "") { + visibleNodeWhereConditions.push("latitude <= {maxLat:Float64}"); + params.maxLat = Number(maxLat); + } + if (minLng !== null && minLng !== undefined && minLng !== "") { + visibleNodeWhereConditions.push("longitude >= {minLng:Float64}"); + params.minLng = Number(minLng); + } + if (maxLng !== null && maxLng !== undefined && maxLng !== "") { + visibleNodeWhereConditions.push("longitude <= {maxLng:Float64}"); + params.maxLng = Number(maxLng); + } + if (nodeTypes && nodeTypes.length > 0) { + visibleNodeWhereConditions.push("type IN {nodeTypes:Array(String)}"); + params.nodeTypes = nodeTypes; + } + if (lastSeen !== null && lastSeen !== undefined && lastSeen !== "") { + visibleNodeWhereConditions.push("last_seen >= now() - INTERVAL {lastSeen:UInt32} SECOND"); + params.lastSeen = Number(lastSeen); + } + + // Build where conditions for meshcore adverts + let meshcoreWhereConditions = []; + if (lastSeen !== null && lastSeen !== undefined && lastSeen !== "") { + meshcoreWhereConditions.push("ingest_timestamp >= now() - INTERVAL {lastSeen:UInt32} SECOND"); + } + + const meshcoreWhere = meshcoreWhereConditions.length > 0 ? `AND ${meshcoreWhereConditions.join(" AND ")}` : ''; + + const allNeighborsQuery = ` + WITH visible_nodes AS ( + -- Get only nodes visible on the current map view + SELECT + node_id, + name, + short_name, + latitude, + longitude, + last_seen, + first_seen, + type + FROM unified_latest_nodeinfo + WHERE ${visibleNodeWhereConditions.join(" AND ")} + ), + visible_node_details AS ( + -- Get latest attributes for visible nodes from meshcore_adverts + SELECT + public_key, + argMax(node_name, ingest_timestamp) as node_name, + argMax(latitude, ingest_timestamp) as latitude, + argMax(longitude, ingest_timestamp) as longitude, + argMax(has_location, ingest_timestamp) as has_location, + argMax(is_repeater, ingest_timestamp) as is_repeater, + argMax(is_chat_node, ingest_timestamp) as is_chat_node, + argMax(is_room_server, ingest_timestamp) as is_room_server, + argMax(has_name, ingest_timestamp) as has_name + FROM meshcore_adverts + WHERE public_key IN (SELECT node_id FROM visible_nodes) + ${meshcoreWhere} + GROUP BY public_key + ), + neighbor_connections AS ( + -- Get all direct connections (path_len = 0) but only between visible nodes + SELECT DISTINCT + hex(origin_pubkey) as source_node, + public_key as target_node + FROM meshcore_adverts + WHERE path_len = 0 + AND hex(origin_pubkey) != public_key + -- Only include connections where both nodes are visible + AND hex(origin_pubkey) IN (SELECT node_id FROM visible_nodes) + AND public_key IN (SELECT node_id FROM visible_nodes) + ${meshcoreWhere} + ) + SELECT + connections.source_node, + connections.target_node, + source_details.node_name as source_name, + source_details.latitude as source_latitude, + source_details.longitude as source_longitude, + source_details.has_location as source_has_location, + target_details.node_name as target_name, + target_details.latitude as target_latitude, + target_details.longitude as target_longitude, + target_details.has_location as target_has_location + FROM neighbor_connections AS connections + LEFT JOIN visible_node_details AS source_details ON connections.source_node = source_details.public_key + LEFT JOIN visible_node_details AS target_details ON connections.target_node = target_details.public_key + WHERE source_details.public_key IS NOT NULL + AND target_details.public_key IS NOT NULL + AND source_details.has_location = 1 + AND target_details.has_location = 1 + AND source_details.latitude IS NOT NULL + AND source_details.longitude IS NOT NULL + AND target_details.latitude IS NOT NULL + AND target_details.longitude IS NOT NULL + ORDER BY connections.source_node, connections.target_node + `; + + const neighborsResult = await clickhouse.query({ + query: allNeighborsQuery, + query_params: params, + format: 'JSONEachRow' + }); + const neighbors = await neighborsResult.json(); + + return neighbors as Array<{ + source_node: string; + target_node: string; + source_name: string; + source_latitude: number; + source_longitude: number; + source_has_location: number; + target_name: string; + target_latitude: number; + target_longitude: number; + target_has_location: number; + }>; + } catch (error) { + console.error('ClickHouse error in getAllNodeNeighbors:', error); + throw error; + } +} + export async function getMeshcoreNodeNeighbors(publicKey: string, lastSeen: string | null = null) { try { // Build base where conditions for both directions