Le Livre

Sphère

Seize images sur une sphère qu'on lance à la main, et pas une ligne de WebGL.

Réglages
page.tsx
<Sphere
  derive={5}
  style={{
    "--sph-r": "148px",
    "--sph-w": "60px",
    "--sph-fuite": "520px",
  }}
/>

Fiche

Chapitre
Objets
Nom courant
Image sphere
Publié le
10 septembre 2026
Fichiers
Sphere.tsx, Sphere.css
Dépendances
Aucune

Pourquoi

La référence de cette planche est écrite en Three.js : une scène, une caméra, seize plans texturés, un lancer de rayon pour savoir ce qu'on survole. Six cents kilo-octets de bibliothèque pour seize vignettes. Celle-ci est en CSS, et la raison n'est pas l'économie, c'est que la moitié du travail disparaît en chemin.

Ce sont de VRAIES IMAGES. Le navigateur les charge en différé, les décode hors du fil principal, choisit leur résolution, et le survol comme le clic sont des événements ordinaires. Le lancer de rayon n'existe plus : le test de collision du navigateur est plus juste que le nôtre, il tient compte de la perspective et des recouvrements, et il ne coûte rien. On ne réécrit pas ce qu'on a déjà.

Le PANNEAU est la seule vraie astuce. Une image posée sur une sphère qui tourne présenterait sa tranche au quart de tour : il faut la contre-tourner, exactement, à chaque trame. Écrire seize transformations par trame serait tenable ; en écrire UNE l'est davantage. La contre-rotation est une propriété personnalisée posée sur le monde, et les seize vignettes en HÉRITENT. Un seul accès au style par trame, que la sphère porte seize images ou deux cents.

Sphere.tsxL140–143
mo.style.setProperty(
  "--sph-inv",
  `rotateY(${(-ry).toFixed(2)}deg) rotateX(${(-rx).toFixed(2)}deg)`
);
Une écriture, seize éléments. L'ordre est l'inverse exact de celui du monde.

