/* ============================================================
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 (
);
}
/* ---------------- 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 (
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 ;
}
Object.assign(window, {
Ico, useInView, Reveal, RiseText, OrganicTransitionImage, BrandCorner, Counter,
HeroMedia, useParallax, RotatingWord, RotatingPhrase, RootsBackdrop
});
/* ---------------- RotatingPhrase (cycles whole phrases, wraps + reserves height) ---------------- */
function RotatingPhrase({ phrases, interval = 2800, className = '', style }) {
const [i, setI] = useState(0);
const [phase, setPhase] = useState('in');
useEffect(() => {
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
const id = setInterval(() => setI((p) => (p + 1) % phrases.length), interval + 800);
return () => clearInterval(id);
}
let outT;
const id = setInterval(() => {
setPhase('out');
outT = setTimeout(() => {
setI((p) => (p + 1) % phrases.length);
setPhase('in');
}, 520);
}, interval);
return () => { clearInterval(id); clearTimeout(outT); };
}, [phrases, interval]);
return (
{phrases.map((p, idx) => (
{p}
))}
);
}
/* ---------------- useParallax (mouse) ---------------- */
function useParallax(strength = 18) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;if (!el) return;
const host = el.closest('header') || el.parentElement || document.body;
let raf = 0,tx = 0,ty = 0,cx = 0,cy = 0;
const onMove = (e) => {
const r = host.getBoundingClientRect();
tx = ((e.clientX - r.left) / r.width - 0.5) * 2;
ty = ((e.clientY - r.top) / r.height - 0.5) * 2;
if (!raf) raf = requestAnimationFrame(loop);
};
const loop = () => {
cx += (tx - cx) * 0.08;cy += (ty - cy) * 0.08;
el.style.setProperty('--px', (cx * strength).toFixed(2) + 'px');
el.style.setProperty('--py', (cy * strength).toFixed(2) + 'px');
if (Math.abs(tx - cx) > 0.001 || Math.abs(ty - cy) > 0.001) raf = requestAnimationFrame(loop);else
raf = 0;
};
host.addEventListener('mousemove', onMove);
return () => {host.removeEventListener('mousemove', onMove);cancelAnimationFrame(raf);};
}, [strength]);
return ref;
}
/* ---------------- HeroMedia (video + animated "invisible science" canvas) ----------------
- If assets/hero-video.mp4 exists it plays under the canvas; otherwise the still + canvas show.
- The canvas draws drifting spores, faint links and slow growing tendrils — works with no asset. */
function HeroMedia({ show, poster = 'assets/hero.jpg', video = 'assets/hero-video.mp4' }) {
const canvasRef = useRef(null);
const [hasVideo, setHasVideo] = useState(false);
useEffect(() => {
const v = document.createElement('video');
v.muted = true;
v.addEventListener('loadeddata', () => setHasVideo(true));
v.addEventListener('error', () => setHasVideo(false));
v.src = video;
return () => {v.src = '';};
}, [video]);
useEffect(() => {
const cv = canvasRef.current;if (!cv) return;
const ctx = cv.getContext('2d');
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,t0 = performance.now();
const N = 60;
const dots = Array.from({ length: N }, () => ({
x: Math.random(), y: Math.random(), r: Math.random() * 1.8 + 0.4,
vx: (Math.random() - 0.5) * 0.00006, vy: -(Math.random() * 0.00012 + 0.00003),
ph: Math.random() * 6.28, sp: Math.random() * 0.4 + 0.2
}));
const tendrils = Array.from({ length: 5 }, (_, i) => ({
x: 0.12 + i * 0.19 + (Math.random() - 0.5) * 0.05, len: Math.random() * 0.18 + 0.12,
sway: Math.random() * 0.02 + 0.01, ph: Math.random() * 6.28, born: Math.random() * 8000
}));
function resize() {
w = cv.clientWidth;h = cv.clientHeight;
cv.width = w * dpr;cv.height = h * dpr;ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
const ro = new ResizeObserver(resize);ro.observe(cv);
function frame(now) {
const t = now - t0;
ctx.clearRect(0, 0, w, h);
ctx.lineCap = 'round';
tendrils.forEach((td) => {
const prog = Math.max(0, Math.min(1, (t - td.born) / 5000));
if (prog <= 0) return;
const baseX = td.x * w,baseY = h + 10;
const segs = 26;ctx.beginPath();
for (let s = 0; s <= segs * prog; s++) {
const u = s / segs;
const yy = baseY - u * td.len * h * 3.0;
const xx = baseX + Math.sin(u * 7 + td.ph + t * 0.0004) * td.sway * w * (0.4 + u);
s === 0 ? ctx.moveTo(xx, yy) : ctx.lineTo(xx, yy);
}
ctx.strokeStyle = 'rgba(214,164,94,0.13)';ctx.lineWidth = 1.4;ctx.stroke();
});
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
const a = dots[i],b = dots[j];
const dx = (a.x - b.x) * w,dy = (a.y - b.y) * h;
const d2 = dx * dx + dy * dy;
if (d2 < 120 * 120) {
const al = (1 - Math.sqrt(d2) / 120) * 0.10;
ctx.strokeStyle = `rgba(157,177,140,${al.toFixed(3)})`;
ctx.lineWidth = 0.6;ctx.beginPath();
ctx.moveTo(a.x * w, a.y * h);ctx.lineTo(b.x * w, b.y * h);ctx.stroke();
}
}
}
dots.forEach((p) => {
if (!reduce) {
p.x += p.vx;p.y += p.vy;
if (p.y < -0.02) {p.y = 1.02;p.x = Math.random();}
if (p.x < -0.02) p.x = 1.02;if (p.x > 1.02) p.x = -0.02;
}
const tw = 0.5 + 0.5 * Math.sin(t * 0.001 * p.sp + p.ph);
const px = p.x * w,py = p.y * h;
const g = ctx.createRadialGradient(px, py, 0, px, py, p.r * 6);
g.addColorStop(0, `rgba(231,200,140,${(0.5 * tw).toFixed(3)})`);
g.addColorStop(1, 'rgba(231,200,140,0)');
ctx.fillStyle = g;ctx.beginPath();ctx.arc(px, py, p.r * 6, 0, 7);ctx.fill();
ctx.fillStyle = `rgba(244,236,210,${(0.7 * tw).toFixed(3)})`;
ctx.beginPath();ctx.arc(px, py, p.r, 0, 7);ctx.fill();
});
if (!reduce) raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
if (reduce) frame(t0 + 3000);
return () => {cancelAnimationFrame(raf);ro.disconnect();};
}, []);
return (