mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-03-28 17:42:58 +01:00
all neighbors
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/neighbors/all/route.ts
Normal file
34
src/app/api/neighbors/all/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
74
src/hooks/useAllNeighbors.ts
Normal file
74
src/hooks/useAllNeighbors.ts
Normal 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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user