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