/* Ramona Brief loop animation (production embed, no playback UI) */ (function () { const LOOP = 6; const BASE_W = 600; const BASE_H = 375; const BLACK = '#222220'; const PINK = '#F1B1BB'; const CAT_NW = 372; const CAT_NH = 355; const CAT_SCALE = 1.55; const CAT_W = CAT_NW * CAT_SCALE; const CAT_H = CAT_NH * CAT_SCALE; const SQ_SIZE = 30; const SQ_RADIUS = 8; const LOGO_SQUARES = [ { px: -8, py: 90, phase: 0.0 }, { px: -42, py: 140, phase: 0.18 }, { px: -2, py: 195, phase: 0.4 }, { px: 18, py: 260, phase: 0.62 }, { px: 380, py: 85, phase: 0.1 }, { px: 408, py: 145, phase: 0.3 }, { px: 360, py: 200, phase: 0.55 }, { px: 392, py: 260, phase: 0.78 }, ]; const N_BLACK = 32; const N_PINK = 4; const Easing = { easeOutCubic: (t) => { const x = t - 1; return x * x * x + 1; }, easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2, }; function rng(seed) { let s = seed * 9301 + 49297; return function next() { s = (s * 9301 + 49297) % 233280; return s / 233280; }; } function buildParticles(edges) { const out = []; const total = N_BLACK + N_PINK; for (let i = 0; i < total; i++) { const r = rng(i + 1); const isPink = i >= N_BLACK; const edge = edges[Math.floor(r() * edges.length)]; const angle = Math.atan2(edge.ny, edge.nx) + (r() - 0.5) * 0.7; const nx = Math.cos(angle); const ny = Math.sin(angle); const distance = 180 + r() * 380; const phase = (i / total + r() * 0.08) % 1; const sizeMul = 0.72 + r() * 0.55; out.push({ px: edge.x, py: edge.y, nx, ny, distance, phase, sizeMul, color: isPink ? PINK : BLACK, rotSpin: (r() - 0.5) * 30, }); } return out.sort((a, b) => a.phase - b.phase); } function mapToBox(boxW, boxH, px, py) { const catX = (boxW - CAT_W) / 2; const catY = (boxH - CAT_H) / 2; return { x: catX + px * CAT_SCALE, y: catY + py * CAT_SCALE, catX, catY, catW: CAT_W, catH: CAT_H, }; } function computeParticlePose(particle, p, boxW, boxH) { const life = (p + particle.phase) % 1; const travel = Easing.easeOutCubic(life); const dist = particle.distance * travel; const startOffset = SQ_SIZE * 0.55; const start = mapToBox(boxW, boxH, particle.px, particle.py); const x = start.x + particle.nx * (startOffset + dist); const y = start.y + particle.ny * (startOffset + dist); let opacity; if (life < 0.15) opacity = life / 0.15; else if (life < 0.55) opacity = 1; else opacity = Math.max(0, 1 - (life - 0.55) / 0.45); opacity = Easing.easeInOutSine(opacity); return { x, y, size: SQ_SIZE * particle.sizeMul, opacity, rotate: particle.rotSpin * travel, }; } function computeLogoPose(s, i, p, boxW, boxH) { const phase = (p + s.phase) % 1; const dx = Math.sin(phase * Math.PI * 2) * 7; const dy = Math.cos(phase * Math.PI * 2 + i) * 6; const rot = Math.sin(phase * Math.PI * 2 + i * 0.7) * 6; const pos = mapToBox(boxW, boxH, s.px, s.py); return { x: pos.x + dx, y: pos.y + dy, rotate: rot, size: SQ_SIZE, }; } function computeSceneFit(boxW, boxH, particles) { const cat = mapToBox(boxW, boxH, 0, 0); let minX = cat.catX; let minY = cat.catY; let maxX = cat.catX + cat.catW; let maxY = cat.catY + cat.catH; const includeSquare = (x, y, size) => { const half = size / 2; minX = Math.min(minX, x - half); maxX = Math.max(maxX, x + half); minY = Math.min(minY, y - half); maxY = Math.max(maxY, y + half); }; const SAMPLES = 180; for (let step = 0; step < SAMPLES; step++) { const p = step / (SAMPLES - 1); for (let i = 0; i < LOGO_SQUARES.length; i++) { const l = computeLogoPose(LOGO_SQUARES[i], i, p, boxW, boxH); includeSquare(l.x, l.y, l.size); } for (let i = 0; i < particles.length; i++) { const pose = computeParticlePose(particles[i], p, boxW, boxH); includeSquare(pose.x, pose.y, pose.size); } } const padding = 8; minX -= padding; minY -= padding; maxX += padding; maxY += padding; const contentW = Math.max(1, maxX - minX); const contentH = Math.max(1, maxY - minY); const fit = Math.min(boxW / contentW, boxH / contentH); const tx = (boxW - contentW * fit) / 2 - minX * fit; const ty = (boxH - contentH * fit) / 2 - minY * fit; return { fit, tx, ty, cat }; } function Square(props) { const { x, y, size, color, opacity, rotate } = props; return (
); } function RamonaBriefLoop() { const [edges, setEdges] = React.useState([]); const [time, setTime] = React.useState(0); const [reducedMotion, setReducedMotion] = React.useState(false); const [viewportScale, setViewportScale] = React.useState(1); const rootRef = React.useRef(null); React.useEffect(() => { let cancelled = false; fetch('assets/anim/edge-points.json') .then((r) => r.json()) .then((json) => { if (!cancelled && Array.isArray(json)) { setEdges(json); } }) .catch(() => { if (!cancelled) setEdges([]); }); return () => { cancelled = true; }; }, []); React.useEffect(() => { if (!window.matchMedia) return; const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); const sync = () => setReducedMotion(!!mq.matches); sync(); if (mq.addEventListener) mq.addEventListener('change', sync); else mq.addListener(sync); return () => { if (mq.removeEventListener) mq.removeEventListener('change', sync); else mq.removeListener(sync); }; }, []); React.useEffect(() => { const el = rootRef.current; if (!el) return; const measure = () => { const { width, height } = el.getBoundingClientRect(); if (!width || !height) return; setViewportScale(Math.min(width / BASE_W, height / BASE_H)); }; measure(); let ro = null; if (window.ResizeObserver) { ro = new ResizeObserver(measure); ro.observe(el); } window.addEventListener('resize', measure); return () => { if (ro) ro.disconnect(); window.removeEventListener('resize', measure); }; }, []); React.useEffect(() => { if (reducedMotion) { setTime(0); return; } let raf = 0; const startedAt = performance.now(); const tick = (now) => { setTime((now - startedAt) / 1000); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [reducedMotion]); const particles = React.useMemo(() => { if (!edges.length) return []; return buildParticles(edges); }, [edges]); const sceneFit = React.useMemo( () => computeSceneFit(BASE_W, BASE_H, particles), [particles] ); const p = (time % LOOP) / LOOP; return ( ); } window.RamonaBriefLoop = RamonaBriefLoop; })();