PageSourceSearch

https://lpcleenmain.netlify.app/js/modules/sections.js

js lpcleenmain.netlify.app collected 2026-10-03 11:01:40 UTC 8,053 bytes, 190 lines download raw bytes

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.