Tour

←→

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.

src/components/Tour.tsx
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émentRôle
1Fil de la traceLe 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.
2Titre et texteCe que montre l’étape, écrit à partir des vraies valeurs de la trace (km, pente, verdict) ; annoncé à chaque changement.
3Actions« Passer la visite », le compteur, l’étape précédente et « Continuer ».

Guidelines

  • MUST
    Ne commenter que ce qui existe sur la trace ; une étape sans matière est sautée, jamais illustrée par un faux exemple.
  • SHOULD
    Garder la visite courte (six étapes au plus) et toujours possible à passer.

Référence de l’API

PropTypeDéfaut
stepsTourStep[]requis
indexnumberrequis
contextTourContextrequis
onNext() => voidrequis
onBack() => voidrequis
onSkip() => voidrequis