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

Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
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 : 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ément | Rôle |
|---|---|---|
| 1 | Barre du haut | Nom de la trace, choix de la pratique et du niveau, nouvelle trace. |
| 2 | Carte | Map3D avec ses boutons flottants (contenu, fond, journée, caméra). |
| 3 | Dock | RouteDock, en bas de la carte. |
| 4 | Panneau | Sidebar, à droite (sous la carte sur téléphone). |
Guidelines
- MUSTChanger de pratique ou de niveau sans recharger la trace ; le verdict et les points d’attention suivent.
Clavier
| Touche | Effet |
|---|---|
| Échap | Ferme la visite guidée si elle est ouverte, sinon retire la sélection et ferme les photos. |
| T | Masque ou affiche la trace sur la carte (hors champs de saisie). |
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
base | BaseAnalysis | requis |
practice | Practice | requis |
level | Level | requis |
onPractice | (p: Practice) => void | requis |
onLevel | (l: Level) => void | requis |
progressétape d'analyse encore en cours (chemins OSM, LiDAR…), affichée en bandeau sur la carte | { step: string; ratio?: number } | null | requis |
onCloseretour à l'accueil pour charger une autre trace | () => void | requis |
onRetryOsm | () => void | requis |
editalternatives 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 } | null | requis |
onRetryEditrelance la réanalyse de la trace modifiée après un échec | () => void | requis |
onApplyDetourapplique 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[] } | null | requis |
canApplyDetourl'alternative se rattache-t-elle à l'origine ? (bouton « Appliquer » affiché ou non) | (found: DetourFound) => boolean | requis |
onRemoveDetour | (id: string) => void | requis |
onResetEdit | () => void | requis |
onDownloadGpx | () => void | requis |
makeShareLinklien de partage de la trace affichée (la trace est dans le lien, lib/share.ts) | () => Promise<string> | requis |
moderéglages de la vue, gardés d'une trace à l'autre | ColorMode | requis |
onMode | (m: ColorMode) => void | requis |
basemap | Basemap | requis |
onBasemap | (b: Basemap) => void | requis |
exaggeration | number | requis |
onExaggeration | (x: number) => void | requis |
overlays | Overlays | requis |
onOverlays | (o: Overlays) => void | requis |
trackHidden | boolean | requis |
onTrackHidden | React.Dispatch<React.SetStateAction<boolean>> | requis |