TechLadder

←→

Cotation technique des sentiers de la trace — une colonne par degré, de S0 à S5 (cotation OpenStreetMap pleine, estimée hachurée) — et la limite de la pratique et du niveau choisis.

stableÀ valider
// Titre : Cotation des sentiers, VTT confirmé
import TechLadder from '@pathou/components/TechLadder';
import { base, riderOf } from '../_fixtures/base';

export default function Exemple() {
  return (
    <div style={{ width: 360 }}>
      <TechLadder base={base} rider={riderOf('vtt', 'confirme')} />
    </div>
  );
}

Installation

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

src/components/TechLadder.tsx
import { useMemo, useState } from 'react';
import type { BaseAnalysis, RiderAnalysis } from '../lib/analyze';
import { techDistribution } from '../lib/display';
import { LEVEL_LABEL, PRACTICE_LABEL, TECH_CAP, levelIndex } from '../lib/difficulty/rules';
import { fmt } from '../lib/format';
import Modal from './Modal';
import { colStyle } from './techStyle';

/** Hauteur de la plus haute colonne (px). */
const BAR_H = 64;
const GRADES = [0, 1, 2, 3, 4, 5];

/** Échelle de difficulté technique VTT (mtb:scale d'OpenStreetMap), avec le niveau conseillé dans l'app. */
const MTB_SCALE: { title: string; desc: string; level: string }[] = [
  { title: 'Facile', desc: 'Chemin ou sentier sans obstacle, sol ferme, pente douce.', level: 'Débutant' },
  { title: 'Petits obstacles', desc: 'Racines et pierres isolées, sol parfois meuble, virages larges.', level: 'Débutant' },
  { title: 'Technique', desc: 'Grosses racines, pierres, petites marches ; sol souvent meuble, virages serrés.', level: 'Intermédiaire' },
  { title: 'Très technique', desc: 'Rochers, marches hautes, épingles et dévers sur pente raide.', level: 'Confirmé' },
  { title: 'Difficile', desc: 'Très raide, épingles très serrées, grandes marches : techniques de trial.', level: 'Expert' },
  { title: 'Extrême', desc: 'Éboulis, gros blocs, passages souvent portés.', level: 'Expert' },
];

const km = (v: number) => `${fmt(v, 1)} km`;

/**
 * Cotation technique des sentiers de la trace : une colonne par degré S0 à S5 (cotation OSM pleine, estimée hachurée)
 * et la limite de la pratique et du niveau choisis.
 */
