diff --git a/src/app/api/map/route.ts b/src/app/api/map/route.ts index f031a33..d5277c6 100644 --- a/src/app/api/map/route.ts +++ b/src/app/api/map/route.ts @@ -10,12 +10,13 @@ export async function GET(req: Request) { const maxLng = searchParams.get("maxLng"); const nodeTypes = searchParams.getAll("nodeTypes"); const lastSeen = searchParams.get("lastSeen"); + const region = searchParams.get("region"); 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); + const neighbors = await getAllNodeNeighbors(lastSeen, minLat, maxLat, minLng, maxLng, nodeTypes, region || undefined); return NextResponse.json({ nodes: positions, neighbors: neighbors diff --git a/src/app/api/neighbors/all/route.ts b/src/app/api/neighbors/all/route.ts index 90e3d10..3c1195a 100644 --- a/src/app/api/neighbors/all/route.ts +++ b/src/app/api/neighbors/all/route.ts @@ -10,8 +10,9 @@ export async function GET(req: Request) { const maxLng = searchParams.get("maxLng"); const nodeTypes = searchParams.getAll("nodeTypes"); const lastSeen = searchParams.get("lastSeen"); + const region = searchParams.get("region"); - const neighbors = await getAllNodeNeighbors(lastSeen, minLat, maxLat, minLng, maxLng, nodeTypes); + const neighbors = await getAllNodeNeighbors(lastSeen, minLat, maxLat, minLng, maxLng, nodeTypes, region || undefined); return NextResponse.json(neighbors); } catch (error) { diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index de014b6..bf3ad03 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -372,6 +372,42 @@ function AllNeighborLines({ visibleNodeIds.has(connection.source_node) && visibleNodeIds.has(connection.target_node) ); + // Calculate logarithmic thresholds based on packet counts for path connections + const pathConnections = visibleConnections.filter(conn => conn.connection_type === 'path'); + const packetCounts = pathConnections.map(conn => conn.packet_count).sort((a, b) => a - b); + + const getLogThresholds = (counts: number[]) => { + if (counts.length === 0) return { min: 1, t1: 1, t2: 1, t3: 1, t4: 1, max: 1 }; + + const min = Math.max(1, counts[0]); // Ensure minimum is at least 1 for log calculation + const max = counts[counts.length - 1]; + + if (min === max) { + return { min, t1: min, t2: min, t3: min, t4: min, max }; + } + + // Use logarithmic scale to create thresholds + const logMin = Math.log10(min); + const logMax = Math.log10(max); + const logRange = logMax - logMin; + + const t1 = Math.pow(10, logMin + logRange * 0.2); + const t2 = Math.pow(10, logMin + logRange * 0.4); + const t3 = Math.pow(10, logMin + logRange * 0.6); + const t4 = Math.pow(10, logMin + logRange * 0.8); + + return { + min, + t1: Math.round(t1), + t2: Math.round(t2), + t3: Math.round(t3), + t4: Math.round(t4), + max + }; + }; + + const thresholds = getLogThresholds(packetCounts); + return ( <> {visibleConnections.map((connection) => { @@ -380,14 +416,34 @@ function AllNeighborLines({ [connection.target_latitude, connection.target_longitude] ]; + // Different colors based on connection type and logarithmic packet count + const getConnectionColor = (connectionType: string, packetCount: number) => { + if (connectionType === 'direct') { + return '#8b5cf6'; // Purple for direct connections + } + + // For path connections, use logarithmic thresholds for color intensity + if (packetCount >= thresholds.t4) return '#dc2626'; // Red for highest log range + if (packetCount >= thresholds.t3) return '#ea580c'; // Dark orange + if (packetCount >= thresholds.t2) return '#f59e0b'; // Orange + if (packetCount >= thresholds.t1) return '#eab308'; // Yellow + if (packetCount > thresholds.min) return '#84cc16'; // Light green for above minimum + return '#6b7280'; // Gray for minimum traffic + }; + + const lineColor = getConnectionColor(connection.connection_type, connection.packet_count); + + // Consistent line weight for all connections + const lineWeight = connection.connection_type === 'direct' ? 2 : 1; + return ( ); @@ -490,6 +546,9 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { if (config?.lastSeen !== null && config?.lastSeen !== undefined) { params.push(`lastSeen=${config.lastSeen}`); } + if (config?.selectedRegion) { + params.push(`region=${encodeURIComponent(config.selectedRegion)}`); + } if (includeNeighbors) { params.push('includeNeighbors=true'); } @@ -656,7 +715,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { return () => { fetchController.current?.abort(); }; - }, [bounds, config?.nodeTypes, config?.lastSeen, fetchNodes, showAllNeighbors]); + }, [bounds, config?.nodeTypes, config?.lastSeen, config?.selectedRegion, fetchNodes, showAllNeighbors]); return (
@@ -736,6 +795,83 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { /> )} + + {/* Traffic Legend */} + {showAllNeighbors && 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); + const legendThresholds = packetCounts.length > 0 ? (() => { + const min = Math.max(1, packetCounts[0]); + const max = packetCounts[packetCounts.length - 1]; + + if (min === max) { + return { min, t1: min, t2: min, t3: min, t4: min, max }; + } + + const logMin = Math.log10(min); + const logMax = Math.log10(max); + const logRange = logMax - logMin; + + return { + min, + t1: Math.round(Math.pow(10, logMin + logRange * 0.2)), + t2: Math.round(Math.pow(10, logMin + logRange * 0.4)), + t3: Math.round(Math.pow(10, logMin + logRange * 0.6)), + t4: Math.round(Math.pow(10, logMin + logRange * 0.8)), + max + }; + })() : null; + + return legendThresholds && ( +
+
+ Path Traffic - Log Scale ({pathConnections.length} connections) +
+
+
+
+ High: {legendThresholds.t4}+ packets +
+
+
+ Med-High: {legendThresholds.t3}-{legendThresholds.t4 - 1} +
+
+
+ Medium: {legendThresholds.t2}-{legendThresholds.t3 - 1} +
+
+
+ Low-Med: {legendThresholds.t1}-{legendThresholds.t2 - 1} +
+
+
+ Low: {legendThresholds.min + 1}-{legendThresholds.t1 - 1} +
+
+
+ Minimal: {legendThresholds.min} +
+
+
+ Direct connections +
+
+
+ ); + })()}
); } \ No newline at end of file diff --git a/src/hooks/useAllNeighbors.ts b/src/hooks/useAllNeighbors.ts index 5543389..0eb5032 100644 --- a/src/hooks/useAllNeighbors.ts +++ b/src/hooks/useAllNeighbors.ts @@ -4,6 +4,8 @@ import { buildApiUrl } from '@/lib/api'; export interface AllNeighborsConnection { source_node: string; target_node: string; + connection_type: string; + packet_count: number; source_name: string; source_latitude: number; source_longitude: number; @@ -21,6 +23,7 @@ interface UseAllNeighborsParams { maxLng?: number | null; nodeTypes?: string[]; lastSeen?: number | null; + region?: string; enabled?: boolean; } @@ -31,10 +34,11 @@ export function useAllNeighbors({ maxLng, nodeTypes, lastSeen, + region, enabled = true }: UseAllNeighborsParams) { return useQuery({ - queryKey: ['allNeighbors', minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen], + queryKey: ['allNeighbors', minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen, region], queryFn: async (): Promise => { const params = new URLSearchParams(); @@ -56,6 +60,9 @@ export function useAllNeighbors({ if (lastSeen !== null && lastSeen !== undefined) { params.append('lastSeen', lastSeen.toString()); } + if (region) { + params.append('region', region); + } const url = `/api/neighbors/all${params.toString() ? `?${params.toString()}` : ''}`; diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index 886ab04..a52d1ba 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -278,7 +278,7 @@ 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[]) { +export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat?: string | null, maxLat?: string | null, minLng?: string | null, maxLng?: string | null, nodeTypes?: string[], region?: string) { try { // Build where conditions for visible nodes let visibleNodeWhereConditions = [ @@ -321,6 +321,10 @@ export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat const meshcoreWhere = meshcoreWhereConditions.length > 0 ? `AND ${meshcoreWhereConditions.join(" AND ")}` : ''; + // Build region filtering for meshcore_packets + const regionFilter = generateRegionWhereClause(region); + const packetsRegionWhere = regionFilter.whereClause ? `AND ${regionFilter.whereClause}` : ''; + const allNeighborsQuery = ` WITH visible_nodes AS ( -- Get only nodes visible on the current map view @@ -353,11 +357,28 @@ export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat ${meshcoreWhere} GROUP BY public_key ), - neighbor_connections AS ( + repeater_prefixes AS ( + -- Get repeater prefixes info, excluding collisions (multiple repeaters per prefix) + -- Only include repeaters from the selected region + SELECT + substring(public_key, 1, 2) as prefix, + count() as node_count, + any(public_key) as representative_key, + any(node_name) as representative_name + FROM meshcore_adverts_latest + WHERE is_repeater = 1 + AND last_seen >= now() - INTERVAL 2 DAY + ${regionFilter.whereClause ? `AND ${regionFilter.whereClause}` : ''} + GROUP BY prefix + HAVING node_count = 1 -- Only include prefixes with exactly one repeater + ), + direct_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 + public_key as target_node, + 'direct' as connection_type, + 1 as packet_count -- Direct connections don't have packet counts, use 1 as default FROM meshcore_adverts WHERE path_len = 0 AND hex(origin_pubkey) != public_key @@ -365,10 +386,82 @@ export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat AND hex(origin_pubkey) IN (SELECT node_id FROM visible_nodes) AND public_key IN (SELECT node_id FROM visible_nodes) ${meshcoreWhere} + ), + path_neighbors AS ( + -- Extract neighbors from routing paths with packet counts + SELECT + source_prefix, + target_prefix, + 'path' as connection_type, + count() as packet_count + FROM ( + SELECT + upper(hex(substring(path, i, 1))) as source_prefix, + upper(hex(substring(path, i + 1, 1))) as target_prefix + FROM ( + SELECT + path, + path_len + FROM meshcore_packets + WHERE path_len >= 2 + AND ingest_timestamp >= now() - INTERVAL 1 DAY + ${packetsRegionWhere} + ) p + ARRAY JOIN range(1, path_len) as i + WHERE i < path_len + ) path_pairs + WHERE source_prefix IN (SELECT prefix FROM repeater_prefixes) + AND target_prefix IN (SELECT prefix FROM repeater_prefixes) + AND source_prefix != target_prefix + GROUP BY source_prefix, target_prefix + ), + prefix_to_key_map AS ( + -- Map prefixes back to full public keys for visible nodes + SELECT + rp.prefix, + rp.representative_key as public_key, + rp.representative_name as node_name + FROM repeater_prefixes rp + WHERE rp.representative_key IN (SELECT node_id FROM visible_nodes) + ), + path_connections AS ( + -- Convert prefix-based path neighbors to public key connections + -- Include all path connections (no exclusion of direct connections) + SELECT + source_map.public_key as source_node, + target_map.public_key as target_node, + 'path' as connection_type, + pn.packet_count + FROM path_neighbors pn + JOIN prefix_to_key_map source_map ON pn.source_prefix = source_map.prefix + JOIN prefix_to_key_map target_map ON pn.target_prefix = target_map.prefix + ), + direct_connections_filtered AS ( + -- Get direct connections but exclude pairs that already have path connections + SELECT + source_node, + target_node, + connection_type, + packet_count + FROM direct_connections + WHERE (source_node, target_node) NOT IN ( + SELECT source_node, target_node FROM path_connections + ) + AND (target_node, source_node) NOT IN ( + SELECT source_node, target_node FROM path_connections + ) + ), + neighbor_connections AS ( + -- Combine path connections and filtered direct connections (path connections take precedence) + SELECT source_node, target_node, connection_type, packet_count FROM path_connections + UNION ALL + SELECT source_node, target_node, connection_type, packet_count FROM direct_connections_filtered ) SELECT connections.source_node, connections.target_node, + connections.connection_type, + connections.packet_count, source_details.node_name as source_name, source_details.latitude as source_latitude, source_details.longitude as source_longitude, @@ -388,7 +481,7 @@ export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat 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 + ORDER BY connections.connection_type, connections.source_node, connections.target_node `; const neighborsResult = await clickhouse.query({ @@ -401,6 +494,8 @@ export async function getAllNodeNeighbors(lastSeen: string | null = null, minLat return neighbors as Array<{ source_node: string; target_node: string; + connection_type: string; + packet_count: number; source_name: string; source_latitude: number; source_longitude: number;