// ============ INTERACTIVE GLOBE (v2 — frame on the Americas) ============ // d3-geo orthographic on canvas. Two categories only (Base / Network). // Behaviour: // - On enter: locked, centered on Buenos Aires (zoomed in). // - On scroll-through-section: zoom-out + rotate toward an "Americas overview". // - At rest in overview: gentle ±15° longitude sway (bounded), never showing // the empty hemisphere. // - Drag rotates, but clamped to keep America always in view; ease-back on release. // - Reduced motion: static Americas overview, no sway, no scroll-zoom. const BA = [-58.3816, -34.6037]; // 6 points, 2 categories. "base" = BA. Everything else "network". const GLOBE_POINTS = [ { id: 'ba', coord: BA, type: 'base' }, { id: 'sp', coord: [-46.6333, -23.5505], type: 'network' }, { id: 'cba', coord: [-64.1888, -31.4201], type: 'network' }, { id: 'er', coord: [-60.5238, -31.7333], type: 'network' }, { id: 'tdl', coord: [-59.1333, -37.3217], type: 'network' }, { id: 'acp', coord: [-99.8237, 16.8531], type: 'network' }, ]; const TYPE_COLOR = { base: '#38bdf8', network: '#8b5cf6' }; // Target views (d3 rotation = [-lon, -lat]). // Entry: centered on Buenos Aires. // Overview: centered roughly on Central America so all 6 points are visible. const VIEW_ENTRY = { rotate: [-BA[0], -BA[1]], scale: 1.95 }; const VIEW_OVERVIEW = { rotate: [80, 8], scale: 1.00 }; // In rotation terms: lon_center ≈ -80 (Caribbean), lat_center ≈ -8. // (d3 rotate is [-lon, -lat] so we store [80, 8].) function Globe() { const { t } = window.useLang(); const ref = window.useReveal(); const reduce = window.useReducedMotion(); const wrapRef = React.useRef(null); const canvasRef = React.useRef(null); const stateRef = React.useRef(null); const [land, setLand] = React.useState(null); const [hover, setHover] = React.useState(null); const [progress, setProgress] = React.useState(0); // ---- Load land geometry ---- React.useEffect(() => { let cancelled = false; const tryFetch = (url) => fetch(url).then(r => r.ok ? r.json() : Promise.reject()); tryFetch('https://cdn.jsdelivr.net/npm/world-atlas@2/land-110m.json') .catch(() => tryFetch('https://unpkg.com/world-atlas@2/land-110m.json')) .then(topo => { if (cancelled) return; const feature = window.topojson.feature(topo, topo.objects.land); setLand(feature); }) .catch(() => { if (!cancelled) setLand({}); }); return () => { cancelled = true; }; }, []); // ---- Scroll progress (0..1 across the section) ---- React.useEffect(() => { const el = wrapRef.current; if (!el) return; const onScroll = () => { const r = el.getBoundingClientRect(); const vh = window.innerHeight || 800; // 0 when top of element hits center, 1 when bottom is at center. const p = (vh / 2 - r.top) / r.height; setProgress(Math.max(0, Math.min(1, p))); }; onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll, { passive: true }); return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); }; }, []); // ---- Mount canvas + draw loop ---- React.useEffect(() => { if (!land) return; const canvas = canvasRef.current; if (!canvas || !window.d3) return; const d3 = window.d3; let raf; const isMobile = window.matchMedia('(pointer:coarse)').matches; const state = { // user-introduced offset from the auto-target (clamped). Resets toward 0. userOffset: [0, 0], // velocity for ease-back userVel: [0, 0], dragging: false, lastPointer: null, sway: 0, arcT: 0, mouse: null, hoverId: null, progress: 0, }; stateRef.current = state; // Allowed user drift (degrees) — keeps Americas in frame const MAX_DLON = 35; const MAX_DLAT = 25; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const onDown = (e) => { state.dragging = true; state.lastPointer = { x: e.clientX, y: e.clientY }; canvas.classList.add('grabbing'); }; const onUp = () => { state.dragging = false; state.lastPointer = null; canvas.classList.remove('grabbing'); }; const onMove = (e) => { const r = canvas.getBoundingClientRect(); state.mouse = { x: e.clientX - r.left, y: e.clientY - r.top }; if (state.dragging && state.lastPointer) { const dx = e.clientX - state.lastPointer.x; const dy = e.clientY - state.lastPointer.y; const sens = 0.35; state.userOffset[0] = clamp(state.userOffset[0] + dx * sens, -MAX_DLON, MAX_DLON); state.userOffset[1] = clamp(state.userOffset[1] - dy * sens, -MAX_DLAT, MAX_DLAT); state.lastPointer = { x: e.clientX, y: e.clientY }; } }; const onLeave = () => { state.mouse = null; state.dragging = false; canvas.classList.remove('grabbing'); }; const onTouchStart = (e) => { if (e.touches[0]) onDown(e.touches[0]); }; const onTouchMove = (e) => { if (e.touches[0]) { onMove(e.touches[0]); e.preventDefault(); } }; const onTouchEnd = () => onUp(); canvas.addEventListener('mousedown', onDown); window.addEventListener('mouseup', onUp); window.addEventListener('mousemove', onMove); canvas.addEventListener('mouseleave', onLeave); canvas.addEventListener('touchstart', onTouchStart, { passive: true }); canvas.addEventListener('touchmove', onTouchMove, { passive: false }); canvas.addEventListener('touchend', onTouchEnd); let lastT = performance.now(); function draw(t) { const dt = Math.min(0.05, (t - lastT) / 1000); lastT = t; const w = canvas.clientWidth; const h = canvas.clientHeight; const dpr = Math.min(window.devicePixelRatio || 1, 2); if (canvas.width !== w * dpr) canvas.width = w * dpr; if (canvas.height !== h * dpr) canvas.height = h * dpr; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); // ease-back to 0 when not dragging if (!state.dragging) { state.userOffset[0] *= Math.pow(0.001, dt); // exp decay state.userOffset[1] *= Math.pow(0.001, dt); if (Math.abs(state.userOffset[0]) < 0.01) state.userOffset[0] = 0; if (Math.abs(state.userOffset[1]) < 0.01) state.userOffset[1] = 0; } // gentle bounded sway (only after overview is reached) if (!reduce && !state.dragging) { state.sway += dt * 0.20; // rad/s } if (!reduce) state.arcT = (state.arcT + dt * 0.55) % 1; // p = scroll progress through section, eased // mobile: skip the entry-zoom; start in overview const p = isMobile || reduce ? 1 : easeInOut(state.progress); // Interpolate scale & target rotation between entry and overview const baseScale = Math.min(w, h) * 0.42; const scaleK = lerp(VIEW_ENTRY.scale, VIEW_OVERVIEW.scale, p); const scale = baseScale * scaleK; const targetLon = lerp(VIEW_ENTRY.rotate[0], VIEW_OVERVIEW.rotate[0], p); const targetLat = lerp(VIEW_ENTRY.rotate[1], VIEW_OVERVIEW.rotate[1], p); // sway only kicks in once we're at overview const swayLon = (!reduce ? Math.sin(state.sway) * 14 * p : 0); const rotation = [ targetLon + swayLon + state.userOffset[0], targetLat + state.userOffset[1], 0 ]; const projection = d3.geoOrthographic() .scale(scale) .translate([w/2, h/2]) .rotate(rotation) .clipAngle(90); const path = d3.geoPath(projection, ctx); // ocean sphere const sphereGrad = ctx.createRadialGradient(w/2 - scale*0.3, h/2 - scale*0.3, scale*0.1, w/2, h/2, scale); sphereGrad.addColorStop(0, 'rgba(20, 28, 50, 1)'); sphereGrad.addColorStop(0.7, 'rgba(10, 14, 28, 1)'); sphereGrad.addColorStop(1, 'rgba(6, 8, 16, 1)'); ctx.beginPath(); path({ type: 'Sphere' }); ctx.fillStyle = sphereGrad; ctx.fill(); // outer rings ctx.beginPath(); ctx.arc(w/2, h/2, scale, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(56,189,248,0.35)'; ctx.lineWidth = 1.2; ctx.stroke(); ctx.beginPath(); ctx.arc(w/2, h/2, scale + 8, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(56,189,248,0.12)'; ctx.lineWidth = 3; ctx.stroke(); // graticule ctx.beginPath(); path(d3.geoGraticule10()); ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 0.6; ctx.stroke(); // land if (land && land.type) { ctx.beginPath(); path(land); ctx.fillStyle = 'rgba(139, 92, 246, 0.16)'; ctx.fill(); ctx.strokeStyle = 'rgba(255, 255, 255, 0.18)'; ctx.lineWidth = 0.55; ctx.stroke(); } // arcs from BA const center = projection.invert([w/2, h/2]) || BA; const visible = (coord) => d3.geoDistance(center, coord) < Math.PI / 2 - 0.02; GLOBE_POINTS.forEach(p => { if (p.id === 'ba') return; const interp = d3.geoInterpolate(BA, p.coord); const samples = 64; const pts = []; for (let i = 0; i <= samples; i++) { const c = interp(i / samples); pts.push({ xy: projection(c), vis: visible(c) }); } ctx.beginPath(); let drawing = false; for (const pp of pts) { if (!pp.vis) { drawing = false; continue; } if (!drawing) { ctx.moveTo(pp.xy[0], pp.xy[1]); drawing = true; } else ctx.lineTo(pp.xy[0], pp.xy[1]); } const gradStart = projection(BA), gradEnd = projection(p.coord); let arcGrad; if (gradStart && gradEnd) { arcGrad = ctx.createLinearGradient(gradStart[0], gradStart[1], gradEnd[0], gradEnd[1]); arcGrad.addColorStop(0, 'rgba(56,189,248,0.85)'); arcGrad.addColorStop(1, 'rgba(139,92,246,0.85)'); } ctx.strokeStyle = arcGrad || 'rgba(56,189,248,0.6)'; ctx.lineWidth = 1.2; ctx.shadowColor = 'rgba(56,189,248,0.6)'; ctx.shadowBlur = 6; ctx.stroke(); ctx.shadowBlur = 0; // traveling dash head if (!reduce) { const head = Math.floor(state.arcT * samples); const tailLen = 6; ctx.beginPath(); let started = false; for (let i = Math.max(0, head - tailLen); i <= head; i++) { const pi = pts[i]; if (!pi || !pi.vis) { started = false; continue; } if (!started) { ctx.moveTo(pi.xy[0], pi.xy[1]); started = true; } else ctx.lineTo(pi.xy[0], pi.xy[1]); } ctx.strokeStyle = 'rgba(255,255,255,0.95)'; ctx.lineWidth = 2; ctx.shadowColor = '#ffffff'; ctx.shadowBlur = 10; ctx.stroke(); ctx.shadowBlur = 0; } }); // markers let hoverFound = null; GLOBE_POINTS.forEach(p => { const xy = projection(p.coord); if (!xy || !visible(p.coord)) return; const color = TYPE_COLOR[p.type]; const isBase = p.type === 'base'; const baseR = isBase ? 6 : 4.5; if (isBase && !reduce) { const pulse = (Math.sin(t / 320) + 1) / 2; ctx.beginPath(); ctx.arc(xy[0], xy[1], baseR + 8 + pulse * 6, 0, Math.PI * 2); ctx.fillStyle = `rgba(56,189,248,${0.18 - pulse * 0.08})`; ctx.fill(); } ctx.beginPath(); ctx.arc(xy[0], xy[1], baseR + 5, 0, Math.PI * 2); ctx.fillStyle = color + '33'; ctx.fill(); ctx.beginPath(); ctx.arc(xy[0], xy[1], baseR, 0, Math.PI * 2); ctx.fillStyle = color; ctx.shadowColor = color; ctx.shadowBlur = 14; ctx.fill(); ctx.shadowBlur = 0; ctx.lineWidth = 1.5; ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.stroke(); if (state.mouse) { const dx = state.mouse.x - xy[0], dy = state.mouse.y - xy[1]; if (Math.hypot(dx, dy) < baseR + 12) hoverFound = { id: p.id, x: xy[0], y: xy[1] }; } }); if (hoverFound) { if (!state.hoverId || state.hoverId !== hoverFound.id) { state.hoverId = hoverFound.id; setHover(hoverFound); } else { setHover((h) => (h && h.x === hoverFound.x && h.y === hoverFound.y) ? h : hoverFound); } } else if (state.hoverId) { state.hoverId = null; setHover(null); } raf = requestAnimationFrame(draw); } raf = requestAnimationFrame(draw); return () => { cancelAnimationFrame(raf); canvas.removeEventListener('mousedown', onDown); window.removeEventListener('mouseup', onUp); window.removeEventListener('mousemove', onMove); canvas.removeEventListener('mouseleave', onLeave); canvas.removeEventListener('touchstart', onTouchStart); canvas.removeEventListener('touchmove', onTouchMove); canvas.removeEventListener('touchend', onTouchEnd); }; }, [land, reduce]); React.useEffect(() => { if (stateRef.current) stateRef.current.progress = progress; }, [progress]); const tipData = hover ? { ...t.map.points[hover.id], type: GLOBE_POINTS.find(p => p.id === hover.id)?.type } : null; return (
{t.map.kicker}

{t.map.h2}

{t.map.legend.base}
{t.map.legend.network}
{t.map.hint}
{hover && tipData && (
{tipData.label}
{tipData.tip}
)}
); } function lerp(a, b, t) { return a + (b - a) * t; } function easeInOut(t) { return t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2)/2; } window.Globe = Globe;