all neighbors

This commit is contained in:
ajvpot
2025-09-15 04:14:50 +02:00
parent 7addbb3165
commit 9f1056095b
7 changed files with 428 additions and 47 deletions

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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;

View File

@@ -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 (
<div style={{
position: "relative",
width: "40px",
height: "40px",
width: "30px",
height: "30px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
background: "transparent"
}}>
<svg width="40" height="40" viewBox="0 0 40 40" style={{
<svg width="30" height="30" viewBox="0 0 30 30" style={{
borderRadius: "50%",
background: "transparent"
}}>
<circle
r="18"
cx="20"
cy="20"
r="13.5"
cx="15"
cy="15"
fill="#fff"
stroke="#fff"
strokeWidth="4"
strokeWidth="3"
opacity="0.7"
/>
<circle
r="18"
cx="20"
cy="20"
r="13.5"
cx="15"
cy="15"
fill="transparent"
stroke="#2563eb"
strokeWidth="36"
strokeWidth="27"
strokeDasharray={`${meshcoreArc} ${c - meshcoreArc}`}
strokeDashoffset="0"
transform="rotate(-90 20 20)"
transform="rotate(-90 15 15)"
opacity="0.7"
/>
<circle
r="18"
cx="20"
cy="20"
r="13.5"
cx="15"
cy="15"
fill="transparent"
stroke="#22c55e"
strokeWidth="36"
strokeWidth="27"
strokeDasharray={`${meshtasticArc} ${c - meshtasticArc}`}
strokeDashoffset={`-${meshcoreArc}`}
transform="rotate(-90 20 20)"
transform="rotate(-90 15 15)"
opacity="0.7"
/>
</svg>
@@ -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",

View File

@@ -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(
<NodeMarker
node={node}
@@ -109,8 +110,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(
<NodeMarker
node={node}
@@ -172,8 +173,8 @@ const ClusteredMarkersGroup = React.memo(function ClusteredMarkersGroup({
return L.divIcon({
html: renderToString(<ClusterMarker>{children}</ClusterMarker>),
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(
<NodeMarker
node={node}
@@ -353,6 +354,48 @@ function NeighborLines({
);
}
// Component to render all neighbor lines for all nodes
function AllNeighborLines({
connections,
nodes
}: {
connections: AllNeighborsConnection[];
nodes: NodePosition[];
}) {
if (connections.length === 0) return null;
// Create a set of visible node IDs for quick lookup
const visibleNodeIds = new Set(nodes.map(node => 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 (
<Polyline
key={`${connection.source_node}-${connection.target_node}`}
positions={positions}
pathOptions={{
color: '#8b5cf6', // Purple color for all-neighbors view
weight: 1,
opacity: 0.5,
}}
/>
);
})}
</>
);
}
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<string | null>(null);
const [showAllNeighbors, setShowAllNeighbors] = useState<boolean>(false);
const [allNeighborConnections, setAllNeighborConnections] = useState<AllNeighborsConnection[]>([]);
const [allNeighborsLoading, setAllNeighborsLoading] = useState<boolean>(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 (
<div style={{ width: "100%", height: "100%", position: "relative" }}>
{/* Only Refresh Button Row */}
<div style={{ position: "absolute", top: 16, right: 16, zIndex: 1000, display: 'flex', alignItems: 'center' }}>
{/* 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>
<RefreshButton
onClick={() => 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 && (
<AllNeighborLines
connections={allNeighborConnections}
nodes={nodePositions}
/>
)}
</MapContainer>
</div>
);

View File

@@ -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<AllNeighborsConnection[]> => {
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
});
}

View File

@@ -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<string, any> = {};
// 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