/* NEKTAR — Section 03 · Notre sélection Physique réelle : rebond bulles ↔ bulles, murs, zone CTA Photo de vigne en fond, bulles or plus grandes + lisibles */ const { useEffect, useRef, useState } = React; /* ------------------------------------------------------- Domaines sélectionnés (les plus connus/chers) ------------------------------------------------------- */ const DOMAINS = [ 'Egly-Ouriet', 'Leclerc Briant', 'Jacquesson', 'Pierre Péters', "Cos d'Estournel", 'Château Climens', 'Marcel Lapierre', 'Leflaive', 'Ramonet', 'Bonneau du Martray', 'Jean-Marc Roulot', 'Domaine des Lambrays', 'Jacques Prieur', 'Jean Grivot', 'Denis Mortet', 'Rossignol-Trapet', "Marquis d'Angerville", 'Henri Boillot', 'Auguste Clape', 'Conterno', 'Dagueneau', ]; /* Mise en page : max 2 lignes dans le cercle */ function formatName(name) { const parts = name.split(' '); if (parts.length <= 1) return name; if (parts.length === 2) return parts[0] + '\n' + parts[1]; const mid = Math.ceil(parts.length / 2); return parts.slice(0, mid).join(' ') + '\n' + parts.slice(mid).join(' '); } /* Pseudo-aléatoire déterministe */ function makeSrand(seed) { let s = seed; return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; } /* ------------------------------------------------------- Initialisation physique Retourne un tableau de bulles avec cx, cy, vx, vy ------------------------------------------------------- */ function initPhysics({ count, r, w, h }) { const rand = makeSrand(99); const gap = 10; const minD = r * 2 + gap; const pad = r + 8; const ctaR = Math.min(w, h) * 0.17; // zone protégée au centre const bubbles = []; const shuffled = [...DOMAINS].sort(() => rand() - 0.5); for (const name of shuffled) { if (bubbles.length >= count) break; for (let attempt = 0; attempt < 400; attempt++) { const cx = pad + rand() * (w - pad * 2); const cy = pad + rand() * (h - pad * 2); /* Exclure zone CTA centrale */ const dx0 = cx - w / 2, dy0 = cy - h / 2; if (dx0 * dx0 + dy0 * dy0 < (ctaR + r) * (ctaR + r)) continue; /* Exclure chevauchements avec bulles déjà placées */ const clash = bubbles.some(b => { const dx = b.cx - cx, dy = b.cy - cy; return dx * dx + dy * dy < minD * minD; }); if (clash) continue; /* Vitesse initiale lente et aléatoire */ const angle = rand() * Math.PI * 2; const speed = 0.25 + rand() * 0.35; bubbles.push({ name, formatted: formatName(name), cx, cy, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, el: null, }); break; } } return bubbles; } /* ------------------------------------------------------- Tick physique — appelé à chaque frame ------------------------------------------------------- */ function tick(bubbles, r, w, h) { const ctaR = Math.min(w, h) * 0.17; const cx0 = w / 2, cy0 = h / 2; for (const b of bubbles) { b.cx += b.vx; b.cy += b.vy; /* Rebond sur les murs */ if (b.cx - r < 0) { b.cx = r; b.vx = Math.abs(b.vx); } if (b.cx + r > w) { b.cx = w - r; b.vx = -Math.abs(b.vx); } if (b.cy - r < 0) { b.cy = r; b.vy = Math.abs(b.vy); } if (b.cy + r > h) { b.cy = h - r; b.vy = -Math.abs(b.vy); } /* Rebond sur la zone CTA centrale (obstacle fixe) */ const dxC = b.cx - cx0, dyC = b.cy - cy0; const distC = Math.sqrt(dxC * dxC + dyC * dyC); const minC = ctaR + r + 6; if (distC < minC) { const nx = dxC / (distC || 1), ny = dyC / (distC || 1); const dot = b.vx * nx + b.vy * ny; if (dot < 0) { b.vx -= 2 * dot * nx; b.vy -= 2 * dot * ny; } b.cx += nx * (minC - distC); b.cy += ny * (minC - distC); } } /* Rebonds entre bulles (O(n²) — acceptable pour n ≤ 15) */ for (let i = 0; i < bubbles.length; i++) { for (let j = i + 1; j < bubbles.length; j++) { const a = bubbles[i], b = bubbles[j]; const dx = b.cx - a.cx, dy = b.cy - a.cy; const dist = Math.sqrt(dx * dx + dy * dy); const minD = r * 2 + 4; if (dist < minD && dist > 0) { const nx = dx / dist, ny = dy / dist; /* Impulsion (masse égale → échange composantes normales) */ const dvx = a.vx - b.vx, dvy = a.vy - b.vy; const dot = dvx * nx + dvy * ny; if (dot > 0) { a.vx -= dot * nx; a.vy -= dot * ny; b.vx += dot * nx; b.vy += dot * ny; } /* Séparation */ const push = (minD - dist) / 2; a.cx -= nx * push; a.cy -= ny * push; b.cx += nx * push; b.cy += ny * push; } } } } /* ------------------------------------------------------- Paramètres selon la taille d'écran ------------------------------------------------------- */ function getParams(w) { if (w >= 1200) return { size: 138, count: 12 }; if (w >= 768) return { size: 116, count: 8 }; return { size: 96, count: 6 }; } /* ------------------------------------------------------- App ------------------------------------------------------- */ function App() { const physRef = useRef(null); const animRef = useRef(null); const [ready, setReady] = useState(false); useEffect(() => { const w = window.innerWidth; const h = window.innerHeight; const { size, count } = getParams(w); const r = size / 2; const bubbles = initPhysics({ count, r, w, h }); physRef.current = { bubbles, r, w, h }; setReady(true); const loop = () => { const { bubbles, r, w, h } = physRef.current; tick(bubbles, r, w, h); for (const b of bubbles) { if (b.el) { b.el.style.left = (b.cx - r).toFixed(1) + 'px'; b.el.style.top = (b.cy - r).toFixed(1) + 'px'; } } animRef.current = requestAnimationFrame(loop); }; animRef.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(animRef.current); }, []); const { bubbles = [], r = 69 } = physRef.current || {}; return (
{ready && bubbles.map(b => (
{ b.el = el; }} aria-hidden="true" style={{ left: (b.cx - r).toFixed(1) + 'px', top: (b.cy - r).toFixed(1) + 'px', width: r * 2 + 'px', height: r * 2 + 'px', }} > {b.formatted}
))}
); } ReactDOM.createRoot(document.getElementById('root')).render();