TrackView

←

L’écran d’une trace analysée — barre du haut (pratique, niveau), carte 3D, dock du parcours et panneau du verdict ; recréé pour chaque nouveau fichier.

stableÀ valider
Capture : TrackView
Capture du 19 octobre 2026, pas un rendu en direct

Installation

Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.

src/components/TrackView.tsx
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { fmt } from '../lib/format';
import { hasPrintedNames, type Basemap, type Overlays } from '../lib/mapOptions';
import RouteDock from './RouteDock';
import Sidebar from './Sidebar';
import type { DetourApply } from './DetourBlock';
import MapContentMenu, { type PoiStatus } from './MapContentMenu';
import MapLayersMenu from './MapLayersMenu';
import LevelInfo from './LevelInfo';
import LeaveConfirm from './LeaveConfirm';
import ShareDialog from './ShareDialog';
import DayPlan from './DayPlan';
import Segmented from './Segmented';
import PhotoCard, { type PhotoTarget } from './PhotoCard';
import Tour from './Tour';
import { verdictLabel } from './Sidebar';
import { alertEntries } from './AlertList';
import { markTourSeen, nextAvailable, tourSeen, tourSteps, type TourStepId } from '../lib/tour';
import { preloadable } from './preloadable';
import { photoDotsAlong, type GroundPhoto, type PhotoDot } from '../lib/photos';
import { fetchPoisAlong, type Poi } from '../lib/osm/pois';
import { fetchPlacesAlong, type Place } from '../lib/osm/places';
import { analyzeRider, type BaseAnalysis } from '../lib/analyze';
import { alertKey, alertRange } from '../lib/difficulty/model';
import { findDetours } from '../lib/detour';
import { isFound, type DetourFound, type DetourResult, type DetourState } from '../lib/detour/result';
import { detourSpan, sampleAt, type AppliedDetour, type Composed, type Origin } from '../lib/edit/compose';
import type { AppliedCardView } from './AlertList';
import { mtbColor, type ColorMode } from '../lib/colors';
import { techLabel, type Focus } from '../lib/display';
import { LEVELS, LEVEL_LABEL, PRACTICE_LABEL, RATING_LABEL } from '../lib/difficulty/rules';
import { WAY_CLASS_LABEL } from '../lib/osm/classify';
import type { Alert, Level, Practice } from '../lib/types';
import { track } from '../lib/analytics';
import { withRetry } from '../lib/chunks';

// MapLibre (~1 Mo) : demandé dès que cette vue est chargée, pour être prêt quand la trace s'affiche
const Map3D = preloadable(withRetry(() => import('./Map3D')));
void Map3D.preload();

/** largeur de la sidebar flottante (px), à garder en phase avec .sidebar dans styles.css */
const SIDEBAR_W = 380;

const PRACTICES: Practice[] = ['vtt', 'gravel', 'route'];

/** Vitesses moyennes saisies par l'utilisateur, une par pratique (km/h). */
type Speeds = Partial<Record<Practice, number>>;
function loadSpeeds(): Speeds {
  try {
    const v = JSON.parse(localStorage.getItem('trace3d.speeds') ?? '{}');
    return v && typeof v === 'object' ? v : {};
  } catch {
    return {};
  }
}
function saveSpeeds(s: Speeds) {
  try {
    localStorage.setItem('trace3d.speeds', JSON.stringify(s));
  } catch {
    /* stockage indisponible : vitesse non mémorisée */
  }
}

interface Props {
  base: BaseAnalysis;
  practice: Practice;
  level: Level;
  onPractice: (p: Practice) => void;
  onLevel: (l: Level) => void;
  /** étape d'analyse encore en cours (chemins OSM, LiDAR…), affichée en bandeau sur la carte */
  progress: { step: string; ratio?: number } | null;
  /** retour à l'accueil pour charger une autre trace */
  onClose: () => void;
  onRetryOsm: () => void;
  /** alternatives appliquées et trace recomposée ; null tant que l'analyse d'origine n'est pas finie */
  edit: { applied: AppliedDetour[]; baseApplied: AppliedDetour[]; composed: Composed; baseComposed: Composed; origin: Origin; updating: boolean; error: string | null } | null;
  /** relance la réanalyse de la trace modifiée après un échec */
  onRetryEdit: () => void;
  /** applique une alternative trouvée sur la trace affichée ; rend son id et les km de celles qu'elle remplace, ou null si elle ne se rattache pas */
  onApplyDetour: (found: DetourFound, alert?: Pick<Alert, 'name' | 'severity'>) => { id: string; replacedKm: number[] } | null;
  /** l'alternative se rattache-t-elle à l'origine ? (bouton « Appliquer » affiché ou non) */
  canApplyDetour: (found: DetourFound) => boolean;
  onRemoveDetour: (id: string) => void;
  onResetEdit: () => void;
  onDownloadGpx: () => void;
  /** lien de partage de la trace affichée (la trace est dans le lien, lib/share.ts) */
  makeShareLink: () => Promise<string>;
  /** réglages de la vue, gardés d'une trace à l'autre */
  mode: ColorMode;
  onMode: (m: ColorMode) => void;
  basemap: Basemap;
  onBasemap: (b: Basemap) => void;
  exaggeration: number;
  onExaggeration: (x: number) => void;
  overlays: Overlays;
  onOverlays: (o: Overlays) => void;
  trackHidden: boolean;
  onTrackHidden: React.Dispatch<React.SetStateAction<boolean>>;
}

