AlertExtras

→

Compléments de la liste des points d’attention — l’état vide qui montre quand même la montée la plus raide, et l’échelle des niveaux qui donne la longueur trop dure pour chacun.

stableÀ valider
// Titre : Aucun point d’attention (niveau expert)
import { EmptyAlerts } from '@pathou/components/AlertExtras';
import { base, riderOf } from '../_fixtures/base';

const rider = { ...riderOf('vtt', 'expert'), alerts: [] };

export default function Exemple() {
  return (
    <div style={{ width: 360 }}>
      <EmptyAlerts base={base} rider={rider} isSelected={() => false} onFocus={() => {}} />
    </div>
  );
}

Installation

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

src/components/AlertExtras.tsx
import { useMemo, useState } from 'react';
import { analyzeRider, type BaseAnalysis, type RiderAnalysis } from '../lib/analyze';
import { LEVELS, LEVEL_LABEL } from '../lib/difficulty/rules';
import type { Level } from '../lib/types';
import AlertList from './AlertList';
import { fmtLen } from './DetourBlock';
import { SteepestClimb } from './ClimbList';

interface Props {
  base: BaseAnalysis;
  rider: RiderAnalysis;
  isSelected: (start: number, end: number) => boolean;
  onFocus: (start: number, end: number, label: string) => void;
}

/** Aucun point d'attention : on le dit, puis on montre le passage le plus exigeant de la trace, sa montée la plus raide. */
export function EmptyAlerts({ base, rider, isSelected, onFocus }: Props) {
  return (
    <div className="alerts-empty">
      <p className="alerts-empty-lead">
        <span className="alerts-empty-check" aria-hidden="true">
          <svg viewBox="0 0 16 16">
            <path d="M3.5 8.5 6.5 11.5 12.5 5" />
          </svg>
        </span>
        <span>
          <b>Rien au-delà de ton niveau</b>
          <span className="muted">
            {base.climbs.length ? 'Voici la montée la plus raide de la trace.' : 'Pas de montée marquée non plus.'}
          </span>
        </span>
      </p>
      <SteepestClimb base={base} rider={rider} isSelected={isSelected} onFocus={onFocus} />
    </div>
  );
}

/** Longueur des passages trop durs pour un niveau (très difficiles, à pied, inadaptés), plages fusionnées. */
export function hardLength(alerts: RiderAnalysis['alerts'], d: number[]): number {
  const spans = alerts.filter((a) => a.hard).map((a) => [d[a.idx], d[a.endIdx]] as const).sort((x, y) => x[0] - y[0]);
  let total = 0;
  let end = -Infinity;
  for (const [a, b] of spans) {
    if (b <= end) continue;
    total += b - Math.max(a, end);
    end = b;
  }
  return total;
}

/**
 * Longueur des passages trop durs pour chaque niveau, le sien mis en avant : elle baisse quand le niveau monte, alors que
 * le nombre d'alertes peut remonter (un long secteur dur se coupe en morceaux). Choisir un autre niveau affiche ses
 * passages, sans changer le verdict ni le compteur du rider.
 */
export function LevelLadder({ base, rider, isSelected, onFocus }: Props) {
  const [shown, setShown] = useState<Level | null>(null);
  const riders = useMemo(
    () => new Map(LEVELS.map((l) => [l, l === rider.level ? rider : analyzeRider(base, rider.practice, l)] as const)),
    [base, rider],
  );
  const other = shown && shown !== rider.level ? riders.get(shown)! : null;
  const label = (l: Level) => LEVEL_LABEL[l].toLowerCase();
  return (
    <div className="level-ladder">
      <p className="level-ladder-head">Passages trop durs, selon le niveau</p>
      <div className="level-ladder-row" role="group" aria-label="Longueur des passages trop durs selon le niveau">
        {LEVELS.map((l) => {
          const m = hardLength(riders.get(l)!.alerts, base.d);
          const content = (
            <>
              <b>{m > 0 ? fmtLen(m) : '0 m'}</b>
              <small>{LEVEL_LABEL[l]}</small>
            </>
          );
          // son propre niveau : le repère, pas un bouton
          return l === rider.level ? (
            <div key={l} className="level-step is-mine" aria-current="true">
              {content}
              <span className="sr-only"> (ton niveau)</span>
            </div>
          ) : (
            <button key={l} type="button" className={`level-step${shown === l ? ' is-shown' : ''}`} aria-pressed={shown === l} onClick={() => setShown(shown === l ? null : l)}>
              {content}
            </button>
          );
        })}
      </div>
      {other && (
        <div className="level-ladder-list">
          <p className="level-ladder-title">Pour un niveau {label(other.level)}</p>
          {other.alerts.length === 0 ? <p className="muted">Aucun point d’attention.</p> : <AlertList base={base} rider={other} detours={{}} isSelected={isSelected} onFocus={onFocus} />}
        </div>
      )}
    </div>
  );
}

Tokens : --panel --text --muted --detour --detour-ink --line --accent --chip --ok --exigeant --ease-out

Utilisation

import { EmptyAlerts, LevelLadder } from './components/AlertExtras';

{rider.alerts.length === 0 ? <EmptyAlerts {...props} /> : <LevelLadder {...props} />}

Exemples

Échelle des niveaux (LevelLadder)

// Titre : Échelle des niveaux (LevelLadder)
import { LevelLadder } from '@pathou/components/AlertExtras';
import { base, riderOf } from '../_fixtures/base';

const rider = riderOf('vtt', 'intermediaire');

export default function Exemple() {
  return (
    <div style={{ width: 360 }}>
      <LevelLadder base={base} rider={rider} isSelected={() => false} onFocus={() => {}} />
    </div>
  );
}

Anatomie

#ÉlémentRôle
1État videCoche, « Rien au-delà de ton niveau », puis la carte de la montée la plus raide.

Guidelines

  • MUST
    Affirmer l’absence de passage difficile plutôt que laisser une liste vide, et montrer le passage le plus exigeant qui reste.
  • SHOULD
    Dans l’échelle des niveaux, mettre en avant le niveau choisi ; choisir un autre niveau montre ses passages sans changer le verdict.

Référence de l’API

EmptyAlerts

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

LevelLadder

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