forked from emre/www_projectmycelium_com
131 lines
3.6 KiB
TypeScript
131 lines
3.6 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import WorldMap from './world-map';
|
|
import { motion } from 'framer-motion';
|
|
|
|
// Interface for the simplified data passed to WorldMap
|
|
interface GeoNode {
|
|
lat: number;
|
|
lng: number;
|
|
label?: string;
|
|
color?: string;
|
|
}
|
|
|
|
// Interface for the raw data structure expected from the gridproxy API
|
|
interface RawNode {
|
|
node_id: number;
|
|
location: {
|
|
latitude: string; // API often returns these as strings
|
|
longitude: string; // API often returns these as strings
|
|
city: string;
|
|
country: string;
|
|
};
|
|
// ... other raw fields you don't need
|
|
}
|
|
|
|
const clusterNodes = (nodeList: GeoNode[], cellSize = 2) => {
|
|
const buckets = new Map<
|
|
string,
|
|
{ latSum: number; lngSum: number; count: number }
|
|
>();
|
|
|
|
nodeList.forEach((node) => {
|
|
const latBucket = Math.round(node.lat / cellSize) * cellSize;
|
|
const lngBucket = Math.round(node.lng / cellSize) * cellSize;
|
|
const key = `${latBucket}|${lngBucket}`;
|
|
|
|
const bucket = buckets.get(key);
|
|
if (bucket) {
|
|
bucket.latSum += node.lat;
|
|
bucket.lngSum += node.lng;
|
|
bucket.count += 1;
|
|
} else {
|
|
buckets.set(key, {
|
|
latSum: node.lat,
|
|
lngSum: node.lng,
|
|
count: 1,
|
|
});
|
|
}
|
|
});
|
|
|
|
return Array.from(buckets.values()).map((bucket) => {
|
|
const avgLat = bucket.latSum / bucket.count;
|
|
const avgLng = bucket.lngSum / bucket.count;
|
|
const count = bucket.count;
|
|
|
|
let color = "#06b6d4";
|
|
if (count > 20) {
|
|
color = "#0891b2";
|
|
} else if (count > 5) {
|
|
color = "#22d3ee";
|
|
}
|
|
|
|
return {
|
|
lat: avgLat,
|
|
lng: avgLng,
|
|
color,
|
|
label: `${count} nodes`,
|
|
};
|
|
});
|
|
};
|
|
|
|
function DynamicMapContainer() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [nodes, setNodes] = useState<GeoNode[]>([]);
|
|
const API_URL = "https://gridproxy.grid.tf/nodes?healthy=true&size=99999";
|
|
|
|
useEffect(() => {
|
|
async function fetchNodeData() {
|
|
try {
|
|
const response = await fetch(API_URL);
|
|
const data: RawNode[] = await response.json(); // Type the incoming data
|
|
|
|
// 🚨 Map the API response to your component's expected GeoNode format
|
|
const geoNodes: GeoNode[] = data
|
|
.filter((node: RawNode) => node.location && node.location.latitude && node.location.longitude)
|
|
.map((node: RawNode) => ({
|
|
// Convert string coordinates to numbers
|
|
lat: parseFloat(node.location.latitude),
|
|
lng: parseFloat(node.location.longitude),
|
|
label: `${node.location.city}, ${node.location.country} (${node.node_id})`,
|
|
// Optionally set color based on some node property if available
|
|
}));
|
|
|
|
const clusteredNodes = clusterNodes(geoNodes);
|
|
setNodes(clusteredNodes);
|
|
setLoading(false);
|
|
} catch (error) {
|
|
console.error("Failed to fetch node data:", error);
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
fetchNodeData();
|
|
}, []);
|
|
|
|
// --- RENDERING ---
|
|
|
|
if (loading) {
|
|
// Show a loading state while data is being fetched
|
|
return (
|
|
<div className="flex justify-center items-center w-full aspect-[2/1] bg-[#111111] rounded-lg text-cyan-500">
|
|
<motion.span
|
|
animate={{ rotate: 360 }}
|
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
|
className="text-4xl"
|
|
>
|
|
🌎
|
|
</motion.span>
|
|
<p className="ml-4">Loading nodes...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Pass the dynamically fetched nodes to your WorldMap component
|
|
return (
|
|
<WorldMap
|
|
nodes={nodes}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default DynamicMapContainer; |