/**
 * Exploration d'une trace analysée : carte 3D, profil, verdict et points d'attention. Chargée à part de l'accueil,
 * et recréée pour chaque nouveau fichier (curseur, sélection, alternatives et calques repartent de zéro).
 */
export default function TrackView(props: Props) {
  const { base, practice, level, mode, basemap, exaggeration, overlays, trackHidden, onTrackHidden } = props;
  const [speeds, setSpeeds] = useState<Speeds>(loadSpeeds);
  const [cursorIdx, setCursorIdx] = useState<number | null>(null);
  const [focus, setFocus] = useState<Focus | null>(null);
  // confirmation avant le retour à l'accueil : l'analyse et les alternatives appliquées ne se récupèrent pas
  const [leaving, setLeaving] = useState(false);
  const [sharing, setSharing] = useState(false);
  // stable : le panneau mémorisé ne se redessine pas au survol
  const openShare = useCallback(() => setSharing(true), []);
  const [detours, setDetours] = useState<Record<string, DetourResult>>({});
  const [detourState, setDetourState] = useState<DetourState>('attente');
  const [pick, setPick] = useState<{ idx: number; key: number } | null>(null);
  const [flying, setFlying] = useState(false);
  // Points d'eau et d'intérêt : demandés à OpenStreetMap seulement quand l'un des deux calques est activé
  const [pois, setPois] = useState<Poi[] | null>(null);
  // Noms de lieux (villages, sommets, cols) : demandés quand le calque des noms est activé
  const [places, setPlaces] = useState<Place[] | null>(null);
  const placesFor = useRef<BaseAnalysis['samples'] | null>(null);
  // Photos au sol du point ou du passage sélectionné (clic sur la trace, le profil, une montée, une alerte)
  const [photoTarget, setPhotoTarget] = useState<PhotoTarget | null>(null);
  const [photo, setPhoto] = useState<GroundPhoto | null>(null);
  const [photoDots, setPhotoDots] = useState<PhotoDot[] | null>(null);
  // clé = points de la trace : l'affinage LiDAR remplace l'analyse sans changer les positions, inutile de rechercher à nouveau
  const photoDotsFor = useRef<BaseAnalysis['samples'] | null>(null);
  const [poiStatus, setPoiStatus] = useState<PoiStatus>('idle');
  // Panneaux flottants sur la carte (profil en bas, sidebar à droite) : espace qu'ils masquent,
  // pour dégager les commandes et cadrer la trace dans la partie visible. Sur mobile, ils passent sous la carte.
  const wideQuery = '(min-width: 901px)';
  const [wide, setWide] = useState(() => window.matchMedia(wideQuery).matches);
  const [dockPx, setDockPx] = useState(0);
  // Cadrage de la carte : à l'ouverture ou au repli du dock, il vise d'emblée la hauteur finale et
  // l'accompagne en douceur ; les autres changements de taille (mesure, fenêtre) le recalent sans animation.
  const [dockFrame, setDockFrame] = useState({ px: 0, ease: false });
  const onDockHeight = useCallback((px: number) => {
    setDockPx(px);
    // un pixel d'arrondi en fin d'animation ne doit pas couper le recadrage en cours
    setDockFrame((f) => (Math.abs(f.px - px) <= 1 ? f : { px, ease: false }));
  }, []);
  const onDockToggle = useCallback((px: number) => setDockFrame({ px, ease: true }), []);
  useEffect(() => {
    const mq = window.matchMedia(wideQuery);
    const update = () => setWide(mq.matches);
    mq.addEventListener('change', update);
    return () => mq.removeEventListener('change', update);
  }, []);
  const dockInset = wide ? dockPx + 12 : 0;
  const sideInset = wide ? SIDEBAR_W + 12 : 0;
  const focusKey = useRef(0);
  // fermeture de la visite guidée, pour Échap (null : visite fermée), et son étape affichée
  const closeTourRef = useRef<(() => void) | null>(null);
  const [tourId, setTourId] = useState<TourStepId | null>(null);
  const tourIdRef = useRef<TourStepId | null>(null);
  // change quand la carte doit être recréée (contexte WebGL perdu)
  const [mapKey, setMapKey] = useState(0);

  // Raccourcis (hors champs de saisie) : T masque / affiche la trace, Échap désélectionne la section
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.target instanceof Element && e.target.closest('input, select, textarea, [contenteditable]')) return;
      if (e.key === 'Escape') {
        // la visite se ferme d'abord ; la sélection reste, Échap à nouveau la retire
        if (closeTourRef.current) return closeTourRef.current();
        setFocus(null);
        setPick(null);
        setPhotoTarget(null);
        return;
      }
      if (e.key.toLowerCase() !== 't' || e.metaKey || e.ctrlKey || e.altKey) return;
      onTrackHidden((h) => !h);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onTrackHidden]);

  // Le marqueur suit la souris ; sinon il reste sur le dernier point cliqué dans le profil.
  // Nouvelle géométrie (alternative appliquée ou retirée, nouvel essai OSM) : les indices d'échantillons ne désignent
  // plus les mêmes endroits, voire n'existent plus. On repart sans sélection, avant tout rendu avec l'ancienne.
  // Au premier chargement, la version provisoire n'a pas de `rawPoints` : la version complète garde la sélection.
  const [geometry, setGeometry] = useState(base.rawPoints);
  if (geometry !== base.rawPoints) {
    setGeometry(base.rawPoints);
    if (geometry !== undefined) {
      setCursorIdx(null);
      setPick(null);
      setFocus(null);
      setPhotoTarget(null);
      // pastilles photo et points d'intérêt repérés sur l'ancienne trace : km et indices faux, on les recharge
      setPhotoDots(null);
      setPois(null);
      setPoiStatus('idle');
    }
  }
  const anyIdx = cursorIdx ?? pick?.idx ?? null;
  // un survol encore calculé sur l'ancienne trace peut arriver après la remise à zéro : on l'ignore
  const shownIdx = anyIdx !== null && anyIdx < base.d.length ? anyIdx : null;

  const setAvgSpeed = useCallback(
    (v: number | null) =>
      setSpeeds((s) => {
        const next = { ...s };
        if (v === null) delete next[practice];
        else next[practice] = v;
        saveSpeeds(next);
        return next;
      }),
    [practice],
  );

  // Pastilles « photos au sol » : une recherche Panoramax le long de toute la trace, une fois l'analyse complète
  useEffect(() => {
    if (!overlays.photos || base.osmStatus === 'chargement' || photoDotsFor.current === base.samples) return;
    const b = base;
    photoDotsFor.current = b.samples;
    // résultat ignoré si une nouvelle analyse a pris la place entre-temps
    photoDotsAlong(b.samples).then((dots) => photoDotsFor.current === b.samples && setPhotoDots(dots));
  }, [overlays.photos, base]);

  const wantPois = overlays.water || overlays.pois;
  const poisFor = useRef<unknown>(null);
  useEffect(() => {
    if (!wantPois || base.osmStatus === 'chargement' || poiStatus !== 'idle') return;
    setPoiStatus('loading');
    const samples = base.samples;
    poisFor.current = samples;
    // résultat ignoré si la géométrie a changé entre-temps (alternative appliquée ou retirée)
    fetchPoisAlong(samples).then(
      (p) => {
        if (poisFor.current !== samples) return;
        setPois(p);
        setPoiStatus('ok');
      },
      () => poisFor.current === samples && setPoiStatus('error'),
    );
  }, [wantPois, base, poiStatus]);

  useEffect(() => {
    // inutile de demander les lieux tant que le fond affiché imprime déjà ses propres noms
    if (!overlays.labels || hasPrintedNames(basemap) || base.osmStatus === 'chargement' || placesFor.current === base.samples) return;
    const b = base;
    placesFor.current = b.samples;
    fetchPlacesAlong(b.samples).then(
      (p) => placesFor.current === b.samples && setPlaces(p),
      () => (placesFor.current = null),
    );
  }, [overlays.labels, basemap, base]);

  const avgSpeed = speeds[practice] ?? null;
  const rider = useMemo(() => analyzeRider(base, practice, level, avgSpeed), [base, practice, level, avgSpeed]);

  // Alternatives aux passages durs : en tâche de fond, une fois les chemins OSM et le LiDAR prêts.
  // La vitesse saisie ne change pas les cotations : on ne relance que sur la trace, la pratique ou le niveau.
  const riderRef = useRef(rider);
  riderRef.current = rider;
  useEffect(() => {
    setDetours({});
    if (base.osmStatus === 'erreur') return setDetourState('indisponible');
    if (base.osmStatus !== 'ok' || base.lidarStatus === 'pending') return setDetourState('attente');
    const ctrl = new AbortController();
    setDetourState('calcul');
    findDetours({ base, rider: riderRef.current, signal: ctrl.signal, onResult: (k, r) => setDetours((m) => ({ ...m, [k]: r })) })
      .catch(() => undefined)
      .finally(() => !ctrl.signal.aborted && setDetourState('fini'));
    return () => ctrl.abort();
  }, [base, practice, level]);

  // Appliquer une alternative : « Appliquée » ne dure que jusqu'à la réanalyse, ensuite le passage n'est plus dur
  // et sa carte disparaît (on retire alors depuis le bandeau). Alternative trouvée → id de l'alternative appliquée.
  const [justApplied, setJustApplied] = useState<Map<DetourFound, string>>(new Map());
  useEffect(() => setJustApplied(new Map()), [base]);
  const { edit, canApplyDetour, onApplyDetour, onRemoveDetour } = props;
  const applyFor = useCallback(
    (found: DetourFound, alert?: Pick<Alert, 'name' | 'severity'>): DetourApply | undefined => {
      if (!edit) return undefined;
      const id = justApplied.get(found);
      // retirée entre-temps depuis le bandeau : la carte repasse à « Appliquer »
      const applied = id !== undefined && edit.applied.some((a) => a.id === id);
      return {
        canApply: canApplyDetour(found),
        applied,
        onApply: () => {
          const r = onApplyDetour(found, alert);
          if (r) setJustApplied((m) => new Map(m).set(found, r.id));
          // appliquée pendant l'étape qui la présente : la visite passe à l'export de la trace modifiée
          if (r && tourIdRef.current === 'alternative') setTourId('export');
          return r;
        },
        onRemove: () => {
          if (id !== undefined) onRemoveDetour(id);
          setJustApplied((m) => {
            const n = new Map(m);
            n.delete(found);
            return n;
          });
        },
      };
    },
    [edit, canApplyDetour, onApplyDetour, onRemoveDetour, justApplied],
  );

  // Alternatives appliquées que l'analyse affichée intègre déjà : leur passage a disparu des points d'attention,
  // leur carte y reste, à leur km sur la trace modifiée, pour pouvoir les retirer.
  const appliedCards = useMemo((): AppliedCardView[] => {
    if (!edit) return [];
    const wanted = new Set(edit.applied.map((a) => a.id));
    return edit.baseApplied.flatMap((a) => {
      const span = a.card && wanted.has(a.id) ? detourSpan(edit.baseComposed, a.id) : null;
      if (!a.card || !span) return [];
      const start = Math.min(sampleAt(base.d, span[0]), base.d.length - 1);
      const end = Math.max(start, Math.min(sampleAt(base.d, span[1]), base.d.length - 1));
      return [{ id: a.id, card: a.card, km: span[0] / 1000, start, end, onRemove: () => onRemoveDetour(a.id) }];
    });
  }, [edit, base.d, onRemoveDetour]);

  // Sur la carte : portions de l'origine remplacées, et tracés appliqués que l'analyse affichée n'a pas encore intégrés
  const mapEdit = useMemo(() => {
    if (!edit || (edit.applied.length === 0 && edit.baseApplied.length === 0)) return { replaced: [], pending: [] };
    const pts = edit.origin.points;
    const segment = (a: AppliedDetour) => pts.slice(Math.min(a.fromIdx, a.toIdx), Math.max(a.fromIdx, a.toIdx) + 1);
    const ids = (l: AppliedDetour[]) => new Set(l.map((a) => a.id));
    const shown = ids(edit.baseApplied);
    const wanted = ids(edit.applied);
    // pendant la réanalyse, le parcours voulu : alternatives ajoutées, et portions d'origine des alternatives retirées
    // après un échec aussi : l'analyse affichée est encore l'ancienne, le parcours voulu reste dessiné à part
    const pending = edit.updating || edit.error
      ? [...edit.applied.filter((a) => !shown.has(a.id)).map((a) => a.path), ...edit.baseApplied.filter((a) => !wanted.has(a.id)).map(segment)]
      : [];
    return { replaced: edit.applied.map(segment), pending };
  }, [edit]);

  // Alternative du point d'attention sélectionné, dessinée sur la carte
  const selectedDetour = useMemo(() => {
    if (!focus) return null;
    const a = rider.alerts.find((x) => {
      if (!x.hard) return false;
      const [s, e] = alertRange(x, base.d.length);
      return s === focus.start && e === focus.end;
    });
    const r = a ? detours[alertKey(a)] : undefined;
    return isFound(r) ? r : null;
  }, [focus, base, rider, detours]);

  useEffect(() => {
    if (selectedDetour) track('alternative-affichee');
  }, [selectedDetour]);
  useEffect(() => {
    if (photo) track('photo-au-sol');
  }, [photo]);

  const focusRange = useCallback((start: number, end: number, label?: string, photos = true) => {
    setFlying(false);
    setFocus({ start, end, label, key: ++focusKey.current });
    if (photos) setPhotoTarget({ a: start, b: end, label, key: focusKey.current });
  }, []);
  const clearFocus = useCallback(() => {
    setFocus(null);
    setPick(null);
    setPhotoTarget(null);
  }, []);
  const pickPoint = useCallback((idx: number) => {
    setFlying(false);
    setPick({ idx, key: ++focusKey.current });
    setPhotoTarget({ a: idx, b: idx, key: focusKey.current });
  }, []);

  // Visite guidée : lancée une fois par navigateur, sur la première trace analysée en entier (chemins OSM compris :
  // une analyse dégradée attend le nouvel essai), quand la recherche d'alternatives est finie ou après 8 s.
  // Relancée par « Revoir la visite ».
  const alertCount = useMemo(() => alertEntries(rider.alerts, base.d, appliedCards).count, [rider, base.d, appliedCards]);
  const canExport = props.edit !== null;
  const steps = useMemo(
    () => tourSteps({ base, rider, detours, alertCount, canExport, canApply: canApplyDetour }),
    [base, rider, detours, alertCount, canExport, canApplyDetour],
  );
  // km de l'étape affichée : si elle disparaît (niveau changé, trace réanalysée), la visite reprend plus loin
  const tourKm = useRef(0);
  if (tourId && !steps.some((s) => s.id === tourId)) setTourId(nextAvailable(tourKm.current, steps, base.d));

  const autoTour = useRef(!tourSeen());
  const settled = base.osmStatus === 'ok' && !props.progress && canExport;
  const detoursSettled = detourState === 'fini' || detourState === 'indisponible';
  const [waited, setWaited] = useState(false);
  useEffect(() => {
    if (!autoTour.current || !settled || detoursSettled) return;
    const t = setTimeout(() => setWaited(true), 8000);
    return () => clearTimeout(t);
  }, [settled, detoursSettled]);
  useEffect(() => {
    if (!autoTour.current || !settled || !(detoursSettled || waited)) return;
    autoTour.current = false;
    markTourSeen();
    track('visite', { lancement: 'auto' });
    setTourId('verdict');
  }, [settled, detoursSettled, waited]);

  const tourIndex = tourId ? steps.findIndex((s) => s.id === tourId) : -1;
  const tourStep = tourIndex >= 0 ? steps[tourIndex] : null;
  if (tourStep) tourKm.current = base.d[tourStep.idx];
  tourIdRef.current = tourId;
  const endTour = useCallback((how: 'terminee' | 'passee', at: TourStepId) => {
    track(`visite-${how}`, { etape: at });
    setTourId(null);
  }, []);
  closeTourRef.current = tourStep ? () => endTour('passee', tourStep.id) : null;

  // la carte et le profil montrent ce que dit l'étape (sans ouvrir les photos au sol)
  const tourView = tourStep ? `${tourStep.id}:${tourStep.idx}` : null;
  useEffect(() => {
    if (!tourStep) return;
    if (tourStep.id === 'passage' || tourStep.id === 'alternative') focusRange(tourStep.range[0], tourStep.range[1], tourStep.alert.title, false);
    else if (tourStep.id === 'montee') focusRange(tourStep.range[0], tourStep.range[1], `Montée du km ${fmt(tourStep.climb.startKm, 1)}`, false);
    else if (tourStep.id === 'relief') {
      // profil entier (une section cadrée le zoomerait), curseur et caméra sur le point haut
      setFocus(null);
      setPhotoTarget(null);
      setFlying(false);
      setPick({ idx: tourStep.idx, key: ++focusKey.current });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tourView]);
  const tourOpen = tourStep?.id === 'passage' || tourStep?.id === 'alternative' ? 'alerts' : tourStep?.id === 'montee' ? 'climbs' : null;
  const startTour = () => {
    // vue à la main : plus de lancement automatique ensuite
    autoTour.current = false;
    markTourSeen();
    track('visite', { lancement: 'manuel' });
    setTourId('verdict');
  };

  return (
    <div className={`app${tourStep ? ' touring' : ''}`}>
      <header className="topbar">
        <button className="ghost topbar-back" onClick={() => setLeaving(true)} title="Charger une autre trace" aria-label="Nouvelle trace">
          <svg width="15" height="15" 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>
          <span className="topbar-back-long">Nouvelle trace</span>
        </button>
        <h1 title={base.name}>{base.name}</h1>
        <button className="ghost topbar-tour" onClick={startTour} title="Revoir la visite guidée" aria-label="Revoir la visite" data-tour="revoir">
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M3 18l5-8 4 5 3-4 6 7z" />
            <circle cx="16" cy="6" r="1.8" />
          </svg>
          <span className="topbar-tour-long">Revoir la visite</span>
        </button>
        {props.edit && (
          <button className="ghost topbar-gpx" data-tour="gpx" onClick={props.onDownloadGpx} title="Télécharger la trace affichée (GPX)" aria-label="Télécharger le GPX">
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />
            </svg>
            <span className="topbar-gpx-long">Télécharger le </span>GPX
          </button>
        )}
        <div className="controls">
          <Segmented label="Pratique" value={practice} options={PRACTICES.map((p) => [p, PRACTICE_LABEL[p]])} onChange={props.onPractice} />
          <div className="level-field" data-tour="level">
            <Segmented label="Niveau" value={level} options={LEVELS.map((l) => [l, LEVEL_LABEL[l]])} onChange={props.onLevel} />
            <LevelInfo practice={practice} />
          </div>
        </div>
      </header>
      {sharing && props.edit && (
        <ShareDialog
          practice={practice}
          level={level}
          applied={props.edit.applied.length}
          makeLink={props.makeShareLink}
          onClose={() => setSharing(false)}
        />
      )}
      {leaving && <LeaveConfirm lost={props.edit?.applied.length ?? 0} onStay={() => setLeaving(false)} onLeave={props.onClose} />}

      <main className="main" style={{ '--dock-h': `${dockInset}px`, '--side-inset': `${sideInset}px`, '--side-w': `${SIDEBAR_W}px` } as React.CSSProperties}>
        <div className="map-wrap">
          <Suspense fallback={<div className="map map-loading">Chargement de la carte 3D…</div>}>
            <Map3D
              key={mapKey}
              base={base}
              rider={rider}
              mode={mode}
              basemap={basemap}
              exaggeration={exaggeration}
              cursorIdx={shownIdx}
              focus={focus}
              pick={pick}
              overlays={overlays}
              trackHidden={trackHidden}
              flying={flying}
              bottomInset={wide ? dockFrame.px + 12 : 0}
              insetEase={dockFrame.ease}
              rightInset={sideInset}
              pois={pois}
              places={places}
              photo={overlays.photos ? photo : null}
              photoDots={photoDots}
              detour={selectedDetour}
              edit={mapEdit}
              onTrackClick={(idx) => setPhotoTarget({ a: idx, b: idx, key: ++focusKey.current })}
              onHover={setCursorIdx}
              onFlyProgress={setCursorIdx}
              onFlyEnd={() => setFlying(false)}
              onContextLost={() => {
                setFlying(false);
                setMapKey((k) => k + 1);
              }}
              onClearFocus={clearFocus}
            />
          </Suspense>
          <div className="map-actions">
            <button className={`map-fab${flying ? ' active' : ''}`} data-tour="survol" onClick={() => {
                if (!flying) track('survol');
                setFlying((f) => !f);
              }} aria-pressed={flying} title={flying ? 'Mettre le survol en pause' : 'Survoler la trace en 3D'}>
              <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
                {flying ? <path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /> : <path d="M8 5.5v13l10.5-6.5z" />}
              </svg>
              <span className="map-fab-label">{flying ? 'Pause' : 'Survol'}</span>
            </button>
            <button className="map-fab" onClick={() => focusRange(0, base.d.length - 1, undefined, false)} title="Voir toute la trace">
              <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
              </svg>
              <span className="map-fab-label">Vue d’ensemble</span>
            </button>
            <MapContentMenu
              overlays={overlays}
              onOverlays={(o) => {
                // réactiver un calque après un échec relance la demande
                if (poiStatus === 'error' && ((o.water && !overlays.water) || (o.pois && !overlays.pois))) setPoiStatus('idle');
                props.onOverlays(o);
              }} trackHidden={trackHidden} onTrackHidden={onTrackHidden} pois={pois} poiStatus={poiStatus} />
            {/* même temps en roulant que le profil : vitesse saisie, sinon estimation du modèle */}
            <DayPlan base={base} hours={avgSpeed ? base.distance / 1000 / avgSpeed : rider.hours} />
          </div>
          <MapLayersMenu
            basemap={basemap}
            onBasemap={props.onBasemap}
            exaggeration={exaggeration}
            onExaggeration={props.onExaggeration}
            center={base.samples[Math.floor(base.samples.length / 2)]}
            region={base.regionsKm[0]}
          />
          {overlays.photos && photoTarget && <PhotoCard base={base} target={photoTarget} onClose={() => setPhotoTarget(null)} onPhoto={setPhoto} />}
          {/* sur mobile, message et info du point s'empilent en haut de la carte (voir .map-stack) */}
          <div className="map-stack">
            {props.progress ? (
              <div className="toast" role="status">
                <span className="spinner" aria-hidden="true" />
                {props.progress.step}…
              </div>
            ) : (
              props.edit?.error ? (
                <div className="toast toast-warn" role="status">
                  <span className="toast-dot" aria-hidden="true" />
                  Mise à jour de l’analyse impossible
                  <button type="button" onClick={props.onRetryEdit}>
                    Réessayer
                  </button>
                </div>
              ) : base.osmStatus === 'erreur' && (
                <div className="toast toast-warn" role="status">
                  <span className="toast-dot" aria-hidden="true" />
                  OpenStreetMap indisponible&nbsp;: pente seule jugée
                  <button type="button" onClick={props.onRetryOsm}>
                    Réessayer
                  </button>
                </div>
              )
            )}
            {shownIdx !== null && <CursorInfo base={base} idx={shownIdx} rating={RATING_LABEL[rider.ratings[shownIdx].rating]} />}
          </div>
        </div>
        <RouteDock
          base={base}
          rider={rider}
          mode={mode}
          onMode={props.onMode}
          avgSpeed={avgSpeed}
          onAvgSpeed={setAvgSpeed}
          cursorIdx={shownIdx}
          focus={focus}
          onHover={setCursorIdx}
          onSelect={focusRange}
          onReset={clearFocus}
          onPick={pickPoint}
          onFocus={focusRange}
          onHeight={onDockHeight}
          onToggle={onDockToggle}
        />
        <Sidebar
          base={base}
          rider={rider}
          focus={focus}
          onFocus={focusRange}
          onRetryOsm={props.onRetryOsm}
          onRetryEdit={props.onRetryEdit}
          detours={detours}
          detourState={detourState}
          edit={props.edit}
          applyFor={applyFor}
          appliedCards={appliedCards}
          onRemoveDetour={props.onRemoveDetour}
          onResetEdit={props.onResetEdit}
          onDownloadGpx={props.onDownloadGpx}
          onShare={props.edit ? openShare : undefined}
          tourOpen={tourOpen}
        />
      </main>
      {tourStep && (
        <Tour
          steps={steps}
          index={tourIndex}
          context={{
            d: base.d,
            ele: base.ele,
            verdict: verdictLabel(rider.verdict),
            practice: practice === 'vtt' ? 'VTT' : PRACTICE_LABEL[practice].toLowerCase(),
            level: LEVEL_LABEL[level].toLowerCase(),
            climbCount: base.climbs.length,
            applied: (props.edit?.applied.length ?? 0) > 0,
            alternatives: steps.some((s) => s.id === 'alternative'),
            wide,
          }}
          onNext={() => (tourIndex < steps.length - 1 ? setTourId(steps[tourIndex + 1].id) : endTour('terminee', tourStep.id))}
          onBack={() => tourIndex > 0 && setTourId(steps[tourIndex - 1].id)}
          onSkip={() => endTour('passee', tourStep.id)}
        />
      )}
    </div>
  );
}

