/* ============================================================ AGROTHECA — shared components & hooks (exported to window) ============================================================ */ const { useState, useEffect, useRef, useCallback } = React; /* ---------------- Inline icon set (stroke) ---------------- */ const ICON_PATHS = { leaf: 'M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z|M2 21c0-3 1.85-5.36 5.08-6', bug: 'M8 2l1.88 1.88|M14.12 3.88 16 2|M9 7.13v-1a3.003 3.003 0 1 1 6 0v1|M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6Z|M12 20v-9|M6.53 9C4.6 8.8 3 7.1 3 5|M6 13H2|M3 21c0-2.1 1.7-3.9 3.8-4|M20.97 5c0 2.1-1.6 3.8-3.5 4|M22 13h-4|M17.2 17c2.1.1 3.8 1.9 3.8 4', sprout: 'M7 20h10|M10 20c5.5-2.5.8-6.4 3-10|M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8Z|M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2Z', cloud: 'M17.5 19a4.5 4.5 0 1 0 0-9h-1.8A7 7 0 1 0 4 16.3|M12 12l-3 5h4l-3 5', terminal: 'M7 11l3-3-3-3|M13 15h5', search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z|M21 21l-4.3-4.3', microscope: 'M6 18h8|M3 22h18|M14 22a7 7 0 0 0 0-14|M9 14h2|M9 12a2 2 0 0 1-2-2V6h4v4a2 2 0 0 1-2 2Z|M12 6V3a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v3', database: 'M12 8c4.97 0 9-1.34 9-3s-4.03-3-9-3-9 1.34-9 3 4.03 3 9 3Z|M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5|M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3', arrowRight: 'M5 12h14|M13 6l6 6-6 6', arrowUpRight: 'M7 17 17 7|M7 7h10v10', plus: 'M12 5v14|M5 12h14', play: 'M6 4l14 8-14 8V4Z', menu: 'M3 6h18|M3 12h18|M3 18h18', globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z|M3.6 9h16.8|M3.6 15h16.8|M12 3a14 14 0 0 0 0 18|M12 3a14 14 0 0 1 0 18' }; function Ico({ name, size = 18, sw = 1.6, style }) { const d = ICON_PATHS[name] || ''; return ( {d.split('|').map((p, i) => )} ); } /* ---------------- useInView (IO + rect fallback) ---------------- */ function useInView(opts) { const ref = useRef(null); const [inView, setInView] = useState(false); useEffect(() => { const el = ref.current;if (!el) return; let done = false,io = null,t = 0; const cleanup = () => { if (io) io.disconnect(); window.removeEventListener('scroll', check, true); window.removeEventListener('resize', check); clearTimeout(t); }; const reveal = () => {if (done) return;done = true;setInView(true);cleanup();}; function check() { const r = el.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; if (r.top < vh * 0.92 && r.bottom > 0) reveal(); } if (typeof IntersectionObserver !== 'undefined') { io = new IntersectionObserver(([e]) => {if (e.isIntersecting) reveal();}, { threshold: 0.15, rootMargin: '0px 0px -8% 0px', ...(opts || {}) }); io.observe(el); } // Fallback for environments where IO never fires: rect checks on scroll + initial. window.addEventListener('scroll', check, true); window.addEventListener('resize', check); requestAnimationFrame(check); t = setTimeout(check, 350); return cleanup; }, []); return [ref, inView]; } /* ---------------- Reveal wrapper ---------------- */ function Reveal({ as = 'div', delay, className = '', style, children, ...rest }) { const [ref, inView] = useInView(); const Tag = as; return ( {children} ); } /* ---------------- RiseText (staggered letters) ---------------- */ function RiseText({ text, className = '', style, step = 0.04, start = 0.05, trigger }) { const [ref, inView] = useInView({ threshold: 0.25 }); const on = trigger === undefined ? inView : trigger; const words = String(text).split(' '); let idx = 0; return ( {words.map((w, wi) => {[...w].map((ch, ci) => { const d = start + idx++ * step; return ( {ch} ); })} {wi < words.length - 1 && {'\u00A0'}} )} ); } /* ---------------- OrganicTransitionImage (dissolve) ---------------- */ function OrganicTransitionImage({ src, alt = '', className = '', style }) { const base = useRef('org' + Math.random().toString(36).slice(2)).current; const fIn = base + '-in',fOut = base + '-out'; const dispIn = useRef(null),dispOut = useRef(null); const raf = useRef(0); const [st, setSt] = useState({ curr: src, prev: null, p: 1 }); useEffect(() => { setSt((s) => s.curr === src ? s : { curr: src, prev: s.curr, p: 0 }); }, [src]); useEffect(() => { if (st.prev == null) return; let start = null;const dur = 1000; const tick = (t) => { if (start === null) start = t; const p = Math.min(1, (t - start) / dur); const enter = 1 - Math.pow(1 - p, 4); const exit = Math.pow(p, 3); if (dispIn.current) dispIn.current.setAttribute('scale', String((1 - enter) * 130)); if (dispOut.current) dispOut.current.setAttribute('scale', String(exit * 170)); setSt((s) => ({ ...s, p })); if (p < 1) raf.current = requestAnimationFrame(tick);else setSt((s) => ({ curr: s.curr, prev: null, p: 1 })); }; raf.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf.current); }, [st.prev, st.curr]); const enter = 1 - Math.pow(1 - st.p, 4); const exit = Math.pow(st.p, 3); const animating = st.prev != null; const imgBase = { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }; return (
{animating && } {alt}
); } /* ---------------- Brand corner ---------------- */ function BrandCorner({ dark = false, tagline = true, href = 'index.html' }) { return ( AGROTECHA {tagline && CIENCIA AGRÍCOLA PARA MENTES CURIOSAS} ); } /* ---------------- Animated counter numeral ---------------- */ function Counter({ value, total = 5 }) { const n = String(value + 1).padStart(2, '0'); return ( {n} / {String(total).padStart(2, '0')} ); } /* ---------------- RotatingWord (cycles words in a loop) ---------------- */ function RotatingWord({ words, interval = 2200, className = '', style }) { const [i, setI] = useState(0); const [phase, setPhase] = useState('in'); // 'in' | 'out' useEffect(() => { const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduce) { const id = setInterval(() => setI((p) => (p + 1) % words.length), interval + 600); return () => clearInterval(id); } let outT, swapT; const id = setInterval(() => { setPhase('out'); outT = setTimeout(() => { setI((p) => (p + 1) % words.length); setPhase('in'); }, 520); }, interval); return () => { clearInterval(id); clearTimeout(outT); clearTimeout(swapT); }; }, [words, interval]); // width follows the widest word so layout doesn't jump const widest = words.reduce((a, b) => (b.length > a.length ? b : a), ''); return ( {words[i]} ); } /* ---------------- RootsBackdrop (book + growing roots, looping canvas) ---------------- */ function RootsBackdrop({ accent = '#C27D38' }) { const ref = useRef(null); useEffect(() => { const cv = ref.current; if (!cv) return; const ctx = cv.getContext('2d'); // brighter stroke colour derived from accent (keeps brand hue, more visible) function lighten(hex, amt) { const n = parseInt(hex.slice(1), 16); let r = n >> 16 & 255, g = n >> 8 & 255, b = n & 255; r = Math.round(r + (255 - r) * amt); g = Math.round(g + (255 - g) * amt); b = Math.round(b + (255 - b) * amt); return `rgb(${r},${g},${b})`; } const stroke = lighten(accent, 0.22); const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; let w = 0, h = 0, dpr = Math.min(2, window.devicePixelRatio || 1), raf = 0; const GROW = 6.2, HOLD = 1.6, FADE = 2.0, SPAWN_AT = 0.62; // seconds / fraction function bookSpine() { return [w / 2, Math.min(h - 40, h * 0.88)]; } function makeTree() { const [bx, by] = bookSpine(); const tree = []; const baseLen = Math.min(h * 0.2, 150); function branch(x, y, ang, len, wd, depth, t0) { if (depth <= 0 || len < 6) return; const x2 = x + Math.cos(ang) * len, y2 = y + Math.sin(ang) * len; const off = (Math.random() - 0.5) * len * 0.5; const cx = (x + x2) / 2 + Math.cos(ang + Math.PI / 2) * off; const cy = (y + y2) / 2 + Math.sin(ang + Math.PI / 2) * off; const t1 = Math.min(1, t0 + 0.15 + Math.random() * 0.05); tree.push({ x, y, cx, cy, x2, y2, w: wd, t0: Math.min(t0, 0.95), t1 }); const n = Math.random() < 0.65 ? 2 : 3; for (let i = 0; i < n; i++) { const spread = 0.42 + Math.random() * 0.5; const na = ang + (i === 0 ? -spread : i === 1 ? spread : (Math.random() - 0.5) * 0.4); branch(x2, y2, na, len * (0.66 + Math.random() * 0.12), wd * 0.72, depth - 1, t1); } } branch(bx, by, -Math.PI / 2 - 0.42, baseLen * 0.82, 4.0, 6, 0.01); branch(bx, by, -Math.PI / 2 - 0.2, baseLen, 4.6, 6, 0); branch(bx, by, -Math.PI / 2 + 0.2, baseLen, 4.6, 6, 0.02); branch(bx, by, -Math.PI / 2 + 0.42, baseLen * 0.82, 4.0, 6, 0.01); branch(bx, by, -Math.PI / 2, baseLen * 1.08, 5.0, 6, 0); return tree; } let gens = []; function resize() { w = cv.clientWidth; h = cv.clientHeight; cv.width = w * dpr; cv.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); gens = [{ birth: performance.now() / 1000, tree: makeTree() }]; } resize(); const ro = new ResizeObserver(resize); ro.observe(cv); function quad(p, x0, y0, cx, cy, x1, y1) { const m = 1 - p; return [m * m * x0 + 2 * m * p * cx + p * p * x1, m * m * y0 + 2 * m * p * cy + p * p * y1]; } function drawTree(tree, g, alpha) { ctx.save(); ctx.globalAlpha = alpha; ctx.lineCap = 'round'; ctx.strokeStyle = stroke; ctx.shadowColor = accent; ctx.shadowBlur = 10; for (const s of tree) { const local = (g - s.t0) / (s.t1 - s.t0); if (local <= 0) continue; const f = Math.min(1, local); ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineWidth = s.w; for (let i = 1; i <= 8; i++) { const p = (i / 8) * f; const pt = quad(p, s.x, s.y, s.cx, s.cy, s.x2, s.y2); ctx.lineTo(pt[0], pt[1]); } ctx.stroke(); if (f < 1) { const pt = quad(f, s.x, s.y, s.cx, s.cy, s.x2, s.y2); ctx.beginPath(); ctx.arc(pt[0], pt[1], s.w * 0.9 + 1.4, 0, 7); ctx.fillStyle = stroke; ctx.fill(); } } ctx.restore(); } function drawBook(alpha) { const [bx, by] = bookSpine(); ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = accent; ctx.lineWidth = 2.2; ctx.lineJoin = 'round'; ctx.shadowColor = accent; ctx.shadowBlur = 6; const pw = Math.min(120, w * 0.12), ph = pw * 0.44; ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx - pw, by - ph * 0.5); ctx.lineTo(bx - pw, by + ph * 0.32); ctx.lineTo(bx, by + ph * 0.5); ctx.closePath(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx + pw, by - ph * 0.5); ctx.lineTo(bx + pw, by + ph * 0.32); ctx.lineTo(bx, by + ph * 0.5); ctx.closePath(); ctx.stroke(); ctx.globalAlpha = alpha * 0.45; ctx.lineWidth = 1; for (let i = 1; i <= 3; i++) { const yy = by - ph * 0.5 + i * ph * 0.2; ctx.beginPath(); ctx.moveTo(bx - pw * 0.8, yy + i * 1.4); ctx.lineTo(bx - pw * 0.12, yy); ctx.stroke(); ctx.beginPath(); ctx.moveTo(bx + pw * 0.12, yy); ctx.lineTo(bx + pw * 0.8, yy + i * 1.4); ctx.stroke(); } ctx.restore(); } function frame(nowMs) { const now = nowMs / 1000; ctx.clearRect(0, 0, w, h); drawBook(0.55 + 0.12 * Math.sin(now * 1.1)); for (const gen of gens) { const age = now - gen.birth; const g = Math.min(1, age / GROW); const inA = Math.min(1, age / 0.5); const mature = GROW + HOLD; const alpha = age < mature ? inA : Math.max(0, 1 - (age - mature) / FADE); gen._g = g; gen._age = age; drawTree(gen.tree, g, alpha); } const newest = gens[gens.length - 1]; if (newest._g >= SPAWN_AT && gens.length < 2) gens.push({ birth: now, tree: makeTree() }); gens = gens.filter((gn) => gn._age === undefined || gn._age < GROW + HOLD + FADE); if (!gens.length) gens.push({ birth: now, tree: makeTree() }); raf = requestAnimationFrame(frame); } if (reduce) { ctx.clearRect(0, 0, w, h); drawBook(0.6); drawTree(gens[0].tree, 1, 0.8); } else raf = requestAnimationFrame(frame); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, [accent]); return