1/* ============================================================================================= 2 Boule animée du logo mobile : partant du logo lui-même, la boule quitte son cochonnet en 3 roulant, fait quelques tours et s'arrête derrière un autre, exactement dans la pose du logo 4 (images/mapetanque-boule-pleine.svg). Première et dernière images sont donc le logo : on peut 5 le laisser affiché en attendant l'animation, et rien ne saute au départ ni à l'arrivée. 6 7 Pourquoi un rendu calculé plutôt qu'une planche d'images comme chargeur-photo.js : 8 le chargeur enchaîne deux planches (boucle + sortie) dessinées séparément, et le raccord entre 9 les deux n'a jamais été parfait â la vitesse de rotation change au basculement. Ici, une seule 10 rotation est calculée image par image, avec une seule courbe de vitesse : la boule démarre en 11 douceur, puis ne fait que ralentir, sans aucun raccord possible. 12 13 Le logo est une vraie sphère vue de face : ses deux bandes blanches sont deux grands cercles 14 perpendiculaires, et l'encoche est un disque (le cochonnet, posé devant). Les valeurs 15 ci-dessous ont été relevées sur les courbes du SVG (écart < 0,5 % du rayon) : la dernière 16 image de l'animation retombe donc exactement sur le logo, qui reprend sa place sans saut. 17============================================================================================= */ 18 19(function () { 20 'use strict'; 21 22 // --- Géométrie du logo (unités : rayon de la boule ; x vers la droite, y vers le haut, 23 // z vers le spectateur) ------------------------------------------------------------ 24 const VERT = [116, 193, 90]; // #74C15A, couleur du SVG 25 const NORMALE_1 = [-0.8924, 0.0883, 0.4426]; // bande « verticale » 26 const NORMALE_2 = [0.2129, 0.9471, 0.2403]; // bande « horizontale » 27 const DEMI_BANDE = 0.0914; // demi-largeur d'une bande 28 const COCHONNET = { x: -0.640, y: -0.715, r: 0.375 }; // encoche, marge blanche comprise 29 30 // --- Réglages (choisis par essais sur téléphone) -------------------------------------- 31 const REGLAGES = { 32 tours: 2, // nombre ENTIER : la première image retombe alors sur le logo 33 duree: 3000, // ms 34 courbe: 4, // 2 = freinage constant ; plus haut, départ plus vif, fin plus douce 35 camera: 45, // hauteur de la caméra au-dessus du sol, en degrés 36 direction: -40, // 0 = la boule roule vers la droite ; négatif = vers le spectateur 37 montee: 0.04 // démarrage depuis l'arrêt (le logo), en fraction de la durée 38 }; 39 40 // --- Petits outils vectoriels -------------------------------------------------------- 41 42 function produitVectoriel(a, b) { 43 return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; 44 } 45 46 // Rotation de v d'un angle a autour de l'axe unitaire k (formule de Rodrigues). 47 function tourner(v, k, a) { 48 const c = Math.cos(a), s = Math.sin(a); 49 const kv = k[0] * v[0] + k[1] * v[1] + k[2] * v[2]; 50 const kxv = produitVectoriel(k, v); 51 return [ 52 v[0] * c + kxv[0] * s + k[0] * kv * (1 - c), 53 v[1] * c + kxv[1] * s + k[1] * kv * (1 - c), 54 v[2] * c + kxv[2] * s + k[2] * kv * (1 - c) 55 ]; 56 } 57 58 /** 59 * Mouvement de la boule : axe de rotation et trajet apparent du cochonnet. 60 * La boule roule sans glisser sur un sol vu d'en haut sous l'angle `camera` : son axe est 61 * perpendiculaire au sol et à la direction de roulement. Comme la caméra suit la boule, c'est 62 * le cochonnet qui semble venir à sa rencontre, d'autant plus vite qu'elle tourne vite : 63 * un radian de rotation = un rayon parcouru. Les deux mouvements sont ainsi liés par 64 * construction et ne peuvent pas se désynchroniser. 65 */ 66 function mouvement(reglages) { 67 const phi = reglages.camera * Math.PI / 180; 68 const alpha = reglages.direction * Math.PI / 180; 69 const haut = [0, Math.cos(phi), Math.sin(phi)]; // normale au sol 70 const loin = [0, Math.sin(phi), -Math.cos(phi)]; // sur le sol, en s'éloignant 71 const sens = [ 72 Math.cos(alpha) + Math.sin(alpha) * loin[0], 73 Math.sin(alpha) * loin[1], 74 Math.sin(alpha) * loin[2] 75 ]; 76 return { axe: produitVectoriel(haut, sens), sens: sens }; 77 } 78 79 /** 80 * Dessine la boule dans le canvas. 81 * @param {CanvasRenderingContext2D} ctx 82 * @param {number} taille côté du canvas en pixels réels 83 * @param {number[]} n1, n2 normales des bandes dans la pose courante 84 * @param {object[]} cochonnets centres {x, y} des cochonnets dans le repère de l'écran 85 */ 86 function dessiner(ctx, taille, n1, n2, cochonnets) { 87 const image = ctx.createImageData(taille, taille); 88 const px = image.data; 89 // Suréchantillonnage pour des bords lisses ; moins de sous-points en grand format. 90 const ss = taille <= 160 ? 4 : 2; 91 const pas = 2 / (taille * ss); 92 const poids = 255 / (ss * ss); 93 const cochR2 = COCHONNET.r * COCHONNET.r; 94 // Seuls comptent les cochonnets qui mordent sur la boule. 95 const visibles = cochonnets.filter(function (c) { 96 return Math.hypot(c.x, c.y) < 1 + COCHONNET.r; 97 }); 98 99 for (let j = 0; j < taille; j++) { 100 for (let i = 0; i < taille; i++) { 101 let couverture = 0; 102 for (let sj = 0; sj < ss; sj++) { 103 const y = 1 - (j * ss + sj + 0.5) * pas; 104 for (let si = 0; si < ss; si++) { 105 const x = (i * ss + si + 0.5) * pas - 1; 106 const r2 = x * x + y * y; 107 if (r2 >= 1) continue; 108 let cache = false;
109 for (let c = 0; c < visibles.length; c++) { 110 const dx = x - visibles[c].x, dy = y - visibles[c].y; 111 if (dx * dx + dy * dy < cochR2) { cache = true; break; } 112 } 113 if (cache) continue; 114 const z = Math.sqrt(1 - r2); 115 if (Math.abs(x * n1[0] + y * n1[1] + z * n1[2]) < DEMI_BANDE) continue; 116 if (Math.abs(x * n2[0] + y * n2[1] + z * n2[2]) < DEMI_BANDE) continue; 117 couverture++; 118 } 119 } 120 if (couverture) { 121 const k = (j * taille + i) * 4; 122 px[k] = VERT[0]; 123 px[k + 1] = VERT[1]; 124 px[k + 2] = VERT[2]; 125 px[k + 3] = Math.round(couverture * poids); 126 } 127 } 128 } 129 ctx.putImageData(image, 0, 0); 130 } 131 132 /** 133 * Profil de la rotation : fraction de l'angle total parcourue en fonction de la fraction u de 134 * la durée. Vitesse = (1 - e^(-u / montee)) x (1 - u)^(courbe - 1) : elle monte en douceur 135 * depuis l'arrêt (la boule part du logo immobile), puis ne fait que décroître jusqu'à zéro. 136 * Intégrée numériquement une fois pour toutes, puis lue par interpolation. 137 */ 138 function profil(r) { 139 const N = 600; 140 const cumul = new Float64Array(N + 1); 141 for (let i = 1; i <= N; i++) { 142 const u = (i - 0.5) / N; 143 cumul[i] = cumul[i - 1] + 144 (1 - Math.exp(-u / r.montee)) * Math.pow(1 - u, r.courbe - 1); 145 } 146 return function parcouru(u) { 147 const x = Math.min(Math.max(u, 0), 1) * N; 148 const i = Math.min(Math.floor(x), N - 1); 149 return (cumul[i] + (cumul[i + 1] - cumul[i]) * (x - i)) / cumul[N]; 150 }; 151 } 152 153 /** 154 * Joue l'animation complète dans un canvas carré. 155 * @param {HTMLCanvasElement} canvas 156 * @param {object} [options] remplace tout ou partie de REGLAGES 157 * @param {function} [fin] appelée sur la dernière image 158 * @returns {function} fonction d'arrêt 159 */ 160 function jouer(canvas, options, fin) { 161 const r = Object.assign({}, REGLAGES, options || {}); 162 const m = mouvement(r); 163 const total = Math.round(r.tours) * 2 * Math.PI; // tours entiers : départ = logo 164 const parcouru = profil(r); 165 const ctx = canvas.getContext('2d'); 166 const debut = performance.now(); 167 let requete = null; 168 169 function image(maintenant) { 170 const u = Math.min((maintenant - debut) / r.duree, 1); 171 const fait = total * parcouru(u); // angle déjà parcouru = distance, en rayons 172 const reste = total - fait; 173 const n1 = tourner(NORMALE_1, m.axe, -reste); 174 const n2 = tourner(NORMALE_2, m.axe, -reste); 175 // Deux cochonnets fixés au sol : celui du départ, que la boule laisse derrière elle, 176 // et celui de l'arrivée, qu'elle rejoint. Leur défilement est lié à la rotation. 177 dessiner(ctx, canvas.width, n1, n2, [ 178 { x: COCHONNET.x - fait * m.sens[0], y: COCHONNET.y - fait * m.sens[1] }, 179 { x: COCHONNET.x + reste * m.sens[0], y: COCHONNET.y + reste * m.sens[1] } 180 ]); 181 if (u < 1) { 182 requete = requestAnimationFrame(image); 183 } else if (fin) { 184 fin(); 185 } 186 } 187 requete = requestAnimationFrame(image); 188 return function arreter() { if (requete) cancelAnimationFrame(requete); }; 189 } 190 191 // --- Branchement sur le logo --------------------------------------------------------- 192 // Seulement en mobile (boule seule), au clic sur le logo, et seulement quand ce clic ne quitte 193 // pas la page (href="#top" sur l'accueil) : sur les autres pages, la navigation couperait 194 // l'animation. Le script n'est chargé que sur les accueils. Pas de lancement automatique à 195 // l'arrivée : essayé, il gênait plus qu'il n'amusait. 196 197 const MOBILE = window.matchMedia('(max-width: 1024px)'); 198 const MOINS_DANIMATION = window.matchMedia('(prefers-reduced-motion: reduce)'); 199
200 function brancherLogo() { 201 const lien = document.querySelector('.brand-link'); 202 if (!lien || (lien.getAttribute('href') || '').charAt(0) !== '#') return; 203 const logo = lien.querySelector('img.logo'); 204 const cadre = lien.querySelector('picture'); 205 if (!logo || !cadre) return; 206 207 let enCours = false; 208 209 function lancer() { 210 if (enCours || !MOBILE.matches || MOINS_DANIMATION.matches) return; 211 const cote = logo.getBoundingClientRect().height; 212 if (!cote) return; 213 enCours = true; 214 215 // Le canvas se pose exactement sur l'image, qui est masquée le temps de l'animation. 216 const canvas = document.createElement('canvas'); 217 canvas.width = canvas.height = Math.round(cote * (window.devicePixelRatio || 1)); 218 canvas.className = 'logo-anime'; 219 canvas.setAttribute('aria-hidden', 'true'); 220 canvas.style.cssText = 'position:absolute;left:0;top:0;width:' + cote + 'px;height:' + 221 cote + 'px;pointer-events:none;'; 222 cadre.style.position = 'relative'; 223 cadre.appendChild(canvas); 224 225 jouer(canvas, null, function () { 226 logo.style.visibility = ''; 227 canvas.remove(); 228 enCours = false; 229 }); 230 // Masqué seulement une fois la première image dessinée (identique au logo) : aucun 231 // instant sans boule. 232 requestAnimationFrame(function () { logo.style.visibility = 'hidden'; }); 233 } 234 235 lien.addEventListener('click', lancer); 236 } 237 238 window.BouleAnimee = { jouer: jouer, reglages: REGLAGES }; 239 240 if (document.readyState === 'loading') { 241 document.addEventListener('DOMContentLoaded', brancherLogo); 242 } else { 243 brancherLogo(); 244 } 245})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.