Sphère
Seize images sur une sphère qu'on lance à la main, et pas une ligne de WebGL.
<Sphere
derive={5}
style={{
"--sph-r": "148px",
"--sph-w": "60px",
"--sph-fuite": "520px",
}}
/>Fiche
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.
mo.style.setProperty(
"--sph-inv",
`rotateY(${(-ry).toFixed(2)}deg) rotateX(${(-rx).toFixed(2)}deg)`
);
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.
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
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.
const ecart = (de: DOMRect, a: DOMRect) =>
`translate(${de.left - a.left}px, ${de.top - a.top}px) scale(${
de.width / a.width
})`;
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. Lepointermoves'arrête net, la sphère se fige à mi-geste, et l'on croit à un bogue de vitesse.touch-action: nonedonne 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 devar(), jamais des déclarations : un repli n'existe que s'il n'y a rien d'autre, et il ne se dispute avec personne.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
clickvers l'élément capturant. Le clic n'atteignait donc jamais la vignette, et aucune image ne s'ouvrait. Rien dans la console, unpointerdownqui arrive bien sur la bonne cible, et unclickqui 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.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
clickest 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.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'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>
);
}
/* Sphère · src/components/book/kit/Sphere.tsx */
/* La boîte. C'est elle qui écoute la main, et elle seule : le monde et
les vignettes sont transparents au pointeur sauf là où il faut cliquer.
`touch-action: none` n'est pas négociable. Sans lui, un glissement
horizontal sur téléphone est happé par le défilement de la page dès
qu'il s'écarte de dix degrés de l'horizontale, et la sphère ne tourne
qu'une fois sur trois. Avec, le geste appartient à la sphère, et la
page reste défilable partout ailleurs, ce qui suffit. */
/* LES VALEURS PAR DÉFAUT SONT DES REPLIS DE `var()`, jamais des
déclarations. C'est la même leçon que la planche de la lueur, et elle
se paie deux fois si on ne l'applique pas : une classe de placement
posée sur le même élément, `.dmo__sphere` ici, 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é. Un repli ne se dispute avec personne : il n'existe que s'il
n'y a rien d'autre. */
.sph {
position: relative;
overflow: hidden;
perspective: calc(var(--sph-fuite, 520px) * var(--sph-zoom, 1));
/* LA LISIÈRE. La perspective agrandit ce qui est devant : une image au
sommet de la sphère occupe un tiers de plus que son rayon ne le
laisse croire, et elle sort de la boîte. Rapetisser la sphère jusqu'à
ce que le pire cas tienne la laisse petite dans une carte large.
On dissout le bord au lieu de le couper. Une photographie tranchée
net contre un filet se lit comme un défaut de mise en page ; la même,
évanouie sur trente pixels, se lit comme une sphère plus grande que
sa fenêtre, ce qu'elle est. Zéro par défaut : un composant ne
décide pas du cadre qu'on lui donne. */
--sph-masque: linear-gradient(
to bottom,
transparent 0,
#000 var(--sph-lisiere, 0%),
#000 calc(100% - var(--sph-lisiere, 0%)),
transparent 100%
);
-webkit-mask-image: var(--sph-masque);
mask-image: var(--sph-masque);
touch-action: none;
cursor: grab;
user-select: none;
-webkit-user-select: none;
}
.sph[data-tire="true"] {
cursor: grabbing;
}
/* Le monde. Il ne porte qu'une `transform` et une variable, celle que
les vignettes héritent pour se contre-tourner. */
.sph__monde {
position: absolute;
inset: 0;
transform-style: preserve-3d;
--sph-inv: rotateY(0deg);
}
/* La place est un POINT, pas une boîte : largeur et hauteur nulles. Elle
ne bouge jamais : sa transformation est calculée au rendu serveur et
n'est plus jamais réécrite. */
.sph__pos {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
transform-style: preserve-3d;
transform: translate3d(
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))
);
}
/* La vignette. Elle se contre-tourne, et SANS TRANSITION : une
transition ici la ferait traîner d'une trame derrière le monde, et
toute la sphère se mettrait à flotter.
Le `translate(-50%, -50%)` la centre sur son point, et il est exact
malgré la rotation : une translation commute avec une rotation quand
elle vient APRÈS elle dans la chaîne. La contre-rotation annulant
celle du monde, ce qui reste est un décalage à l'écran, en pixels
d'écran, quelle que soit l'orientation. Sans lui, chaque image est
accrochée par son coin haut-gauche et toute la sphère est décalée
d'une demi-vignette, vers le bas, et d'autant plus que l'image est
haute. */
.sph__place {
position: absolute;
display: block;
width: calc(var(--sph-w, 60px) * var(--sph-zoom, 1));
padding: 0;
border: 0;
background: none;
transform: var(--sph-inv) translate(-50%, -50%);
transform-style: preserve-3d;
}
.sph__place:focus-visible {
outline: none;
}
.sph__place:focus-visible .sph__cadre {
outline: 2px solid var(--fg);
outline-offset: 2px;
}
/* Le cadre. Lui porte le survol, et sa transition : c'est le seul des
trois niveaux qui ait le droit d'en avoir une. `flat` le sort de la
chaîne 3D : son agrandissement est un agrandissement à l'écran, pas
un déplacement dans la scène. */
.sph__cadre {
display: block;
overflow: hidden;
border-radius: 5px;
background: var(--chip);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 6px 18px -6px rgba(0, 0, 0, 0.7);
transform-style: flat;
/* SEULE LA TRANSFORMATION EST INTERPOLÉE, et c'est un choix mesuré.
L'opacité est réécrite à chaque trame par la boucle : la mettre en
transition la fait poursuivre sa cible sans jamais l'atteindre, avec
un retard constant de la durée de la transition. À la dérive on ne
le voit pas ; sur un lancer, le fondu de profondeur traîne d'un
cinquième de seconde derrière les images, et la sphère se met à
patiner. Une valeur calculée par trame ne se transitionne pas. */
transition: transform var(--d-md, 260ms) var(--ease-out),
box-shadow var(--d-md, 260ms) var(--ease-out);
}
/* La profondeur, traduite en présence, et posée sur l'IMAGE, pas sur le
cadre. Une opacité sur un nœud de la chaîne 3D en ferait un contexte
d'empilement, ce qui aplatit `preserve-3d` chez ses descendants.
L'image est une feuille : elle n'a rien à aplatir. */
.sph__cadre img {
opacity: calc(0.17 + 0.83 * var(--d, 1));
}
/* Le survol grandit et creuse l'ombre. Il n'éclaircit PAS : ce serait
remettre l'opacité en jeu, et donc le retard avec elle. Une vignette
qui grandit sous la main dit déjà tout ce qu'il y a à dire. */
.sph__place:hover .sph__cadre {
transform: scale(1.16);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6), 0 14px 34px -10px rgba(0, 0, 0, 0.85);
}
/* Le clavier, lui, a besoin de VOIR sa cible : une vignette à dix-sept
pour cent d'opacité au fond de la sphère ne montre pas son anneau de
focus. Elle passe à pleine présence, d'un coup : un fondu ferait
attendre pour une information qu'on veut immédiate. */
.sph__place:focus-visible .sph__cadre {
transform: scale(1.16);
}
.sph__place:focus-visible .sph__cadre img {
opacity: 1;
}
.sph__cadre img {
display: block;
width: 100%;
height: auto;
pointer-events: none;
}
/* Une image ouverte : le fond recule, et rien d'autre ne répond. */
.sph[data-vue="true"] .sph__monde {
pointer-events: none;
}
/* ---------- L'image mise en avant ---------- */
.sph__vue {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
}
/* Le voile. Il RECOUVRE, il n'atténue pas : une sphère laissée à un
quart d'opacité 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. Il est aussi la surface de sortie : cliquer à
côté referme, comme partout ailleurs.
`both` sur l'animation, et pas une transition : à l'ouverture le nœud
vient d'apparaître, et une transition sur un élément qui n'a pas
d'état précédent ne joue rien. */
.sph__voile {
position: absolute;
inset: 0;
z-index: 0;
display: block;
border: 0;
padding: 0;
background: var(--leaf-face, #0b0001);
cursor: zoom-out;
animation: sph-voile var(--d-md, 260ms) var(--ease-out) both;
}
@keyframes sph-voile {
from {
opacity: 0;
}
to {
opacity: 0.94;
}
}
.sph__vue[data-ferme] .sph__voile {
animation: sph-voile var(--d-md, 260ms) var(--ease-out) reverse both;
}
.sph__vue img {
position: relative;
z-index: 1;
display: block;
max-width: calc(var(--sph-avant, 0.72) * 100%);
max-height: calc(var(--sph-avant, 0.72) * 100%);
width: auto;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.55), 0 24px 60px -18px rgba(0, 0, 0, 0.8);
/* La transition du FLIP. Elle n'est écrite qu'ici : le composant pose
la transformation de départ, puis la retire, il ne pilote pas la
durée. */
/* Le voyage. L'aller est plus long que le retour : on ouvre pour
regarder, on ferme parce qu'on a fini. */
transition: transform 520ms var(--ease-swift, cubic-bezier(0.16, 1, 0.3, 1));
}
.sph__vue[data-ferme] img {
transition: transform 420ms var(--ease-out, cubic-bezier(0.22, 1, 0.36, 1));
pointer-events: none;
}
.sph__fermer {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
padding: 0.28em 0.7em;
border: 1px solid var(--leaf-ring);
border-radius: 6px;
background: var(--bg);
color: var(--muted-strong);
font-size: 0.8em;
transition: color var(--d-xs) var(--ease-out),
border-color var(--d-xs) var(--ease-out);
}
.sph__fermer:hover,
.sph__fermer:focus-visible {
color: var(--fg);
border-color: var(--rule);
}
/* Mouvement réduit : la sphère ne dérive plus et ne garde plus d'élan :
elle ne tourne que sous le doigt, quand on le lui demande. Le voyage
de l'image ouverte s'en va aussi : elle se pose. */
@media (prefers-reduced-motion: reduce) {
.sph__cadre,
.sph__vue img {
transition: none;
}
.sph__voile {
animation: none;
opacity: 0.94;
}
}
/* En contraste forcé, le système impose ses couleurs et l'ombre
disparaît : sans bord, seize photographies posées côte à côte se
fondent les unes dans les autres. */
@media (forced-colors: active) {
.sph__cadre {
border: 1px solid CanvasText;
}
}