Visite guidée de la première trace, posée sur la trace elle-même du départ à l’arrivée ; chaque étape commente un élément réel de l’analyse.
stableÀ valider
// Titre : Feuille en bas d’écran (téléphone), trace Cévennes J1
import { useState } from 'react';
import { verdictLabel } from '@pathou/components/Sidebar';
import Tour, { type TourContext } from '@pathou/components/Tour';
import { tourSteps } from '@pathou/lib/tour';
import { base, riderOf } from '../_fixtures/base';
const rider = riderOf('vtt', 'confirme');
const steps = tourSteps({ base, rider, detours: {}, alertCount: rider.alerts.length, canExport: true });
const context: TourContext = {
d: base.d,
ele: base.ele,
verdict: verdictLabel(rider.verdict),
practice: 'VTT',
level: 'confirmé',
climbCount: base.climbs.length,
applied: false,
alternatives: false,
wide: false,
};
export default function Exemple() {
const [index, setIndex] = useState(0);
return (
<div style={{ minHeight: 300, width: '100%' }}>
<Tour steps={steps} index={index} context={context} onNext={() => setIndex((i) => Math.min(i + 1, steps.length - 1))} onBack={() => setIndex((i) => Math.max(i - 1, 0))} onSkip={() => setIndex(0)} />
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { formatDelta } from '../lib/detour/result';
import { alertKey } from '../lib/difficulty/model';
import { fmt } from '../lib/format';
import { tourProfile, type TourStep } from '../lib/tour';
/** Ce que les textes de la visite disent de la trace et du réglage choisi. */
export interface TourContext {
d: number[];
ele: number[];
/** libellé du verdict tel que l'affiche le panneau (« Exigeante », « Longue pour ton niveau »…) */
verdict: string;
/** « VTT », « gravel », « route » */
practice: string;
/** « intermédiaire »… */
level: string;
climbCount: number;
/** au moins une alternative est appliquée à la trace */
applied: boolean;
/** une alternative est proposée sur la trace (étape de la visite) */
alternatives: boolean;
/** écran assez large pour survoler le profil à la souris */
wide: boolean;
}
const lower = (s: string) => s.charAt(0).toLowerCase() + s.slice(1);
/** Titre, texte et repère sous le fil de chaque étape. */
export function stepText(step: TourStep, c: TourContext): { title: string; body: string; label: string } {
const km = (idx: number) => fmt(c.d[idx] / 1000, 1);
switch (step.id) {
case 'verdict':
return {
title: 'Le verdict, pour ton niveau',
body: `${c.verdict}, en ${c.practice}, niveau ${c.level}. Change de pratique ou de niveau en haut : le verdict et les points d’attention suivent.`,
label: 'Départ',
};
case 'passage': {
const n = step.count === 1 ? 'Un point d’attention' : `${step.count} points d’attention`;
return {
title: 'Les passages à surveiller',
body: `${n} sur cette trace. Le premier, au km ${km(step.alert.idx)} : ${lower(step.alert.title)}. Choisis un point dans la liste, la carte s’y rend.`,
label: `km ${km(step.alert.idx)}`,
};
}
case 'montee': {
const k = step.climb;
const which = c.climbCount > 1 ? 'La plus grande part' : 'Elle part';
return {
title: 'Les montées, une par une',
body: `${which} du km ${fmt(k.startKm, 1)} : ${fmt(k.lengthKm, 1)} km à ${fmt(k.avgGrade, 1)} % de moyenne, +${fmt(k.gain)} m. Les couleurs montrent où ça se raidit.`,
label: `Sommet, ${fmt(c.ele[step.idx])} m`,
};
}
case 'relief':
return {
title: 'Le relief, en 3D',
body: c.wide
? 'Survole le profil : le curseur suit sur la carte. «\u00a0Survol\u00a0» fait voler la caméra le long de la trace, du départ à l’arrivée.'
: 'Touche le profil : le curseur se place sur la carte. «\u00a0Survol\u00a0» fait voler la caméra le long de la trace, du départ à l’arrivée.',
label: `Point haut, ${fmt(c.ele[step.idx])} m`,
};
case 'alternative': {
const f = step.found;
const gain = Math.round(f.extraGain);
const climb = gain === 0 ? 'sans D+ en plus' : `${gain < 0 ? '−' : '+'}${Math.abs(gain)} m de D+`;
const kind = f.status === 'praticable' ? 'une alternative praticable' : 'une alternative moins dure';
return {
title: 'Un contournement, quand il existe',
body: `Au km ${km(step.alert.idx)}, ${lower(step.alert.name)} : ${kind} passe à côté (${formatDelta(f.extraM)}, ${climb}). Le bouton + l’applique à ta trace, et le verdict se recalcule.`,
label: `km ${km(step.alert.idx)}`,
};
}
case 'export':
return {
title: 'Repartir avec ta trace',
body: `${
c.applied
? 'Télécharger le GPX donne ta trace avec les alternatives appliquées, prête pour ton GPS ou ton appli.'
: `Télécharger le GPX donne la trace affichée, prête pour ton GPS ou ton appli.${c.alternatives ? ' Une alternative appliquée y est incluse.' : ''}`
} «\u00a0Revoir la visite\u00a0», juste à côté, la relance quand tu veux.`,
label: `Arrivée, ${fmt(c.d[c.d.length - 1] / 1000, 1)} km`,
};
}
}
/** Élément commenté par l'étape, éléments mis en avant avec lui, et où poser la carte (bureau). */
function targetsOf(step: TourStep): { main: string; extra: string[]; place: 'left' | 'top' | 'bottom' } {
switch (step.id) {
case 'verdict':
return { main: '[data-tour="verdict"]', extra: ['[data-tour="level"] .segmented'], place: 'left' };
case 'passage':
return { main: `[data-tour-alert="${alertKey(step.alert)}"]`, extra: [], place: 'left' };
case 'montee':
return { main: `[data-tour-climb="${step.climb.startIdx}"]`, extra: [], place: 'left' };
case 'relief':
return { main: '[data-tour="dock"]', extra: ['[data-tour="survol"]'], place: 'top' };
case 'alternative':
return { main: `[data-tour-alert="${alertKey(step.alert)}"] .detour`, extra: [], place: 'left' };
case 'export':
return { main: '[data-tour="gpx"]', extra: ['[data-tour="revoir"]'], place: 'bottom' };
}
}
const CARD_W = 344;
const GAP = 14;
const EDGE = 8;
const ARROW_MIN = 20;
/** temps sans bouger avant de déplacer la carte (ms) : un défilement doux ne marque pas de pause aussi longue */
const STILL_MS = 160;
interface Layout {
left: number;
top: number;
arrow: { side: 'right' | 'bottom' | 'top'; at: number } | null;
}
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
interface Ring {
left: number;
top: number;
width: number;
height: number;
radius: string;
}
/** Contour d'un élément commenté, rogné au panneau qui le fait défiler (il ne déborde pas sur la carte). */
function ringOf(el: Element): Ring {
const r = el.getBoundingClientRect();
const box = el.closest('.sidebar')?.getBoundingClientRect();
const top = box ? Math.max(r.top, box.top) : r.top;
const bottom = box ? Math.min(r.bottom, box.bottom) : r.bottom;
return { left: r.left, top, width: r.width, height: Math.max(0, bottom - top), radius: getComputedStyle(el).borderRadius };
}
/**
* Position de la carte à côté de sa cible, et de la pointe qui la désigne. Cible hors écran : la carte reste dans la fenêtre.
* `shift` : déplacement vertical de la cible encore à venir (défilement du panneau en cours), pour viser sa place finale.
*/
function place(step: TourStep, el: Element, cardH: number, d: number[], shift = 0): Layout {
const b = el.getBoundingClientRect();
const r = { left: b.left, width: b.width, height: b.height, top: b.top + shift, bottom: b.bottom + shift };
const { place: side } = targetsOf(step);
const vw = window.innerWidth;
const vh = window.innerHeight;
const topMin = (document.querySelector('.topbar')?.getBoundingClientRect().bottom ?? 0) + EDGE;
if (side === 'left') {
// une grande cible (le verdict) est désignée par son haut, là où on lit son titre
const scroller = el.closest('.sidebar')?.getBoundingClientRect();
const anchorY = clamp(r.top + Math.min(r.height / 2, 28), scroller ? scroller.top + 12 : topMin, scroller ? scroller.bottom - 12 : vh - EDGE);
// contre le bord du panneau, même pour une cible en retrait (l'alternative, dans sa carte) : la carte ne le chevauche pas
const left = Math.max(EDGE, (scroller ? Math.min(scroller.left, r.left) : r.left) - GAP - CARD_W);
// au-dessus du profil flottant quand il passe sous la carte et qu'il reste la place
const dock = document.querySelector('[data-tour="dock"]')?.getBoundingClientRect();
const bottom = dock && dock.right > left && dock.top - EDGE - cardH >= topMin ? dock.top - EDGE : vh - EDGE;
const top = clamp(anchorY - 40, topMin, bottom - cardH);
return { left, top, arrow: { side: 'right', at: clamp(anchorY - top, ARROW_MIN, cardH - ARROW_MIN) } };
}
if (side === 'top') {
// le relief : la pointe vise le point haut sur le profil
const plot = el.querySelector('.dock-plot')?.getBoundingClientRect() ?? r;
const ax = step.id === 'relief' ? plot.left + (plot.width * d[step.idx]) / d[d.length - 1] : r.left + r.width / 2;
const left = clamp(ax - CARD_W / 2, EDGE, vw - CARD_W - EDGE);
return { left, top: Math.max(topMin, r.top - GAP - cardH), arrow: { side: 'bottom', at: clamp(ax - left, ARROW_MIN, CARD_W - ARROW_MIN) } };
}
const ax = r.left + r.width / 2;
const left = clamp(ax - CARD_W / 2, EDGE, vw - CARD_W - EDGE);
return { left, top: r.bottom + GAP, arrow: { side: 'top', at: clamp(ax - left, ARROW_MIN, CARD_W - ARROW_MIN) } };
}
interface Props {
steps: TourStep[];
index: number;
context: TourContext;
onNext: () => void;
onBack: () => void;
onSkip: () => void;
}
/**
* Carte de la visite guidée : posée à côté de l'élément commenté (bureau) ou en feuille en bas de l'écran (téléphone).
* Le fil en tête est le profil de la trace, avec une balise par étape. La visite n'est pas modale : le reste de
* l'app reste utilisable, et ses gestes (ouvrir un point, appliquer une alternative) la font avancer.
*/
export default function Tour({ steps, index, context, onNext, onBack, onSkip }: Props) {
const step = steps[index];
const text = stepText(step, context);
const card = useRef<HTMLElement>(null);
const [layout, setLayout] = useState<Layout | null>(null);
const reduced = useMemo(() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, []);
const [rings, setRings] = useState<Ring[]>([]);
// Défilement du panneau lancé par la visite : jusqu'où, et jusqu'à quand le viser plutôt que la position du moment
const aim = useRef<{ box: Element; top: number; until: number } | null>(null);
// l'élément commenté, amené au milieu de son panneau (bureau) ou en haut de la page (téléphone). Le défilement du
// panneau est calculé ici plutôt que confié à scrollIntoView : la carte connaît d'emblée la place finale de sa cible
// et y va directement, au lieu de suivre la liste qui défile.
useLayoutEffect(() => {
const el = document.querySelector(targetsOf(step).main);
const behavior = reduced ? 'auto' : 'smooth';
const box = context.wide ? el?.closest('.sidebar') : null;
if (el && box) {
const r = el.getBoundingClientRect();
const b = box.getBoundingClientRect();
const top = clamp(box.scrollTop + r.top - b.top - (box.clientHeight - r.height) / 2, 0, box.scrollHeight - box.clientHeight);
aim.current = { box, top, until: performance.now() + 1500 };
box.scrollTo({ top, behavior });
} else if (el && context.wide) {
aim.current = null;
el.scrollIntoView({ block: 'center', behavior });
} else if (el) {
aim.current = null;
// téléphone : la feuille couvre le bas de l'écran. Le profil se pose juste au-dessus d'elle, sous le bas de la
// carte 3D ; l'alternative vient avec le passage qu'elle contourne (celui que nomme le texte) quand les deux tiennent.
const free = window.innerHeight - (card.current?.offsetHeight ?? 0);
if (step.id === 'relief') {
window.scrollTo({ top: window.scrollY + el.getBoundingClientRect().bottom - free + 12, behavior });
} else {
const whole = step.id === 'alternative' ? el.closest('[data-tour-alert]') : null;
(whole && whole.getBoundingClientRect().height <= free - 24 ? whole : el).scrollIntoView({ block: 'start', behavior });
}
}
// la cible change avec l'étape ; un nouveau rendu de la liste (niveau changé) garde les mêmes sélecteurs
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step.id, index, context.wide]);
// Suit ses cibles image par image (défilement du panneau, profil replié, fenêtre redimensionnée) : place la carte
// et le contour des éléments commentés. Contour dessiné par-dessus plutôt que posé en classe : React réécrit la
// classe d'un élément à chaque changement d'état (sélection), ce qui l'effacerait.
useLayoutEffect(() => {
let raf = 0;
let shownRings = '';
let shownLayout = '';
let seen = '';
let since = performance.now();
// dernier défilement d'un panneau ou de la page : la carte ne bouge pas tant que ça défile
let scrolled = performance.now();
const onScroll = () => (scrolled = performance.now());
document.addEventListener('scroll', onScroll, { capture: true, passive: true });
const tick = () => {
const { main, extra } = targetsOf(step);
const el = document.querySelector(main);
// défilement lancé par la visite encore en cours : la carte vise la place finale de sa cible
const a = aim.current;
if (a && (Math.abs(a.box.scrollTop - a.top) < 1 || performance.now() > a.until)) aim.current = null;
const shift = aim.current ? aim.current.box.scrollTop - aim.current.top : 0;
const next = context.wide && el && card.current ? place(step, el, card.current.offsetHeight, context.d, shift) : null;
const r = [main, ...extra].flatMap((sel) => {
const e = document.querySelector(sel);
return e ? [ringOf(e)] : [];
});
// le contour colle à son élément à chaque image
const rk = JSON.stringify(r);
if (rk !== shownRings) {
shownRings = rk;
setRings(r);
}
// la carte, elle, attend que sa cible s'arrête (défilement doux du panneau) : elle ne monte ni ne descend avec
// la liste qui défile, et glisse une seule fois jusqu'à sa place
const lk = JSON.stringify(next);
if (lk !== seen) {
seen = lk;
since = performance.now();
}
const now = performance.now();
// défilement de la visite : place finale connue, la carte y part tout de suite ; défilement à la main : elle attend
if ((aim.current || (now - since >= STILL_MS && now - scrolled >= STILL_MS)) && lk !== shownLayout) {
shownLayout = lk;
setLayout(next);
}
raf = requestAnimationFrame(tick);
};
tick();
return () => {
cancelAnimationFrame(raf);
document.removeEventListener('scroll', onScroll, { capture: true });
};
}, [step, context.wide, context.d]);
// ouverture : le focus va sur la carte, pour que le lecteur d'écran l'annonce et que Tab atteigne ses boutons.
// Fermeture : il revient là où il était, sinon sur « Revoir la visite » plutôt que de se perdre en haut de la page.
useEffect(() => {
const before = document.activeElement;
card.current?.focus({ preventScroll: true });
return () => {
if (document.activeElement !== document.body && document.activeElement !== null) return;
const back = before instanceof HTMLElement && before !== document.body && before.isConnected ? before : document.querySelector<HTMLElement>('[data-tour="revoir"]');
back?.focus({ preventScroll: true });
};
}, []);
const total = context.d[context.d.length - 1] || 1;
const profile = useMemo(() => tourProfile(context.d, context.ele), [context.d, context.ele]);
const [lo, hi] = useMemo(() => context.ele.reduce(([a, b], e) => [Math.min(a, e), Math.max(b, e)], [Infinity, -Infinity]), [context.ele]);
const Y = (e: number) => 3 + 38 * (1 - (e - lo) / (hi - lo || 1));
const line = profile.map(([x, y], k) => `${k ? 'L' : 'M'}${(x * 300).toFixed(1)} ${(3 + 38 * (1 - y)).toFixed(1)}`).join('');
const balises = steps.map((s) => ({ id: s.id, x: (context.d[s.idx] / total) * 300, y: Y(context.ele[s.idx]) }));
const here = balises[index];
const share = (here.x + 6) / 312;
const labelStyle = share < 0.2 ? { left: 0 } : share > 0.8 ? { right: 0 } : { left: `${share * 100}%`, transform: 'translateX(-50%)' };
const style = context.wide && layout ? { left: layout.left, top: layout.top } : undefined;
const arrow = layout?.arrow;
return (
<>
{rings.map((g, k) => (
<span key={k} className="tour-ring" style={{ left: g.left, top: g.top, width: g.width, height: g.height, borderRadius: g.radius }} aria-hidden="true" />
))}
<section
ref={card}
className={`tour${context.wide ? '' : ' tour-sheet'}`}
style={style}
role="dialog"
aria-modal="false"
aria-labelledby="tour-title"
aria-describedby="tour-body"
tabIndex={-1}
data-ready={!context.wide || layout ? '' : undefined}
>
{context.wide && arrow && <span className={`tour-arrow tour-arrow-${arrow.side}`} style={arrow.side === 'right' ? { top: arrow.at - 7 } : { left: arrow.at - 7 }} aria-hidden="true" />}
<div className="tour-strip">
<svg viewBox="-6 -6 312 56" role="img" aria-label={`Étape ${index + 1} sur ${steps.length} : ${text.label}`}>
<defs>
<clipPath id="tour-done">
<rect x="-6" y="-6" height="56" width={here.x + 6} />
</clipPath>
</defs>
<path className="tour-line" d={line} />
<path className="tour-line-done" d={line} clipPath="url(#tour-done)" />
{/* balise de l'étape en cours dessinée en dernier : une voisine proche (point d'attention au km 1) ne la recouvre pas */}
{balises.map((b, k) => k !== index && <circle key={b.id} className={k < index ? 'tour-dot done' : 'tour-dot'} cx={b.x} cy={b.y} r={3.5} />)}
<circle className="tour-dot here" cx={here.x} cy={here.y} r={6} />
</svg>
<span className="tour-label" style={labelStyle}>
{text.label}
</span>
</div>
<div aria-live="polite">
<h2 id="tour-title" className="tour-title">
{text.title}
</h2>
<p id="tour-body" className="tour-body">
{text.body}
</p>
</div>
<div className="tour-actions">
<button type="button" className="ghost tour-skip" onClick={onSkip}>
Passer la visite
</button>
<span className="tour-count">
{index + 1} sur {steps.length}
</span>
<button type="button" className="tour-back" onClick={onBack} disabled={index === 0} aria-label="Étape précédente" title="Étape précédente">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M19 12H5M11 6l-6 6 6 6" />
</svg>
</button>
<button type="button" className="primary tour-next" onClick={onNext}>
{index === steps.length - 1 ? 'Terminer' : 'Continuer'}
</button>
</div>
</section>
</>
);
}
Tokens : --panel --muted --line --accent --accent-ink --chip --ease-in-out
Utilisation
import Tour from './components/Tour';
import { tourSteps } from './lib/tour';
const steps = tourSteps({ base, rider, detours, alertCount, canExport });
<Tour steps={steps} index={index} context={context} onNext={next} onBack={back} onSkip={skip} />Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | Fil de la trace | Le profil simplifié, avec une balise par étape ; l’étape en cours est la plus grosse. Nommé « Étape n sur N » pour les lecteurs d’écran. |
| 2 | Titre et texte | Ce que montre l’étape, écrit à partir des vraies valeurs de la trace (km, pente, verdict) ; annoncé à chaque changement. |
| 3 | Actions | « Passer la visite », le compteur, l’étape précédente et « Continuer ». |
Guidelines
- MUSTNe commenter que ce qui existe sur la trace ; une étape sans matière est sautée, jamais illustrée par un faux exemple.
- SHOULDGarder la visite courte (six étapes au plus) et toujours possible à passer.
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
steps | TourStep[] | requis |
index | number | requis |
context | TourContext | requis |
onNext | () => void | requis |
onBack | () => void | requis |
onSkip | () => void | requis |