/* competences.js : interactions propres à la page /competences/ - chiffres du manifeste qui défilent jusqu'à leur valeur à l'entrée dans l'écran - méthode : l'étape au centre de l'écran s'allume, le grand numéro suit - preuves : la vignette du projet suit la souris (lerp, comme le curseur) Mode capture ?ybstatic=1 (html.yb-static) : tout reste figé dans son état final. */ (function () { const STATIC = document.documentElement.classList.contains('yb-static'); /* Compteurs */ const nums = document.querySelectorAll('.cp-num[data-count]'); const show = (el, v) => { const pad = +el.dataset.pad || 0, suf = el.dataset.suffix || ''; el.textContent = String(Math.round(v)).padStart(pad, '0') + suf; }; if (!STATIC && nums.length) { nums.forEach(el => show(el, 0)); const io = new IntersectionObserver(es => es.forEach(en => { if (!en.isIntersecting) return; io.unobserve(en.target); const el = en.target, to = +el.dataset.count, t0 = performance.now(), dur = 1400; const step = now => { const k = Math.min(1, (now - t0) / dur), e = 1 - Math.pow(1 - k, 3); show(el, to * e); if (k < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); }), { threshold: 0.6 }); nums.forEach(el => io.observe(el)); } /* Méthode */ const steps = [...document.querySelectorAll('.cp-method__step')]; const bigs = [...document.querySelectorAll('.cp-method__num')]; if (steps.length && !STATIC) { const set = i => { steps.forEach((s, k) => s.classList.toggle('is-on', k === i)); bigs.forEach((b, k) => b.classList.toggle('is-on', k === i)); }; const io = new IntersectionObserver(es => es.forEach(en => { if (en.isIntersecting) set(+en.target.dataset.step); }), { rootMargin: '-45% 0px -45% 0px' }); steps.forEach(s => io.observe(s)); } /* ── Preuves : paquet de cartes qui suit la souris + roue de sélection ── - plusieurs projets : paquet (le premier devant, les autres derrière, un coin visible) - près du bord droit, l'aperçu passe à gauche du curseur en glissant - clic sur une compétence à plusieurs projets : le paquet se fige et une roue façon « menu d'armes » s'ouvre autour ; la direction de la souris choisit le projet */ const prev = document.querySelector('.cp-preview'); const wheel = document.querySelector('.cp-wheel'); const rows = [...document.querySelectorAll('.cp-proof')]; if (!prev || !rows.length) return; const TOUCH = matchMedia('(pointer: coarse)').matches; const esc = t => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const itemsOf = li => [...li.querySelectorAll('a[data-thumb]')].map(a => ({ a, name: a.dataset.name, thumb: a.dataset.thumb, tag: a.dataset.tag || '', kind: a.dataset.kind || 'projet' })); const label = it => (it.kind === 'article' ? 'ARTICLE · ' : '') + it.name; /* Paquet de cartes */ const buildDeck = (items, frontIdx = 0) => { const order = [items[frontIdx], ...items.filter((_, k) => k !== frontIdx)].slice(0, 3); prev.innerHTML = order.map((it, k) => `
`).reverse().join('') + `${esc(label(order[0]))}` + (items.length > 1 ? `+${items.length - 1}` : ''); prev.classList.toggle('is-stack', items.length > 1); }; /* Suivi de la souris avec bascule gauche/droite fluide */ const W = () => prev.offsetWidth || 360, GAP = 40; const m = { x: innerWidth / 2, y: innerHeight / 2, cx: innerWidth / 2, cy: innerHeight / 2, off: 1 }; let hovering = false, pinned = false, curRow = null; if (!STATIC && !TOUCH) { addEventListener('mousemove', e => { m.x = e.clientX; m.y = e.clientY; }, { passive: true }); rows.forEach(li => { li.addEventListener('mouseenter', () => { if (pinned) return; curRow = li; buildDeck(itemsOf(li)); prev.classList.add('is-on'); hovering = true; }); li.addEventListener('mouseleave', () => { if (pinned) return; prev.classList.remove('is-on'); hovering = false; }); itemsOf(li).forEach((it, k) => it.a.addEventListener('mouseenter', () => { if (!pinned) buildDeck(itemsOf(li), k); })); }); (function loop() { if (!pinned) { const w = W(); /* Côté voulu : à droite du curseur, sauf si l'aperçu sortirait de l'écran */ const want = (m.x + GAP + w + 24 > innerWidth) ? -1 : 1; m.off += (want - m.off) * 0.12; // glisse d'un côté à l'autre const tx = m.x + m.off * (GAP + w / 2); const ty = Math.max(w * 0.32 + 16, Math.min(innerHeight - w * 0.32 - 16, m.y)); m.cx += (tx - m.cx) * 0.16; m.cy += (ty - m.cy) * 0.16; prev.style.left = m.cx + 'px'; prev.style.top = m.cy + 'px'; prev.style.setProperty('--tilt', Math.max(-7, Math.min(7, (tx - m.cx) * 0.03)) + 'deg'); } requestAnimationFrame(loop); })(); } /* ── Menu circulaire : les cartes du paquet s'écartent en cercle autour du clic ── 2 cartes : gauche / droite ; 3 et plus : réparties en partant du haut. La direction de la souris (ou du doigt) choisit la carte, comme une roue de jeu. */ if (!wheel) return; const ring = wheel.querySelector('.cp-wheel__svg'); const cSkill = wheel.querySelector('.cp-wheel__skill'), cName = wheel.querySelector('.cp-wheel__name'), cHint = wheel.querySelector('.cp-wheel__hint'); let wItems = [], cards = [], angles = [], hot = -1, wc = { x: 0, y: 0 }, rad = 230; const setHot = i => { if (i === hot) return; hot = i; cards.forEach((c, k) => c.classList.toggle('is-hot', k === i)); wheel.classList.toggle('has-hot', i >= 0); const it = wItems[i]; cName.textContent = it ? it.name : ''; cHint.textContent = it ? (it.kind === 'article' ? it.tag : (it.tag ? it.tag.split(' · ').slice(0, 2).join(' · ') : 'VOIR LE PROJET')) : ''; }; function openWheel(li, x, y) { wItems = itemsOf(li); if (wItems.length < 2) { wItems[0] && wItems[0].a.click(); return; } pinned = true; hot = -1; const N = wItems.length, small = innerWidth < 720; const cw = small ? Math.min(132, innerWidth * 0.34) : 250, ch = cw / 1.6; const start = N === 2 ? Math.PI : -Math.PI / 2, step = (Math.PI * 2) / N; const base = wItems.map((_, k) => start + k * step); const maxCos = Math.max(...base.map(a => Math.abs(Math.cos(a)))) || 0.01; const maxSin = Math.max(...base.map(a => Math.abs(Math.sin(a)))) || 0.01; /* Écartement voulu, puis réduit si la carte la plus excentrée sortirait de l'écran. Sur téléphone le cercle devient un ovale : étroit en largeur, plus haut. */ const want = small ? cw * 0.95 + 24 : (N === 2 ? cw * 0.82 + 40 : cw * 0.95 + 20); /* La carte visée grossit (×1.12) et s'écarte (×1.08) : on réserve cette place */ const HX = 1.08, HW = 0.56; const rx = Math.min(want, (innerWidth / 2 - cw * HW - 10) / (maxCos * HX)); const ry = Math.min(small ? Math.max(want, rx * 1.35, ch + 70) : want, (innerHeight / 2 - ch * HW - 10) / (maxSin * HX)); rad = Math.max(rx, ry); const ex = maxCos * rx * HX + cw * HW + 10, ey = maxSin * ry * HX + ch * HW + 10; wc.x = Math.max(ex, Math.min(innerWidth - ex, x)); wc.y = Math.max(ey, Math.min(innerHeight - ey, y)); /* Angles réels des cartes (ovale) : servent à la visée par direction */ angles = base.map(a => Math.atan2(Math.sin(a) * ry, Math.cos(a) * rx)); const pos = base.map(a => [Math.cos(a) * rx, Math.sin(a) * ry]); ring.innerHTML = ''; wheel.querySelectorAll('.cp-wheel__card').forEach(c => c.remove()); cards = wItems.map((it, k) => { const el = document.createElement('div'); el.className = 'cp-wheel__card'; el.style.cssText = `width:${cw}px;height:${ch}px;left:${wc.x}px;top:${wc.y}px;--d:${k * 45}ms;--tx:${pos[k][0]}px;--ty:${pos[k][1]}px`; el.innerHTML = `${esc(label(it))}`; el.addEventListener('pointerenter', () => setHot(k)); wheel.appendChild(el); return el; }); ring.style.cssText = `left:${wc.x}px;top:${wc.y}px;width:${rx * 2}px;height:${ry * 2}px`; const c = wheel.querySelector('.cp-wheel__center'); c.style.left = wc.x + 'px'; c.style.top = wc.y + 'px'; cSkill.textContent = li.querySelector('.cp-proof__skill').textContent; wheel.hidden = false; requestAnimationFrame(() => requestAnimationFrame(() => wheel.classList.add('is-open'))); prev.classList.add('is-pinned'); document.body.classList.add('no-scroll'); setHot(0); } function closeWheel() { if (wheel.hidden) return; wheel.classList.remove('is-open', 'has-hot'); prev.classList.remove('is-pinned'); document.body.classList.remove('no-scroll'); setTimeout(() => { wheel.hidden = true; pinned = false; if (!hovering) prev.classList.remove('is-on'); }, 380); } rows.forEach(li => li.addEventListener('click', e => { if (e.target.closest('a')) return; // les liens texte restent des liens const rect = prev.getBoundingClientRect(); const onDeck = prev.classList.contains('is-on') && !TOUCH; openWheel(li, onDeck ? rect.left + rect.width / 2 : (TOUCH ? innerWidth / 2 : e.clientX), onDeck ? rect.top + rect.height / 2 : (TOUCH ? innerHeight / 2 : e.clientY)); })); /* Carte la plus proche de la direction visée */ const aim = (x, y) => { const dx = x - wc.x, dy = y - wc.y; if (Math.hypot(dx, dy) < 40) return; const a = Math.atan2(dy, dx); let best = 0, bd = 9; angles.forEach((b, k) => { const d = Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b))); if (d < bd) { bd = d; best = k; } }); setHot(best); }; wheel.addEventListener('pointermove', e => aim(e.clientX, e.clientY)); wheel.addEventListener('click', e => { const d = Math.hypot(e.clientX - wc.x, e.clientY - wc.y); const onCard = e.target.closest('.cp-wheel__card'); if (!onCard && (d > rad * 1.9 || d < 40)) return closeWheel(); // clic au centre ou loin dehors if (!onCard) aim(e.clientX, e.clientY); const it = wItems[hot]; if (it) { wheel.classList.add('is-go'); setTimeout(() => it.a.click(), 180); } }); addEventListener('keydown', e => { if (wheel.hidden) return; if (e.key === 'Escape') closeWheel(); if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); setHot((hot + 1) % wItems.length); } if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); setHot((hot - 1 + wItems.length) % wItems.length); } if (e.key === 'Enter' && wItems[hot]) wItems[hot].a.click(); }); addEventListener('pageshow', () => { wheel.classList.remove('is-open', 'is-go', 'has-hot'); wheel.hidden = true; pinned = false; document.body.classList.remove('no-scroll'); }); })();