Mode clair / sombre et thèmes de saison

This commit is contained in:
Yasser Boulayoune 2026-10-08 16:26:00 +02:00
parent da05adede1
commit 17cf2c8380
19 changed files with 3563 additions and 0 deletions

193
assets/js/competences.js Normal file
View file

@ -0,0 +1,193 @@
/* 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'); });
})();