Sidebar

←→

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.

src/components/Sidebar.tsx
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&nbsp;: 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&nbsp;: 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)&nbsp;: seule la première est analysée.
          </p>
        )}
        {base.osmStatus === 'chargement' && <p className="verdict-note">Provisoire&nbsp;: 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)&nbsp;: 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&nbsp;: 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&nbsp;: {altitudeCredit(base)}. Chemins&nbsp;: © contributeurs OpenStreetMap
        {base.osmWays.length > 0 && ` (${base.osmWays.length} voies)`}. Cotation indicative&nbsp;: 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&nbsp;; 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&nbsp;; remplacée par&nbsp;: {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&nbsp;: {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é&nbsp;: 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émentRôle
1VerdictLe niveau de la trace (« Adaptée », « Engagée mais faisable », « Exigeante », « Non adaptée en l’état »), avec ses raisons technique et durée.
2ConfianceJauge de confiance dans le verdict, dépliable pour voir ce qui la fait baisser.
3Section repliableTitre, compteur et chevron ; l’état replié est mémorisé d’une trace à l’autre.

Guidelines

  • MUST
    Donner le verdict en premier et en mots ; les couleurs et jauges le complètent sans le remplacer.
  • MUST
    Dire la confiance du verdict avec ce qui la fait baisser (données manquantes, chemins non cotés).
  • SHOULD
    Ouvrir les sections par défaut et mémoriser celles que la personne replie.

Référence de l’API

Sidebar (export par défaut)

PropTypeDéfaut
baseBaseAnalysisrequis
riderRiderAnalysisrequis
focusFocus | nullrequis
onFocus(start: number, end: number, label?: string) => voidrequis
onRetryOsm() => voidrequis
detours
alternatives aux passages durs, par `alertKey`
Record<string, DetourResult>requis
detourStateDetourStaterequis
edit
alternatives appliquées à la trace et réanalyse en cours
{ applied: AppliedDetour[]; updating: boolean; error: string | null } | nullrequis
onRetryEdit() => voidrequis
applyFor(found: DetourFound, alert?: Pick<Alert, 'name' | 'severity'>) => DetourApply | undefinedrequis
appliedCards
alternatives appliquées dont le passage a disparu de l'analyse : leur carte reste dans la liste
AppliedCardView[]requis
onRemoveDetour(id: string) => voidrequis
onResetEdit() => voidrequis
onDownloadGpx() => voidrequis
onShare
ouvre le partage par lien ; absent tant que la trace n'est pas modifiable
() => void—
tourOpen
section repliable ouverte de force par la visite guidée
'alerts' | 'climbs' | nullnull

ReliefMondialNote

PropTypeDéfaut
kmnumberrequis

EleFailureNote

PropTypeDéfaut
basePick<BaseAnalysis, 'eleFailures' | 'eleSource' | 'eleSources'>requis

GradeLegend

Aucune prop.