// 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 */}
{bubbles.map((b, i) => {
const visible = i < revealCount;
const initials = initialsOf(b.item.nom); // unused, kept for stable keys
const nameSize = Math.max(11, Math.round(b.r * 0.22));
const floatDelay = (i * 0.31) % 4;
return (
);
})}
{bubbles.length === 0 && items.length === 0 && (
)}
);
}
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;