Panneau de droite de la trace — le verdict pour la pratique et le niveau choisis, sa confiance, puis les sections repliables des points d’attention, des montées, de la cotation et des sources.
stableÀ valider
// Titre : Panneau du verdict, Cévennes J1 en VTT confirmé
import { useState } from 'react';
import Sidebar from '@pathou/components/Sidebar';
import type { Focus } from '@pathou/lib/display';
import { base, riderOf } from '../_fixtures/base';
const rider = riderOf('vtt', 'confirme');
// sans alternatives ni modification de la trace : les callbacks qui y touchent ne font rien ici
export default function Exemple() {
const [focus, setFocus] = useState<Focus | null>(null);
return (
<div style={{ width: 380, maxHeight: 760, display: 'grid' }}>
<Sidebar
base={base}
rider={rider}
focus={focus}
onFocus={(start, end, label) => setFocus({ start, end, label, key: Date.now() })}
onRetryOsm={() => {}}
detours={{}}
detourState="fini"
edit={null}
onRetryEdit={() => {}}
applyFor={() => undefined}
appliedCards={[]}
onRemoveDetour={() => {}}
onResetEdit={() => {}}
onDownloadGpx={() => {}}
/>
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { memo, useMemo, useState, type ReactNode } from 'react';
import type { BaseAnalysis, RiderAnalysis } from '../lib/analyze';
import { GRADE_STOPS, RATING_COLOR, WAY_COLOR } from '../lib/colors';
import { altitudeCredit, eleSourceLabel, type Focus } from '../lib/display';
import { SOURCE_LABEL } from '../lib/sources/types';
import { LEVEL_LABEL, PRACTICE_LABEL, RATINGS, RATING_LABEL } from '../lib/difficulty/rules';
import { VERDICT_RULES, alertKey, alertRange, type Verdict } from '../lib/difficulty/model';
import type { DetourFound, DetourResult, DetourState } from '../lib/detour/result';
import type { AppliedDetour } from '../lib/edit/compose';
import { KNOWLEDGE_LABEL, KNOWLEDGE_WEIGHT, type Confidence } from '../lib/difficulty/confidence';
import { WAY_CLASS_LABEL } from '../lib/osm/classify';
import { EmptyAlerts, LevelLadder } from './AlertExtras';
import AlertList, { alertEntries, type AppliedCardView } from './AlertList';
import DetourGlyph from './DetourGlyph';
import { fmtLen, type DetourApply } from './DetourBlock';
import EditBanner from './EditBanner';
import ClimbList from './ClimbList';
import Modal from './Modal';
import ServiceStatus from './ServiceStatus';
import TechLadder from './TechLadder';
import { fmt } from '../lib/format';
import { load, save } from '../lib/storage';
import { formatHours } from '../lib/time';
import type { Alert, WayClass } from '../lib/types';
interface Props {
base: BaseAnalysis;
rider: RiderAnalysis;
focus: Focus | null;
onFocus: (start: number, end: number, label?: string) => void;
onRetryOsm: () => void;
/** alternatives aux passages durs, par `alertKey` */
detours: Record<string, DetourResult>;
detourState: DetourState;
/** alternatives appliquées à la trace et réanalyse en cours */
edit: { applied: AppliedDetour[]; updating: boolean; error: string | null } | null;
onRetryEdit: () => void;
applyFor: (found: DetourFound, alert?: Pick<Alert, 'name' | 'severity'>) => DetourApply | undefined;
/** alternatives appliquées dont le passage a disparu de l'analyse : leur carte reste dans la liste */
appliedCards: AppliedCardView[];
onRemoveDetour: (id: string) => void;
onResetEdit: () => void;
onDownloadGpx: () => void;
/** ouvre le partage par lien ; absent tant que la trace n'est pas modifiable */
onShare?: () => void;
/** section repliable ouverte de force par la visite guidée */
tourOpen?: 'alerts' | 'climbs' | null;
}
/**
* Mémorisée : le survol du profil ou de la carte et le survol animé font redessiner l'app à chaque mouvement,
* alors que rien ici ne dépend du curseur (près de 1 000 éléments et une centaine de chiffres).
*/
export default memo(Sidebar);
/**
* Section repliable du panneau, ouverte par défaut. L'état est mémorisé dans le navigateur :
* une liste repliée le reste pour les traces suivantes.
*/
function FoldSection({ id, title, count, forceOpen = false, children }: { id: string; title: string; count: number; forceOpen?: boolean; children: ReactNode }) {
const key = `trace3d.fold.${id}`;
const [saved, setOpen] = useState(() => load(key, ['open', 'closed'] as const, 'open') === 'open');
// ouverte de force pendant la visite guidée, sans toucher au choix mémorisé
const open = saved || forceOpen;
const toggle = () => {
setOpen(!open);
save(key, open ? 'closed' : 'open');
};
return (
<section className={`fold${open ? '' : ' closed'}`}>
<h3>
<button type="button" className="fold-btn" aria-expanded={open} aria-controls={`fold-${id}`} onClick={toggle}>
{title} <span className="count">{count}</span>
<svg className="fold-chevron" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5.5 8l4.5 4.5L14.5 8" />
</svg>
</button>
</h3>
<div id={`fold-${id}`} className="fold-body" hidden={!open}>
{children}
</div>
</section>
);
}
function Sidebar({ base, rider, focus, onFocus, onRetryOsm, onRetryEdit, detours, detourState, edit, applyFor, appliedCards, onRemoveDetour, onResetEdit, onDownloadGpx, onShare, tourOpen = null }: Props) {
const osmStatus = useStatusLink();
const isSelected = (start: number, end: number) => focus?.start === start && focus?.end === end;
const total = base.distance / 1000;
const classes = useMemo(() => {
const kmByClass = new Map<WayClass, number>();
for (let i = 1; i < base.d.length; i++) {
const c = base.ways[i].cls;
kmByClass.set(c, (kmByClass.get(c) ?? 0) + (base.d[i] - base.d[i - 1]) / 1000);
}
return [...kmByClass].sort((a, b) => b[1] - a[1]);
}, [base]);
const alertCount = alertEntries(rider.alerts, base.d, appliedCards).count;
return (
<aside className="sidebar">
{edit && <EditBanner applied={edit.applied} onRemove={onRemoveDetour} onReset={onResetEdit} onDownload={onDownloadGpx} />}
<section className={`verdict verdict-${rider.verdict.level}`} data-tour="verdict">
<div className="verdict-head">
<div className="verdict-heading">
<div className="verdict-title">
{verdictLabel(rider.verdict)}
<span className="sr-only">. {rider.verdict.title}</span>
</div>
<div className="verdict-who">{who(rider)}</div>
</div>
{onShare && (
<button type="button" className="ghost verdict-share" onClick={onShare} title="Partager la trace et son verdict par un lien">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="2.5" />
<circle cx="6" cy="12" r="2.5" />
<circle cx="18" cy="19" r="2.5" />
<path d="M8.2 10.8l7.6-4.4M8.2 13.2l7.6 4.4" />
</svg>
Partager
</button>
)}
</div>
{edit?.updating && <p className="verdict-note">Mise à jour en cours : verdict de la trace avant ta dernière modification…</p>}
{edit?.error && !edit.updating && (
<div className="verdict-retry">
<p>Mise à jour de l’analyse impossible : verdict, points d’attention et profil sont ceux de la trace avant ta dernière modification.</p>
<button onClick={onRetryEdit}>Réessayer</button>
</div>
)}
{(base.otherTracks ?? 0) > 0 && (
<p className="verdict-note">
{base.otherTracks === 1 ? 'Ce fichier contient une autre trace, qui part ailleurs' : `Ce fichier contient ${base.otherTracks} autres traces, qui partent ailleurs`} (autre jour,
autre itinéraire) : seule la première est analysée.
</p>
)}
{base.osmStatus === 'chargement' && <p className="verdict-note">Provisoire : pente seule, analyse des chemins OpenStreetMap en cours…</p>}
{base.eleFailures.length > 0 && (
<EleFailureNote base={base} />
)}
{base.reliefMondialInterpoleKm !== undefined && <ReliefMondialNote km={base.reliefMondialInterpoleKm} />}
<VerdictReasons base={base} rider={rider} total={total} onFocus={onFocus} />
<DetourSummary hard={rider.alerts.filter((a) => a.hard)} detours={detours} state={detourState} />
{base.osmStatus === 'erreur' && (
<div className="verdict-retry">
<p>
Chemins OpenStreetMap indisponibles (serveurs saturés ou connexion coupée) : le verdict ne juge que la pente, sa confiance est donc
faible. Réessaie pour obtenir l’analyse complète. {osmStatus.link}
</p>
<button onClick={onRetryOsm}>Réessayer</button>
{osmStatus.modal}
</div>
)}
{base.osmStatus !== 'chargement' && <ConfidenceRow c={rider.confidence} provisional={base.lidarStatus === 'pending'} />}
</section>
<FoldSection id="alerts" title="Points d’attention" count={alertCount} forceOpen={tourOpen === 'alerts'}>
{rider.alerts.length === 0 && appliedCards.length === 0 ? (
<EmptyAlerts base={base} rider={rider} isSelected={isSelected} onFocus={onFocus} />
) : (
<AlertList base={base} rider={rider} detours={detours} isSelected={isSelected} onFocus={onFocus} applyFor={applyFor} applied={appliedCards} />
)}
{base.osmStatus === 'ok' && <LevelLadder base={base} rider={rider} isSelected={isSelected} onFocus={onFocus} />}
{detourState === 'indisponible' && rider.alerts.some((a) => a.hard) && <p className="muted small">Alternatives indisponibles sans les chemins OpenStreetMap.</p>}
</FoldSection>
<FoldSection id="climbs" title="Montées" count={base.climbs.length} forceOpen={tourOpen === 'climbs'}>
<ClimbList base={base} rider={rider} isSelected={isSelected} onFocus={onFocus} />
</FoldSection>
<section>
<h3>Nature des voies</h3>
{base.osmStatus === 'chargement' ? (
<p className="muted loading-note">Chargement des chemins OpenStreetMap…</p>
) : base.osmStatus === 'erreur' ? (
<div className="warn">
<p>Chemins OpenStreetMap indisponibles : seule la pente est évaluée.</p>
<p className="muted small">{base.osmError}</p>
<button onClick={onRetryOsm}>Réessayer</button>
</div>
) : (
<>
<Bar parts={classes.map(([c, km]) => ({ key: c, value: km, color: WAY_COLOR[c], label: WAY_CLASS_LABEL[c] }))} total={total} />
<ul className="legend-list">
{classes.map(([c, km]) => (
<li key={c}>
<i style={{ background: WAY_COLOR[c] }} />
{WAY_CLASS_LABEL[c]}
<span>{fmt(km, 1)} km</span>
</li>
))}
</ul>
</>
)}
</section>
{base.osmStatus === 'ok' && <TechLadder base={base} rider={rider} />}
<section className="sources muted small">
Altitude : {altitudeCredit(base)}. Chemins : © contributeurs OpenStreetMap
{base.osmWays.length > 0 && ` (${base.osmWays.length} voies)`}. Cotation indicative : elle dépend de ce que les contributeurs OpenStreetMap ont renseigné (cotation VTT, état du sol…). Vérifie
toujours sur la photo aérienne et les photos au sol.
</section>
</aside>
);
}
const VERDICT_LABEL: Record<Verdict['level'], string> = { ok: 'Adaptée', engage: 'Engagée mais faisable', exigeant: 'Exigeante', inadapte: 'Non adaptée en l’état' };
/** Libellé du verdict ; une trace facile mais longue est dite longue, pas engagée. */
export const verdictLabel = (v: Verdict) => (v.volumeOnly ? (v.level === 'exigeant' ? 'Très longue pour ton niveau' : 'Longue pour ton niveau') : VERDICT_LABEL[v.level]);
/** Note d'une raison du verdict (0 à 3) : libellé et couleur. */
const GRADE = [
{ label: 'adaptée', color: 'var(--ok)' },
{ label: 'engagée', color: 'var(--engage)' },
{ label: 'exigeante', color: 'var(--exigeant)' },
{ label: 'non adaptée', color: 'var(--inadapte)' },
];
const who = (r: RiderAnalysis) => `En ${r.practice === 'vtt' ? 'VTT' : PRACTICE_LABEL[r.practice].toLowerCase()}, niveau ${LEVEL_LABEL[r.level].toLowerCase()}`;
const share = (x: number) => {
const p = x * 100;
return `${fmt(p, p < 10 && Math.round(p * 10) % 10 !== 0 ? 1 : 0)}\u00a0%`;
};
/** Durée ronde : « 7 h », « 4 h 30 ». */
const fmtH = (h: number) => {
const t = formatHours(h);
return t.endsWith(' h 00') ? t.slice(0, -3) : t;
};
/** Sur l'axe de la durée, les crans se disent « longue » et « très longue » : la longueur n'est pas une difficulté technique. */
const RIDE_GRADE_LABEL = ['adaptée', 'longue', 'très longue'];
/** Lien vers la fenêtre « État des services » et la fenêtre elle-même : à rendre séparément, la fenêtre ne va pas dans un `<p>`. */
function useStatusLink(): { link: ReactNode; modal: ReactNode } {
const [status, setStatus] = useState(false);
return {
link: (
<button className="link-button" onClick={() => setStatus(true)}>
Voir l’état des services
</button>
),
modal: status && (
<Modal title="État des services" onClose={() => setStatus(false)}>
<ServiceStatus />
</Modal>
),
};
}
/** Relief mondial indisponible pour combler les trous hors couverture : altitude interpolée en ligne droite, aucune source de remplacement. */
export function ReliefMondialNote({ km }: { km: number }) {
const { link, modal } = useStatusLink();
return (
<>
<p className="verdict-note">
Relief mondial indisponible pour l’instant ; altitude interpolée en ligne droite sur {fmt(km, 1)} km. {link}
</p>
{modal}
</>
);
}
/** Altitude d'une source indisponible, remplacée par une autre. */
export function EleFailureNote({ base }: { base: Pick<BaseAnalysis, 'eleFailures' | 'eleSource' | 'eleSources'> }) {
const { link, modal } = useStatusLink();
return (
<>
<p className="verdict-note">
Altitude {base.eleFailures.join(', ')} indisponible pour l’instant ; remplacée par : {eleSourceLabel(base.eleSource, base.eleSources)}. {link}
</p>
{modal}
</>
);
}
function GradeTag({ g, label }: { g: number; label?: string }) {
return (
<span className="verdict-tag" style={{ background: GRADE[g].color }}>
{label ?? GRADE[g].label}
</span>
);
}
/**
* Les raisons du verdict, celle qui décide en premier : la technique (passages très difficiles ou à pied), la durée
* (temps de roulage face aux seuils du niveau) et, s'il bloque, un accès interdit. Le verdict prend la pire note.
*/
function VerdictReasons({ base, rider, total, onFocus }: { base: BaseAnalysis; rider: RiderAnalysis; total: number; onFocus: Props['onFocus'] }) {
const v = rider.verdict;
const t = v.tech.level;
const r = v.ride?.level ?? 0;
const why = v.blocked
? 'Un accès interdit ou privé bloque la trace, quelle que soit la difficulté.'
: r > t
? `C’est la durée qui fait basculer la trace. Techniquement, elle est ${GRADE[t].label}.`
: t > r && r > 0
? `C’est la technique qui décide. En durée, elle est ${GRADE[r].label}.`
: null;
const passages = [v.tech.walkKm >= VERDICT_RULES.minWalkKm && `${fmtLen(v.tech.walkKm * 1000)} à pied`, v.tech.blackKm >= 0.05 && `${fmtLen(v.tech.blackKm * 1000)} très difficiles`]
.filter(Boolean)
.join(', ');
const techRow = (
<div key="tech" className="verdict-axis">
<div className="verdict-axis-head">
<span className="verdict-axis-name">Technique</span>
<GradeTag g={t} />
</div>
<Bar parts={RATINGS.map((k) => ({ key: k, value: v.kmByRating[k], color: RATING_COLOR[k], label: RATING_LABEL[k] }))} total={total} />
<p className="verdict-axis-sub">{passages ? passages.charAt(0).toUpperCase() + passages.slice(1) : 'Rien de très difficile ni à pied'}</p>
{t === 3 ? (
<p className="verdict-axis-note">
À pied sur {share(v.tech.walkShare)} de la trace, non adaptée dès {share(VERDICT_RULES.inadapte)}
</p>
) : (
v.tech.hardShare > 0 && (
<p className="verdict-axis-note">
Très difficile ou à pied sur {share(v.tech.hardShare)} de la trace, exigeante dès {share(VERDICT_RULES.exigeant)}
</p>
)
)}
</div>
);
const ride = v.ride;
let rideRow = null;
if (ride) {
// l'échelle dépasse le seuil d'« exigeante » d'un tiers, ou la durée si elle va plus loin
const max = Math.max(ride.exigeantH * 1.35, ride.hours * 1.08);
const at = (h: number) => `${(h / max) * 100}%`;
rideRow = (
<div key="ride" className="verdict-axis">
<div className="verdict-axis-head">
<span className="verdict-axis-name">Durée</span>
<GradeTag g={r} label={RIDE_GRADE_LABEL[r]} />
<span className="verdict-axis-value">≈ {formatHours(ride.hours)} en roulant</span>
</div>
<div className="verdict-gauge" role="img" aria-label={`${formatHours(ride.hours)} de roulage : longue au-delà de ${fmtH(ride.engageH)}, très longue au-delà de ${fmtH(ride.exigeantH)} pour ton niveau`}>
<span className="verdict-gauge-track">
{[0, 1, 2].map((g) => {
const from = g === 0 ? 0 : g === 1 ? ride.engageH : ride.exigeantH;
const to = g === 0 ? ride.engageH : g === 1 ? ride.exigeantH : max;
return <i key={g} className={g === r ? 'on' : ''} style={{ left: at(from), width: `${((to - from) / max) * 100}%`, background: GRADE[g].color }} />;
})}
</span>
<span className="verdict-gauge-mark" style={{ left: at(ride.hours) }} />
<span className="verdict-gauge-ticks" aria-hidden="true">
<span style={{ left: at(ride.engageH) }}>{fmtH(ride.engageH)}</span>
<span style={{ left: at(ride.exigeantH) }}>{fmtH(ride.exigeantH)}</span>
</span>
</div>
</div>
);
}
let accessRow = null;
if (v.blocked) {
const a = v.blocked;
accessRow = (
<div key="access" className="verdict-axis">
<div className="verdict-axis-head">
<span className="verdict-axis-name">Accès</span>
<GradeTag g={3} />
<button type="button" className="verdict-axis-value link-like" onClick={() => onFocus(...alertRange(a, base.d.length), a.title)}>
{a.name}, km {fmt(a.km, 1)}
</button>
</div>
</div>
);
}
const rows = r > t ? [rideRow, techRow] : [techRow, rideRow];
return (
<>
{why && <p className="verdict-why">{why}</p>}
<div className="verdict-axes">
{accessRow}
{rows}
</div>
</>
);
}
/** Ligne de la carte verdict : combien de passages durs ont une alternative praticable. */
function DetourSummary({ hard, detours, state }: { hard: Alert[]; detours: Record<string, DetourResult>; state: DetourState }) {
if (hard.length === 0 || state === 'indisponible') return null;
if (state !== 'fini') return <p className="verdict-note">Recherche d’alternatives aux passages difficiles…</p>;
const ok = hard.filter((a) => detours[alertKey(a)]?.status === 'praticable').length;
if (ok === 0) return <p className="verdict-note">Aucune alternative praticable trouvée pour les passages difficiles.</p>;
const s = ok > 1 ? 's' : '';
return (
<p className="verdict-note verdict-alt">
<DetourGlyph kind="praticable" />
{ok} passage{s} difficile{s} sur {hard.length} {ok > 1 ? 'ont' : 'a'} une alternative praticable.
</p>
);
}
function Bar({ parts, total }: { parts: { key: string; value: number; color: string; label: string }[]; total: number }) {
return (
<div className="bar" role="img" aria-label={parts.map((p) => `${p.label} ${fmt(p.value, 1)} km`).join(', ')}>
{parts
.filter((p) => p.value > 0)
.map((p) => (
<span key={p.key} style={{ width: `${(p.value / total) * 100}%`, background: p.color }} title={`${p.label}\u00a0: ${fmt(p.value, 1)} km`} />
))}
</div>
);
}
export function GradeLegend() {
return (
<div className="legend-inline">
{GRADE_STOPS.map((s) => (
<span key={s.label}>
<i style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
);
}
const CONFIDENCE_LABEL: Record<Confidence['level'], string> = { elevee: 'élevée', moyenne: 'moyenne', faible: 'faible' };
/** Confiance dans le verdict, d'après la qualité des données ; le détail se déplie. */
function ConfidenceRow({ c, provisional }: { c: Confidence; provisional: boolean }) {
const pct = Math.round(c.score * 100);
return (
<details className={`confidence conf-${c.level}`}>
<summary>
<span className="conf-label">Confiance</span>
<span className="conf-meter" role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct} aria-label="Confiance dans le verdict">
<i style={{ width: `${pct}%` }} />
</span>
<b>
{CONFIDENCE_LABEL[c.level]} · {pct} %{provisional && ' (provisoire)'}
</b>
<svg className="conf-chevron" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5.5 8l4.5 4.5L14.5 8" />
</svg>
</summary>
<p className="conf-intro">La confiance dit à quel point le verdict s’appuie sur des données précises. Elle ne mesure pas la difficulté.</p>
<ul className="conf-factors">
{c.factors
.filter((f) => f.km >= 0.05)
.map((f) => (
<li key={f.kind}>
<i style={{ opacity: 0.25 + 0.75 * KNOWLEDGE_WEIGHT[f.kind] }} />
<span>{f.kind === 'officiel' && c.officialSources.length ? `Hors OpenStreetMap, qualifié par ${c.officialSources.map((id) => SOURCE_LABEL[id] ?? id).join(', ')}` : KNOWLEDGE_LABEL[f.kind]}</span>
<b>{fmt(f.km, 1)} km</b>
</li>
))}
<li>
<i style={{ opacity: c.eleSource === 'precise' ? 1 : 0.6 }} />
<span>Altitude : {altitudeCredit(c)}</span>
</li>
{c.missing
.filter((m) => m.km >= 0.1)
.map((m) => (
<li key={m.check} className="conf-missing">
<i style={{ opacity: 0.15 }} />
<span>Non vérifié : dévers et sentiers en balcon</span>
<b>{fmt(m.km, 1)} km</b>
</li>
))}
</ul>
</details>
);
}
Tokens : --panel --text --muted --detour --detour-ink --line --accent --accent-ink --chip --ok --engage --exigeant --inadapte --ease-out --ease-in-out --replaced
Utilisation
import Sidebar from './components/Sidebar';
<Sidebar base={base} rider={rider} focus={focus} onFocus={focusRange} detours={detours} detourState={detourState} edit={edit} … />Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | Verdict | Le niveau de la trace (« Adaptée », « Engagée mais faisable », « Exigeante », « Non adaptée en l’état »), avec ses raisons technique et durée. |
| 2 | Confiance | Jauge de confiance dans le verdict, dépliable pour voir ce qui la fait baisser. |
| 3 | Section repliable | Titre, compteur et chevron ; l’état replié est mémorisé d’une trace à l’autre. |
Guidelines
- MUSTDonner le verdict en premier et en mots ; les couleurs et jauges le complètent sans le remplacer.
- MUSTDire la confiance du verdict avec ce qui la fait baisser (données manquantes, chemins non cotés).
- SHOULDOuvrir les sections par défaut et mémoriser celles que la personne replie.
Référence de l’API
Sidebar (export par défaut)
| Prop | Type | Défaut |
|---|---|---|
base | BaseAnalysis | requis |
rider | RiderAnalysis | requis |
focus | Focus | null | requis |
onFocus | (start: number, end: number, label?: string) => void | requis |
onRetryOsm | () => void | requis |
detoursalternatives aux passages durs, par `alertKey` | Record<string, DetourResult> | requis |
detourState | DetourState | requis |
editalternatives appliquées à la trace et réanalyse en cours | { applied: AppliedDetour[]; updating: boolean; error: string | null } | null | requis |
onRetryEdit | () => void | requis |
applyFor | (found: DetourFound, alert?: Pick<Alert, 'name' | 'severity'>) => DetourApply | undefined | requis |
appliedCardsalternatives appliquées dont le passage a disparu de l'analyse : leur carte reste dans la liste | AppliedCardView[] | requis |
onRemoveDetour | (id: string) => void | requis |
onResetEdit | () => void | requis |
onDownloadGpx | () => void | requis |
onShareouvre le partage par lien ; absent tant que la trace n'est pas modifiable | () => void | — |
tourOpensection repliable ouverte de force par la visite guidée | 'alerts' | 'climbs' | null | null |
ReliefMondialNote
| Prop | Type | Défaut |
|---|---|---|
km | number | requis |
EleFailureNote
| Prop | Type | Défaut |
|---|---|---|
base | Pick<BaseAnalysis, 'eleFailures' | 'eleSource' | 'eleSources'> | requis |
GradeLegend
Aucune prop.