PageSourceSearch

https://mapetanque.be/boule-animee.js

js mapetanque.be collected 2026-10-09 22:08:25 UTC 12,063 bytes, 245 lines download raw bytes

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.