/**
 * Chaque nombre a une place fixe, à la mesure du plus long de la trace (chiffres à chasse fixe, styles.css), et le
 * cadre ne rétrécit pas tant que le survol dure : seuls les libellés le font grandir, quand la voie change.
 */
function CursorInfo({ base, idx, rating }: { base: BaseAnalysis; idx: number; rating: string }) {
  const tl = techLabel(base.ways[idx]);
  const slots = useMemo(() => {
    let maxEle = 0;
    let maxGrade = 0;
    for (let i = 0; i < base.d.length; i++) {
      maxEle = Math.max(maxEle, Math.round(base.ele[i]));
      maxGrade = Math.max(maxGrade, Math.abs(base.grade50[i]));
    }
    return {
      km: `${fmt(base.d[base.d.length - 1] / 1000, 2).length}ch`,
      ele: `${String(maxEle).length}ch`,
      grade: `${fmt(-maxGrade, 1).length}ch`,
    };
  }, [base]);
  const ref = useRef<HTMLDivElement>(null);
  const widest = useRef(0);
  useLayoutEffect(() => {
    const el = ref.current!;
    widest.current = Math.max(widest.current, el.offsetWidth);
    el.style.minWidth = `${widest.current}px`;
  });
  return (
    <div ref={ref} className="cursor-info">
      km <b className="ci-num" style={{ minWidth: slots.km }}>{fmt(base.d[idx] / 1000, 2)}</b> ·{' '}
      <span className="ci-num" style={{ minWidth: slots.ele }}>{Math.round(base.ele[idx])}</span> m ·{' '}
      <b>
        <span className="ci-num" style={{ minWidth: slots.grade }}>{fmt(base.grade50[idx], 1)}</span> %
      </b> · {WAY_CLASS_LABEL[base.ways[idx].cls]}
      {tl && (
        <>
          {' '}
          <span className="pill-tech" style={{ '--c': mtbColor(tl.scale) } as React.CSSProperties}>
            {tl.text}
          </span>
        </>
      )}{' '}
      · {rating}
    </div>
  );
}

