// ============================================================
// PACE — Primitivas de motion (Reveal, CountUp, Marquee,
// Magnetic, Parallax, ScrollProgress, Cursor)
// ============================================================
const { useState, useEffect, useRef, useCallback } = React;
// ---- useInView hook (rect-based: fiable en iframes y capturas) ----
console.log("pace-motion v2 (rect-based reveals)");
function useInView(opts) {
const ref = useRef(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
let done = false;
const check = () => {
if (done || !ref.current) return;
const r = ref.current.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
if (r.top < vh * 0.92 && r.bottom > 0) {
done = true;
setInView(true);
window.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
}
};
// chequeo inicial tras el primer layout
const t = setTimeout(check, 60);
window.addEventListener("scroll", check, { passive: true });
window.addEventListener("resize", check);
return () => {
clearTimeout(t);
window.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
};
}, []);
return [ref, inView];
}
// ---- Reveal ----
function Reveal({ children, delay, as, className, style }) {
const [ref, inView] = useInView();
const Tag = as || "div";
return (
{children}
);
}
// ---- CountUp ----
function CountUp({ end, prefix, suffix, duration }) {
const [ref, inView] = useInView({ threshold: 0.6 });
const [val, setVal] = useState(0);
useEffect(() => {
if (!inView) return;
const dur = duration || 1600;
const t0 = performance.now();
let raf;
const tick = (t) => {
const p = Math.min(1, (t - t0) / dur);
const eased = 1 - Math.pow(1 - p, 4);
setVal(Math.round(end * eased));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [inView, end]);
return (
{(prefix || "") + val.toLocaleString("es-CO") + (suffix || "")}
);
}
// ---- Marquee ----
function Marquee({ words, dark }) {
const items = [];
for (let r = 0; r < 3; r++) {
words.forEach((w, i) => {
items.push(
{w} /
);
});
}
return (
);
}
// ---- Magnetic button wrapper ----
function Magnetic({ children, strength }) {
const ref = useRef(null);
const s = strength == null ? 0.25 : strength;
const onMove = useCallback(
(e) => {
const el = ref.current;
if (!el || window.matchMedia("(pointer: coarse)").matches) return;
const r = el.getBoundingClientRect();
const x = e.clientX - (r.left + r.width / 2);
const y = e.clientY - (r.top + r.height / 2);
el.style.transform = "translate(" + x * s + "px," + y * s + "px)";
},
[s]
);
const onLeave = useCallback(() => {
const el = ref.current;
if (!el) return;
el.style.transition = "transform 0.6s cubic-bezier(0.22,1,0.36,1)";
el.style.transform = "translate(0,0)";
setTimeout(() => { if (ref.current) ref.current.style.transition = ""; }, 600);
}, []);
return (
{children}
);
}
// ---- Parallax image ----
function ParallaxImg({ src, alt, className, style, strength }) {
const wrapRef = useRef(null);
const imgRef = useRef(null);
const k = strength == null ? 0.07 : strength;
useEffect(() => {
let raf = null;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = null;
const wrap = wrapRef.current;
const img = imgRef.current;
if (!wrap || !img) return;
const r = wrap.getBoundingClientRect();
const center = r.top + r.height / 2 - window.innerHeight / 2;
img.style.transform = "translateY(" + center * -k + "px) scale(" + (1 + k * 2.2) + ")";
});
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [k]);
return (
);
}
// ---- Scroll progress bar ----
function ScrollProgress() {
const ref = useRef(null);
useEffect(() => {
const onScroll = () => {
const el = ref.current;
if (!el) return;
const h = document.documentElement;
const p = h.scrollTop / (h.scrollHeight - h.clientHeight);
el.style.width = p * 100 + "%";
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return ;
}
// ---- Custom cursor ----
function CustomCursor({ enabled }) {
const dotRef = useRef(null);
const ringRef = useRef(null);
useEffect(() => {
if (!enabled || window.matchMedia("(pointer: coarse)").matches) {
document.body.classList.remove("cursor-on");
return;
}
document.body.classList.add("cursor-on");
let rx = -100, ry = -100, tx = -100, ty = -100;
let raf;
const onMove = (e) => {
tx = e.clientX;
ty = e.clientY;
if (dotRef.current)
dotRef.current.style.transform = "translate(" + (tx - 3.5) + "px," + (ty - 3.5) + "px)";
const el = e.target.closest && e.target.closest("a, button, .goal-tab");
if (ringRef.current) ringRef.current.classList.toggle("hot", !!el);
};
const loop = () => {
rx += (tx - rx) * 0.16;
ry += (ty - ry) * 0.16;
if (ringRef.current) {
const w = ringRef.current.offsetWidth;
ringRef.current.style.transform =
"translate(" + (rx - w / 2) + "px," + (ry - w / 2) + "px)";
}
raf = requestAnimationFrame(loop);
};
window.addEventListener("mousemove", onMove, { passive: true });
raf = requestAnimationFrame(loop);
return () => {
window.removeEventListener("mousemove", onMove);
cancelAnimationFrame(raf);
document.body.classList.remove("cursor-on");
};
}, [enabled]);
if (!enabled) return null;
return (
);
}
// ---- Speed lines decorativas (eco del logo) ----
function SpeedLines({ count, width, style, animate }) {
const [ref, inView] = useInView({ threshold: 0.4 });
const n = count || 5;
const lines = [];
for (let i = 0; i < n; i++) {
lines.push(
);
}
return (
{lines}
);
}
Object.assign(window, {
useInView,
Reveal,
CountUp,
Marquee,
Magnetic,
ParallaxImg,
ScrollProgress,
CustomCursor,
SpeedLines,
});