// scenes-v2-b.jsx — Scenes 5–8 of the CAMS animation // 5. Resolución 40 km · 6. Downscaling · 7. Pronóstico 72h · 8. Outro // ─── Scene 5: Resolución 40 km sobre el Valle ────────────────────── function S5Resolution() { 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 tMap = Easing.easeOutCubic(clamp((localTime - 1.0) / 0.7, 0, 1)); const tGrid = Easing.easeOutCubic(clamp((localTime - 1.6) / 0.8, 0, 1)); const tHL = Easing.easeOutCubic(clamp((localTime - 3.0) / 0.6, 0, 1)); const tFact = Easing.easeOutCubic(clamp((localTime - 4.2) / 0.6, 0, 1)); const fadeOut = clamp((duration - localTime) / 0.45, 0, 1); // Stylised Colombia silhouette (very rough) — frame on right side const cx = 880, cy = 380; const colW = 360, colH = 480; // CAMS 40km grid in screen space — visualised as ~50px cells const cellPx = 56; const gridCols = Math.ceil(colW / cellPx) + 1; const gridRows = Math.ceil(colH / cellPx) + 1; const gridX = cx - colW / 2; const gridY = cy - colH / 2; // Valle de Aburrá lens-shape centered on one cell const valleX = cx - 20; const valleY = cy + 50; return ( {/* Left text */}
Capítulo IV · El reto de la escala
CAMS describe
el planeta
en celdas de 40 km.
Cada celda promedia ~1 600 km² de atmósfera. El Valle de Aburrá, con sus ~60 km de norte a sur y montañas de 1 800 m de altura, cabe casi entero en una sola celda.
{/* Big stat row */}
celda CAMS
10 mun.
todo el Valle
{/* Right: map + grid */}
{/* Colombia silhouette */} {/* CAMS 40km grid — clipped to country */} {Array.from({ length: gridRows }, (_, r) => ( ))} {Array.from({ length: gridCols }, (_, c) => ( ))} {/* Pollution heat — random colored cell fills */} {(() => { const cells = []; const r = rand(11); for (let i = 0; i < gridCols; i++) { for (let j = 0; j < gridRows; j++) { const v = r(); const a = v * 0.22; cells.push( 0.8 ? C.orange : (v > 0.5 ? C.amber : C.sky)} fillOpacity={a * tGrid} /> ); } } return cells; })()} {/* Country border on top */} {/* Highlighted cell over Valle de Aburrá */}
{/* Valle de Aburrá — small region marker */}
Valle de Aburrá
≈60 × 10 km · 10 municipios
{/* Annotation lines */} 40 km
); } // ─── Scene 6: Ensamble local ──────────────────────────────────── function S6Downscale() { 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.6, 0, 1)); const tCams = Easing.easeOutCubic(clamp((localTime - 1.2) / 0.6, 0, 1)); const tWrf = Easing.easeOutCubic(clamp((localTime - 1.8) / 0.6, 0, 1)); const tStn = Easing.easeOutCubic(clamp((localTime - 2.4) / 0.6, 0, 1)); const tArr = Easing.easeOutCubic(clamp((localTime - 3.2) / 0.7, 0, 1)); const tOut = Easing.easeOutCubic(clamp((localTime - 4.2) / 0.7, 0, 1)); const tCap = Easing.easeOutCubic(clamp((localTime - 5.4) / 0.6, 0, 1)); const fadeOut = clamp((duration - localTime) / 0.45, 0, 1); // Layout: 3 input panels left, ML pipe middle, output right const inputs = [ { key: 'cams', label: 'CAMS · 40 km', sub: 'background químico', color: C.orange, t: tCams, y: 170, // Coarse 3×3 grid cells: 3, }, { key: 'wrf', label: 'WRF · 2 km', sub: 'meteorología regional', color: C.cyan, t: tWrf, y: 320, cells: 9, }, { key: 'stn', label: '20 estaciones SIATA', sub: 'PM2.5 in situ', color: C.amber, t: tStn, y: 470, cells: 0, }, ]; // Output high-res field (15x15 cells) const outCells = 15; return ( {/* Header */}
Capítulo VI · Ensamble local
Del modelo global a cada estación SIATA
{/* Input panels */} {inputs.map((inp, i) => (
{/* Card */}
{/* Label */}
{inp.label}
{inp.sub}
{/* Visual */}
0 ? 2 : 0, }}> {inp.cells > 0 ? ( Array.from({ length: inp.cells * inp.cells }, (_, k) => { const v = (Math.sin(k * 1.7 + time * 0.5) + 1) / 2; return (
); }) ) : ( // Station dots scattered {Array.from({ length: 20 }, (_, k) => { const r = rand(k + 9); const x = 8 + r() * 64; const y = 8 + r() * 64; const pulse = 0.55 + Math.sin(time * 2 + k) * 0.35; return ; })} )}
))} {/* Arrows + ML node */} {inputs.map((inp, i) => { const sy = inp.y + 55; const len = 1; // for dash anim return ( ); })} {/* ML out arrow */} {/* Travelling dots into ML */} {inputs.map((inp, i) => { const ph = ((time * 0.5 + i * 0.33) % 1); const x = 380 + (580 - 380) * ph; const y = (inp.y + 55) + (360 - (inp.y + 55)) * ph; return ( ); })} {/* ML / ensemble node */}
Machine
Learning
ensamble
{/* Output: 20 stations × 72h forecast heatmap */}
Pronóstico · 20 estaciones × 72 h
{/* Hour-axis header */}
T+0+24h+48h+72h
{/* Matrix: 20 rows × 24 cols */} {Array.from({ length: 20 }, (_, row) => (
{/* Station label */}
E{String(row + 1).padStart(2, '0')}
{/* Forecast cells */}
{Array.from({ length: 24 }, (_, col) => { // Per-station baseline (some stations consistently higher) const stationLevel = 0.4 + (Math.sin(row * 1.3) + 1) / 2 * 0.5; // Diurnal pattern + station phase const phase = col / 24 * Math.PI * 6 + row * 0.18; const diurnal = (Math.sin(phase - Math.PI / 2) + 1) / 2; const drift = (Math.sin(col * 0.18 + row * 0.07) + 1) / 2 * 0.3; const v = stationLevel * (0.4 + diurnal * 0.5) + drift; const color = v > 0.75 ? C.coral : v > 0.55 ? C.orange : v > 0.35 ? C.amber : C.green; const ap = Easing.easeOutCubic(clamp((localTime - 4.2 - (row + col) * 0.02) / 0.4, 0, 1)); return (
); })}
))} {/* Color legend strip */}
0µg/m³50+
{/* Bottom caption */}
Modelos de machine learning aprenden a corregir el sesgo de CAMS en la coordenada exacta de cada estación, usando WRF como contexto meteorológico y los datos históricos de PM2.5 como anclaje real.
); } // ─── Scene 7: Pronóstico 72h sobre el Valle ──────────────────────── function S7Forecast() { 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.6, 0, 1)); const tAxes = Easing.easeOutCubic(clamp((localTime - 1.0) / 0.5, 0, 1)); const tBands = Easing.easeOutCubic(clamp((localTime - 1.3) / 0.7, 0, 1)); const tCams = clamp((localTime - 1.8) / 1.8, 0, 1); const tCorr = clamp((localTime - 2.6) / 2.0, 0, 1); const tObs = Easing.easeOutCubic(clamp((localTime - 3.4) / 0.5, 0, 1)); const tLegend = Easing.easeOutCubic(clamp((localTime - 4.8) / 0.5, 0, 1)); const tCallout = Easing.easeOutCubic(clamp((localTime - 5.6) / 0.5, 0, 1)); const fadeOut = clamp((duration - localTime) / 0.45, 0, 1); // Chart bounds const cx0 = 220, cy0 = 220; const cw = 880, ch = 320; // Generate forecast curves (deterministic) const hours = 73; const data = React.useMemo(() => { const arr = []; for (let h = 0; h < hours; h++) { // Diurnal cycle + slow drift const hour = h % 24; const diurnal = 18 + Math.sin((hour - 6) / 24 * Math.PI * 2) * 14; const drift = Math.sin(h / 24 * Math.PI * 0.8) * 6; const noise = Math.sin(h * 0.7) * 2 + Math.cos(h * 1.3) * 1.5; const obs = Math.max(2, diurnal + drift + noise); // Corrected: closely tracks obs (model output ≈ measured) const corr = Math.max(2.5, obs + Math.sin(h * 0.4) * 1.2); // CAMS: dampened amplitude + systematic underestimation. // Enforced to stay at least 5 µg/m³ below the corrected line at every hour. const camsRaw = diurnal * 0.32 + drift * 0.15 + 3.5; const cams = Math.max(0.5, Math.min(camsRaw, corr - 5)); arr.push({ h, obs, cams, corr }); } return arr; }, []); // Y axis 0..50 const yMin = 0, yMax = 50; const xToPx = h => cx0 + (h / 72) * cw; const yToPx = v => cy0 + ch - (v - yMin) / (yMax - yMin) * ch; // ICA bands (Colombia): Buena 0-12, Aceptable 13-37, Dañina SG 38-55, etc. const bands = [ { from: 0, to: 12, color: C.green, label: 'Buena' }, { from: 12, to: 37, color: C.amber, label: 'Aceptable' }, { from: 37, to: 50, color: C.orange, label: 'Dañina · GS' }, ]; // Build path strings (animated by current progress) const buildPath = (key, prog) => { const cap = Math.floor(prog * (hours - 1)); if (cap <= 0) return ''; let p = 'M ' + xToPx(0) + ' ' + yToPx(data[0][key]); for (let h = 1; h <= cap; h++) { p += ' L ' + xToPx(h) + ' ' + yToPx(data[h][key]); } return p; }; return ( {/* Header */}
Capítulo VII · El resultado
Pronóstico horario · PM2.5 · 72 h
{/* Chart area */} {/* Bands */} {bands.map((b, i) => ( ))} {/* Band labels */} {bands.map((b, i) => ( {b.label} ))} {/* Axes */} {/* X ticks every 12h */} {[0, 12, 24, 36, 48, 60, 72].map((h, i) => { const x = xToPx(h); const isMid = h % 24 === 0; return ( {h === 0 ? 'T+0' : ('T+' + h + 'h')} {isMid && h > 0 && ( )} ); })} {/* Y ticks */} {[0, 12, 25, 37, 50].map((v, i) => ( {v} ))} µg/m³ {/* CAMS coarse line (dashed) */} {/* Corrected line (solid) */} {/* Glow under corrected line */} {/* Observed dots — only on first 24h */} {data.filter(d => d.h <= 24 && d.h % 3 === 0).map((d, i) => { const ap = clamp((tObs - i * 0.05), 0, 1); return ( ); })} {/* T-now divider */} AHORA {/* Annotation arrows: gap between cams and corrected */} {tCallout > 0 && (() => { const h = 48; const xA = xToPx(h); const yCams = yToPx(data[h].cams); const yCorr = yToPx(data[h].corr); return ( +Δ corregido ); })()} {/* Legend */}
CAMS · 40 km
background sin ajustar
Ajuste SIATA
CAMS + WRF + estaciones
Observado
20 estaciones
); } // ─── Scene 8: Outro / cierre ─────────────────────────────────────── function S8Outro() { const { localTime, duration } = useSprite(); const time = useTime(); // Subtle background flow const field = React.useMemo(() => directionalField(Math.PI * 0.98, 0.0035, 0.15, 0.55, 0.4), []); 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 tStats = Easing.easeOutCubic(clamp((localTime - 1.5) / 0.6, 0, 1)); const tFoot = Easing.easeOutCubic(clamp((localTime - 3.0) / 0.5, 0, 1)); const fadeOut = clamp((duration - localTime) / 0.6, 0, 1); const stats = [ { v: '4', l: 'fuentes integradas', c: C.cyan, d: 0 }, { v: '20', l: 'estaciones SIATA', c: C.amber, d: 0.15 }, { v: '72 h', l: 'horizonte de pronóstico', c: C.orange, d: 0.3, nowrap: true }, { v: '>80%', l: 'acierto categoría ICA', c: C.green, d: 0.45 }, ]; return (
El círculo se cierra
CAMS observa el planeta.
SIATA lo aterriza en el Valle.
{/* Stats grid */}
{stats.map((s, i) => { const ap = Easing.easeOutBack(clamp((localTime - 1.5 - s.d) / 0.6, 0, 1)); return (
{s.v}
{s.l}
); })}
{/* Footer */}
Publicado cada 6 horas en siata.gov.co · PM2.5 horario, 20 estaciones, 3 días vista.
CCT 261/2025 · SIATA
); } Object.assign(window, { S5Resolution, S6Downscale, S7Forecast, S8Outro });