ClimbList

←→

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.

src/components/ClimbList.tsx
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.&nbsp;{fmt(v.summit)}&nbsp;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)}&nbsp;km</b>
            <small>longueur</small>
          </span>
          <span>
            <b>+{fmt(c.gain)}&nbsp;m</b>
            <small>dénivelé</small>
          </span>
          <span>
            <b>{pct(c.avgGrade)}&nbsp;%</b>
            <small>moyenne</small>
          </span>
          <span>
            <b>
              <i style={{ background: gradeColor(c.maxGrade) }} />
              {pct(c.maxGrade)}&nbsp;%
            </b>
            <small>max sur 100&nbsp;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)}&nbsp;m sur les {fmt(base.gain)}&nbsp;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émentRôle
1IntroductionLa part du dénivelé positif faite dans ces montées.
2TitreLe km de départ, la catégorie de la montée (comme un col) et l’altitude du sommet.
3SilhouetteLe profil de la montée, coloré par la pente avec sa légende.
4ChiffresLongueur, dénivelé, pente moyenne et maximale.

Guidelines

  • SHOULD
    Classer les montées comme les cols (catégories) ; c’est le repère que les cyclistes connaissent.
  • SHOULD
    Signaler le poussage probable dans une montée trop raide pour le niveau.

Référence de l’API

ClimbList (export par défaut)

PropTypeDéfaut
baseBaseAnalysisrequis
riderRiderAnalysisrequis
isSelected(start: number, end: number) => booleanrequis
onFocus(start: number, end: number, label: string) => voidrequis

SteepestClimb

PropTypeDéfaut
baseBaseAnalysisrequis
riderRiderAnalysisrequis
isSelected(start: number, end: number) => booleanrequis
onFocus(start: number, end: number, label: string) => voidrequis