Les montées de la trace, chacune dessinée comme un profil de col coloré par la pente, avec sa catégorie, son altitude, sa longueur et sa pente.
stableÀ valider
// Titre : Montées de la trace
import ClimbList from '@pathou/components/ClimbList';
import { base, riderOf } from '../_fixtures/base';
const rider = riderOf('vtt', 'confirme');
export default function Exemple() {
return (
<div style={{ width: 360, maxHeight: 640, overflowY: 'auto' }}>
<ClimbList base={base} rider={rider} isSelected={() => false} onFocus={() => {}} />
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { useMemo } from 'react';
import type { BaseAnalysis, RiderAnalysis } from '../lib/analyze';
import { GRADE_STOPS, gradeColor } from '../lib/colors';
import { fmt } from '../lib/format';
import type { Climb } from '../lib/types';
/** Silhouette d'une montée : hauteur du dessin (px) et largeur visée d'une tranche de pente (m). */
const H = 52;
const MIN_H = 16;
const SLICE_M = 300;
interface Slice {
/** position de début et de fin, en part de la longueur (0–1) */
x0: number;
x1: number;
/** hauteur au début et à la fin, en part du dénivelé (0–1) */
y0: number;
y1: number;
grade: number;
}
interface ClimbView {
climb: Climb;
slices: Slice[];
/** passages à pied pour la pratique et le niveau choisis, en part de la longueur */
push: { x0: number; x1: number }[];
pushM: number;
summit: number;
/** hauteur du dessin, proportionnelle au dénivelé de la plus grosse montée */
height: number;
}
function climbTitle(c: Climb): string {
if (c.category === 'Bosse') return 'Bosse';
if (c.category === 'HC') return 'Montée hors catégorie';
return `Montée de ${c.category.replace('cat.', 'catégorie')}`;
}
function buildView(base: BaseAnalysis, rider: RiderAnalysis, c: Climb, maxGain: number): ClimbView {
const { d, ele } = base;
const a = c.startIdx;
const b = c.endIdx;
const len = d[b] - d[a];
const low = ele[a];
const gain = Math.max(1, ele[b] - low);
// tranches de ~300 m, entre 4 et 14 : assez pour lire où ça se raidit, pas trop pour rester lisible
const n = Math.max(4, Math.min(14, Math.round(len / SLICE_M)));
const slices: Slice[] = [];
let i = a;
for (let k = 0; k < n; k++) {
const target = d[a] + (len * (k + 1)) / n;
let j = i;
while (j < b && d[j] < target) j++;
const dist = d[j] - d[i];
slices.push({
x0: (d[i] - d[a]) / len,
x1: (d[j] - d[a]) / len,
y0: Math.max(0, (ele[i] - low) / gain),
y1: Math.max(0, (ele[j] - low) / gain),
grade: dist > 0 ? ((ele[j] - ele[i]) / dist) * 100 : 0,
});
i = j;
}
const push: ClimbView['push'] = [];
let pushM = 0;
for (let k = a; k < b; k++) {
if (!rider.ratings[k]?.hikeABike) continue;
pushM += d[k + 1] - d[k];
const x0 = (d[k] - d[a]) / len;
const x1 = (d[k + 1] - d[a]) / len;
const last = push[push.length - 1];
if (last && x0 - last.x1 < 0.005) last.x1 = x1;
else push.push({ x0, x1 });
}
// racine : une bosse reste visible à côté d'un col, sans écraser l'écart entre les deux
const height = Math.round(MIN_H + (H - MIN_H) * Math.sqrt(c.gain / maxGain));
return { climb: c, slices, push, pushM, summit: ele[b], height };
}
/** Distance à pied arrondie : au mètre près ne voudrait rien dire pour une estimation. */
const walkLabel = (m: number) => (m >= 1000 ? `~${fmt(m / 1000, 1)}\u00a0km` : `~${fmt(Math.round(m / 10) * 10)}\u00a0m`);
/** Échelle des pentes utiles en montée (à partir du faux plat), mêmes couleurs que le mode « Pente » du profil. */
const LEGEND = GRADE_STOPS.filter((s) => s.min >= -2);
function GradeLegend() {
return (
<div className="climb-legend" role="img" aria-label="Couleurs des pentes : vert sous 3 %, puis jaune, orange, rouge, rouge foncé et violet au-delà de 15 %">
<span className="climb-legend-bar">
{LEGEND.map((s) => (
<i key={s.min} style={{ background: s.color }} />
))}
</span>
<span className="climb-legend-ticks" aria-hidden="true">
{LEGEND.slice(1).map((s, k, all) => (
<span key={s.min}>{k === all.length - 1 ? `${s.min}\u00a0%` : s.min}</span>
))}
</span>
</div>
);
}
/** Pourcentage arrondi à l'unité, sauf les petites pentes où la décimale compte. */
const pct = (g: number) => (g < 10 ? fmt(g, 1) : fmt(g));
/**
* Silhouette au rendu du profil du dock : remplissage pâle (30 %) et trait plein sur le dessus.
* Chaque tranche garde sa couleur de pente jusqu'à son bord (deux arrêts au même endroit), comme dans le dock.
*/
function Silhouette({ v }: { v: ClimbView }) {
const W = 100;
const h = v.height;
const id = `climb-grade-${v.climb.startIdx}`;
// le trait est dessiné un peu sous le bord pour ne pas être rogné par le haut du dessin
const top = (y: number) => 1.5 + (h - 1.5) * (1 - y);
const first = v.slices[0];
const line = [`M0 ${top(first.y0)}`, ...v.slices.map((s) => `L${s.x1 * W} ${top(s.y1)}`)].join('');
return (
<svg className="climb-shape" viewBox={`0 0 ${W} ${h}`} preserveAspectRatio="none" style={{ height: h }} aria-hidden="true">
<defs>
<linearGradient id={id} x1="0" x2="1" y1="0" y2="0">
{v.slices.flatMap((s, k) => [
<stop key={`${k}a`} offset={s.x0} stopColor={gradeColor(s.grade)} />,
<stop key={`${k}b`} offset={s.x1} stopColor={gradeColor(s.grade)} />,
])}
</linearGradient>
</defs>
<path d={`${line}L${W} ${h}L0 ${h}Z`} fill={`url(#${id})`} fillOpacity="0.3" />
<path d={line} fill="none" stroke={`url(#${id})`} strokeWidth="2.5" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</svg>
);
}
interface Props {
base: BaseAnalysis;
rider: RiderAnalysis;
isSelected: (start: number, end: number) => boolean;
onFocus: (start: number, end: number, label: string) => void;
}
/** Carte d'une montée : km, nom, silhouette colorée par la pente, chiffres. */
function ClimbCard({ v, isSelected, onFocus }: { v: ClimbView; isSelected: Props['isSelected']; onFocus: Props['onFocus'] }) {
const c = v.climb;
const title = climbTitle(c);
const selected = isSelected(c.startIdx, c.endIdx);
return (
<li>
<button type="button" className={`climb${selected ? ' selected' : ''}`} aria-pressed={selected} onClick={() => onFocus(c.startIdx, c.endIdx, title)} data-tour-climb={c.startIdx}>
<span className="climb-head">
<span className="alert-km">km {fmt(c.startKm, 1)}</span>
<span className="climb-title">{title}</span>
<span className="climb-summit" title="Altitude du sommet">
Alt. {fmt(v.summit)} m
</span>
</span>
<span className="climb-figure">
<Silhouette v={v} />
{v.push.length > 0 && (
<span className="climb-push" aria-hidden="true">
{v.push.map((p, k) => (
<i key={k} style={{ left: `${p.x0 * 100}%`, width: `max(3px, ${(p.x1 - p.x0) * 100}%)` }} />
))}
</span>
)}
</span>
<span className="climb-stats">
<span>
<b>{fmt(c.lengthKm, 1)} km</b>
<small>longueur</small>
</span>
<span>
<b>+{fmt(c.gain)} m</b>
<small>dénivelé</small>
</span>
<span>
<b>{pct(c.avgGrade)} %</b>
<small>moyenne</small>
</span>
<span>
<b>
<i style={{ background: gradeColor(c.maxGrade) }} />
{pct(c.maxGrade)} %
</b>
<small>max sur 100 m</small>
</span>
</span>
{v.pushM >= 20 && <span className="climb-push-note">À pied sur {walkLabel(v.pushM)} pour ton niveau</span>}
</button>
</li>
);
}
/** La montée la plus raide de la trace (pente max sur 100 m), en carte ; null s'il n'y a aucune montée. */
export function SteepestClimb({ base, rider, isSelected, onFocus }: Props) {
const v = useMemo(() => {
if (!base.climbs.length) return null;
const maxGain = Math.max(1, ...base.climbs.map((c) => c.gain));
const steepest = base.climbs.reduce((m, c) => (c.maxGrade > m.maxGrade ? c : m));
return buildView(base, rider, steepest, maxGain);
}, [base, rider]);
if (!v) return null;
return (
<ul className="climb-list">
<ClimbCard v={v} isSelected={isSelected} onFocus={onFocus} />
</ul>
);
}
/** Montées de la trace, chacune dessinée comme un profil de col coloré par la pente. */
export default function ClimbList({ base, rider, isSelected, onFocus }: Props) {
const views = useMemo(() => {
const maxGain = Math.max(1, ...base.climbs.map((c) => c.gain));
return base.climbs.map((c) => buildView(base, rider, c, maxGain));
}, [base, rider]);
if (views.length === 0) return <p className="muted">Aucune montée significative.</p>;
const climbGain = base.climbs.reduce((s, c) => s + c.gain, 0);
return (
<>
<p className="climbs-lead">
{fmt(climbGain)} m sur les {fmt(base.gain)} m de dénivelé positif se font dans {views.length === 1 ? 'cette montée' : `ces ${views.length} montées`}.
</p>
<GradeLegend />
<ul className="climb-list">
{views.map((v) => (
<ClimbCard key={v.climb.startIdx} v={v} isSelected={isSelected} onFocus={onFocus} />
))}
</ul>
</>
);
}
Tokens : --panel --text --muted --line --accent --chip --exigeant
Utilisation
import ClimbList, { SteepestClimb } from './components/ClimbList';
<ClimbList base={base} rider={rider} isSelected={isSelected} onFocus={focusRange} />Exemples
La montée la plus raide (SteepestClimb)
// Titre : La montée la plus raide (SteepestClimb)
import { SteepestClimb } from '@pathou/components/ClimbList';
import { base, riderOf } from '../_fixtures/base';
const rider = riderOf('vtt', 'confirme');
export default function Exemple() {
return (
<div style={{ width: 360 }}>
<SteepestClimb base={base} rider={rider} isSelected={() => false} onFocus={() => {}} />
</div>
);
}
Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | Introduction | La part du dénivelé positif faite dans ces montées. |
| 2 | Titre | Le km de départ, la catégorie de la montée (comme un col) et l’altitude du sommet. |
| 3 | Silhouette | Le profil de la montée, coloré par la pente avec sa légende. |
| 4 | Chiffres | Longueur, dénivelé, pente moyenne et maximale. |
Guidelines
- SHOULDClasser les montées comme les cols (catégories) ; c’est le repère que les cyclistes connaissent.
- SHOULDSignaler le poussage probable dans une montée trop raide pour le niveau.
Référence de l’API
ClimbList (export par défaut)
| Prop | Type | Défaut |
|---|---|---|
base | BaseAnalysis | requis |
rider | RiderAnalysis | requis |
isSelected | (start: number, end: number) => boolean | requis |
onFocus | (start: number, end: number, label: string) => void | requis |
SteepestClimb
| Prop | Type | Défaut |
|---|---|---|
base | BaseAnalysis | requis |
rider | RiderAnalysis | requis |
isSelected | (start: number, end: number) => boolean | requis |
onFocus | (start: number, end: number, label: string) => void | requis |