Mode clair / sombre et thèmes de saison
This commit is contained in:
parent
da05adede1
commit
17cf2c8380
19 changed files with 3563 additions and 0 deletions
193
assets/js/competences.js
Normal file
193
assets/js/competences.js
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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'); });
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue