// Etape detail v3 — constellation layout (bubbles) // Left: title, description, chips, big counter // Right: bubble cluster — entreprises as floating circles with initials + name const { useMemo, useEffect, useRef, useLayoutEffect, useState } = React; // Compute initials from a company name (max 2 letters, uppercase) function initialsOf(name) { if (!name) return '?'; const cleaned = name .replace(/\([^)]*\)/g, '') .replace(/[\u2018\u2019']/g, '') .trim(); const words = cleaned.split(/\s+/).filter(w => /^[A-Za-zÀ-ÿ]/.test(w)); if (words.length === 0) return cleaned.slice(0, 2).toUpperCase(); if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); // skip generic prefix words const SKIP = new Set(['les', 'le', 'la', 'des', 'de', 'du', 'l', 'd']); const filtered = words.filter(w => !SKIP.has(w.toLowerCase())); const pick = filtered.length >= 2 ? filtered : words; return (pick[0][0] + (pick[1]?.[0] || pick[0][1] || '')).toUpperCase(); } // Deterministic pseudo-random for stable layout on rerender function seededRng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0xffffffff; }; } // Pack circles with non-overlapping placement, biased toward the center. // Strategy: candidate positions are sampled from a Gaussian-like distribution // centred on the stage, so bubbles cluster in the middle while still filling // the available area when there are many items. // Returns { bubbles: [{x,y,r,item,idx}], contentHeight }. function packBubbles(items, width, height, seed = 1) { if (!items.length || !width || !height) return { bubbles: [], contentHeight: height }; const rng = seededRng(seed); const count = items.length; let R_MIN, R_MAX; if (count <= 8) { R_MIN = 70; R_MAX = 105; } else if (count <= 15) { R_MIN = 58; R_MAX = 88; } else if (count <= 30) { R_MIN = 48; R_MAX = 72; } else if (count <= 60) { R_MIN = 40; R_MAX = 58; } else { R_MIN = 34; R_MAX = 50; } const margin = 6; const avgR = (R_MIN + R_MAX) / 2; const requiredArea = count * Math.PI * avgR * avgR / 0.62; const stageH = Math.max(height, Math.ceil(requiredArea / width)); // Box-Muller transform: two uniform → one standard-normal sample function gaussianRng() { const u1 = Math.max(1e-9, rng()); const u2 = rng(); return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); } // Sample a candidate position biased toward (cx, cy) with given spread (sigma) function sampleCentered(cx, cy, sigmaX, sigmaY, r) { const x = cx + gaussianRng() * sigmaX; const y = cy + gaussianRng() * sigmaY; // Clamp so bubble stays within bounds return { x: Math.max(r + margin, Math.min(width - r - margin, x)), y: Math.max(r + margin, Math.min(stageH - r - margin, y)), }; } const bubbles = items.map((item, i) => { const r = R_MIN + rng() * (R_MAX - R_MIN); return { item, r, x: 0, y: 0, idx: i }; }); bubbles.sort((a, b) => b.r - a.r); const cx = width / 2; const cy = stageH / 2; // Spread: roughly 28 % of each dimension — tight enough to look clustered, // loose enough to avoid excessive overlaps. const sigmaX = width * 0.28; const sigmaY = stageH * 0.28; const placed = []; for (const b of bubbles) { let bestValid = null, bestValidScore = -Infinity; let bestInvalid = null, bestInvalidScore = -Infinity; const TRIES = 400; for (let t = 0; t < TRIES; t++) { const pos = t < TRIES * 0.75 ? sampleCentered(cx, cy, sigmaX, sigmaY, b.r) : { x: b.r + margin + rng() * (width - 2 * (b.r + margin)), y: b.r + margin + rng() * (stageH - 2 * (b.r + margin)), }; let minSlack = Infinity; let ok = true; for (const p of placed) { const dx = p.x - pos.x, dy = p.y - pos.y; const d = Math.sqrt(dx * dx + dy * dy); const slack = d - (p.r + b.r + 4); if (slack < minSlack) minSlack = slack; if (slack < 0) ok = false; } if (ok) { const distToCenter = Math.sqrt((pos.x - cx) ** 2 + (pos.y - cy) ** 2); const score = -distToCenter; if (score > bestValidScore) { bestValidScore = score; bestValid = pos; } if (t < TRIES * 0.75) break; } else { if (minSlack > bestInvalidScore) { bestInvalidScore = minSlack; bestInvalid = pos; } } } const chosen = bestValid ?? bestInvalid; if (chosen) { b.x = chosen.x; b.y = chosen.y; placed.push(b); } } placed.sort((a, b) => a.idx - b.idx); return { bubbles: placed, contentHeight: stageH }; } function AllEntreprisesPanel({ entreprises, onClose, onPick }) { const [enter, setEnter] = useState(false); useEffect(() => { requestAnimationFrame(() => setEnter(true)); }, []); const unique = useMemo(() => { const seen = new Set(); return entreprises .filter(e => { if (seen.has(e.nom)) return false; seen.add(e.nom); return true; }) .sort((a, b) => a.nom.localeCompare(b.nom, 'fr')); }, [entreprises]); return (
e.stopPropagation()} style={{ transform: enter ? 'translateX(0)' : 'translateX(60px)', opacity: enter ? 1 : 0, transition: 'transform 360ms cubic-bezier(0.22,1,0.36,1), opacity 360ms cubic-bezier(0.22,1,0.36,1)', }} >
Répertoire