export default function TechLadder({ base, rider }: { base: BaseAnalysis; rider: RiderAnalysis }) {
  const [info, setInfo] = useState(false);
  const cols = useMemo(() => {
    const out = GRADES.map(() => ({ known: 0, est: 0 }));
    let unknown = 0;
    for (const t of techDistribution(base)) {
      if (t.scale === null) unknown += t.km;
      else out[Math.min(5, t.scale)][t.estimated ? 'est' : 'known'] += t.km;
    }
    return { out, unknown };
  }, [base]);

  const total = cols.out.reduce((s, c) => s + c.known + c.est, 0);
  if (total === 0 && cols.unknown === 0) return null;
  const max = Math.max(...cols.out.map((c) => c.known + c.est), 0.001);
  const cap = TECH_CAP[rider.practice][levelIndex(rider.level)];
  const above = cols.out.reduce((s, c, g) => (g > cap ? s + c.known + c.est : s), 0);
  const who = `${rider.practice === 'vtt' ? 'VTT' : PRACTICE_LABEL[rider.practice].toLowerCase()}, niveau ${LEVEL_LABEL[rider.level].toLowerCase()}`;
  // la limite se place entre la colonne de la cotation maximale et la suivante ; aucune ligne si tout est permis
  const limitAt = cap >= 5 ? null : (cap + 1) / GRADES.length;

  return (
    <section className="tech">
      <h3 className="tech-head">
        Cotation VTT des sentiers
        <button type="button" className="info-btn" onClick={() => setInfo(true)} aria-label="Comprendre l’échelle de cotation" title="Comprendre l’échelle de cotation">
          <svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
            <circle cx="10" cy="10" r="7.5" />
            <path d="M10 9v5" strokeLinecap="round" />
            <circle cx="10" cy="6.3" r="0.6" fill="currentColor" />
          </svg>
        </button>
      </h3>

      <p className="tech-lead">
        {total === 0
          ? 'Aucun sentier coté sur la trace.'
          : cap < 0
            ? `En route, seul le bitume convient\u00a0: les ${km(above)} de sentiers cotés sont hors de portée.`
            : above < 0.05
            ? `Tous les sentiers cotés sont à ta portée en ${who}.`
            : `${km(above)} de sentiers au-delà de ta limite en ${who}.`}
        {cols.unknown >= 0.1 && ` ${km(cols.unknown)} sans cotation.`}
      </p>

      <div className="tech-ladder" role="img" aria-label={cols.out.map((c, g) => `S${g} : ${km(c.known + c.est)}`).join(', ')}>
        {/* toujours rendue, déplacée en transform pour glisser d'un degré à l'autre quand la pratique ou le niveau change ;
            sans limite (tout est permis), elle sort par la droite */}
        <span
          className={`tech-beyond${limitAt === null ? ' off' : ''}`}
          style={{ transform: `translateX(${(limitAt ?? 1) * 100}%)` }}
          aria-hidden="true"
        >
          <span>ta limite</span>
        </span>
        {cols.out.map((c, g) => {
          const v = c.known + c.est;
          return (
            <div key={g} className={`tech-col${v === 0 ? ' empty' : ''}`} style={colStyle(g)}>
              <div className="tech-bar" style={{ height: BAR_H }}>
                {c.est > 0 && <i className="est" style={{ height: `${(c.est / max) * 100}%` }} />}
                {c.known > 0 && <i style={{ height: `${(c.known / max) * 100}%` }} />}
              </div>
              <span className="pill-tech">S{g}</span>
              <span className="tech-km">{v >= 0.05 ? km(v) : '–'}</span>
            </div>
          );
        })}
      </div>

      <p className="tech-key" aria-hidden="true">
        <span>
          <i className="tech-swatch" /> renseignée dans OpenStreetMap
        </span>
        <span>
          <i className="tech-swatch est" /> estimée par Pathou
        </span>
      </p>

      {info && (
        <Modal title="Échelle de cotation VTT" onClose={() => setInfo(false)}>
          <p className="modal-lead">
            La cotation mesure la difficulté technique d’un sentier, de S0 à S5. Pathou la reprend d’OpenStreetMap quand elle y est renseignée, sinon il
            l’estime d’après le type de sentier et l’état du sol.
          </p>
          <ul className="mtb-scale">
            {MTB_SCALE.map((s, i) => (
              <li key={i}>
                <span className="pill-tech tech-pill" style={colStyle(i)}>
                  S{i}
                </span>
                <div>
                  <div className="mtb-scale-title">
                    {s.title} <span>{s.level}</span>
                  </div>
                  <div className="mtb-scale-desc">{s.desc}</div>
                </div>
              </li>
            ))}
          </ul>
          <p className="muted small">
            Sur la carte, une pastille pleine indique une cotation renseignée dans OpenStreetMap, une pastille pointillée une cotation estimée.
          </p>
        </Modal>
      )}
    </section>
  );
}

Tokens : --panel --text --muted --line --chip --exigeant --ease-out --ease-in-out --tech-black --tech-black-ink

Utilisation

import TechLadder from './components/TechLadder';

<TechLadder base={base} rider={rider} />

Anatomie

#ÉlémentRôle
1En-têteTitre, bouton « i » qui explique l’échelle, et une phrase de bilan pour le niveau choisi.
2ColonnesUne par degré, hauteur proportionnelle à la longueur, pleine si cotée dans OSM, hachurée si estimée ; l’ensemble est une image (role="img") dont le nom donne la longueur de chaque degré.
3LimiteZone « ta limite », au-delà de ce que permettent la pratique et le niveau ; elle glisse d’un degré à l’autre quand ils changent.

Guidelines

  • MUST
    Distinguer la cotation renseignée de la cotation estimée par Pathou (plein ou hachuré, et légende).
  • SHOULD
    Toujours dire la longueur sans cotation ; c’est la part de la trace que l’échelle ne couvre pas.

Référence de l’API

PropTypeDéfaut
baseBaseAnalysisrequis
riderRiderAnalysisrequis