diff --git a/src/components/ui/DynamicMapContainer.tsx b/src/components/ui/DynamicMapContainer.tsx new file mode 100644 index 0000000..4ea6596 --- /dev/null +++ b/src/components/ui/DynamicMapContainer.tsx @@ -0,0 +1,131 @@ +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([]); + 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 ( +
+ + 🌎 + +

Loading nodes...

+
+ ); + } + + // Pass the dynamically fetched nodes to your WorldMap component + return ( + + ); +} + +export default DynamicMapContainer; \ No newline at end of file diff --git a/src/components/ui/world-map.tsx b/src/components/ui/world-map.tsx index 64e30c8..41ce284 100644 --- a/src/components/ui/world-map.tsx +++ b/src/components/ui/world-map.tsx @@ -1,7 +1,7 @@ "use client"; import { useRef } from "react"; -import { motion } from "motion/react"; +import { motion } from "framer-motion"; import DottedMap from "dotted-map"; interface MapProps { @@ -9,34 +9,39 @@ interface MapProps { start: { lat: number; lng: number; label?: string }; end: { lat: number; lng: number; label?: string }; }>; + // New prop for dynamic standalone nodes + nodes?: Array<{ lat: number; lng: number; label?: string; color?: string }>; lineColor?: string; } export default function WorldMap({ dots = [], - lineColor = "#06b6d4", // cyan-500 + nodes = [], + lineColor = "#06b6d4", }: MapProps) { const svgRef = useRef(null); - // βœ… Force dark-dotted map theme + // βœ… Force dark-dotted map theme const map = new DottedMap({ height: 100, grid: "diagonal" }); const svgMap = map.getSVG({ radius: 0.22, - color: "#06b6d480", // cyan-500 at 50% opacity + color: "#06b6d480", shape: "circle", backgroundColor: "#111111", }); - // βœ… Point projection stays the same + // βœ… Point projection stays the same + // Projects lat/lng to the SVG's 800x400 viewBox coordinates const projectPoint = (lat: number, lng: number) => { const x = (lng + 180) * (800 / 360); - const y = (90 - lat) * (400 / 180); + const y = (90 - lat) * (400 / 180) + 45; return { x, y }; }; const createCurvedPath = (start: any, end: any) => { const midX = (start.x + end.x) / 2; - const midY = Math.min(start.y, end.y) - 50; + // Creates an arc that bows upward by 50 units + const midY = Math.min(start.y, end.y) - 50; return `M ${start.x} ${start.y} Q ${midX} ${midY} ${end.x} ${end.y}`; }; @@ -49,13 +54,53 @@ export default function WorldMap({ draggable={false} /> - {/* βœ… Lines + points */} + {/* βœ… Lines + points + new standalone nodes */} - {/* βœ… animated curved travel lines */} + {/* Glowing path gradient DEFS */} + + + + + + + + + + {/* βœ… DYNAMIC STANDALONE NODE DOTS (New Section) */} + {nodes.map((node, i) => { + const p = projectPoint(node.lat, node.lng); + const dotColor = node.color || lineColor; + + return ( + + {/* Outer pulsing circle */} + + + + + {/* Inner fixed circle */} + + + ); + })} + + {/* βœ… Animated curved travel lines (Existing Logic) */} {dots.map((dot, i) => { const startPoint = projectPoint(dot.start.lat, dot.start.lng); const endPoint = projectPoint(dot.end.lat, dot.end.lng); @@ -78,17 +123,7 @@ export default function WorldMap({ ); })} - {/* βœ… glowing path gradient */} - - - - - - - - - - {/* βœ… start & end points with pulsing cyan glow */} + {/* βœ… Start & end points with pulsing cyan glow (Existing Logic) */} {dots.map((dot, i) => { const s = projectPoint(dot.start.lat, dot.start.lng); const e = projectPoint(dot.end.lat, dot.end.lng); @@ -122,4 +157,4 @@ export default function WorldMap({ ); -} +} \ No newline at end of file diff --git a/src/pages/home/HomeMap.tsx b/src/pages/home/HomeMap.tsx index c4ea97b..5770c30 100644 --- a/src/pages/home/HomeMap.tsx +++ b/src/pages/home/HomeMap.tsx @@ -1,22 +1,102 @@ "use client"; -import WorldMap from "@/components/ui/world-map"; +import { useEffect, useState } from "react"; +import DynamicMapContainer from "@/components/ui/DynamicMapContainer"; import { Eyebrow, H3, P } from "@/components/Texts"; -const stats = [ - { id: 1, name: 'CORES', value: '54,958', description: 'Total Central Processing Unit cores available on the grid.' }, - { id: 2, name: 'NODES', value: '1,493', description: 'Total number of nodes on the grid.' }, - { id: 3, name: 'SSD CAPACITY', value: '5,388,956', description: 'Total GB of storage (SSD, HDD, & RAM) on the grid.' }, - { id: 4, name: 'COUNTRIES', value: '44', description: 'Total number of countries with active nodes.' }, -] +type StatKey = "cores" | "nodes" | "ssd" | "countries"; + +type StatsData = Record; + +const STAT_API_URL = "https://stats.grid.tf/api/stats-summary"; + +const DEFAULT_STATS: StatsData = { + cores: "31,669", + nodes: "1157", + ssd: "4,199,303", + countries: "41", +}; + +const STAT_CARDS: Array<{ key: StatKey; title: string; description: string }> = [ + { + key: "ssd", + title: "SSD CAPACITY", + description: "Total GB of storage (SSD, HDD, & RAM) on the grid.", + }, + { + key: "cores", + title: "CORES", + description: "Total Central Processing Unit cores available on the grid.", + }, + { + key: "nodes", + title: "NODES", + description: "Total number of nodes on the grid.", + }, + + { + key: "countries", + title: "COUNTRIES", + description: "Total number of countries with active nodes.", + }, +]; export function HomeMap() { + const [stats, setStats] = useState(DEFAULT_STATS); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + const formatValue = (value: unknown, fallback: string) => { + if (typeof value === "number") { + return value.toLocaleString(); + } + if (typeof value === "string" && value.trim().length) { + const numeric = Number(value); + return Number.isNaN(numeric) ? value : numeric.toLocaleString(); + } + return fallback; + }; + + async function fetchStats() { + try { + const response = await fetch(STAT_API_URL); + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + const data = await response.json(); + + if (!isMounted) return; + + setStats({ + cores: formatValue(data?.cores, DEFAULT_STATS.cores), + nodes: formatValue(data?.nodes, DEFAULT_STATS.nodes), + ssd: formatValue(data?.ssd, DEFAULT_STATS.ssd), + countries: formatValue(data?.countries, DEFAULT_STATS.countries), + }); + } catch (error) { + console.error("[HomeMap] Failed to load stats", error); + } finally { + if (isMounted) { + setIsLoading(false); + } + } + } + + fetchStats(); + + return () => { + isMounted = false; + }; + }, []); + return (
{/* βœ… Top horizontal line with spacing */}
-
+
PROJECT MYCELIUM IS LIVE.

Host a Node, Grow the Network

@@ -32,37 +112,28 @@ Configure it once. Your node takes over from there.

{/* βœ… Match same side margins */}
- +
- {stats.map((stat) => ( + {STAT_CARDS.map(({ key, title, description }) => (
- {stat.name} + {title}
- {stat.value} + {isLoading ? "…" : stats[key]}

- {stat.description} + {description}

))}