yb2026/assets/js/competences.js

193 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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) => `<div class="cp-deck__card" style="--k:${k}"><img src="${esc(it.thumb)}" alt=""></div>`).reverse().join('')
+ `<span class="cp-deck__name">${esc(label(order[0]))}</span>`
+ (items.length > 1 ? `<span class="cp-deck__count">+${items.length - 1}</span>` : '');
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 = `<img src="${esc(it.thumb)}" alt=""><span class="cp-wheel__label${it.kind === 'article' ? ' is-article' : ''}">${esc(label(it))}</span>`;
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'); });
})();