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.
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ément | Rôle |
|---|---|---|
| 1 | En-tête | Titre, bouton « i » qui explique l’échelle, et une phrase de bilan pour le niveau choisi. |
| 2 | Colonnes | Une 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é. |
| 3 | Limite | Zone « ta limite », au-delà de ce que permettent la pratique et le niveau ; elle glisse d’un degré à l’autre quand ils changent. |
Guidelines
- MUSTDistinguer la cotation renseignée de la cotation estimée par Pathou (plein ou hachuré, et légende).
- SHOULDToujours dire la longueur sans cotation ; c’est la part de la trace que l’échelle ne couvre pas.
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
base | BaseAnalysis | requis |
rider | RiderAnalysis | requis |