310 lines
16 KiB
TypeScript
310 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, Suspense, useEffect } from "react";
|
|
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
|
|
import { OrbitControls, QuadraticBezierLine } from "@react-three/drei";
|
|
import * as THREE from "three";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { MapPin, Calendar, History, X, ArrowUpRight } from "lucide-react";
|
|
import CaseStudyModal, { CaseStudyData } from "@/components/ui/CaseStudyModal";
|
|
import { useLocale, useTranslations } from "next-intl";
|
|
|
|
const RADIUS = 2;
|
|
|
|
function latLongToVector3(lat: number, lon: number, radius: number) {
|
|
const phi = (90 - lat) * (Math.PI / 180);
|
|
const theta = (lon + 180) * (Math.PI / 180);
|
|
const x = -(radius * Math.sin(phi) * Math.cos(theta));
|
|
const z = (radius * Math.sin(phi) * Math.sin(theta));
|
|
const y = (radius * Math.cos(phi));
|
|
return new THREE.Vector3(x, y, z);
|
|
}
|
|
|
|
// ── COMPONENTE OPTIMIZADO 1: TEXTURA DE LA TIERRA CON ALTA RESOLUCIÓN ──
|
|
function EarthMesh({ isDark }: { isDark: boolean }) {
|
|
const earthTexture = useLoader(THREE.TextureLoader, "https://unpkg.com/three-globe/example/img/earth-water.png");
|
|
const { gl } = useThree();
|
|
|
|
// 🔥 Filtro de hardware para forzar nitidez al hacer Zoom
|
|
useEffect(() => {
|
|
if (earthTexture) {
|
|
earthTexture.anisotropy = gl.capabilities.getMaxAnisotropy(); // Máximo detalle en ángulos inclinados
|
|
earthTexture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
earthTexture.magFilter = THREE.LinearFilter;
|
|
earthTexture.colorSpace = THREE.SRGBColorSpace; // Colores más vibrantes y definidos
|
|
earthTexture.generateMipmaps = true;
|
|
earthTexture.needsUpdate = true;
|
|
}
|
|
}, [earthTexture, gl]);
|
|
|
|
return (
|
|
<mesh>
|
|
{/* Regresamos a 64 segmentos para optimizar rendimiento (la nitidez ahora viene de la textura) */}
|
|
<sphereGeometry args={[RADIUS * 0.99, 64, 64]} />
|
|
<meshBasicMaterial
|
|
map={earthTexture}
|
|
color={isDark ? "#06F5E1" : "#86868B"}
|
|
transparent
|
|
opacity={isDark ? 0.4 : 0.3}
|
|
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
|
|
/>
|
|
</mesh>
|
|
);
|
|
}
|
|
|
|
// ── COMPONENTE OPTIMIZADO 2: EL NODO INTELIGENTE QUE RESPONDE AL ZOOM ──
|
|
function MapNode({ marker, isSelected, hqPosition, onSelectMarker, isDark }: any) {
|
|
const meshRef = useRef<THREE.Group>(null);
|
|
const pos = latLongToVector3(marker.lat, marker.lon, RADIUS);
|
|
|
|
const isHQ = marker.nodeType === "hq";
|
|
const isEvent = marker.nodeType === "event";
|
|
const nodeColor = isHQ ? (isDark ? "#FFFFFF" : "#1D1D1F") : isEvent ? "#A855F7" : "#0066CC";
|
|
|
|
const baseSize = isHQ ? 0.04 : isEvent ? 0.035 : 0.025;
|
|
|
|
useFrame(({ camera }) => {
|
|
if (!meshRef.current) return;
|
|
const dist = camera.position.length();
|
|
const scaleFactor = Math.max(0.2, dist / 12);
|
|
const finalScale = isSelected ? scaleFactor * 1.8 : scaleFactor;
|
|
meshRef.current.scale.set(finalScale, finalScale, finalScale);
|
|
});
|
|
|
|
const distance = hqPosition.distanceTo(pos);
|
|
const arcElevation = RADIUS + (distance * 0.25) + 0.1;
|
|
const midPoint = hqPosition.clone().lerp(pos, 0.5).normalize().multiplyScalar(arcElevation);
|
|
|
|
return (
|
|
<group>
|
|
<group ref={meshRef} position={pos}>
|
|
<mesh>
|
|
<sphereGeometry args={[baseSize, 32, 32]} />
|
|
<meshBasicMaterial color={nodeColor} />
|
|
</mesh>
|
|
|
|
{/* CAJA DE COLISIÓN AMPLIADA */}
|
|
<mesh
|
|
visible={false}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelectMarker(isSelected ? null : marker.id);
|
|
}}
|
|
onPointerOver={(e) => { e.stopPropagation(); document.body.style.cursor = 'pointer'; }}
|
|
onPointerOut={(e) => { e.stopPropagation(); document.body.style.cursor = 'auto'; }}
|
|
>
|
|
<sphereGeometry args={[baseSize * 4, 16, 16]} />
|
|
<meshBasicMaterial transparent opacity={0} />
|
|
</mesh>
|
|
</group>
|
|
|
|
{!isHQ && (
|
|
<QuadraticBezierLine
|
|
start={hqPosition}
|
|
end={pos}
|
|
mid={midPoint}
|
|
color={nodeColor}
|
|
lineWidth={isSelected ? 2.5 : 1.5}
|
|
transparent
|
|
opacity={isSelected ? 0.9 : 0.25}
|
|
/>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// ── COMPONENTE DE ENSAMBLAJE DE LA ESFERA ──
|
|
function HologramSphere({ activeFilter, activeSubFilter, selectedMarker, onSelectMarker, isDark, dbNodes, hqPosition }: any) {
|
|
const globeRef = useRef<THREE.Group>(null);
|
|
|
|
// 🔥 MAGIA DE ROTACIÓN INTELIGENTE 🔥
|
|
useFrame(({ camera }) => {
|
|
// La cámara inicia en Z=7. Si el usuario hace zoom in (distancia < 6.5), detenemos la rotación.
|
|
// Si vuelve a alejar el mapa a su estado normal (distancia > 6.5), retoma la rotación.
|
|
const distance = camera.position.length();
|
|
|
|
if (globeRef.current && !selectedMarker && distance > 6.5) {
|
|
globeRef.current.rotation.y += 0.0005;
|
|
}
|
|
});
|
|
|
|
return (
|
|
<group ref={globeRef}>
|
|
<mesh><sphereGeometry args={[RADIUS * 1.04, 64, 64]} /><meshBasicMaterial color={isDark ? "#00F0FF" : "#0066CC"} transparent opacity={isDark ? 0.1 : 0.05} side={THREE.BackSide} blending={THREE.AdditiveBlending} depthWrite={false} /></mesh>
|
|
<mesh><sphereGeometry args={[RADIUS * 0.98, 64, 64]} /><meshBasicMaterial color={isDark ? "#050505" : "#E5E5EA"} /></mesh>
|
|
|
|
{/* Esfera Terrestre mejorada con texturas nítidas */}
|
|
<EarthMesh isDark={isDark} />
|
|
|
|
<mesh><sphereGeometry args={[RADIUS, 64, 64]} /><meshBasicMaterial color={isDark ? "#0066CC" : "#86868B"} wireframe transparent opacity={0.06} /></mesh>
|
|
|
|
{dbNodes.map((marker: any) => {
|
|
const isHQ = marker.nodeType === "hq";
|
|
const isEvent = marker.nodeType === "event";
|
|
|
|
const matchesMain = activeFilter === "all" || (activeFilter === "installation" && !isEvent && !isHQ) || (activeFilter === "event" && isEvent) || (activeFilter === "legacy" && isHQ);
|
|
const matchesSub = !activeSubFilter || marker.application === activeSubFilter || isHQ;
|
|
const isVisible = matchesMain && matchesSub;
|
|
|
|
if (!isVisible) return null;
|
|
|
|
return (
|
|
<MapNode
|
|
key={marker.id}
|
|
marker={marker}
|
|
isSelected={selectedMarker === marker.id}
|
|
hqPosition={hqPosition}
|
|
onSelectMarker={onSelectMarker}
|
|
isDark={isDark}
|
|
/>
|
|
);
|
|
})}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// ── INTERFAZ GRÁFICA PRINCIPAL ──
|
|
export default function GlobalOperations({ dbNodes = [], dbApps = [] }: { dbNodes?: any[], dbApps?: any[] }) {
|
|
const [activeFilter, setActiveFilter] = useState("all");
|
|
const [activeSubFilter, setActiveSubFilter] = useState<string | null>(null);
|
|
const [selectedMarkerId, setSelectedMarkerId] = useState<string | null>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isDark, setIsDark] = useState(false);
|
|
|
|
const t = useTranslations("GlobalOperations");
|
|
|
|
const dynamicSubFilters = dbApps
|
|
.filter(app => app.isActive)
|
|
.map(app => ({ id: app.slug, label: app.title }));
|
|
|
|
useEffect(() => {
|
|
const checkTheme = () => setIsDark(document.documentElement.classList.contains("dark"));
|
|
checkTheme();
|
|
const observer = new MutationObserver(checkTheme);
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const filters = [
|
|
{ id: "all", label: t("filterAll"), icon: MapPin },
|
|
{ id: "installation", label: t("filterInstallations"), icon: MapPin },
|
|
{ id: "event", label: t("filterEvents"), icon: Calendar },
|
|
{ id: "legacy", label: t("filterHQ"), icon: History }
|
|
];
|
|
|
|
const selectedData = dbNodes.find(d => d.id === selectedMarkerId);
|
|
|
|
const hqNode = dbNodes.find(d => d.application === "hq");
|
|
const hqLat = hqNode ? hqNode.lat : 45.78;
|
|
const hqLon = hqNode ? hqNode.lon : 11.76;
|
|
const hqPosition = latLongToVector3(hqLat, hqLon, RADIUS);
|
|
|
|
const handleMainFilter = (id: string) => {
|
|
setActiveFilter(id);
|
|
setActiveSubFilter(null);
|
|
setSelectedMarkerId(null);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<section id="global" className="relative w-full max-w-7xl mx-auto px-6 py-32 z-10">
|
|
<div className="flex flex-col lg:flex-row gap-12 items-center h-auto lg:h-[700px]">
|
|
|
|
<div className="w-full lg:w-1/3 flex flex-col h-full justify-center mb-12 lg:mb-0">
|
|
<div className="p-6 md:p-8 -ml-6 md:-ml-8 rounded-3xl bg-white/40 dark:bg-[#0A0A0C]/50 backdrop-blur-md border border-white/50 dark:border-white/5 shadow-sm transition-colors">
|
|
<h2 className="text-sm font-semibold tracking-widest text-[#0066CC] dark:text-[#4DA6FF] uppercase mb-4">{t("subtitle")}</h2>
|
|
<h3 className="text-4xl md:text-5xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] tracking-tight mb-8 leading-[1.1] transition-colors">
|
|
{t("title1")} <br /><span className="font-medium">{t("title2")}</span>
|
|
</h3>
|
|
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|
{filters.map((f) => (
|
|
<button key={f.id} onClick={() => handleMainFilter(f.id)} className={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-300 border ${ activeFilter === f.id ? "bg-[#1D1D1F] dark:bg-white text-white dark:text-[#1D1D1F] border-[#1D1D1F] dark:border-white shadow-md" : "bg-white/60 dark:bg-white/5 text-[#86868B] dark:text-[#A1A1A6] border-white/60 dark:border-white/10 hover:text-[#1D1D1F] dark:hover:text-white" } backdrop-blur-md`}>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{activeFilter === "installation" && (
|
|
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="flex flex-wrap gap-2 mb-4 overflow-hidden">
|
|
<span className="w-full text-xs font-semibold uppercase tracking-widest text-[#86868B] mb-1">{t("filterByApp")}</span>
|
|
{dynamicSubFilters.map((sub) => (
|
|
<button key={sub.id} onClick={() => { setActiveSubFilter(activeSubFilter === sub.id ? null : sub.id); setSelectedMarkerId(null); }} className={`px-3 py-1.5 rounded-full text-xs transition-all duration-200 border ${ activeSubFilter === sub.id ? "bg-[#0066CC]/10 dark:bg-[#0066CC]/20 text-[#0066CC] dark:text-[#4DA6FF] border-[#0066CC]/30 font-medium" : "bg-white/40 dark:bg-transparent text-[#86868B] border-transparent hover:text-[#1D1D1F] dark:hover:text-white" }`}>
|
|
{sub.label}
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<AnimatePresence mode="wait">
|
|
{!selectedMarkerId && (
|
|
<motion.div key={activeFilter + (activeSubFilter || "")} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="bg-white/60 dark:bg-[#1D1D1F]/60 backdrop-blur-2xl border border-white/40 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.04)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] rounded-3xl p-8 mt-4 transition-colors">
|
|
<h4 className="text-[#86868B] text-xs font-semibold uppercase tracking-wider mb-2 flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-[#0066CC] animate-pulse"></span>{t("networkStatus")}</h4>
|
|
<p className="text-xl font-light text-[#1D1D1F] dark:text-[#F5F5F7] mb-6 leading-relaxed transition-colors">
|
|
{activeSubFilter
|
|
? t("statusShowing", { app: activeSubFilter.replace("-", " ") })
|
|
: t("statusTracking", { count: dbNodes.filter(n =>
|
|
(activeFilter === "all") ||
|
|
(activeFilter === "installation" && n.nodeType === "installation") ||
|
|
(activeFilter === "event" && n.nodeType === "event") ||
|
|
(activeFilter === "legacy" && n.nodeType === "hq")
|
|
).length })}
|
|
</p>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<div className="w-full lg:w-2/3 h-[500px] lg:h-full relative rounded-[2.5rem] overflow-hidden bg-white/30 dark:bg-transparent backdrop-blur-sm dark:backdrop-blur-none border border-white/40 dark:border-transparent transition-all duration-700">
|
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150%] h-[150%] bg-[radial-gradient(circle_at_center,rgba(0,102,204,0.15)_0%,transparent_60%)] hidden dark:block pointer-events-none blur-3xl" />
|
|
|
|
<div className="absolute top-4 right-4 z-10 text-[10px] font-mono text-[#86868B] dark:text-[#A1A1A6] tracking-widest uppercase bg-white/50 dark:bg-white/5 px-3 py-1 rounded-full backdrop-blur-md border border-white/50 dark:border-white/10 transition-colors pointer-events-none">
|
|
{t("helpText")}
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{selectedData && (
|
|
<motion.div initial={{ opacity: 0, x: 20, y: 20 }} animate={{ opacity: 1, x: 0, y: 0 }} exit={{ opacity: 0, x: 20, y: 20 }} className="absolute bottom-4 right-4 z-20 w-[calc(100%-2rem)] md:w-80 bg-white/80 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl border border-white/60 dark:border-white/10 shadow-lg dark:shadow-[0_10px_40px_rgba(0,0,0,0.5)] rounded-2xl p-6 transition-colors">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="flex items-center gap-2 text-[#0066CC] dark:text-[#4DA6FF]">
|
|
<MapPin size={14} />
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider">
|
|
{selectedData.nodeType === 'event' ? t("typeEvent") : selectedData.nodeType === 'hq' ? t("typeHQ") : t("typeInstall")}
|
|
</span>
|
|
</div>
|
|
<button onClick={() => setSelectedMarkerId(null)} className="text-[#86868B] hover:text-[#1D1D1F] dark:hover:text-white transition-colors">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
<h4 className="text-xl font-medium text-[#1D1D1F] dark:text-[#F5F5F7] mb-1">{selectedData.title}</h4>
|
|
<p className="text-sm text-[#86868B] dark:text-[#A1A1A6] mb-4">{selectedData.location}</p>
|
|
<div className="bg-[#F5F5F7] dark:bg-white/5 rounded-lg p-3 mb-4 border border-transparent dark:border-white/5">
|
|
<span className="text-[10px] uppercase tracking-wider text-[#86868B] dark:text-[#A1A1A6] block mb-1">{t("statusDetails")}</span>
|
|
<span className="text-sm font-medium text-[#1D1D1F] dark:text-[#F5F5F7]">{selectedData.stats}</span>
|
|
</div>
|
|
<button onClick={() => setIsModalOpen(true)} className="w-full flex justify-center items-center gap-2 bg-[#1D1D1F] dark:bg-[#0066CC] text-white py-2.5 rounded-xl text-sm font-medium hover:bg-[#0066CC] dark:hover:bg-[#4DA6FF] transition-colors">
|
|
{t("viewCaseStudy")} <ArrowUpRight size={14} />
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<Canvas camera={{ position: [0, 0, 7], fov: 55 }} dpr={[1, 2]} style={{ touchAction: "none" }}>
|
|
<ambientLight intensity={1.5} />
|
|
<directionalLight position={[10, 10, 5]} intensity={2} />
|
|
<OrbitControls enableZoom={true} minDistance={3} maxDistance={12} enablePan={false} autoRotate={false} />
|
|
<Suspense fallback={null}>
|
|
<HologramSphere activeFilter={activeFilter} activeSubFilter={activeSubFilter} selectedMarker={selectedMarkerId} onSelectMarker={setSelectedMarkerId} isDark={isDark} dbNodes={dbNodes} hqPosition={hqPosition} />
|
|
</Suspense>
|
|
</Canvas>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<CaseStudyModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} data={selectedData as CaseStudyData || null} />
|
|
</>
|
|
);
|
|
} |