// game.jsx — interactive prediction game for SIATA forecast. // 5 screens: Welcome → Round 1 (blind) → Round 2 (ingredients) → // Round 3 (decisions on 72h forecast) → Round 4 (why it matters). const STEPS = [ 'Bienvenida', 'N1 · Las fuentes', 'N2 · Significados', 'N3 · Matemáticas', 'N4 · Comprensión', 'N5a · A ciegas', 'N5b · Suma datos', 'N5c · Decide', 'N5d · Cierre', ]; // ─────────────────────────────────────────────────────────────── // ROUND 1 — BLIND GUESS // ─────────────────────────────────────────────────────────────── function RoundBlind({onNext, store, setStore}){ const [guess, setGuess] = React.useState(store.guess1 ?? 25); const [submitted, setSubmitted] = React.useState(store.guess1 != null); const real = 38; // "ground truth" for the round const diff = Math.abs(guess - real); const score = Math.max(0, Math.round(100 - diff*3)); return (
{/* Big number readout */}
Tu predicción · PM2.5 μg/m³
{pmEmoji(guess)} Aire {pmLabel(guess)}
{/* Slider */}
setGuess(Number(e.target.value))} />
{/* CTA / Reveal */} {!submitted ? (
{ setStore({...store, guess1:guess}); setSubmitted(true); }}>Confirmar mi predicción →
Una vez confirmes, te muestro lo que pasó realmente
) : ( )}
); } function Reveal({guess, real, score, onNext}){ return (
Tu margen de error
± {Math.abs(guess-real).toFixed(0)} μg/m³
Adivinar el aire es difícil: la diferencia entre respirar aire limpio y respirar aire contaminado son apenas 15 μg. Por eso los pronósticos no se hacen a ojo.
Veamos cómo lo hace la ciencia →
); } function MiniReadout({label, value, muted, highlight}){ return (
{label}
{value}
μg/m³
{pmLabel(value)}
); } // ─────────────────────────────────────────────────────────────── // ROUND 2 — INGREDIENTS (toggle data sources, watch uncertainty shrink) // ─────────────────────────────────────────────────────────────── const INGREDIENTS = [ { id:'sat', icon:'🛰️', name:'Satélites globales', sys:'CAMS · Copernicus', desc:'Cada 12 h, satélites europeos miden polvo, humo y gases sobre todo el planeta. Nos dicen, por ejemplo, si llega humo del Amazonas o polvo del Sahara.', reduces: 18, // μg/m³ uncertainty removed color:'#A78BFA', }, { id:'wrf', icon:'🌬️', name:'Modelo meteorológico local', sys:'WRF · 1 km Valle de Aburrá', desc:'Una simulación meteorológica del valle: viento, lluvia, inversión térmica. Nos dice si el material particulado se va a quedar atrapado entre las montañas o se va a dispersar.', reduces: 14, color:'#0DCCC0', }, { id:'sens', icon:'🌡️', name:'Sensores en tierra', sys:'20 estaciones SIATA', desc:'Mediciones reales, minuto a minuto, en estaciones del Valle. Corrigen al modelo cuando se equivoca: lo aterrizan al aire que de verdad estamos respirando.', reduces: 8, color:'#84BD45', }, ]; function RoundIngredients({onNext, store, setStore}){ const [active, setActive] = React.useState(store.active || {}); const baseUncertainty = 42; const reduced = INGREDIENTS.reduce((s,i)=> active[i.id] ? s + i.reduces : s, 0); const uncertainty = Math.max(2, baseUncertainty - reduced); const prediction = 38; const accuracy = Math.round( ((baseUncertainty - uncertainty) / baseUncertainty) * 100 ); const allOn = INGREDIENTS.every(i => active[i.id]); const toggle = (id)=>{ const next = {...active, [id]: !active[id]}; setActive(next); setStore({...store, active: next}); }; return (
{/* Left: ingredient toggles */}
{INGREDIENTS.map((ing, i)=>( toggle(ing.id)} delay={i} /> ))}
{/* Right: forecast band */}
{allOn ? 'Usar este pronóstico para decidir →' : 'Activa los 3 ingredientes para continuar'}
); } function IngredientCard({ing, active, onToggle, delay}){ return (
{ing.icon}
{ing.name}
{ing.sys}
{ing.desc}
); } function ForecastBand({prediction, uncertainty, accuracy, ingredients, active}){ const min = Math.max(0, prediction - uncertainty); const max = prediction + uncertainty; const scale = 80; // 0..80 μg/m³ const pct = (v) => Math.min(100, (v/scale)*100) + '%'; return (
Tu pronóstico para mañana
{prediction} μg/m³
± μg/m³ de incertidumbre
{/* Bar */}
{/* Color background */}
{/* Uncertainty band */}
{/* Prediction marker */}
{/* Labels */}
0
80 μg
{/* Accuracy bar */}
Certeza %
{/* Active sources */}
{ingredients.filter(i=>active[i.id]).length === 0 ? ↑ Activa fuentes de datos para reducir la incertidumbre : (
{ingredients.filter(i=>active[i.id]).map(i=>( {i.icon} {i.sys} ))}
) }
); } // ─────────────────────────────────────────────────────────────── // ROUND 3 — DECISIONS (apply the 72h forecast to 3 people) // ─────────────────────────────────────────────────────────────── const HOURS = Array.from({length:24}, (_,i) => i); // 3-day fake forecast, but plausible: bad morning, ok afternoon, peak Sunday const FORECAST = [ // Day 1 — Saturday {label:'Sábado', values: [22,28,34,38,42,46,48,44,38,32,28,24,22,20,18,17,18,22,28,34,38,42,40,36]}, // Day 2 — Sunday (asado peak evening) {label:'Domingo', values: [30,32,36,40,44,48,52,50,44,36,30,26,24,22,22,24,28,36,46,56,62,58,50,42]}, // Day 3 — Monday {label:'Lunes', values: [34,30,26,24,22,20,22,28,32,30,26,22,20,18,16,16,18,22,26,30,32,30,28,26]}, ]; const PEOPLE = [ { id:'sofia', emoji:'🏃‍♀️', name:'Sofía, 12 años', plan:'Quiere correr en el parque mañana sábado a las 7 a.m.', when:{day:0, hour:7}, options:[ {id:'go', label:'Sí, que corra'}, {id:'late', label:'Esperar a la tarde'}, {id:'no', label:'Mejor que descanse'}, ], best:'late', feedback:{ go:{ok:false, text:'A las 7 a.m. el aire está en su peor momento (~48 μg/m³). Sofía termina con tos y dolor de cabeza.'}, late:{ok:true, text:'¡Perfecto! Hacia las 3 p.m. el viento baja el PM2.5 a ~17 μg/m³ — aire aceptable para correr.'}, no:{ok:false, text:'No hace falta cancelar. El pronóstico muestra una ventana limpia por la tarde — solo había que esperar.'}, }, }, { id:'carlos', emoji:'🔥', name:'Don Carlos, 58 años', plan:'Quiere hacer un asado con leña el domingo a las 7 p.m.', when:{day:1, hour:19}, options:[ {id:'go', label:'Encender la leña'}, {id:'gas', label:'Cambiar a parrilla a gas'}, {id:'move', label:'Aplazar al lunes'}, ], best:'move', feedback:{ go:{ok:false, text:'Domingo a las 7 p.m. el valle está en ~56 μg/m³ y atrapando humo bajo inversión térmica. Sumar leña empeora todo el barrio.'}, gas:{ok:false, text:'Mejor, pero el aire afuera ya está dañino — los invitados respirarán mal igual.'}, move:{ok:true, text:'Lunes 7 p.m. el pronóstico baja a ~30 μg/m³. Asado feliz, vecinos felices.'}, }, }, { id:'rosa', emoji:'🚶‍♀️', name:'Doña Rosa, 71 años, asmática', plan:'Acostumbra caminar 30 minutos cada mañana. ¿Y el lunes?', when:{day:2, hour:8}, options:[ {id:'go', label:'Caminar como siempre'}, {id:'mask', label:'Caminar con tapabocas'}, {id:'in', label:'Caminar adentro'}, ], best:'go', feedback:{ go:{ok:true, text:'Lunes 8 a.m. el aire estará en ~28 μg/m³ — aceptable para caminar. ¡Que disfrute!'}, mask:{ok:false, text:'No es necesario. El pronóstico muestra aire aceptable — el tapabocas es para los días dañinos.'}, in:{ok:false, text:'Encerrarla cuando no hace falta le quita salud. El pronóstico es para usarlo, no para tenerle miedo.'}, }, }, ]; function RoundDecide({onNext, store, setStore}){ const [choices, setChoices] = React.useState(store.choices || {}); const allDone = PEOPLE.every(p => choices[p.id]); const pickFor = (pid, oid) => { const next = {...choices, [pid]:oid}; setChoices(next); setStore({...store, choices: next}); }; return (
{PEOPLE.map(p => ( pickFor(p.id, o)} /> ))}
{allDone ? 'Ver mi resultado →' : 'Decide por las 3 personas para continuar'}
); } function ForecastChart({forecast, highlights, choices}){ // 72 bars total const max = 70; return (
Pronóstico 72 h · PM2.5 μg/m³
Valle de Aburrá · próximos 3 días
{forecast.flatMap((day, di) => day.values.map((v, hi) => { const idx = di*24 + hi; const hl = highlights.find(p => p.when.day===di && p.when.hour===hi); const chosen = hl && choices[hl.id]; const isBest = hl && chosen === hl.best; return (
{hl && (
{hl.emoji}
)}
); }))} {/* Day separators */} {[24, 48].map(d => (
))}
{/* Day labels */}
{forecast.map(d =>
{d.label}
)}
); } function PMScaleMini(){ const items = [ {c:'#00E676', l:'0–12'}, {c:'#FFEB3B', l:'12–25'}, {c:'#FFB300', l:'25–37'}, {c:'#FF8A3D', l:'37–55'}, {c:'#E8735A', l:'55+'}, ]; return (
{items.map((i,k)=>(
{i.l}
))}
); } function PersonCard({person, chosen, onPick}){ const fb = chosen ? person.feedback[chosen] : null; return (
{person.emoji}
{person.name}
{person.plan}
{person.options.map(o => { const sel = chosen === o.id; const showResult = !!chosen; const isBest = o.id === person.best; return ( ); })}
{chosen && (
{fb.ok ? '¡Buena decisión!' : 'Hmm...'} {fb.text}
)}
); } // ─────────────────────────────────────────────────────────────── // ROUND 4 — WHY IT MATTERS (recap + impact) // ─────────────────────────────────────────────────────────────── function RoundOutro({onRestart, store}){ const correct = PEOPLE.filter(p => store.choices?.[p.id] === p.best).length; const verdict = correct === 3 ? {title:'¡Pronosticador del SIATA!', sub:'Las 3 decisiones acertadas. Sabes leer el aire del valle.'} : correct === 2 ? {title:'Vas por buen camino', sub:'2 de 3. Mira la gráfica de nuevo — el aire cambia mucho según la hora.'} : correct === 1 ? {title:'Aprendizaje en curso', sub:'1 de 3. El pronóstico solo sirve si lo lees con calma — la peor hora no siempre es la noche.'} : {title:'Vuelve a intentarlo', sub:'Las decisiones rápidas no son aliadas del aire. Reinicia y observa las horas pico.'}; return (
Lo que aprendiste · resumen
🫁
Por eso pronosticamos
El pronóstico no es un juego. Le dice a colegios cuándo cancelar deporte al aire libre, a hospitales cuándo preparar más broncodilatadores, a alcaldías cuándo declarar pico-y-placa ambiental. Tu cuerpo lo agradece sin enterarse.
); } function ImpactStat({num, label, tone}){ const toneC = {cyan:'#0DCCC0', green:'#84BD45', amber:'#FFB300'}[tone] || '#0DCCC0'; return (
{num}
{label}
); } function LessonRow({num, title, text}){ return (
{num}
{title}
{text}
); } // ─────────────────────────────────────────────────────────────── // WELCOME — cinematic hero // ─────────────────────────────────────────────────────────────── function Welcome({onStart}){ return (
SIATA · Valle de Aburrá