Toutes les entreprises

{unique.length} entreprise{unique.length > 1 ? 's' : ''}
{unique.map((ent, i) => ( ))}
); } function BubbleCluster({ items, onPick }) { const wrapRef = useRef(null); const [size, setSize] = useState({ w: 0, h: 0 }); const [revealCount, setRevealCount] = useState(0); useLayoutEffect(() => { if (!wrapRef.current) return; const ro = new ResizeObserver((entries) => { const e = entries[0]; if (!e) return; const { width, height } = e.contentRect; setSize({ w: width, h: height }); }); ro.observe(wrapRef.current); return () => ro.disconnect(); }, []); // Stable seed by item-set signature so layout reshuffles when filter changes const seed = useMemo(() => { let h = 0; items.forEach((e, i) => { const s = e.nom + i; for (let k = 0; k < s.length; k++) h = ((h << 5) - h + s.charCodeAt(k)) | 0; }); return Math.abs(h) || 1; }, [items]); const { bubbles, contentHeight } = useMemo( () => packBubbles(items, size.w, size.h, seed), [items, size.w, size.h, seed] ); // Stagger reveal useEffect(() => { setRevealCount(0); const total = items.length; const dur = total > 60 ? 12 : total > 30 ? 18 : 28; let n = 0; const id = setInterval(() => { n = Math.min(total, n + Math.max(2, Math.floor(total / 24))); setRevealCount(n); if (n >= total) clearInterval(id); }, dur); return () => clearInterval(id); }, [items]); // When contentHeight > viewport h, the wrapper is scrollable; // we set the inner canvas to that height. return (
{/* Decorative drift halo */}
); } function EtapeDetail3({ etape, onBack, onPickEntreprise, transversalNav, mergedNav, activeFilter: activeFilterFromRoute, onFilterChange, isTv }) { const filters = useMemo(() => window.SVAUtils.buildFilters(etape), [etape]); const allEnts = useMemo(() => window.SVAUtils.allEntreprisesInEtape(etape), [etape]); const activeFilter = activeFilterFromRoute ?? (filters[0]?.id || '__all__'); const tabsRef = useRef(null); const [tabInd, setTabInd] = useState({ left: 0, width: 0 }); const [showAllPanel, setShowAllPanel] = useState(false); const visibleEnts = useMemo(() => { if (activeFilter === '__all__') return allEnts; const f = filters.find(x => x.id === activeFilter); return f ? f.entreprises : allEnts; }, [activeFilter, filters, allEnts]); // Animated count const totalUnique = new Set(allEnts.map(e => e.nom)).size; const visibleUnique = new Set(visibleEnts.map(e => e.nom)).size; const [animCount, setAnimCount] = useState(visibleUnique); useEffect(() => { const start = performance.now(); const from = animCount; const to = visibleUnique; let raf = 0; const tick = (t) => { const p = Math.min(1, (t - start) / 700); const e = 1 - Math.pow(1 - p, 3); setAnimCount(Math.round(from + (to - from) * e)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [visibleUnique]); const activeNav = transversalNav || mergedNav; useEffect(() => { if (!activeNav || !tabsRef.current) return; const btns = tabsRef.current.querySelectorAll('button'); const active = btns[activeNav.active]; if (!active) return; setTabInd({ left: active.offsetLeft, width: active.offsetWidth }); }, [activeNav?.active]); const num = etape.nom.split(/\.|\(/)[0].trim(); const looksLikeNum = /^\d+[a-z]?$/i.test(num); const name = mergedNav ? mergedNav.mergedName : looksLikeNum ? etape.nom.replace(/^\d+[a-z]?\.\s*/i, '') : etape.nom; // Description: build from the etape's own description if available, else summary of services const description = useMemo(() => { if (etape.description) return etape.description; // build subtle summary: list service names truncated const names = filters.map(f => f.label).filter(Boolean); if (names.length === 0) return null; return names.slice(0, 4).join(' · ') + (names.length > 4 ? '…' : ''); }, [etape, filters]); return (
{/* LEFT — title, intro, chips, counter */} {/* RIGHT — bubble constellation */}
{showAllPanel && ( setShowAllPanel(false)} onPick={onPickEntreprise} /> )}
); } window.EtapeDetail3 = EtapeDetail3;