1/* ============================================================ 2 SECTIONS â clé en main (grands titres), chiffres XXL, 3 réalisations (marquee auto), CTA 4 ============================================================ */ 5import { STEPS, PROJECTS } from './data.js'; 6 7/* utilitaire : image avec repli dégradé si le réseau est bloqué */ 8function guardImage(img, onFail) { 9 const fail = () => onFail(img); 10 img.addEventListener('error', fail, { once: true }); 11 if (img.complete && img.naturalWidth === 0) fail(); 12} 13 14/* ---------------- 3 · Clé en main â grands titres animés ---------------- */ 15function initSteps(ctx) { 16 const tl = document.getElementById('tl'); 17 const fill = document.getElementById('tl-rail-fill'); 18 19 tl.insertAdjacentHTML('beforeend', STEPS.map(s => ` 20 <div class="tl-step"> 21 <span class="tl-node" aria-hidden="true"> 22 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="${s.icon}"/></svg> 23 </span> 24 <div class="tl-body"> 25 <div class="tl-num" aria-hidden="true">${s.n}</div> 26 <h3>${s.t}</h3> 27 </div> 28 </div>`).join('')); 29 30 const steps = [...tl.querySelectorAll('.tl-step')]; 31 32 if (!ctx.motionOK) { steps.forEach(s => s.classList.add('is-in')); if (fill) fill.style.transform = 'scaleX(1)'; return; } 33 34 /* entrée en cascade des étapes */ 35 gsap.set(steps, { autoAlpha: 0, y: 40 }); 36 ScrollTrigger.batch(steps, { 37 start: 'top 88%', 38 onEnter: (els) => gsap.to(els, { 39 autoAlpha: 1, y: 0, duration: 0.7, stagger: 0.12, ease: 'power3.out', 40 onStart() { els.forEach(el => el.classList.add('is-in')); }, 41 }), 42 }); 43 44 /* remplissage de la ligne de progression, piloté par le scroll */ 45 if (fill) { 46 gsap.fromTo(fill, { scaleX: 0 }, { 47 scaleX: 1, ease: 'none', 48 scrollTrigger: { trigger: tl, start: 'top 72%', end: 'bottom 62%', scrub: 0.6 }, 49 }); 50 } 51} 52 53/* ---------------- 4 · Chiffres XXL (odomètres) ---------------- */ 54function initFigures(ctx) { 55 document.querySelectorAll('.odo').forEach(el => { 56 const target = String(el.dataset.odo); 57 el.setAttribute('role', 'img'); 58 el.setAttribute('aria-label', el.getAttribute('aria-label') || target); 59 el.textContent = ''; 60 const cols = [...target].map(ch => { 61 const digit = document.createElement('span'); 62 digit.className = 'odo-digit'; 63 digit.setAttribute('aria-hidden', 'true'); 64 const strip = document.createElement('span'); 65 strip.className = 'odo-strip'; 66 for (let n = 0; n <= 9; n++) { 67 const s = document.createElement('span'); 68 s.textContent = String(n); 69 strip.appendChild(s); 70 } 71 digit.appendChild(strip); 72 el.appendChild(digit); 73 return { strip, n: +ch }; 74 }); 75 76 if (!ctx.motionOK) { 77 cols.forEach(({ strip, n }) => { strip.style.transform = `translateY(${-n * 10}%)`; }); 78 return; 79 } 80 gsap.set(cols.map(c => c.strip), { yPercent: 0 }); 81 /* déclenché dès l'arrivée sur la section « clé en main », pas seulement quand 82 les chiffres (situés en bas de section) sont au centre de l'écran */ 83 ScrollTrigger.create({ 84 trigger: el.closest('.process') || el.closest('.figure-panel'), 85 start: 'top 60%', 86 once: true, 87 onEnter: () => cols.forEach(({ strip, n }, i) => 88 gsap.to(strip, { yPercent: -n * 10, duration: 1.7 + i * 0.12, ease: 'power4.inOut', delay: i * 0.06 })), 89 }); 90 }); 91 92 if (ctx.motionOK) { 93 document.querySelectorAll('.figure-panel').forEach(panel => { 94 const trig = panel.closest('.process') || panel; 95 gsap.from(panel.querySelectorAll('.figure-label, .figure-value, .figure-caption'), { 96 y: 44, autoAlpha: 0, duration: 0.9, stagger: 0.1, ease: 'power3.out', 97 scrollTrigger: { trigger: trig, start: 'top 60%', once: true }, 98 }); 99 }); 100 } 101} 102 103/* ---------------- 5 · Réalisations â marquee auto ---------------- */ 104function initWorks(ctx) { 105 const marquee = document.getElementById('works-marquee'); 106 const viewport = document.getElementById('works-viewport'); 107 if (!marquee) return; 108 109 const cardHTML = (p) => ` 110 <a class="work-card" href="${p.url}" target="_blank" rel="noopener" 111 aria-label="Réalisation AirFit à ${p.city}, voir la fiche sur airfit.co"> 112 <div class="work-media">
113 <span class="work-ghost" aria-hidden="true">${p.city}</span> 114 <img src="${p.img}" alt="Plateau sportif AirFit installé à ${p.city}" loading="lazy"> 115 </div> 116 <div class="work-fiche"> 117 <div class="work-place"> 118 <span class="work-region">${p.region}</span> 119 <h3>${p.city}</h3> 120 </div> 121 <span class="work-go" aria-hidden="true">â</span> 122 </div> 123 </a>`; 124 125 /* deux pistes identiques : la boucle CSS translate de -50% => défilement continu */ 126 const laneHTML = `<div class="works-lane">${PROJECTS.map(cardHTML).join('')}</div>`; 127 marquee.innerHTML = laneHTML + laneHTML; 128 129 marquee.querySelectorAll('img').forEach(img => 130 guardImage(img, (el) => el.closest('.work-card')?.classList.add('no-img'))); 131 132 /* durée proportionnelle au nombre de cartes pour une vitesse constante */ 133 marquee.style.setProperty('--marquee-dur', `${PROJECTS.length * 6}s`); 134 135 if (!ctx.motionOK) marquee.classList.add('is-paused'); 136 137 /* pause au survol / focus clavier */ 138 ['mouseenter', 'focusin'].forEach(ev => viewport.addEventListener(ev, () => marquee.classList.add('is-paused'))); 139 ['mouseleave', 'focusout'].forEach(ev => viewport.addEventListener(ev, () => { 140 if (!viewport.matches(':hover') && !viewport.contains(document.activeElement)) marquee.classList.remove('is-paused'); 141 })); 142 /* Cliquer sur une réa laisse le focus sur ce lien même après le retour 143 sur l'onglet (rien d'autre ne prend le focus entre-temps) : on ne doit 144 donc PAS se fier à document.activeElement ici, seulement à :hover 145 réel, sinon le défilement resterait bloqué en pause tant qu'on n'a pas 146 cliqué ailleurs pour retirer le focus du lien. */ 147 const resumeUnlessHovered = () => { 148 if (!viewport.matches(':hover')) marquee.classList.remove('is-paused'); 149 }; 150 /* les cartes s'ouvrent en nouvel onglet : la souris ne « quitte » jamais 151 la zone (pas de mouseleave), donc le défilement resterait en pause au 152 retour sur l'onglet si on ne le relance pas explicitement ici. */ 153 document.addEventListener('visibilitychange', () => { 154 if (document.visibilityState === 'visible') resumeUnlessHovered(); 155 }); 156 /* clic sur une réa -> navigation vers airfit.co -> retour avec le bouton 157 "précédent" : le navigateur restaure la page depuis le bfcache, ce qui 158 ne déclenche pas toujours visibilitychange. pageshow est l'événement 159 fiable pour ce cas précis (event.persisted = true = restauration). */ 160 window.addEventListener('pageshow', resumeUnlessHovered); 161} 162 163/* ---------------- 6 · Conseil expert (après les chiffres) ---------------- */ 164function initClosing(ctx) { 165 if (!ctx.motionOK) return; 166 if (!document.querySelector('.process-cta')) return; 167 gsap.from('.process-cta > *', { 168 y: 40, autoAlpha: 0, duration: 1, stagger: 0.12, ease: 'power3.out', 169 scrollTrigger: { trigger: '.process-cta', start: 'top 82%', once: true }, 170 }); 171} 172 173/* ---------------- reveals génériques ---------------- */ 174function initReveals(ctx) { 175 if (!ctx.motionOK) return;
176 document.querySelectorAll('.process .section-head, .works-head-inner').forEach(head => { 177 gsap.from(head.children, { 178 y: 34, autoAlpha: 0, duration: 0.9, stagger: 0.09, ease: 'power3.out', 179 scrollTrigger: { trigger: head, start: 'top 78%', once: true }, 180 }); 181 }); 182} 183 184export function initSections(ctx) { 185 initSteps(ctx); 186 initFigures(ctx); 187 initWorks(ctx); 188 initClosing(ctx); 189 initReveals(ctx); 190}
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.