¿Puedes predecir el aire
del Valle de Aburrá?

Conviértete en pronosticador del SIATA. 5 niveles para entender, paso a paso, cómo predecir el aire del valle — desde las fuentes que usamos hasta las decisiones que cambian vidas.

{[ {n:'01', t:'Las fuentes', d:'Conoce a CAMS, WRF y los sensores', c:'#A78BFA'}, {n:'02', t:'Significados', d:'Qué ve y qué no ve cada uno', c:'#0DCCC0'}, {n:'03', t:'Matemáticas', d:'Las 3 ecuaciones que mueven todo', c:'#FFB300'}, {n:'04', t:'Comprensión', d:'Test de 5 preguntas', c:'#FF8A3D'}, {n:'05', t:'A pronosticar', d:'4 rondas de decisiones reales', c:'#84BD45'}, ].map((s, i) => (
Nivel {s.n}
{s.t}
{s.d}
))}
Empezar Nivel 1 →
∼ 10 minutos · niños desde 10 años · curiosos de toda edad
); } // ─────────────────────────────────────────────────────────────── // APP // ─────────────────────────────────────────────────────────────── function App(){ const [step, setStep] = React.useState(0); const [store, setStore] = React.useState({}); const [levelKey, setLevelKey] = React.useState(0); const next = ()=> setStep(s => Math.min(STEPS.length-1, s+1)); const restart = ()=> { setStep(0); setStore({}); setLevelKey(k=>k+1); window.scrollTo({top:0, behavior:'smooth'}); }; const retryLevel = ()=> { const s = {...store}; if (step === 4) delete s.quiz; if (step === 5) delete s.guess1; if (step === 6) delete s.active; if (step === 7) delete s.choices; setStore(s); setLevelKey(k => k + 1); window.scrollTo({top:0, behavior:'smooth'}); }; React.useEffect(()=>{ window.scrollTo({top:0, behavior:'smooth'}); }, [step]); return (
{step > 0 && } {step === 0 && } {step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && } {step === 5 && } {step === 6 && } {step === 7 && } {step === 8 && }
); } ReactDOM.createRoot(document.getElementById('root')).render();