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

322
assets/js/orb.js Normal file
View file

@ -0,0 +1,322 @@
/* orb.js : Orbe de particules persistante (vanilla JS)
<canvas id="orb-canvas"></canvas> en position:fixed; inset:0; z-index:0; pointer-events:none
YBOrb.init('#orb-canvas', { sections: { works:'#travaux', about:'#apropos', cta:'#cta' } });
Sur les pages internes (sans sections), l'orbe reste dans son état "hero". */
window.YBOrb = (function () {
let cv, ctx, cw, ch, run = false, secs = {};
const m = { x: -9999, y: -9999 };
const pts = [], rx = [], ry = [];
const N = 550;
for (let i = 0; i < N; i++) {
const y = 1 - (i / (N - 1)) * 2, r = Math.sqrt(1 - y * y), th = i * 2.39996323;
pts.push([Math.cos(th) * r, y, Math.sin(th) * r]);
rx.push(Math.sin(i * 12.9898) * 0.9 + Math.sin(i * 0.7) * 0.4);
ry.push(Math.cos(i * 78.233) * 0.9 + Math.cos(i * 1.3) * 0.4);
}
// États [cx, cy, R, éclatement, alpha] (fractions du viewport)
const A = [0.72, 0.42, 0.34, 0, 1]; // hero : grande, à droite
const B = [0.11, 0.24, 0.16, 0, 0.55]; // travaux : petite lune en haut à gauche
const C = [0.5, 0.48, 0.46, 1, 0.4]; // à propos : éclatée en champ d'étoiles
const D = [0.76, 0.52, 0.3, 0, 0.95]; // CTA : reformée
const c01 = v => Math.max(0, Math.min(1, v));
const ease = k => k * k * (3 - 2 * k);
const mix = (a, b, k) => a.map((v, i) => v + (b[i] - v) * k);
const top = el => el ? el.getBoundingClientRect().top : null;
/* Couleurs lues dans le thème (--lime-rgb / --ink-rgb), relues à chaque bascule clair/sombre */
let LIME = '211,253,80', INK = '245,245,240', GOLD = '242,193,78';
function readColors() {
const cs = getComputedStyle(document.documentElement);
LIME = cs.getPropertyValue('--lime-rgb').trim() || LIME;
INK = cs.getPropertyValue('--ink-rgb').trim() || INK;
GOLD = cs.getPropertyValue('--season-2-rgb').trim() || GOLD;
}
/* Halloween : visage de citrouille (repère de la sphère, y vers le bas, rayon 1) */
const inTri = (x, y, a, b, c) => {
const d = (p, q, r) => (p[0] - r[0]) * (q[1] - r[1]) - (q[0] - r[0]) * (p[1] - r[1]);
const P = [x, y], d1 = d(P, a, b), d2 = d(P, b, c), d3 = d(P, c, a);
return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
};
const EYE_L = [[-0.55, -0.06], [-0.17, -0.06], [-0.36, -0.42]], EYE_R = [[0.17, -0.06], [0.55, -0.06], [0.36, -0.42]];
const NOSE = [[-0.08, 0.13], [0.08, 0.13], [0, 0.01]];
const smile = x => { const k = 1 - (x / 0.64) * (x / 0.64); return [0.2 + 0.13 * k, 0.27 + 0.3 * k]; };
function inFace(x, y) {
if (inTri(x, y, ...EYE_L) || inTri(x, y, ...EYE_R) || inTri(x, y, ...NOSE)) return true;
if (Math.abs(x) > 0.64) return false;
const [t, b] = smile(x);
if (y < t || y > b) return false;
if (Math.abs(Math.abs(x) - 0.22) < 0.07 && y < t + 0.09) return false; // dents du haut
if (Math.abs(x) < 0.08 && y > b - 0.09) return false; // dent du bas
return true;
}
/* Autres fêtes : forme de l'orbe [kx, ky], pulsation, couleur des points, décor dessous / dessus.
point() renvoie [rgb, alpha, taille] ou rien (couleur par défaut). C = { LIME, INK, GOLD } */
const tag = (c, x, y, w, h, txt, C, a) => { // étiquette de prix suspendue
c.save(); c.translate(x, y); c.rotate(0.12);
c.fillStyle = 'rgba(' + C.LIME + ',' + a + ')';
c.beginPath(); c.moveTo(0, 0); c.lineTo(w, 0); c.lineTo(w, h); c.lineTo(0, h); c.lineTo(-h * 0.45, h / 2); c.closePath(); c.fill();
c.fillStyle = 'rgba(0,0,0,' + a + ')'; c.font = '800 ' + Math.round(h * 0.46) + 'px "JetBrains Mono", monospace';
c.textBaseline = 'middle'; c.fillText(txt, h * 0.12, h / 2 + 1);
c.beginPath(); c.arc(-h * 0.12, h / 2, h * 0.09, 0, 6.283); c.fill(); c.restore();
};
const PASTEL = ['255,159,203', '142,230,194', '255,222,120', '190,160,255', '140,200,255'];
const SEASON = {
'nouvel-an': {
point: (i, p, x, y, z, lime, a, t) => { const tw = Math.sin(t * 4 + i * 1.7); return tw > 0.75 ? ['255,255,255', Math.min(1, a + 0.5), 1.5] : [i % 2 ? GOLD_() : LIME, a * 1.1, 1]; },
over: (c, cx, cy, R, t, k, C) => { // boule à facettes suspendue
c.strokeStyle = 'rgba(' + C.INK + ',' + 0.3 * k + ')'; c.lineWidth = 1;
c.beginPath(); c.moveTo(cx, 0); c.lineTo(cx, cy - R * 1.02); c.stroke();
for (let j = 0; j < 4; j++) { const ang = t * 0.6 + j * 1.57, rr = R * 1.12; // reflets qui tournent
c.fillStyle = 'rgba(255,255,255,' + 0.5 * k * (0.5 + 0.5 * Math.sin(t * 3 + j)) + ')';
c.beginPath(); c.arc(cx + Math.cos(ang) * rr, cy + Math.sin(ang) * rr * 0.35, 2.2, 0, 6.283); c.fill(); }
}
},
'juillet': {
point: (i, p, x, y, z, lime, a) => [x < -0.33 ? '40,90,255' : x > 0.33 ? '255,60,80' : INK, a * 1.15, 1]
},
'st-patrick': {
point: (i, p, x, y, z, lime, a, t) => i % 9 === 0 ? [GOLD, Math.min(1, a + 0.3 * (0.5 + 0.5 * Math.sin(t * 3 + i))), 1.3] : [i % 3 ? LIME : INK, a, 1],
under: (c, cx, cy, R, t, k) => { // arc-en-ciel derrière l'orbe
['255,80,80', '255,160,60', '255,225,80', '80,210,120', '80,150,255', '160,100,255'].forEach((col, j) => {
c.strokeStyle = 'rgba(' + col + ',' + 0.16 * k + ')'; c.lineWidth = R * 0.035;
c.beginPath(); c.arc(cx, cy, R * (1.42 - j * 0.04), Math.PI * 1.12, Math.PI * 1.88); c.stroke();
});
}
},
'paques': {
shape: [0.84, 1.1],
point: (i, p, x, y, z, lime, a) => { const band = Math.floor((p[1] + 1) * 4.5); return [band % 2 ? PASTEL[band % PASTEL.length] : INK, a * (band % 2 ? 1.25 : 0.8), band % 2 ? 1.2 : 1]; }
},
'poisson': {
shape: [1.14, 0.8],
point: (i, p, x, y, z, lime, a) => [(Math.round(x * 6) + Math.round(y * 6)) % 2 ? LIME : GOLD, a * 1.05, 1],
over: (c, cx, cy, R, t, k, C) => {
const w = Math.sin(t * 5) * R * 0.08;
c.fillStyle = 'rgba(' + C.LIME + ',' + 0.55 * k + ')';
c.beginPath(); c.moveTo(cx + R * 1.02, cy); c.lineTo(cx + R * 1.5, cy - R * 0.42 + w); c.lineTo(cx + R * 1.38, cy + w * 0.3); c.lineTo(cx + R * 1.5, cy + R * 0.42 + w); c.closePath(); c.fill(); // queue
c.beginPath(); c.moveTo(cx - R * 0.2, cy - R * 0.74); c.quadraticCurveTo(cx + R * 0.2, cy - R * 1.12, cx + R * 0.5, cy - R * 0.66); c.closePath(); c.fill(); // nageoire
c.fillStyle = 'rgba(' + C.INK + ',' + k + ')'; c.beginPath(); c.arc(cx - R * 0.62, cy - R * 0.12, R * 0.09, 0, 6.283); c.fill(); // œil
c.fillStyle = 'rgba(' + C.LIME + ',' + k + ')'; c.beginPath(); c.arc(cx - R * 0.6, cy - R * 0.14, R * 0.035, 0, 6.283); c.fill();
for (let j = 0; j < 3; j++) { const ph = (t * 0.5 + j / 3) % 1; // bulles
c.strokeStyle = 'rgba(' + C.INK + ',' + 0.45 * k * (1 - ph) + ')'; c.lineWidth = 1.2;
c.beginPath(); c.arc(cx - R * 1.1 - Math.sin(ph * 9) * 6, cy - R * 0.2 - ph * R * 0.9, 3 + j * 2, 0, 6.283); c.stroke(); }
}
},
'musique': {
pulse: t => { const b = (t * 2) % 1; return 1 + 0.035 * Math.exp(-b * 6); }, // 120 BPM
point: (i, p, x, y, z, lime, a, t) => [i % 4 === 0 ? GOLD : lime ? LIME : INK, a, 1],
under: (c, cx, cy, R, t, k, C) => { // vinyle : sillons + macaron
c.save(); c.translate(cx, cy); c.rotate(t * 1.4);
for (let r = 0.3; r < 0.98; r += 0.06) { c.strokeStyle = 'rgba(' + C.INK + ',' + 0.07 * k + ')'; c.lineWidth = 1; c.beginPath(); c.arc(0, 0, R * r, 0, 6.283); c.stroke(); }
for (let j = 0; j < 2; j++) { c.strokeStyle = 'rgba(255,255,255,' + 0.1 * k + ')'; c.lineWidth = R * 0.012; c.beginPath(); c.arc(0, 0, R * (0.5 + j * 0.2), -0.3 + j * 3.14, 0.3 + j * 3.14); c.stroke(); }
c.fillStyle = 'rgba(' + C.LIME + ',' + 0.7 * k + ')'; c.beginPath(); c.arc(0, 0, R * 0.22, 0, 6.283); c.fill();
c.fillStyle = 'rgba(' + C.GOLD + ',' + 0.8 * k + ')'; c.fillRect(-R * 0.12, -R * 0.03, R * 0.24, R * 0.06);
c.fillStyle = 'rgba(0,0,0,' + 0.8 * k + ')'; c.beginPath(); c.arc(0, 0, R * 0.025, 0, 6.283); c.fill();
c.restore();
}
},
'anniversaire': {
shape: [0.9, 1.06],
point: (i, p, x, y, z, lime, a) => [i % 5 === 0 ? GOLD : i % 5 === 1 ? '255,107,129' : lime || i % 2 ? LIME : INK, a * 1.1, 1],
over: (c, cx, cy, R, t, k, C) => { // ballon : nœud + ficelle qui ondule
const by = cy + R * 1.06;
c.fillStyle = 'rgba(' + C.LIME + ',' + 0.8 * k + ')';
c.beginPath(); c.moveTo(cx, by - R * 0.02); c.lineTo(cx - R * 0.07, by + R * 0.1); c.lineTo(cx + R * 0.07, by + R * 0.1); c.closePath(); c.fill();
c.strokeStyle = 'rgba(' + C.INK + ',' + 0.4 * k + ')'; c.lineWidth = 1.2;
c.beginPath(); c.moveTo(cx, by + R * 0.1);
for (let j = 1; j <= 12; j++) c.lineTo(cx + Math.sin(t * 2 + j * 0.8) * R * 0.05 * (j / 12), by + R * 0.1 + j * R * 0.07);
c.stroke();
}
},
'algerie': {
// drapeau : moitié verte / moitié blanche, croissant et étoile rouges au centre
point: (i, p, x, y, z, lime, a) => [x < 0 ? '0,150,75' : INK, a * 1.2, 1],
over: (c, cx, cy, R, t, k) => {
c.save(); c.fillStyle = 'rgba(210,16,52,' + 0.92 * k + ')'; c.shadowColor = 'rgba(210,16,52,' + 0.6 * k + ')'; c.shadowBlur = 18;
// croissant : arc extérieur (r1) puis arc intérieur (r2) entre leurs deux points d'intersection
const x1 = cx + R * 0.02, r1 = R * 0.36, x2 = cx + R * 0.11, r2 = R * 0.29, d = x2 - x1;
const ix = (d * d + r1 * r1 - r2 * r2) / (2 * d), iy = Math.sqrt(Math.max(0, r1 * r1 - ix * ix));
const a1 = Math.atan2(iy, ix), a2 = Math.atan2(iy, ix - d);
c.beginPath(); c.arc(x1, cy, r1, a1, 2 * Math.PI - a1, false); c.arc(x2, cy, r2, 2 * Math.PI - a2, a2, true); c.closePath(); c.fill();
const sx = cx + R * 0.2, sr = R * 0.12; c.beginPath();
for (let j = 0; j < 10; j++) { const ang = -Math.PI / 2 + j * Math.PI / 5, rr = j % 2 ? sr * 0.42 : sr; c.lineTo(sx + Math.cos(ang) * rr, cy + Math.sin(ang) * rr); }
c.closePath(); c.fill(); c.restore();
}
},
'japon': {
// soleil levant derrière l'orbe, points rose sakura et encre
point: (i, p, x, y, z, lime, a) => [i % 3 === 0 ? '255,183,197' : (lime ? LIME : INK), a * (i % 3 === 0 ? 1.3 : 1), i % 3 === 0 ? 1.2 : 1],
under: (c, cx, cy, R, t, k) => {
const g = c.createRadialGradient(cx, cy, R * 0.1, cx, cy, R * 0.62);
g.addColorStop(0, 'rgba(220,40,40,' + 0.55 * k + ')'); g.addColorStop(0.85, 'rgba(200,30,30,' + 0.42 * k + ')'); g.addColorStop(1, 'rgba(200,30,30,0)');
c.fillStyle = g; c.beginPath(); c.arc(cx, cy, R * 0.62, 0, 6.283); c.fill();
}
},
'hack': {
// radar : un balayage tourne, les points s'allument à son passage puis s'éteignent
point: (i, p, x, y, z, lime, a, t) => {
const sweep = (t * 1.4) % 6.283, ang = (Math.atan2(y, x) + 6.283) % 6.283;
const d = (sweep - ang + 6.283) % 6.283, hit = d < 0.9 ? 1 - d / 0.9 : 0;
return hit > 0 ? ['220,255,230', Math.min(1, a + hit * 0.8), 1 + hit * 0.9] : [LIME, a * 0.55, 1];
},
over: (c, cx, cy, R, t, k, C) => {
const sweep = (t * 1.4) % 6.283;
c.save();
c.strokeStyle = 'rgba(' + C.LIME + ',' + 0.18 * k + ')'; c.lineWidth = 1;
[0.35, 0.7, 1.02].forEach(r => { c.beginPath(); c.arc(cx, cy, R * r, 0, 6.283); c.stroke(); });
c.beginPath(); c.moveTo(cx - R * 1.1, cy); c.lineTo(cx + R * 1.1, cy); c.moveTo(cx, cy - R * 1.1); c.lineTo(cx, cy + R * 1.1); c.stroke();
const g = c.createConicGradient ? c.createConicGradient(sweep - 0.9, cx, cy) : null;
if (g) {
g.addColorStop(0, 'rgba(' + C.LIME + ',0)'); g.addColorStop(0.143, 'rgba(' + C.LIME + ',' + 0.22 * k + ')'); g.addColorStop(0.1431, 'rgba(' + C.LIME + ',0)'); g.addColorStop(1, 'rgba(' + C.LIME + ',0)');
c.fillStyle = g; c.beginPath(); c.arc(cx, cy, R * 1.02, 0, 6.283); c.fill();
}
c.strokeStyle = 'rgba(' + C.LIME + ',' + 0.8 * k + ')'; c.lineWidth = 1.5;
c.beginPath(); c.moveTo(cx, cy); c.lineTo(cx + Math.cos(sweep) * R * 1.02, cy + Math.sin(sweep) * R * 1.02); c.stroke();
c.restore();
}
},
'black-friday': {
point: (i, p, x, y, z, lime, a) => [lime || i % 3 === 0 ? LIME : INK, a * 1.1, 1],
over: (c, cx, cy, R, t, k, C) => {
const ax = cx + R * 0.62, ay = cy - R * 0.8, sw = Math.sin(t * 1.6) * 0.08;
c.strokeStyle = 'rgba(' + C.INK + ',' + 0.6 * k + ')'; c.lineWidth = 1.2;
c.beginPath(); c.moveTo(ax, ay); c.lineTo(ax + R * 0.18, ay + R * 0.3); c.stroke();
c.save(); c.translate(ax + R * 0.18, ay + R * 0.3); c.rotate(sw); tag(c, 0, 0, R * 0.62, R * 0.24, '-100 %', C, k); c.restore();
}
}
};
const GOLD_ = () => GOLD;
function facePath(cx, cy, R) {
const P = (x, y) => [cx + x * R, cy + y * R];
ctx.beginPath();
[EYE_L, EYE_R, NOSE].forEach(tri => { tri.forEach((v, i) => ctx[i ? 'lineTo' : 'moveTo'](...P(v[0], v[1]))); ctx.closePath(); });
const top = [], bot = [];
for (let k = 0; k <= 24; k++) { const x = -0.64 + k * 0.64 * 2 / 24, [t, b] = smile(x); top.push([x, t]); bot.push([x, b]); }
// contour de la bouche avec deux dents en haut et une en bas
ctx.moveTo(...P(top[0][0], top[0][1]));
top.forEach(([x, t]) => {
const tooth = Math.abs(Math.abs(x) - 0.22) < 0.07;
ctx.lineTo(...P(x, tooth ? t + 0.09 : t));
});
bot.reverse().forEach(([x, b]) => ctx.lineTo(...P(x, Math.abs(x) < 0.08 ? b - 0.09 : b)));
ctx.closePath();
}
function size() {
const dpr = Math.min(devicePixelRatio || 1, 2);
cw = innerWidth; ch = innerHeight;
cv.width = cw * dpr; cv.height = ch * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function state() {
const vh = innerHeight; let s = A, t;
if ((t = top(secs.works)) !== null) s = mix(s, B, ease(c01(1 - t / vh)));
if ((t = top(secs.about)) !== null) s = mix(s, C, ease(c01((vh - t) / (vh * 0.9))));
if ((t = top(secs.cta)) !== null) s = mix(s, D, ease(c01((vh - t) / (vh * 0.9))));
// Mobile : orbe recentrée et un peu plus petite
if (cw < 720) s = [s[0] * 0.5 + 0.25, s[1] * 0.8, s[2] * 0.85, s[3], s[4] * 0.8];
return s;
}
function draw(t) {
ctx.clearRect(0, 0, cw, ch);
const S = document.documentElement.getAttribute('data-season') || '';
const [fx, fy, fr, spread, ga] = state();
let cx = cw * fx, cy = ch * fy, R = Math.min(cw, ch) * fr;
if (S) { R *= 0.88; cy += R * 0.17; } // saison : orbe un peu plus basse pour que l'attache de la boule de Noël sorte de sous la barre
const H = SEASON[S], C = { LIME, INK, GOLD }, sol = ga * Math.max(0, 1 - spread * 2.2);
if (H && H.pulse) R *= H.pulse(t);
const kx = H && H.shape ? H.shape[0] : 1, ky = H && H.shape ? H.shape[1] : 1;
// Orientation neutre tant que la souris/le doigt n'a pas bougé (chargement, mobile)
const has = m.x > -9000, sx = has ? (m.x - cx) / cw : 0, sy = has ? (m.y - cy) / ch : 0;
const rY = t * 0.35 + sx * 1.2, rX = 0.35 + sy * 0.8;
const cY = Math.cos(rY), sY = Math.sin(rY), cX = Math.cos(rX), sX = Math.sin(rX);
const g = ctx.createRadialGradient(cx, cy, R * 0.2, cx, cy, R * 1.6);
g.addColorStop(0, 'rgba(' + LIME + ',' + 0.08 * ga * (1 - spread * 0.8) + ')');
g.addColorStop(1, 'rgba(' + LIME + ',0)');
ctx.fillStyle = g; ctx.fillRect(cx - R * 2, cy - R * 2, R * 4, R * 4);
if (H && H.under && sol > 0.01) H.under(ctx, cx, cy, R, t, sol, C);
for (let i = 0; i < N; i++) {
const p = pts[i];
let x = p[0] * cY + p[2] * sY, z = -p[0] * sY + p[2] * cY;
const y = p[1] * cX - z * sX; z = p[1] * sX + z * cX;
const f = 2.4 / (2.4 + z);
let px = cx + x * kx * R * f + rx[i] * spread * cw * 0.42;
let py = cy + y * ky * R * f + ry[i] * spread * ch * 0.42;
let boost = 0;
const dx = px - m.x, dy = py - m.y, d2 = dx * dx + dy * dy;
if (d2 < 32400) { // répulsion souris, rayon 180px
const d = Math.sqrt(d2) || 1, k = (1 - d / 180) * 46;
px += dx / d * k; py += dy / d * k; boost = 1 - d / 180;
}
const s = Math.max(0.4, (1 - z) * 1.5) + boost * 2.4;
const a = Math.max(0.05, (1 - z) * 0.5) * ga + boost * 0.55;
const lime = boost > 0.15 || p[1] > 0.72 || x > 0.85;
if (H && H.point) {
const r = H.point(i, p, x, y, z, lime, a, t);
if (r) { ctx.fillStyle = 'rgba(' + r[0] + ',' + Math.min(r[1], 1) + ')'; ctx.beginPath(); ctx.arc(px, py, s * (r[2] || 1), 0, 6.283); ctx.fill(); continue; }
}
if (S === 'halloween') {
// citrouille : points orangés, le visage reste vide (la lueur est dessinée après)
if (z < 0.1 && spread < 0.5 && inFace((px - cx) / R, (py - cy) / R)) continue;
ctx.fillStyle = 'rgba(' + (lime ? LIME : (i % 3 ? LIME : INK)) + ',' + Math.min(a * (lime ? 1.3 : 0.85), 1) + ')';
ctx.beginPath(); ctx.arc(px, py, s, 0, 6.283); ctx.fill();
continue;
}
if (S === 'noel' && Math.abs(Math.abs(p[1]) - 0.3) < 0.075) {
// boule de Noël : deux bandes dorées qui scintillent
const tw = 0.6 + 0.4 * Math.sin(t * 3 + i);
ctx.fillStyle = 'rgba(' + GOLD + ',' + Math.min((a + 0.3 * ga) * tw, 1) + ')';
ctx.beginPath(); ctx.arc(px, py, s * 1.15, 0, 6.283); ctx.fill();
continue;
}
ctx.fillStyle = lime ? 'rgba(' + LIME + ',' + Math.min(a + 0.25 * ga, 1) + ')' : 'rgba(' + INK + ',' + a + ')';
ctx.beginPath(); ctx.arc(px, py, s, 0, 6.283); ctx.fill();
}
const solid = sol; // décor visible tant que l'orbe n'est pas éclatée
if (H && H.over && solid > 0.01) H.over(ctx, cx, cy, R, t, solid, C);
if (S === 'halloween' && solid > 0.01) {
// lueur de bougie qui vacille
const fl = 0.78 + 0.14 * Math.sin(t * 9) + 0.08 * Math.sin(t * 23 + 1.3);
ctx.save();
facePath(cx, cy, R);
const fg = ctx.createRadialGradient(cx, cy + R * 0.1, R * 0.05, cx, cy + R * 0.1, R * 0.8);
fg.addColorStop(0, 'rgba(255,236,150,' + solid * fl + ')');
fg.addColorStop(1, 'rgba(' + LIME + ',' + solid * fl * 0.9 + ')');
ctx.shadowColor = 'rgba(' + LIME + ',' + solid + ')'; ctx.shadowBlur = 28 * fl;
ctx.fillStyle = fg; ctx.fill();
ctx.restore();
}
if (S === 'noel' && solid > 0.01) {
// attache dorée et fil qui pend depuis le haut de l'écran
const w = R * 0.3, h = R * 0.15, top = cy - R * 1.02;
ctx.strokeStyle = 'rgba(' + INK + ',' + 0.3 * solid + ')'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, top - h - R * 0.08); ctx.stroke();
ctx.strokeStyle = 'rgba(' + GOLD + ',' + solid + ')'; ctx.lineWidth = Math.max(1.5, R * 0.02);
ctx.beginPath(); ctx.ellipse(cx, top - h - R * 0.04, R * 0.05, R * 0.06, 0, 0, 6.283); ctx.stroke();
const cg = ctx.createLinearGradient(cx - w / 2, 0, cx + w / 2, 0);
cg.addColorStop(0, 'rgba(' + GOLD + ',' + 0.55 * solid + ')'); cg.addColorStop(0.45, 'rgba(255,240,200,' + solid + ')'); cg.addColorStop(1, 'rgba(' + GOLD + ',' + 0.5 * solid + ')');
ctx.fillStyle = cg; ctx.fillRect(cx - w / 2, top - h, w, h);
}
if (spread < 0.6) {
ctx.strokeStyle = 'rgba(' + LIME + ',' + 0.22 * ga * (1 - spread) + ')';
ctx.lineWidth = 1; ctx.beginPath();
ctx.ellipse(cx, cy, R * 1.3, R * 0.4, -0.3 + sx * 0.3, 0, 6.283); ctx.stroke();
}
}
function loop(ms) { if (!run) return; try { draw(ms / 1000); } catch (e) {} requestAnimationFrame(loop); }
return {
init(sel, opts) {
cv = document.querySelector(sel); if (!cv) return;
ctx = cv.getContext('2d');
const o = (opts && opts.sections) || {};
secs = { works: document.querySelector(o.works), about: document.querySelector(o.about), cta: document.querySelector(o.cta) };
readColors(); addEventListener('yb:theme', () => { readColors(); if (!run) draw(0); });
size(); addEventListener('resize', size);
addEventListener('mousemove', e => { m.x = e.clientX; m.y = e.clientY; });
addEventListener('touchmove', e => { const t = e.touches[0]; m.x = t.clientX; m.y = t.clientY; }, { passive: true });
addEventListener('touchend', () => { m.x = m.y = -9999; });
if (document.documentElement.classList.contains('yb-static')) { draw(0); return; } // ?ybstatic=1 : image fixe
run = true; requestAnimationFrame(loop);
},
destroy() { run = false; }
};
})();