Trois niveaux, et chacun son métier : la PLACE ne bouge jamais (sa position est calculée au rendu serveur et n'est plus jamais réécrite) ; la VIGNETTE se contre-tourne, sans transition ; le CADRE porte le survol, avec transition. Empiler les trois sur le même nœud revient à faire transitionner la contre-rotation, et toute la sphère se met à flotter d'une trame derrière la main. C'est le genre de défaut qu'on sent avant de le voir.

Les seize points ne sont pas tirés au sort. Le hasard fait des grappes : sur seize tirages, il en colle trois dans un coin et laisse un trou en face, et la sphère se lit comme un tas. La spirale de Fibonacci répartit sans jamais aligner, un angle d'or entre deux points consécutifs, et aucune rangée n'apparaît, quel que soit le nombre.

Sphere.tsxL68–76
function spirale(n: number) {
  const or = Math.PI * (3 - Math.sqrt(5));
  return Array.from({ length: n }, (_, i) => {
    const y = n === 1 ? 0 : 1 - (i / (n - 1)) * 2;
    const r = Math.sqrt(Math.max(0, 1 - y * y));
    const t = or * i;
    /* Le rayon respire d'un sixième. Sur une coque parfaite, les seize
       images sont à la même distance : elles se lisent comme un anneau
       vu de face, pas comme un volume. Un peu d'épaisseur, et l'œil
Neuf lignes. Elles remplacent seize appels à `Math.random`.

Le fondu au loin n'est pas une coquetterie. Sans lui, une sphère creuse se lit comme un nuage plat : la perspective seule ne suffit pas à dire ce qui est devant. La profondeur de chaque place est donc calculée à la main (un produit scalaire, seize fois par trame) et passée en variable, que la feuille traduit en opacité. Poussez « Fuite » à dix-huit cents dans la carte : la perspective s'aplatit presque entièrement, et c'est le fondu, seul, qui continue de tenir la forme.

Ouvrir une image se fait en FLIP, et c'est ce qui la fait VENIR au lieu de réapparaître. On relève la place qu'occupe la vignette à l'écran, on laisse le navigateur poser la grande image à sa place finale, on applique l'écart entre les deux, et on le retire dans une transition. Deux mesures et une soustraction remplacent une interpolation de position dans une scène en trois dimensions, et le trajet est exact, parce qu'il part d'une mesure et non d'un modèle.

Sphere.tsxL341–344
const ecart = (de: DOMRect, a: DOMRect) =>
  `translate(${de.left - a.left}px, ${de.top - a.top}px) scale(${
    de.width / a.width
  })`;
Le voyage entier tient dans cette soustraction. Poser l'écart, forcer le calcul, le retirer.

Ce qui coince

  • touch-action: none, ou la sphère ne tourne qu'une fois sur trois. Sur téléphone, un glissement horizontal est happé par le défilement de la page dès qu'il s'écarte d'une dizaine de degrés de l'horizontale. Le pointermove s'arrête net, la sphère se fige à mi-geste, et l'on croit à un bogue de vitesse. touch-action: none donne le geste à la sphère et à elle seule. Ce n'est pas gratuit (on ne peut plus faire défiler la page en partant de la carte), mais la page reste défilable partout ailleurs, et une sphère qui ne tourne pas ne sert à rien.
  • Le kit ne DÉCLARE pas ses valeurs par défaut, il les REPLIE. Le piège de la planche voisine, retrouvé ici au mot près. Une classe de placement posée sur le même élément, .dmo__sphere, a exactement la même spécificité que .sph. À égalité, c'est l'ordre des feuilles qui tranche, et cet ordre est décidé par le bundler : la feuille du kit, chargée par la mise en page du Livre, passe après la feuille globale. Le kit gagnait, et le rayon réduit du téléphone n'était jamais appliqué : la sphère sortait de sa carte, et les curseurs annonçaient une valeur que personne ne voyait. Les valeurs par défaut sont donc des REPLIS de var(), jamais des déclarations : un repli n'existe que s'il n'y a rien d'autre, et il ne se dispute avec personne.
    Sphere.cssL73–75
    calc(var(--x) * var(--sph-r, 148px) * var(--sph-zoom, 1)),
    calc(var(--y) * var(--sph-r, 148px) * var(--sph-zoom, 1) * -1),
    calc(var(--z) * var(--sph-r, 148px) * var(--sph-zoom, 1))
    
  • Une console qui ment tant qu'on n'y touche pas. Conséquence directe, et plus sournoise : les curseurs partaient de valeurs écrites en dur. Sur un écran étroit, la carte réduit le rayon : le curseur annonçait cent quarante-huit pixels devant une sphère de cent huit, et la sphère sautait au premier réglage. Ils partent maintenant de ce que la feuille applique VRAIMENT, relevé au montage. Ce qui suppose de ne poser aucun style en ligne avant ce relevé : un style écrit dès le premier rendu masque la règle de placement, et l'on relit sa propre valeur en croyant lire celle du thème.
  • La capture du pointeur vole le clic. Capturer le pointeur dès l'appui règle le vrai problème (poursuivre le geste quand la main sort de la boîte) et en crée un pire, qu'aucune documentation ne met en avant : tant que la capture est en place, le navigateur redirige AUSSI le click vers l'élément capturant. Le clic n'atteignait donc jamais la vignette, et aucune image ne s'ouvrait. Rien dans la console, un pointerdown qui arrive bien sur la bonne cible, et un click qui atterrit trois niveaux plus haut. La capture n'est demandée qu'une fois le glissement avéré, au bout de trois pixels : en deçà, c'est un clic et il passe ; au-delà, c'est un geste et il est tenu.
    Sphere.tsxL228–231
    if (!capture && chemin > 3) {
      capture = e.pointerId;
      el.setPointerCapture?.(e.pointerId);
    }
    
  • Six pixels séparent un clic d'un glissement. Sans seuil, tout glissement qui s'arrête sur une vignette l'ouvre : on lance la sphère, on relâche, et une image saute au premier plan. Ce n'est pas la distance entre l'appui et le relâchement qui compte, mais le CHEMIN parcouru : un aller-retour revient à son point de départ et a pourtant fait tourner le monde d'un demi-tour. On accumule donc, et le click est annulé en phase de capture au-delà de six pixels. En capture, et pas au bouillonnement : il faut arrêter l'événement avant qu'il n'atteigne le bouton.
    Sphere.tsxL280–283
    if (chemin > 6) {
      e.preventDefault();
      e.stopPropagation();
    }
    
  • L'élan reprend le dernier déplacement, pas une moyenne. Une moyenne des dernières trames donne un lancer mou : la sphère part toujours un peu moins vite que la main, et le geste ne se termine pas : il s'éteint. Le dernier déplacement, lui, donne l'impression d'avoir lâché quelque chose. C'est moins exact et c'est plus juste : ce qu'on imite n'est pas la physique, c'est la sensation d'avoir lancé.
  • Deux tailles par image, et la grande n'arrive qu'à l'ouverture. Une sphère de vignettes de soixante pixels n'a aucune raison de télécharger des photographies entières. Les seize vignettes font 240 px et pèsent quatre-vingt-quatre kilo-octets à elles toutes, chargées en différé ; la grande version, à 720 px, n'est demandée qu'à l'ouverture d'une image, et seulement celle-là : c'est le seul moment où quelqu'un la regarde vraiment. Elles sont aussi EMBARQUÉES et non appelées à un service d'images au hasard : seize requêtes vers un tiers à chaque chargement, ce sont seize occasions de tomber en panne, et l'adresse IP du lecteur chez quelqu'un d'autre.
  • La rotation s'arrête quand une image est ouverte. Pas par prudence : par nécessité arithmétique. Le retour du FLIP mesure la place de la vignette pour y ramener la grande image. Si la sphère a continué de tourner pendant la lecture, cette place n'est plus la même, et l'image rentre en visant à côté. Arrêter la dérive rend le retour exact, et c'est de toute façon ce qu'on veut d'une image qu'on est en train de regarder.

Le code

Les fichiers en entier, lus sur le disque au moment du build, dans le composant même qui tourne au-dessus. Les extraits cités plus haut en sont des lignes exactes : les numéros correspondent. Copiez-les, le composant n'a besoin de rien d'autre.

"use client";

import { useEffect, useRef, useState } from "react";

/* ------------------------------------------------------------------
   Sphère · des images posées sur une sphère qu'on fait tourner.

   La référence est écrite en Three.js : une scène, une caméra, des
   plans texturés, un lancer de rayon pour savoir ce qu'on survole. Six
   cents kilo-octets pour vingt vignettes. Celle-ci est en CSS, et la
   raison n'est pas l'économie : c'est que la moitié du travail
   disparaît.

   DES IMAGES, PAS DES TEXTURES. Ce sont de vrais `<img>` : le
   navigateur les charge en différé, les décode hors du fil principal,
   les sert au bon format, et le survol comme le clic sont des
   événements ordinaires. Le lancer de rayon de la version WebGL
   n'existe plus : c'est le test de collision du navigateur, qui est
   plus juste que le nôtre et qui ne coûte rien.

   LE PANNEAU EST L'ASTUCE. Une image posée sur une sphère qui tourne
   présenterait sa tranche dès le quart de tour. Il faut donc la
   contre-tourner, exactement, à chaque trame. Écrire seize
   transformations par trame serait tenable ; en écrire UNE l'est
   davantage : la contre-rotation est une propriété personnalisée posée
   sur le monde, et elle est HÉRITÉE par les seize vignettes. Un seul
   accès au style par trame, quelle que soit la population.

   TROIS NIVEAUX, ET CHACUN SON MÉTIER. La place ne bouge jamais, la
   vignette se contre-tourne sans transition (sous peine de traîner
   d'une trame derrière le monde), et le cadre porte le survol, avec
   transition. Empiler les trois sur le même nœud, c'est faire
   transitionner la contre-rotation, et la sphère devient molle.

   LE FONDU AU LOIN n'est pas une coquetterie : sans lui, une sphère
   creuse se lit comme un nuage plat. La profondeur de chaque place est
   calculée à la main (un produit scalaire, vingt fois par trame) et
   passée en variable, que la feuille traduit en opacité.
------------------------------------------------------------------- */

export type Vignette = {
  src: string;
  /** La pleine résolution, chargée seulement à l'ouverture. */
  grand?: string;
  /** Vide pour une image décorative : le bouton prend alors son rang. */
  alt: string;
};

export type SphereProps = {
  images: Vignette[];
  /** Vitesse de la rotation au repos, en degrés par seconde. */
  derive?: number;
  /** Part de la boîte qu'occupe une image mise en avant. */
  avant?: number;
  className?: string;
  /**
   * Le reste passe par les propriétés personnalisées : `--sph-r` le
   * rayon, `--sph-w` la largeur d'une vignette, `--sph-fuite` la
   * distance du point de fuite.
   */
  style?: React.CSSProperties;
};

/* La spirale de Fibonacci, et pas vingt tirages au sort. Le hasard fait
   des grappes : sur seize points, il en colle trois dans un coin et
   laisse un trou en face, et la sphère se lit comme un tas. La spirale
   dorée répartit sans jamais aligner. */
function spirale(n: number) {
  const or = Math.PI * (3 - Math.sqrt(5));
  return Array.from({ length: n }, (_, i) => {
    const y = n === 1 ? 0 : 1 - (i / (n - 1)) * 2;
    const r = Math.sqrt(Math.max(0, 1 - y * y));
    const t = or * i;
    /* Le rayon respire d'un sixième. Sur une coque parfaite, les seize
       images sont à la même distance : elles se lisent comme un anneau
       vu de face, pas comme un volume. Un peu d'épaisseur, et l'œil
       trouve enfin des images DEVANT d'autres. Déterministe, pas
       tirée au sort : le serveur et le client doivent tomber d'accord. */
    const e = 1 + 0.17 * Math.sin(i * 2.3999632);
    return { x: Math.cos(t) * r * e, y: y * e, z: Math.sin(t) * r * e };
  });
}

const rad = Math.PI / 180;

export default function Sphere({
  images,
  derive = 5,
  avant = 0.72,
  className,
  style,
}: SphereProps) {
  const boite = useRef<HTMLDivElement>(null);
  /* La boucle est montée une fois : elle appelle la fermeture par
     référence, faute de quoi Échap fermerait avec la version du premier
     rendu, et sauterait le voyage de retour. */
  const fermerRef = useRef<() => void>(() => {});
  const monde = useRef<HTMLDivElement>(null);
  const vue = useRef<HTMLDivElement>(null);
  const [ouverte, setOuverte] = useState<number | null>(null);
  /* La fermeture est un ÉTAT, pas un démontage. L'image doit refaire le
     chemin inverse avant de disparaître : tant qu'elle vole, elle est
     encore là. */
  const [ferme, setFerme] = useState(false);
  const ouverteRef = useRef<number | null>(null);
  ouverteRef.current = ouverte;
  const minuteurF = useRef(0);
  useEffect(() => () => window.clearTimeout(minuteurF.current), []);

  const points = spirale(images.length);

  useEffect(() => {
    const el = boite.current;
    const mo = monde.current;
    if (!el || !mo) return;

    const calme = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const places = [...mo.children] as HTMLElement[];

    let rx = -12;
    let ry = 0;
    let vx = 0;
    let vy = 0;
    let tire = false;
    let dernierX = 0;
    let dernierY = 0;
    let dx = 0;
    let dy = 0;
    let chemin = 0;
    let raf = 0;
    let tourne = false;
    let clavier = false;
    let approche = false;
    let avantT = 0;

    const peindre = () => {
      mo.style.transform = `rotateX(${rx.toFixed(2)}deg) rotateY(${ry.toFixed(2)}deg)`;
      /* La contre-rotation, écrite UNE fois et héritée par tout le
         monde. L'ordre est l'inverse exact de celui du monde. */
      mo.style.setProperty(
        "--sph-inv",
        `rotateY(${(-ry).toFixed(2)}deg) rotateX(${(-rx).toFixed(2)}deg)`
      );

      /* La profondeur, à la main. Le z d'un point après `rotateX(a)
         rotateY(b)` : un produit scalaire, pas une matrice. */
      const sa = Math.sin(rx * rad);
      const ca = Math.cos(rx * rad);
      const sb = Math.sin(ry * rad);
      const cb = Math.cos(ry * rad);
      for (let i = 0; i < places.length; i++) {
        const p = points[i];
        if (!p) continue;
        const z = sa * p.y + ca * (p.z * cb - p.x * sb);
        places[i].style.setProperty("--d", ((z + 1) / 2).toFixed(3));
      }
    };

    const trame = (t: number) => {
      if (!tourne) return;
      const dt = avantT ? Math.min(0.05, (t - avantT) / 1000) : 1 / 60;
      avantT = t;

      if (!tire) {
        if (vx || vy) {
          ry += vy;
          rx -= vx;
          const k = Math.pow(0.94, dt * 60);
          vx *= k;
          vy *= k;
          if (Math.abs(vx) < 0.004) vx = 0;
          if (Math.abs(vy) < 0.004) vy = 0;
        }
        /* Le repos n'est pas l'immobilité : une sphère arrêtée ne dit
           pas qu'elle tourne. Elle ne dérive que lorsque personne ne la
           tient, qu'aucune image n'est ouverte, que le clavier n'est pas
           dedans (une cible qu'on vient d'atteindre à la tabulation n'a
           pas le droit de s'en aller toute seule), et que la main n'est
           pas au-dessus. Une sphère qui continue de tourner sous le
           curseur DÉROBE ce qu'on vise : on approche d'une image, elle
           glisse, on clique à côté. Elle se pose à l'approche. */
        if (ouverteRef.current === null && !clavier && !approche)
          ry += derive * dt;
      }
      rx = Math.max(-72, Math.min(72, rx));
      peindre();
      raf = requestAnimationFrame(trame);
    };

    const partir = () => {
      if (tourne || calme) return;
      tourne = true;
      avantT = 0;
      raf = requestAnimationFrame(trame);
    };
    const arreter = () => {
      tourne = false;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    /* ---------- La main ---------- */
    let capture = 0;
    const surAppui = (e: PointerEvent) => {
      if (e.button !== 0 && e.pointerType === "mouse") return;
      tire = true;
      vx = vy = dx = dy = chemin = 0;
      capture = 0;
      dernierX = e.clientX;
      dernierY = e.clientY;
      el.dataset.tire = "true";
    };
    const surMouvement = (e: PointerEvent) => {
      if (!tire) return;
      dx = e.clientX - dernierX;
      dy = e.clientY - dernierY;
      dernierX = e.clientX;
      dernierY = e.clientY;
      chemin += Math.hypot(dx, dy);
      /* La capture du pointeur n'est demandée QU'UNE FOIS LE GLISSEMENT
         AVÉRÉ, et c'est la correction la moins évidente de la planche.
         Capturer dès l'appui règle le vrai problème (poursuivre le
         geste quand la main sort de la boîte) et en crée un pire : tant
         que la capture est en place, le navigateur redirige aussi le
         `click` vers l'élément capturant. Le clic n'atteint donc JAMAIS
         la vignette, et aucune image ne s'ouvre. Trois pixels de retard,
         et les deux gestes cohabitent. */
      if (!capture && chemin > 3) {
        capture = e.pointerId;
        el.setPointerCapture?.(e.pointerId);
      }
      ry += dx * 0.28;
      rx -= dy * 0.28;
      rx = Math.max(-72, Math.min(72, rx));
      if (calme) peindre();
    };
    const surRelache = () => {
      if (!tire) return;
      tire = false;
      if (capture) {
        el.releasePointerCapture?.(capture);
        capture = 0;
      }
      delete el.dataset.tire;
      /* L'élan reprend le DERNIER déplacement, pas une moyenne : c'est
         ce qui donne l'impression d'avoir lâché quelque chose plutôt
         que d'avoir relâché un ressort. */
      vy = dx * 0.22;
      vx = -dy * 0.22;
    };
    /* La main s'approche, la sphère se pose. Souris seulement : sur un
       écran tactile il n'y a pas de survol, et l'appui suffit déjà à
       l'arrêter. */
    const surEntree2 = (e: PointerEvent) => {
      if (e.pointerType === "mouse") approche = true;
    };
    const surSortie2 = () => {
      approche = false;
    };
    el.addEventListener("pointerenter", surEntree2);
    el.addEventListener("pointerleave", surSortie2);
    el.addEventListener("pointerdown", surAppui);
    el.addEventListener("pointermove", surMouvement);
    el.addEventListener("pointerup", surRelache);
    el.addEventListener("pointercancel", surRelache);

    /* ---------- Le clic, distingué du glissement ----------

       Ce n'est pas la distance entre l'appui et le relâchement qui
       compte, c'est le chemin PARCOURU pendant qu'on tenait la sphère :
       un aller-retour revient à son point de départ, et il a pourtant
       fait tourner le monde d'un demi-tour. On accumule donc, et on
       annule le clic au-delà de six pixels.

       Le compteur part de zéro et n'est incrémenté que sous la main. Si
       l'appui n'est jamais arrivé (un pointeur exotique, un événement
       synthétique), il vaut zéro et le clic passe. C'est le bon sens de
       la panne : un garde-fou qui doute laisse passer, il ne bloque pas. */
    const surFin = (e: MouseEvent) => {
      if (chemin > 6) {
        e.preventDefault();
        e.stopPropagation();
      }
    };
    el.addEventListener("click", surFin, true);

    const surTouche = (e: KeyboardEvent) => {
      if (e.key === "Escape" && ouverteRef.current !== null) fermerRef.current();
    };
    window.addEventListener("keydown", surTouche);

    /* Le clavier entre, la sphère s'arrête. Sans ça, on tabule jusqu'à
       une vignette et elle part en tournant : l'anneau de focus s'en va
       avec elle, et l'on ne sait plus où l'on est. */
    const surEntree = () => { clavier = true; };
    const surSortie = () => { clavier = false; };
    el.addEventListener("focusin", surEntree);
    el.addEventListener("focusout", surSortie);

    let visible = false;
    let cachee = false;
    const accorder = () => (visible && !cachee ? partir() : arreter());
    const io = new IntersectionObserver(
      (es) => { visible = es[0]?.isIntersecting ?? false; accorder(); },
      { threshold: 0.1 }
    );
    io.observe(el);
    const surVisibilite = () => { cachee = document.hidden; accorder(); };
    document.addEventListener("visibilitychange", surVisibilite);

    peindre();
    accorder();

    return () => {
      arreter();
      io.disconnect();
      document.removeEventListener("visibilitychange", surVisibilite);
      window.removeEventListener("keydown", surTouche);
      el.removeEventListener("focusin", surEntree);
      el.removeEventListener("focusout", surSortie);
      el.removeEventListener("pointerenter", surEntree2);
      el.removeEventListener("pointerleave", surSortie2);
      el.removeEventListener("pointerdown", surAppui);
      el.removeEventListener("pointermove", surMouvement);
      el.removeEventListener("pointerup", surRelache);
      el.removeEventListener("pointercancel", surRelache);
      el.removeEventListener("click", surFin, true);
    };
  }, [derive, images.length]);

  /* ---------- L'ouverture, en FLIP ----------

     L'image ne réapparaît pas au centre : elle y VA. On relève la place
     qu'occupe la vignette à l'écran, on laisse le navigateur poser la
     grande image à sa place finale, puis on lui applique l'écart entre
     les deux et on le retire dans une transition. Deux mesures et une
     soustraction remplacent une interpolation de position dans une
     scène 3D, et le trajet est exact, parce qu'il part d'une mesure et
     non d'un modèle. */
  /** L'écart entre deux rectangles, en transformation. */
  const ecart = (de: DOMRect, a: DOMRect) =>
    `translate(${de.left - a.left}px, ${de.top - a.top}px) scale(${
      de.width / a.width
    })`;

  const ouvrir = (i: number, source: HTMLElement) => {
    const de = source.getBoundingClientRect();
    window.clearTimeout(minuteurF.current);
    setFerme(false);
    setOuverte(i);
    requestAnimationFrame(() => {
      const cible = vue.current?.querySelector("img");
      if (!cible) return;
      const a = cible.getBoundingClientRect();
      if (!a.width || !de.width) return;
      cible.style.transition = "none";
      cible.style.transformOrigin = "top left";
      cible.style.transform = ecart(de, a);
      void cible.offsetWidth;
      cible.style.transition = "";
      cible.style.transform = "";
    });
  };

  /* Le retour, et c'est la moitié qui manquait. Une image qui arrive en
     volant et repart en disparaissant laisse le geste inachevé : on ne
     sait plus d'où elle venait, donc où l'on en était. Elle refait le
     chemin, à l'envers, vers la vignette qu'elle a quittée, laquelle
     n'a pas bougé, puisque la sphère s'arrête tant qu'une image est
     ouverte. */
  const fermer = () => {
    const i = ouverteRef.current;
    const cible = vue.current?.querySelector("img");
    const source = boite.current?.querySelectorAll<HTMLElement>(".sph__place")[
      i ?? -1
    ];
    if (i === null || !cible || !source) {
      setOuverte(null);
      return;
    }
    const calme = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    setFerme(true);
    if (calme) {
      setOuverte(null);
      setFerme(false);
      return;
    }
    const a = cible.getBoundingClientRect();
    const de = source.getBoundingClientRect();
    if (a.width && de.width) {
      cible.style.transformOrigin = "top left";
      cible.style.transform = ecart(de, a);
    }
    /* On attend la fin du voyage pour démonter, et pas une milliseconde
       de plus : un `transitionend` serait plus juste, mais il ne se
       déclenche pas si la transformation n'a rien à interpoler (une
       vignette exactement à la taille de la grande image, un moteur qui
       coupe court), et la carte resterait alors avec son voile pour
       toujours. Un minuteur ne rate jamais. */
    minuteurF.current = window.setTimeout(() => {
      setOuverte(null);
      setFerme(false);
    }, 460);
  };

  fermerRef.current = fermer;
  const dessus = ouverte !== null ? images[ouverte] : null;

  return (
    <div
      ref={boite}
      className={className ? `sph ${className}` : "sph"}
      data-vue={dessus ? "true" : undefined}
      style={style}
    >
      <div className="sph__monde" ref={monde}>
        {images.map((im, i) => {
          const p = points[i];
          return (
            <div
              key={im.src}
              className="sph__pos"
              style={
                {
                  "--x": p.x.toFixed(4),
                  "--y": p.y.toFixed(4),
                  "--z": p.z.toFixed(4),
                  "--d": ((p.z + 1) / 2).toFixed(3),
                } as React.CSSProperties
              }
            >
              <button
                type="button"
                className="sph__place"
                onClick={(e) => ouvrir(i, e.currentTarget)}
                tabIndex={dessus ? -1 : 0}
                /* Le NOM est sur le bouton, pas sur l'image. Une image
                   décorative garde un `alt` vide : la décrire deux fois
                   ferait annoncer « image, bouton, image ». Sans texte
                   fourni, le rang sert de nom : il dit au moins où l'on
                   est dans la série. */
                aria-label={
                  im.alt || `Agrandir l'image ${i + 1} sur ${images.length}`
                }
              >
                <span className="sph__cadre">
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img src={im.src} alt={im.alt} loading="lazy" draggable={false} />
                </span>
              </button>
            </div>
          );
        })}
      </div>

      {dessus && (
        <div className="sph__vue" ref={vue} data-ferme={ferme || undefined}>
          {/* Le voile. Il ne se contente pas d'assombrir la sphère : il
              la RECOUVRE. Une sphère seulement atténuée derrière une
              photographie reste une bouillie d'images, et l'œil continue
              d'y chercher des formes au lieu de regarder celle qu'on
              vient d'ouvrir. */}
          <button
            type="button"
            className="sph__voile"
            onClick={fermer}
            tabIndex={-1}
            aria-hidden="true"
          />
          <button
            type="button"
            className="sph__fermer"
            onClick={fermer}
            autoFocus
          >
            <span className="sr">Fermer l&apos;image</span>
            <span aria-hidden="true">Fermer</span>
          </button>
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={dessus.grand ?? dessus.src}
            alt={dessus.alt}
            draggable={false}
            style={{ "--sph-avant": avant } as React.CSSProperties}
          />
        </div>
      )}
    </div>
  );
}

Ailleurs dans le Livre

Sedan, disponible pour de nouveaux projetsObjets · publié le 10 septembre 2026