Merge remote-tracking branch 'origin/development_ehab' into development

This commit is contained in:
2025-11-19 13:10:57 +01:00
3 changed files with 281 additions and 44 deletions

View 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;

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRef } from "react"; import { useRef } from "react";
import { motion } from "motion/react"; import { motion } from "framer-motion";
import DottedMap from "dotted-map"; import DottedMap from "dotted-map";
interface MapProps { interface MapProps {
@@ -9,33 +9,38 @@ interface MapProps {
start: { lat: number; lng: number; label?: string }; start: { lat: number; lng: number; label?: string };
end: { 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; lineColor?: string;
} }
export default function WorldMap({ export default function WorldMap({
dots = [], dots = [],
lineColor = "#06b6d4", // cyan-500 nodes = [],
lineColor = "#06b6d4",
}: MapProps) { }: MapProps) {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
// ✅ Force dark-dotted map theme // ✅ Force dark-dotted map theme
const map = new DottedMap({ height: 100, grid: "diagonal" }); const map = new DottedMap({ height: 100, grid: "diagonal" });
const svgMap = map.getSVG({ const svgMap = map.getSVG({
radius: 0.22, radius: 0.22,
color: "#06b6d480", // cyan-500 at 50% opacity color: "#06b6d480",
shape: "circle", shape: "circle",
backgroundColor: "#111111", 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 projectPoint = (lat: number, lng: number) => {
const x = (lng + 180) * (800 / 360); const x = (lng + 180) * (800 / 360);
const y = (90 - lat) * (400 / 180); const y = (90 - lat) * (400 / 180) + 45;
return { x, y }; return { x, y };
}; };
const createCurvedPath = (start: any, end: any) => { const createCurvedPath = (start: any, end: any) => {
const midX = (start.x + end.x) / 2; const midX = (start.x + end.x) / 2;
// Creates an arc that bows upward by 50 units
const midY = Math.min(start.y, end.y) - 50; const midY = Math.min(start.y, end.y) - 50;
return `M ${start.x} ${start.y} Q ${midX} ${midY} ${end.x} ${end.y}`; return `M ${start.x} ${start.y} Q ${midX} ${midY} ${end.x} ${end.y}`;
}; };
@@ -49,13 +54,53 @@ export default function WorldMap({
draggable={false} draggable={false}
/> />
{/* ✅ Lines + points */} {/* ✅ Lines + points + new standalone nodes */}
<svg <svg
ref={svgRef} ref={svgRef}
viewBox="0 0 800 400" viewBox="0 0 800 400"
className="w-full h-full absolute inset-0 pointer-events-none select-none" 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) => { {dots.map((dot, i) => {
const startPoint = projectPoint(dot.start.lat, dot.start.lng); const startPoint = projectPoint(dot.start.lat, dot.start.lng);
const endPoint = projectPoint(dot.end.lat, dot.end.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 (Existing Logic) */}
<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 */}
{dots.map((dot, i) => { {dots.map((dot, i) => {
const s = projectPoint(dot.start.lat, dot.start.lng); const s = projectPoint(dot.start.lat, dot.start.lng);
const e = projectPoint(dot.end.lat, dot.end.lng); const e = projectPoint(dot.end.lat, dot.end.lng);

View File

@@ -1,22 +1,102 @@
"use client"; "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"; import { Eyebrow, H3, P } from "@/components/Texts";
const stats = [ type StatKey = "cores" | "nodes" | "ssd" | "countries";
{ 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.' }, type StatsData = Record<StatKey, string>;
{ 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.' }, 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() { 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 ( return (
<div className="bg-[#121212] w-full"> <div className="bg-[#121212] w-full">
{/* ✅ Top horizontal line with spacing */} {/* ✅ 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="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="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> <Eyebrow>PROJECT MYCELIUM IS LIVE. </Eyebrow>
<H3 className="text-white">Host a Node, Grow the Network</H3> <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"> <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 "> <div className="max-w-7xl mx-auto border border-t-0 border-b-0 border-gray-800 ">
{/* ✅ Match same side margins */} {/* ✅ Match same side margins */}
<div className="max-w-5xl mx-auto px-6 "> <div className="max-w-5xl mx-auto px-6 ">
<WorldMap <DynamicMapContainer />
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
]}
/>
</div> </div>
</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"> <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"> <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 <div
key={stat.id} key={key}
className="flex flex-col bg-white/1 p-8" className="flex flex-col bg-white/1 p-8"
> >
<dt className="text-sm/6 font-semibold text-gray-300"> <dt className="text-sm/6 font-semibold text-gray-300">
{stat.name} {title}
</dt> </dt>
<dd className="order-first text-3xl font-semibold tracking-tight text-white"> <dd className="order-first text-3xl font-semibold tracking-tight text-white">
{stat.value} {isLoading ? "…" : stats[key]}
</dd> </dd>
<p className="mt-2 text-sm text-gray-400"> <p className="mt-2 text-sm text-gray-400">
{stat.description} {description}
</p> </p>
</div> </div>
))} ))}