forked from emre/www_projectmycelium_com
Merge remote-tracking branch 'origin/development_ehab' into development
This commit is contained in:
131
src/components/ui/DynamicMapContainer.tsx
Normal file
131
src/components/ui/DynamicMapContainer.tsx
Normal file
@@ -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<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;
|
||||
@@ -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<SVGSVGElement>(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 */}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox="0 0 800 400"
|
||||
className="w-full h-full absolute inset-0 pointer-events-none select-none"
|
||||
>
|
||||
{/* ✅ animated curved travel lines */}
|
||||
{/* Glowing path gradient DEFS */}
|
||||
<defs>
|
||||
<linearGradient id="path-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="black" stopOpacity="0" />
|
||||
<stop offset="5%" stopColor={lineColor} stopOpacity="1" />
|
||||
<stop offset="95%" stopColor={lineColor} stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="black" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* ✅ DYNAMIC STANDALONE NODE DOTS (New Section) */}
|
||||
{nodes.map((node, i) => {
|
||||
const p = projectPoint(node.lat, node.lng);
|
||||
const dotColor = node.color || lineColor;
|
||||
|
||||
return (
|
||||
<g key={`node-${i}`}>
|
||||
{/* Outer pulsing circle */}
|
||||
<circle cx={p.x} cy={p.y} r="2" fill={dotColor} opacity="0.5">
|
||||
<animate
|
||||
attributeName="r"
|
||||
from="2"
|
||||
to="7"
|
||||
dur="1.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
from="0.6"
|
||||
to="0"
|
||||
dur="1.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
{/* Inner fixed circle */}
|
||||
<circle cx={p.x} cy={p.y} r="2" fill={dotColor} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ✅ 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 */}
|
||||
<defs>
|
||||
<linearGradient id="path-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="black" stopOpacity="0" />
|
||||
<stop offset="5%" stopColor={lineColor} stopOpacity="1" />
|
||||
<stop offset="95%" stopColor={lineColor} stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="black" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* ✅ 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({
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<StatKey, string>;
|
||||
|
||||
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<StatsData>(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 (
|
||||
<div className="bg-[#121212] w-full">
|
||||
{/* ✅ Top horizontal line with spacing */}
|
||||
<div className="max-w-7xl bg-transparent mx-auto py-6 border border-t-0 border-b-0 border-gray-800"></div>
|
||||
<div className="w-full border-t border-l border-r border-gray-800" />
|
||||
|
||||
<div className="max-w-7xl mx-auto text-center pt-12 border border-t-0 border-b-0 border-gray-800">
|
||||
<div className="max-w-7xl mx-auto text-center pt-12 border border-t-0 border-b-0 border-gray-800 px-4">
|
||||
<Eyebrow>PROJECT MYCELIUM IS LIVE. </Eyebrow>
|
||||
<H3 className="text-white">Host a Node, Grow the Network</H3>
|
||||
<P className="text-sm md:text-lg text-gray-200 max-w-3xl mx-auto py-4">
|
||||
@@ -32,37 +112,28 @@ Configure it once. Your node takes over from there.
|
||||
<div className="max-w-7xl mx-auto border border-t-0 border-b-0 border-gray-800 ">
|
||||
{/* ✅ Match same side margins */}
|
||||
<div className="max-w-5xl mx-auto px-6 ">
|
||||
<WorldMap
|
||||
dots={[
|
||||
{ start: { lat: 64.2008, lng: -149.4937 }, end: { lat: 34.0522, lng: -118.2437 } }, // Alaska → LA
|
||||
{ start: { lat: 64.2008, lng: -149.4937 }, end: { lat: -15.7975, lng: -47.8919 } }, // Alaska → Brasília
|
||||
{ start: { lat: -15.7975, lng: -47.8919 }, end: { lat: 38.7223, lng: -9.1393 } }, // Brasília → Lisbon
|
||||
{ start: { lat: 51.5074, lng: -0.1278 }, end: { lat: 28.6139, lng: 77.209 } }, // London → New Delhi
|
||||
{ start: { lat: 28.6139, lng: 77.209 }, end: { lat: 43.1332, lng: 131.9113 } }, // New Delhi → Vladivostok
|
||||
{ start: { lat: 28.6139, lng: 77.209 }, end: { lat: -1.2921, lng: 36.8219 } }, // New Delhi → Nairobi
|
||||
]}
|
||||
/>
|
||||
<DynamicMapContainer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8 border border-t-0 border-b-0 border-gray-800 pb-12">
|
||||
|
||||
<dl className="pt-6 grid grid-cols-1 gap-0.5 overflow-hidden rounded-md text-center sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
{STAT_CARDS.map(({ key, title, description }) => (
|
||||
<div
|
||||
key={stat.id}
|
||||
key={key}
|
||||
className="flex flex-col bg-white/1 p-8"
|
||||
>
|
||||
<dt className="text-sm/6 font-semibold text-gray-300">
|
||||
{stat.name}
|
||||
{title}
|
||||
</dt>
|
||||
|
||||
<dd className="order-first text-3xl font-semibold tracking-tight text-white">
|
||||
{stat.value}
|
||||
{isLoading ? "…" : stats[key]}
|
||||
</dd>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
{stat.description}
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user