Tokens : --bg --panel --text --muted --detour --detour-ink --line --accent --accent-ink --chip --shadow --ok --engage --exigeant --inadapte --ease-out --ease-in-out

Anatomie

#ÉlémentRôle
1Barre du hautNom de la trace, choix de la pratique et du niveau, nouvelle trace.
2CarteMap3D avec ses boutons flottants (contenu, fond, journée, caméra).
3DockRouteDock, en bas de la carte.
4PanneauSidebar, à droite (sous la carte sur téléphone).

Guidelines

  • MUST
    Changer de pratique ou de niveau sans recharger la trace ; le verdict et les points d’attention suivent.

Clavier

ToucheEffet
ÉchapFerme la visite guidée si elle est ouverte, sinon retire la sélection et ferme les photos.
TMasque ou affiche la trace sur la carte (hors champs de saisie).

Référence de l’API

PropTypeDéfaut
baseBaseAnalysisrequis
practicePracticerequis
levelLevelrequis
onPractice(p: Practice) => voidrequis
onLevel(l: Level) => voidrequis
progress
étape d'analyse encore en cours (chemins OSM, LiDAR…), affichée en bandeau sur la carte
{ step: string; ratio?: number } | nullrequis
onClose
retour à l'accueil pour charger une autre trace
() => voidrequis
onRetryOsm() => voidrequis
edit
alternatives appliquées et trace recomposée ; null tant que l'analyse d'origine n'est pas finie
{ applied: AppliedDetour[]; baseApplied: AppliedDetour[]; composed: Composed; baseComposed: Composed; origin: Origin; updating: boolean; error: string | null } | nullrequis
onRetryEdit
relance la réanalyse de la trace modifiée après un échec
() => voidrequis
onApplyDetour
applique une alternative trouvée sur la trace affichée ; rend son id et les km de celles qu'elle remplace, ou null si elle ne se rattache pas
(found: DetourFound, alert?: Pick<Alert, 'name' | 'severity'>) => { id: string; replacedKm: number[] } | nullrequis
canApplyDetour
l'alternative se rattache-t-elle à l'origine ? (bouton « Appliquer » affiché ou non)
(found: DetourFound) => booleanrequis
onRemoveDetour(id: string) => voidrequis
onResetEdit() => voidrequis
onDownloadGpx() => voidrequis
makeShareLink
lien de partage de la trace affichée (la trace est dans le lien, lib/share.ts)
() => Promise<string>requis
mode
réglages de la vue, gardés d'une trace à l'autre
ColorModerequis
onMode(m: ColorMode) => voidrequis
basemapBasemaprequis
onBasemap(b: Basemap) => voidrequis
exaggerationnumberrequis
onExaggeration(x: number) => voidrequis
overlaysOverlaysrequis
onOverlays(o: Overlays) => voidrequis
trackHiddenbooleanrequis
onTrackHiddenReact.Dispatch<React.SetStateAction<boolean>>requis