// scenes-v2-a.jsx — Scenes 1–4 of the CAMS animation // 1. Intro · 2. ¿Qué es CAMS? · 3. Satélites + IFS · 4. Aerosoles globales (Windy) // ─── Scene 1: Intro ───────────────────────────────────────────────── function S1Intro() { const { localTime, duration } = useSprite(); const time = useTime(); // Ambient flow field for backdrop const field = React.useMemo(() => directionalField(Math.PI * 0.95, 0.0035, 0.18, 0.55, 0.45), []); const tTitle = Easing.easeOutCubic(clamp((localTime - 0.6) / 0.8, 0, 1)); const tKick = Easing.easeOutCubic(clamp((localTime - 0.3) / 0.6, 0, 1)); const tSub = Easing.easeOutCubic(clamp((localTime - 1.4) / 0.7, 0, 1)); const tFoot = Easing.easeOutCubic(clamp((localTime - 2.0) / 0.7, 0, 1)); const fadeOut = clamp((duration - localTime) / 0.6, 0, 1); return ( {/* Background flow */} {/* Soft vignette */}
{/* Kicker */}
Pronóstico de Calidad del Aire · SIATA
{/* Title */}
Cómo funciona el pronóstico
estadístico de PM2.5
en el Valle de Aburrá
{/* Subtitle */}
Cuatro fuentes — CAMS, WRF, estaciones SIATA y variables temporales — integradas por aprendizaje automatizado.
{/* Foot tag with pulsing dot */}
SIATA · Sistema de Alerta Temprana del Valle de Aburrá
); } // ─── Scene 2: ¿Qué es CAMS? ──────────────────────────────────────── function S2What() { const { localTime, duration } = useSprite(); const time = useTime(); const tKick = Easing.easeOutCubic(clamp((localTime - 0.2) / 0.5, 0, 1)); const tHead = Easing.easeOutCubic(clamp((localTime - 0.6) / 0.7, 0, 1)); const tBody = Easing.easeOutCubic(clamp((localTime - 1.2) / 0.7, 0, 1)); const tDiag = Easing.easeOutCubic(clamp((localTime - 1.8) / 0.6, 0, 1)); const fadeOut = clamp((duration - localTime) / 0.45, 0, 1); // Animated path drawing for "flow" diagram const dur = duration; const flowT = clamp((localTime - 2.3) / 2.5, 0, 1); // Diagram nodes (right side) const cx = 880, cy = 360; const nodes = [ { id: 'sat', x: cx - 240, y: cy - 130, r: 28, color: C.cyan, label: 'Satélites', sub: 'MODIS · TROPOMI', delay: 0 }, { id: 'grd', x: cx - 240, y: cy, r: 28, color: C.amber, label: 'Obs. terrestres', sub: 'AERONET · IAGOS', delay: 0.15 }, { id: 'snd', x: cx - 240, y: cy + 130, r: 28, color: C.purple, label: 'Modelo IFS', sub: 'ECMWF', delay: 0.3 }, { id: 'cams', x: cx + 30, y: cy, r: 56, color: C.orange, label: 'CAMS', sub: 'Análisis · Pronóstico', delay: 0.6 }, { id: 'out', x: cx + 280, y: cy, r: 26, color: C.green, label: 'Variables', sub: 'PM, O₃, NO₂, CO, BC...', delay: 1.0 }, ]; return ( {/* Left text column */}
Capítulo I · ¿Qué es CAMS?
El modelo
atmosférico
de Copernicus.
CAMS opera en el ECMWF desde 2015. Combina observaciones satelitales, sondeos y estaciones en un mismo modelo numérico para describir la composición de la atmósfera en todo el planeta.
{/* Stats pills */}
{[ { v: '~40 km', l: 'resolución global' }, { v: '120 h', l: 'horizonte' }, { v: '2×/día', l: 'corridas' }, ].map((s, i) => (
{s.v}
{s.l}
))}
{/* Right diagram */}
{/* Connecting lines */} {/* Inputs → CAMS */} {[0, 1, 2].map(i => { const n = nodes[i]; const cam = nodes[3]; const apT = clamp((flowT - i * 0.08) * 1.6, 0, 1); const len = Math.hypot(cam.x - n.x, cam.y - n.y); const dashOff = (1 - apT) * len; return ( ); })} {/* CAMS → output */} {(() => { const cam = nodes[3], out = nodes[4]; const apT = clamp((flowT - 0.5) * 1.6, 0, 1); const len = Math.hypot(out.x - cam.x, out.y - cam.y); return ( ); })()} {/* Travelling data pulses */} {[0, 1, 2].map(i => { const n = nodes[i]; const cam = nodes[3]; const ph = (time * 0.7 + i * 0.4) % 1; const px = n.x + (cam.x - n.x) * ph; const py = n.y + (cam.y - n.y) * ph; const apT = clamp((flowT - i * 0.08) * 1.6, 0, 1); return ( ); })} {/* Nodes */} {nodes.map((n, i) => { const ap = Easing.easeOutBack(clamp((localTime - 1.8 - (n.delay || 0)) / 0.5, 0, 1)); const isCenter = n.id === 'cams'; const pulse = isCenter ? (1 + Math.sin(time * 1.8) * 0.04) : 1; return (
{isCenter && (
CAMS
)}
{/* Label */} {!isCenter && (
{n.label}
{n.sub}
)} {isCenter && (
{n.sub}
)}
); })}
); } // ─── Scene 3: Satélites + IFS asimilación ─────────────────────────── function S3Satellites() { const { localTime, duration } = useSprite(); const time = useTime(); const tKick = Easing.easeOutCubic(clamp((localTime - 0.2) / 0.5, 0, 1)); const tHead = Easing.easeOutCubic(clamp((localTime - 0.5) / 0.6, 0, 1)); const tGlobe = Easing.easeOutCubic(clamp((localTime - 1.0) / 0.7, 0, 1)); const tSat = Easing.easeOutCubic(clamp((localTime - 1.6) / 0.6, 0, 1)); const tStats = Easing.easeOutCubic(clamp((localTime - 4.5) / 0.6, 0, 1)); const fadeOut = clamp((duration - localTime) / 0.45, 0, 1); const cx = 640, cy = 405; const globeR = 165; const satellites = [ { name: 'Sentinel-5P', sub: 'TROPOMI · NO₂, CO, O₃', ang: -2.4, r: 245, color: C.cyan }, { name: 'MetOp', sub: 'IASI · CO, CH₄', ang: -1.6, r: 280, color: C.amber }, { name: 'EOS Aqua', sub: 'MODIS · AOD', ang: -0.7, r: 252, color: C.orange }, { name: 'Sentinel-3', sub: 'OLCI · aerosoles', ang: 0.2, r: 275, color: C.purple }, { name: 'GOES-16', sub: 'ABI · humo, polvo', ang: 1.0, r: 240, color: C.rose }, ]; // Latitude / longitude grid const lats = [-60, -30, 0, 30, 60]; const lons = [-150, -90, -30, 30, 90, 150]; return ( {/* Header */}
Capítulo II · Asimilación 4D-Var
Cinco constelaciones alimentan el modelo
{/* Globe (stylised) */}
{/* Sphere fill */} {/* Equator/longitude lines */} {lats.map((lat, i) => { const ry = globeR * Math.cos(lat * Math.PI / 180); const y = (globeR + 20) - globeR * Math.sin(lat * Math.PI / 180); return ( ); })} {lons.map((lon, i) => { const phase = (lon + time * 6) * Math.PI / 180; const rx = Math.abs(Math.sin(phase)) * globeR; return ( ); })} {/* Outer rim */} {/* Random sampling dots — representing observation density */} {(() => { const dots = []; const r = rand(7); for (let i = 0; i < 90; i++) { const theta = r() * Math.PI * 2; const phi = (r() - 0.5) * Math.PI; const xs = Math.cos(phi) * Math.sin(theta + time * 0.1); const ys = Math.sin(phi); const zs = Math.cos(phi) * Math.cos(theta + time * 0.1); if (zs < 0) continue; const px = (globeR + 20) + xs * globeR * 0.95; const py = (globeR + 20) - ys * globeR * 0.95; const flash = 0.4 + Math.sin(time * 2 + i) * 0.4; dots.push( ); } return dots; })()} {/* Center label */}
IFS-COMPO
modelo global ECMWF
{/* Satellites orbiting */} {satellites.map((s, i) => { const ap = Easing.easeOutCubic(clamp((localTime - 1.6 - i * 0.15) / 0.6, 0, 1)); const ang = s.ang + time * 0.12; const sx = cx + Math.cos(ang) * s.r; const sy = cy + Math.sin(ang) * s.r * 0.78; const labelRight = Math.cos(ang) >= 0; return ( {/* Data line to globe */} {/* Travelling dot */} {(() => { const k = ((time * 0.55 + i * 0.18) % 1); const px = sx + (cx - sx) * k; const py = sy + (cy - sy) * k; return ; })()} {/* Satellite dot */}
{/* Solar panel bars */}
{/* Label */}
{s.name}
{s.sub}
); })} {/* Bottom stats */}
{[ { v: '~40 M', l: 'observaciones/día', c: C.cyan }, { v: '4D-Var', l: 'asimilación variacional', c: C.amber }, { v: '60+', l: 'especies químicas', c: C.orange }, ].map((s, i) => (
{s.v}
{s.l}
))}
); } // ─── Scene 4: Aerosoles globales · El momento Windy ───────────────── function S4Aerosols() { const { localTime, duration } = useSprite(); const time = useTime(); // Map projection rectangle (full canvas area) const W = 1280, H = 720; // Source plumes (in screen coords on stylised map) const sources = React.useMemo(() => [ { name: 'Polvo del Sahara', tag: 'AOD 1.2', color: C.orange, x: 1010, y: 290, spread: 80, weight: 1.0, }, { name: 'Quemas · Amazonía', tag: 'BC + OC', color: C.coral, x: 720, y: 470, spread: 70, weight: 0.8, }, { name: 'Aerosol marino', tag: 'sea salt', color: C.cyan, x: 900, y: 540, spread: 90, weight: 0.6, }, { name: 'Volcán Popocatépetl', tag: 'SO₂', color: C.purple, x: 605, y: 332, spread: 38, weight: 0.35, }, ], []); // Composite flow field — directional (trade winds E → W in tropics) // plus per-source contributions const field = React.useMemo(() => { return (x, y, t) => { // Latitude band (closer to equator y≈400) — westward trade winds const lat = (H / 2 - y) / (H / 2); // -1 (south) to +1 (north) // Easterlies dominate near equator const trade = Math.exp(-lat * lat * 2); const vx = -1 * trade - 0.25; // Slight equatorward drift const vy = lat * 0.25; // Curl noise perturbation const tt = t * 0.08; const swirl = 0.45; const cx2 = Math.sin(x * 0.005 + tt) + Math.cos(y * 0.006 - tt * 0.7); const cy2 = Math.cos(x * 0.0055 - tt * 0.5) + Math.sin(y * 0.0048 + tt); return [vx + cx2 * swirl, vy + cy2 * swirl]; }; }, []); // Particles get colored by which source they last touched. // For simplicity, spawn particles at sources weighted by `weight`. const totalW = sources.reduce((s, p) => s + p.weight, 0); const spawnFn = React.useMemo(() => (t, i) => { let pick = (i * 0.6180339 + t * 0.001) % 1; pick *= totalW; let acc = 0; for (const src of sources) { acc += src.weight; if (pick <= acc) { const r = rand(i * 13 + Math.floor(t * 100)); const ang = r() * Math.PI * 2; const rad = Math.sqrt(r()) * src.spread; return { x: src.x + Math.cos(ang) * rad, y: src.y + Math.sin(ang) * rad, }; } } return { x: sources[0].x, y: sources[0].y }; }, [sources, totalW]); // Phase timing const tKick = Easing.easeOutCubic(clamp((localTime - 0.3) / 0.5, 0, 1)); const tHead = Easing.easeOutCubic(clamp((localTime - 0.7) / 0.7, 0, 1)); const tMap = Easing.easeOutCubic(clamp((localTime - 1.0) / 0.8, 0, 1)); const tSrc = Easing.easeOutCubic(clamp((localTime - 2.3) / 0.5, 0, 1)); const tPin = Easing.easeOutCubic(clamp((localTime - 4.0) / 0.6, 0, 1)); const tFact = Easing.easeOutCubic(clamp((localTime - 6.0) / 0.6, 0, 1)); const fadeOut = clamp((duration - localTime) / 0.5, 0, 1); // Valle de Aburrá ~ NW South America: ~6°N, 75.5°W. On our stylised map // we'll place it at (700, 425) — roughly upper-NW South America. const valle = { x: 700, y: 425 }; return ( {/* The Windy flow */}
{/* Source-region halos — soft tinted zones (replaces continent outlines) */} {sources.map((s, i) => ( ))} {sources.map((s, i) => ( ))} {/* Reinforced graticule with degree labels */} {/* Latitude lines */} {[ { lat: 30, label: '30°N' }, { lat: 15, label: '15°N' }, { lat: 0, label: '0°', equator: true }, { lat: -15, label: '15°S' }, { lat: -30, label: '30°S' }, ].map((row, i) => { // Map lat → y: equator at y=468, scale 7.1 px/deg const y = 468 - row.lat * 7.1; if (y < 0 || y > 720) return null; return ( {row.label} ); })} {/* Longitude lines */} {[ { lon: -90, label: '90°W' }, { lon: -60, label: '60°W' }, { lon: -30, label: '30°W' }, { lon: 0, label: '0°' }, { lon: 30, label: '30°E' }, ].map((col, i) => { // Map lon → x: Valle at lon -75 → x=700, scale 4.13 px/deg const x = 700 + (col.lon + 75) * 4.13; if (x < 0 || x > 1280) return null; return ( {col.label} ); })} {/* Wind direction compass — top-left corner of "instrument" feel */}
Flujo dominante
E → O
vientos alisios tropicales
{/* Header strip — top */}
Capítulo III · Transporte de aerosoles
Lo que ven los satélites,
CAMS lo transporta hora a hora.
CAMS Global / aerosol optical depth
level 550 nm · superficie · UTC {String(Math.floor((time * 6) % 24)).padStart(2, '0')}:00
{/* Source pins */} {sources.map((s, i) => { const ap = Easing.easeOutBack(clamp((localTime - 2.3 - i * 0.2) / 0.5, 0, 1)); return (
{/* Halo */}
{/* Dot */}
{/* Label card */}
{s.name}
{s.tag}
); })} {/* Valle de Aburrá target */}
{/* Concentric pulse rings */} {[0, 1, 2].map(i => { const ph = ((time * 0.6 + i / 3) % 1); return (
); })}
Valle de Aburrá
06°15′N · 75°34′W
{/* Bottom fact */}
Por qué importa para el Valle
Polvo del Sahara, humo de quemas amazónicas y aerosoles marinos pueden cruzar el continente en 3 a 5 días y modular nuestra calidad del aire local.
fuente · CAMS global forecast
aerosol_550nm · superficie
); } Object.assign(window, { S1Intro, S2What, S3Satellites, S4Aerosols });