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.
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ément | Rôle |
|---|---|---|
| 1 | État vide | Coche, « Rien au-delà de ton niveau », puis la carte de la montée la plus raide. |
Guidelines
- MUSTAffirmer l’absence de passage difficile plutôt que laisser une liste vide, et montrer le passage le plus exigeant qui reste.
- SHOULDDans 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
| 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 |
LevelLadder
| 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 |