// anim-helpers-v3.jsx — Institutional skin (SIATA · Área Metropolitana). // Same engine as v2, but with palette, typography and chrome aligned to // the SIATA Plantilla Presentaciones 2025: deep institutional navy, teal // & AMVA-green accents, Rubik typography, topographic contour decoration. // ── Palette ───────────────────────────────────────────────────────── const C = { // Institutional dark navy from the SIATA logo (azul oscuro). navy: '#0A2540', ink: '#081E36', panel: '#10355E', sky: '#1E88E5', // Primary accent: teal from the SIATA 2025 theme (#0DCCC0). cyan: '#0DCCC0', teal: '#00897B', // Institutional secondary: Área Metropolitana lime green. green: '#84BD45', amber: '#FFB300', orange: '#FF8A3D', coral: '#E8735A', rose: '#F472B6', purple: '#A78BFA', white: '#FFFFFF', gray: 'rgba(255,255,255,0.62)', dim: 'rgba(255,255,255,0.28)', faint: 'rgba(255,255,255,0.10)', }; // Rubik is the SIATA institutional typeface (theme1.xml). const FONT = "'Rubik', 'Plus Jakarta Sans', sans-serif"; const MONO = "'JetBrains Mono', ui-monospace, monospace"; const TOTAL_DUR = 60; // ── Generic helpers ───────────────────────────────────────────────── function rand(seed) { // tiny deterministic LCG let s = seed | 0; return function() { s = (s * 1664525 + 1013904223) | 0; return ((s >>> 0) % 1000000) / 1000000; }; } function fmt(n, digits) { return Number(n).toFixed(digits == null ? 1 : digits); } // ── WindyCanvas ───────────────────────────────────────────────────── // A Windy-style flow-field particle renderer. Particles get pushed // through a vector field, leaving fading trails on a canvas. // // Props: // width, height — canvas size in px // count — number of particles // field — fn(x, y, t) -> [vx, vy] (vector field) // palette — array of stroke colors; particles are striped over this // fade — 0..1 trail fade per frame (lower = longer trails) // speed — global velocity multiplier // life — max age in frames before respawn // lineWidth — stroke width // opacity — overall opacity (0..1) // mask — optional fn(x,y) -> 0..1 visibility (e.g. clip to map shape) // spawn — optional fn(t) -> {x,y} spawn point (default: anywhere) // reactKey — change to force restart of particle pool function WindyCanvas(props) { const { width, height, count = 1200, field, palette = ['#22d3ee', '#1E88E5', '#A78BFA', '#FFB300', '#FF8A3D'], fade = 0.05, speed = 1.2, life = 90, lineWidth = 1, opacity = 1, mask = null, spawn = null, reactKey = 0, background = null, } = props; const time = useTime(); const canvasRef = React.useRef(null); const stateRef = React.useRef(null); // Re-initialise pool when count/key changes React.useEffect(() => { const r = rand(42 + reactKey * 17); stateRef.current = { particles: Array.from({ length: count }, (_, i) => ({ x: r() * width, y: r() * height, ox: 0, oy: 0, age: Math.floor(r() * life), c: palette[i % palette.length], sp: 0.7 + r() * 0.7, })), lastT: time, }; // Clear canvas const c = canvasRef.current; if (c) { const ctx = c.getContext('2d'); ctx.clearRect(0, 0, width, height); if (background) { ctx.fillStyle = background; ctx.fillRect(0, 0, width, height); } } }, [count, reactKey, width, height]); // Per-frame draw React.useEffect(() => { const c = canvasRef.current; if (!c || !stateRef.current) return; const ctx = c.getContext('2d'); const st = stateRef.current; const dt = Math.min(0.05, Math.max(0.001, time - st.lastT)); st.lastT = time; // Trail fade: paint translucent background over previous frame ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = background ? hexWithAlpha(background, fade) : hexWithAlpha(C.navy, fade); ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = 'lighter'; ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; const r = rand((reactKey + 1) * 9301); const ps = st.particles; for (let i = 0; i < ps.length; i++) { const p = ps[i]; p.age++; const v = field(p.x, p.y, time); const vx = v[0] * speed * p.sp * 60 * dt; const vy = v[1] * speed * p.sp * 60 * dt; p.ox = p.x; p.oy = p.y; p.x += vx; p.y += vy; let alpha = 1; if (mask) { const m = mask(p.x, p.y); alpha = m; } const outOfBounds = p.x < -10 || p.x > width + 10 || p.y < -10 || p.y > height + 10; const tooOld = p.age > life; if (outOfBounds || tooOld || alpha <= 0.001) { // Respawn if (spawn) { const s = spawn(time, i); p.x = s.x; p.y = s.y; } else if (mask) { // Try a few times to find a spawn inside mask let tries = 0; do { p.x = r() * width; p.y = r() * height; tries++; } while (mask(p.x, p.y) < 0.2 && tries < 6); } else { p.x = r() * width; p.y = r() * height; } p.ox = p.x; p.oy = p.y; p.age = Math.floor(r() * (life * 0.4)); continue; } // Stroke segment const ageFactor = 1 - Math.abs((p.age / life) - 0.5) * 2; // peak in middle const a = opacity * alpha * (0.35 + 0.65 * ageFactor); ctx.strokeStyle = hexWithAlpha(p.c, a); ctx.beginPath(); ctx.moveTo(p.ox, p.oy); ctx.lineTo(p.x, p.y); ctx.stroke(); } ctx.globalCompositeOperation = 'source-over'; }); return ( ); } function hexWithAlpha(hex, a) { if (hex.startsWith('rgba') || hex.startsWith('rgb(')) return hex; const h = hex.replace('#', ''); const full = h.length === 3 ? h.split('').map(ch => ch + ch).join('') : h.substring(0, 6); const r = parseInt(full.substring(0, 2), 16); const g = parseInt(full.substring(2, 4), 16); const b = parseInt(full.substring(4, 6), 16); return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'; } // ── Common flow fields ────────────────────────────────────────────── function curlField(scale, timeScale, amp) { scale = scale || 0.004; timeScale = timeScale || 0.1; amp = amp || 1; return function(x, y, t) { const tt = t * timeScale; const a = Math.sin(x * scale + tt) + Math.cos(y * scale * 1.3 - tt * 0.7); const b = Math.cos(x * scale * 0.8 - tt * 0.5) + Math.sin(y * scale + tt); return [a * amp, b * amp]; }; } // Directional flow with curl perturbation (used for trade winds, etc.) function directionalField(angle, scale, timeScale, amp, swirl) { scale = scale || 0.005; timeScale = timeScale || 0.15; amp = amp || 1; swirl = swirl == null ? 0.6 : swirl; const cx = Math.cos(angle); const cy = Math.sin(angle); return function(x, y, t) { const tt = t * timeScale; const sw1 = Math.sin(x * scale + y * scale * 0.7 + tt); const sw2 = Math.cos(x * scale * 0.6 - y * scale + tt * 0.8); return [ (cx + sw1 * swirl) * amp, (cy + sw2 * swirl) * amp, ]; }; } // ── SceneFade helper (cross-fade in/out) ──────────────────────────── function SceneFade({ children, fadeIn = 0.5, fadeOut = 0.45 }) { const { localTime, duration } = useSprite(); const o = Math.min( Easing.easeOutCubic(clamp(localTime / fadeIn, 0, 1)), Easing.easeInCubic(clamp((duration - localTime) / fadeOut, 0, 1)) ); return (
{children}
); } // ── Caption block (kicker + title + sub) ─────────────────────────── function Caption({ kicker, title, sub, x = 80, y = 80, width = 520, align = 'left', delay = 0 }) { const { localTime } = useSprite(); const t1 = Easing.easeOutCubic(clamp((localTime - delay) / 0.5, 0, 1)); const t2 = Easing.easeOutCubic(clamp((localTime - delay - 0.2) / 0.5, 0, 1)); const t3 = Easing.easeOutCubic(clamp((localTime - delay - 0.4) / 0.5, 0, 1)); return (
{kicker && (
{kicker}
)} {title && (
{title}
)} {sub && (
{sub}
)}
); } // ── Topographic contour decoration (matches plantilla SIATA 2025) ── function TopoContours({ side = 'left', opacity = 0.18, color }) { const c = color || C.cyan; // Hand-drawn nested closed contours — mimic the topo lines on the // plantilla's "¡Gracias!" slide. Cluster sits in one corner. const paths = [ 'M -20 60 C 60 40, 140 90, 210 70 S 320 50, 360 100', 'M -20 100 C 70 78, 150 130, 220 110 S 330 95, 370 140', 'M -20 150 C 80 130, 160 175, 230 158 S 330 145, 380 188', 'M -20 200 C 90 178, 175 220, 240 205 S 335 195, 390 235', 'M -20 255 C 100 235, 180 270, 245 256 S 340 248, 400 285', 'M 30 30 C 80 20, 120 60, 150 50 S 200 30, 230 70', 'M 50 320 C 120 305, 180 335, 220 322 S 290 318, 340 350', ]; const transform = side === 'right' ? 'translate(1280, 0) scale(-1, 1)' : ''; return ( {paths.map((d, i) => ( ))} ); } // ── SIATA institutional brand mark (inline SVG, white on dark) ────── function SiataMark({ width = 72 }) { // Stylised re-creation of the SIATA sun-cloud glyph, simplified. // The original logo is bundled as assets/siata-logo.png for marketing // use; this inline mark gives crisp rendering at any size. const s = width / 220; return ( {/* Sun rays */} {[ [12, 60], [16, 32], [40, 14], [16, 88], [40, 106], ].map(([x, y], i) => { const cx = 70, cy = 60; const dx = x - cx, dy = y - cy; const len = Math.hypot(dx, dy); const ux = dx / len, uy = dy / len; return ( ); })} {/* Sun arc (open on right where cloud overlaps) */} {/* Cloud */} SIATA ); } // ── Bottom chrome: progress + scene dots + tick marker ───────────── function NavOverlay({ scenes }) { const { time, duration, setTime } = useTimeline(); let cur = 0; for (let i = 0; i < scenes.length; i++) { if (time >= scenes[i].start) cur = i; } return ( {/* ── Top institutional brand strip ─────────────────────────── */}
{/* Official SIATA + AMVA logo lockup */} SIATA · Área Metropolitana del Valle de Aburrá
Sistema de Alerta Temprana · Valle de Aburrá
{/* Right side : status pill */}
Operacional · 4×/día
{/* Progress bar */}
{/* Scene chips */}
{scenes.map(function(s, i) { const active = cur === i; return (
setTime(s.start + 0.3)} title={s.label} style={{ width: active ? 28 : 8, height: 8, borderRadius: 4, background: active ? C.cyan : 'rgba(255,255,255,0.18)', cursor: 'pointer', transition: 'all 0.3s ease', }} /> ); })}
{/* Scene label, bottom-left */}
{String(cur + 1).padStart(2, '0')} / {String(scenes.length).padStart(2, '0')} {scenes[cur].label}
{/* Brand, bottom-right */}
siata.gov.co · CCT 261/2025
); } // ── Tiny iconography ──────────────────────────────────────────────── function GridBg({ cell = 40, color = 'rgba(255,255,255,0.025)', dot = false }) { return (
); } Object.assign(window, { C, FONT, MONO, TOTAL_DUR, WindyCanvas, curlField, directionalField, hexWithAlpha, rand, fmt, SceneFade, Caption, NavOverlay, GridBg, TopoContours, SiataMark, });