Liste des points d’attention dans l’ordre du parcours — passages durs, balcons, obstacles, accès — chacun en carte avec sa coupe, ses chiffres et son alternative ; les remarques « à vérifier » sont regroupées à la fin.
stableÀ valider
// Titre : Points d’attention, Cévennes J1 en VTT intermédiaire
import { useState } from 'react';
import AlertList from '@pathou/components/AlertList';
import { base, riderOf } from '../_fixtures/base';
const rider = riderOf('vtt', 'intermediaire');
export default function Exemple() {
const [sel, setSel] = useState<[number, number] | null>(null);
return (
<div style={{ width: 360, maxHeight: 640, overflowY: 'auto' }}>
<AlertList base={base} rider={rider} detours={{}} isSelected={(a, b) => sel?.[0] === a && sel?.[1] === b} onFocus={(a, b) => setSel([a, b])} />
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { useMemo, useState, type ReactNode } from 'react';
import type { BaseAnalysis, RiderAnalysis } from '../lib/analyze';
import { WAY_COLOR, gradeColor } from '../lib/colors';
import { isFound, type DetourFound, type DetourResult } from '../lib/detour/result';
import { alertKey, alertRange } from '../lib/difficulty/model';
import type { AppliedCard } from '../lib/edit/compose';
import { fmt } from '../lib/format';
import { WAY_CLASS_LABEL } from '../lib/osm/classify';
import type { Alert } from '../lib/types';
import DetourBlock, { WalkIcon, fmtLen, type DetourApply } from './DetourBlock';
import BalconyFigure, { FIG_H } from './BalconyFigure';
import { colStyle } from './techStyle';
interface Props {
base: BaseAnalysis;
rider: RiderAnalysis;
/** alternatives aux passages durs, par `alertKey` */
detours: Record<string, DetourResult>;
isSelected: (start: number, end: number) => boolean;
onFocus: (start: number, end: number, label: string) => void;
/** appliquer ou retirer une alternative trouvée ; absent tant que l'analyse d'origine n'est pas finie */
applyFor?: (found: DetourFound, alert?: Pick<Alert, 'name' | 'severity'>) => DetourApply | undefined;
/** alternatives appliquées dont le passage a disparu de l'analyse : leur carte reste, cochée */
applied?: AppliedCardView[];
}
/** Carte d'une alternative appliquée : le passage évité, à son km sur la trace modifiée, et l'alternative cochée. */
export interface AppliedCardView {
id: string;
card: AppliedCard;
km: number;
/** échantillons de l'analyse affichée couverts par l'alternative, pour la cadrer */
start: number;
end: number;
onRemove: () => void;
}
const SEVERITY_LABEL: Record<Alert['severity'], string> = { bloquant: 'Bloquant', attention: 'Attention', info: 'À vérifier' };
/** Obstacles identiques regroupés en une ligne quand ils se suivent à moins de cette distance (m). */
const OBSTACLE_GAP_M = 1000;
type Row = { type: 'card'; a: Alert } | { type: 'obstacles'; items: Alert[] } | { type: 'applied'; c: AppliedCardView };
const rowKm = (r: Row) => (r.type === 'card' ? r.a.km : r.type === 'obstacles' ? r.items[0].km : r.c.km);
/**
* Lignes de la liste des points d'attention, dans l'ordre du parcours : obstacles identiques proches regroupés, alternatives
* appliquées à leur km, puis remarques « à vérifier » regroupées par type. `count` est le nombre d'entrées affichées : c'est
* lui que montre le compteur, pas le nombre brut d'alertes.
*/
export function alertEntries(alerts: Alert[], d: number[], applied: AppliedCardView[] = []) {
const rows: Row[] = [];
const groups = new Map<string, Alert[]>();
for (const a of alerts) {
if (a.kind === 'verifier') {
groups.set(a.name, [...(groups.get(a.name) ?? []), a]);
continue;
}
const last = rows[rows.length - 1];
if (a.kind === 'obstacle' && last?.type === 'obstacles') {
const prev = last.items[last.items.length - 1];
if (prev.name === a.name && d[a.idx] - d[prev.endIdx] <= OBSTACLE_GAP_M) {
last.items.push(a);
continue;
}
}
rows.push(a.kind === 'obstacle' ? { type: 'obstacles', items: [a] } : { type: 'card', a });
}
// les alternatives appliquées prennent leur place dans l'ordre du parcours
for (const c of applied) {
const at = rows.findIndex((r) => rowKm(r) > c.km);
rows.splice(at < 0 ? rows.length : at, 0, { type: 'applied', c });
}
return { rows, groups: [...groups], count: rows.length + groups.size };
}
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const pct = (g: number) => (Math.abs(g) < 10 ? fmt(g, 1) : fmt(g));
const lengthM = (base: BaseAnalysis, a: Alert) => base.d[a.endIdx] - base.d[a.idx];
/** 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)} km` : `~${fmt(Math.round(m / 10) * 10)} m`);
/** Indices qui encadrent le passage avec `padM` mètres de contexte de part et d'autre. */
function around(d: number[], a: number, b: number, padM: number): [number, number] {
let lo = a;
while (lo > 0 && d[a] - d[lo - 1] <= padM) lo--;
let hi = b;
while (hi < d.length - 1 && d[hi + 1] - d[b] <= padM) hi++;
return [lo, hi];
}
/** Pluriel d'un nom d'obstacle (« Échalier » → « échaliers », « Gué à pas japonais » → « gués à pas japonais »). */
function plural(name: string): string {
const [first, ...rest] = name.split(' ');
const word = /[sx]$/.test(first) ? first : `${first}s`;
return [word.toLowerCase(), ...rest].join(' ');
}
/**
* Points d'attention dans l'ordre du parcours, chacun avec la carte de sa famille :
* profil pour les passages durs, coupe pour les balcons,
* ligne compacte pour les obstacles. Les remarques à vérifier sont regroupées par type à la fin.
*/
export default function AlertList({ base, rider, detours, isSelected, onFocus, applyFor, applied = [] }: Props) {
const { rows, groups } = useMemo(() => alertEntries(rider.alerts, base.d, applied), [base, rider, applied]);
const focusAlert = (a: Alert) => {
const [s, e] = alertRange(a, base.d.length);
onFocus(s, e, a.title);
};
const selected = (a: Alert) => {
const [s, e] = alertRange(a, base.d.length);
return isSelected(s, e);
};
return (
<ul className="alerts">
{rows.map((r) =>
r.type === 'applied' ? (
<AppliedAlertCard key={`applied-${r.c.id}`} c={r.c} selected={isSelected(r.c.start, r.c.end)} onClick={() => onFocus(r.c.start, r.c.end, `${r.c.card.name} évité`)} />
) : r.type === 'obstacles' ? (
<ObstacleRow key={`${r.items[0].name}${r.items[0].idx}`} items={r.items} base={base} isSelected={isSelected} onFocus={onFocus} />
) : (
<AlertCard
key={`${r.a.name}${alertKey(r.a)}`}
a={r.a}
base={base}
rider={rider}
detour={r.a.hard ? detours[alertKey(r.a)] : undefined}
applyFor={applyFor}
selected={selected(r.a)}
onClick={() => focusAlert(r.a)}
/>
),
)}
{groups.map(([name, items]) => (
<CheckGroup key={name} name={name} items={items} base={base} selected={selected} onPick={focusAlert} />
))}
</ul>
);
}
/** Kilomètre du point d'attention ; la sévérité n'est dite qu'aux lecteurs d'écran. */
function KmBadge({ a, km }: { a: Pick<Alert, 'severity'>; km: string }) {
return (
<span className="alert-km">
{km}
<span className="sr-only">, {SEVERITY_LABEL[a.severity]}</span>
</span>
);
}
function AlertCard({
a,
base,
rider,
detour,
applyFor,
selected,
onClick,
}: {
a: Alert;
base: BaseAnalysis;
rider: RiderAnalysis;
detour?: DetourResult;
applyFor?: Props['applyFor'];
selected: boolean;
onClick: () => void;
}) {
const len = lengthM(base, a);
let body: ReactNode = <span className="alert-detail">{a.detail}</span>;
if (a.kind === 'passage') body = <PassageBody a={a} base={base} rider={rider} />;
else if (a.kind === 'balcon' && a.facts?.sideSlope) body = <BalconyBody slope={a.facts.sideSlope} side={a.facts.side ?? 'gauche'} />;
// la carte entière (alternative comprise) cadre le passage ; le bouton porte le focus clavier et l'état
return (
<li className={`alert alert-${a.severity} alert-${a.kind}${selected ? ' selected' : ''}`} onClick={onClick} data-tour-alert={alertKey(a)}>
<button type="button" className="alert-main" aria-pressed={selected}>
<span className="alert-head">
<KmBadge a={a} km={`km ${fmt(a.km, 1)}`} />
<span className="alert-name">{a.name}</span>
{len >= 10 && <span className="alert-len">{fmtLen(len)}</span>}
</span>
{body}
</button>
{detour && <DetourBlock r={detour} d={base.d} shown={selected} apply={isFound(detour) ? applyFor?.(detour, a) : undefined} />}
</li>
);
}
/** Passage évité par une alternative appliquée : il n'est plus sur la trace, sa carte reste pour retirer l'alternative. */
function AppliedAlertCard({ c, selected, onClick }: { c: AppliedCardView; selected: boolean; onClick: () => void }) {
const apply: DetourApply = { canApply: true, applied: true, onApply: () => null, onRemove: c.onRemove };
return (
<li className={`alert alert-applied${selected ? ' selected' : ''}`} onClick={onClick}>
<button type="button" className="alert-main" aria-pressed={selected}>
<span className="alert-head">
<KmBadge a={{ severity: c.card.severity }} km={`km ${fmt(c.km, 1)}`} />
<span className="alert-name">{c.card.name}</span>
<span className="alert-avoided">Évité</span>
</span>
<span className="alert-detail">Ta trace passe maintenant par l’alternative ci-dessous.</span>
</button>
<DetourBlock r={c.card.found} d={c.card.d} shown={selected} apply={apply} />
</li>
);
}
/** Une valeur chiffrée sous la figure, comme dans les cartes de montée. */
function Stat({ value, label, swatch, icon }: { value: string; label: string; swatch?: string; icon?: ReactNode }) {
return (
<span>
<b>
{swatch && <i style={{ background: swatch }} />}
{icon}
{value}
</b>
<small>{label}</small>
</span>
);
}
/** Passage dur : profil de la zone (passage coloré par la pente, contexte en gris), chiffres, voies et cotation. */
function PassageBody({ a, base, rider }: { a: Alert; base: BaseAnalysis; rider: RiderAnalysis }) {
const f = a.facts ?? {};
const len = lengthM(base, a);
const fig = useMemo(() => passageFigure(base, rider, a), [base, rider, a]);
const up = (f.maxUp ?? 0) >= (f.maxDown ?? 0);
const steepest = up ? (f.maxUp ?? 0) : -(f.maxDown ?? 0);
return (
<>
<span className="alert-figure">
<svg className="alert-shape" viewBox={`0 0 100 ${FIG_H}`} preserveAspectRatio="none" style={{ height: FIG_H }} aria-hidden="true">
<defs>
<linearGradient id={fig.id} x1="0" x2="1" y1="0" y2="0">
{fig.stops.map((s, k) => (
<stop key={k} offset={s.offset} stopColor={s.color} />
))}
</linearGradient>
</defs>
<path className="alert-context" d={`${fig.context}L100 ${FIG_H}L0 ${FIG_H}Z`} />
<path className="alert-context-line" d={fig.context} vectorEffect="non-scaling-stroke" />
<path d={`${fig.passage}L${fig.x1} ${FIG_H}L${fig.x0} ${FIG_H}Z`} fill={`url(#${fig.id})`} fillOpacity="0.3" />
<path d={fig.passage} fill="none" stroke={`url(#${fig.id})`} strokeWidth="2.5" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
<line className="alert-bound" x1={fig.x0} x2={fig.x0} y1="0" y2={FIG_H} vectorEffect="non-scaling-stroke" />
<line className="alert-bound" x1={fig.x1} x2={fig.x1} y1="0" y2={FIG_H} vectorEffect="non-scaling-stroke" />
</svg>
{fig.walk.length > 0 && (
<span className="climb-push" aria-hidden="true">
{fig.walk.map((p, k) => (
<i key={k} style={{ left: `${p.x0}%`, width: `max(3px, ${p.x1 - p.x0}%)` }} />
))}
</span>
)}
</span>
<span className="alert-stats">
<Stat value={`${pct(steepest)} %`} label={up ? 'pente max' : 'descente max'} swatch={gradeColor(steepest)} />
{f.ramp !== undefined && <Stat value={`${pct(f.ramp)} %`} label="rampe sur 50 m" />}
{fig.walkM >= 10 && <Stat value={walkLabel(fig.walkM)} label={fig.walkM >= len * 0.95 ? 'à pied, tout du long' : 'à pied'} icon={<WalkIcon />} />}
</span>
{(f.classes?.length || f.tech !== undefined) && (
<span className="alert-chips">
{f.tech !== undefined && (
<span className={`pill-tech${f.techEstimated ? ' est' : ' tech-pill'}`} style={colStyle(f.tech)} title={f.techEstimated ? 'Cotation estimée par Pathou' : 'Cotation renseignée dans OpenStreetMap'}>
{f.techEstimated ? '≈' : ''}S{f.tech}
</span>
)}
{f.classes?.map((c) => (
<span key={c} className="detour-chip">
<i style={{ background: WAY_COLOR[c] }} />
{WAY_CLASS_LABEL[c]}
</span>
))}
</span>
)}
</>
);
}
/** Profil du passage et de ses abords, en part de la largeur (0–100) et de la hauteur de la figure. */
function passageFigure(base: BaseAnalysis, rider: RiderAnalysis, a: Alert) {
const { d, ele } = base;
const len = d[a.endIdx] - d[a.idx];
const [lo, hi] = around(d, a.idx, a.endIdx, clamp(len * 0.8, 150, 400));
let min = Infinity;
let max = -Infinity;
for (let i = lo; i <= hi; i++) {
min = Math.min(min, ele[i]);
max = Math.max(max, ele[i]);
}
// au moins 20 m de haut : un passage presque plat reste plat, sans exagérer ses bosses
const range = Math.max(20, max - min);
const span = d[hi] - d[lo] || 1;
const X = (i: number) => ((d[i] - d[lo]) / span) * 100;
const Y = (i: number) => 1.5 + (FIG_H - 3) * (1 - (ele[i] - min) / range);
const line = (i0: number, i1: number) => {
let p = `M${X(i0)} ${Y(i0)}`;
for (let i = i0 + 1; i <= i1; i++) p += `L${X(i)} ${Y(i)}`;
return p;
};
// tranches de pente de ~50 m sur le passage (plus court, le bruit d'altitude colorerait au hasard), arrêts nets comme le profil du dock
const x0 = X(a.idx);
const x1 = X(a.endIdx);
const n = clamp(Math.round(len / 50), 1, 12);
const stops: { offset: number; color: string }[] = [];
let i = a.idx;
for (let k = 0; k < n; k++) {
const target = d[a.idx] + (len * (k + 1)) / n;
let j = i;
while (j < a.endIdx && d[j] < target) j++;
const dist = d[j] - d[i];
const color = gradeColor(dist > 0 ? ((ele[j] - ele[i]) / dist) * 100 : 0);
const off = (v: number) => (len > 0 ? (d[v] - d[a.idx]) / len : 0);
stops.push({ offset: off(i), color }, { offset: off(j), color });
i = j;
}
// passages à pied (inadaptés ou poussés), hachurés sous le profil comme dans les montées
const walk: { x0: number; x1: number }[] = [];
let walkM = 0;
for (let k = a.idx; k < a.endIdx; k++) {
const r = rider.ratings[k + 1];
if (r.rating !== 'impraticable' && !r.hikeABike) continue;
walkM += d[k + 1] - d[k];
const last = walk[walk.length - 1];
if (last && X(k) - last.x1 < 0.5) last.x1 = X(k + 1);
else walk.push({ x0: X(k), x1: X(k + 1) });
}
return { id: `alert-grade-${a.idx}-${a.endIdx}`, context: line(lo, hi), passage: line(a.idx, a.endIdx), x0, x1, stops, walk, walkM };
}
/** Sentier en balcon : coupe du terrain à l'angle mesuré, vide du bon côté, et ses chiffres. */
function BalconyBody({ slope, side }: { slope: number; side: 'gauche' | 'droit' }) {
const deg = Math.round((Math.atan(slope / 100) * 180) / Math.PI);
return (
<span className="alert-balcony">
<BalconyFigure slope={slope} side={side} />
<span className="alert-stats two">
<Stat value={`${fmt(slope)} % (${deg}°)`} label="pente sous le sentier" />
<Stat value={`à ${side}`} label="vide, dans le sens de la marche" />
</span>
</span>
);
}
/** Obstacles identiques qui se suivent, en une ligne : pictogramme, nombre, consigne et kilomètres. */
function ObstacleRow({ items, base, isSelected, onFocus }: { items: Alert[]; base: BaseAnalysis; isSelected: Props['isSelected']; onFocus: Props['onFocus'] }) {
const first = items[0];
const last = items[items.length - 1];
const many = items.length > 1;
const name = many ? `${items.length} ${plural(first.name)}` : first.name;
const [s] = alertRange(first, base.d.length);
const [, e] = alertRange(last, base.d.length);
const selected = isSelected(s, e);
const km = many ? `km ${fmt(first.km, 1)}–${fmt(last.km, 1)}` : `km ${fmt(first.km, 1)}`;
return (
<li className={`alert-row alert-${first.severity}${selected ? ' selected' : ''}`} data-tour-alert={alertKey(first)}>
<button type="button" className="alert-row-btn" aria-pressed={selected} onClick={() => onFocus(s, e, name)}>
<KmBadge a={first} km={km} />
<span className="alert-row-text">
<span className="alert-name">{name}</span>
<span className="alert-detail">{first.detail}</span>
</span>
<span className="alert-icon" aria-hidden="true">
<ObstacleIcon name={first.name} />
</span>
</button>
</li>
);
}
function ObstacleIcon({ name }: { name: string }) {
const common = { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
if (/^Gué/.test(name))
return (
<svg {...common}>
<path d="M3 9c2-1.5 4-1.5 6 0s4 1.5 6 0 4-1.5 6 0M3 15c2-1.5 4-1.5 6 0s4 1.5 6 0 4-1.5 6 0" />
</svg>
);
if (/^Escalier/.test(name))
return (
<svg {...common}>
<path d="M3 20h5v-5h5v-5h5V5h3" />
</svg>
);
// échalier, chicane, tourniquet : clôture à franchir
return (
<svg {...common}>
<path d="M6 4v17M18 4v17M3 9h18M3 15h18" />
</svg>
);
}
/** Remarques d'un même type à vérifier, regroupées et repliées : où elles sont sur la trace, l'explication une fois, un accès par tronçon. */
function CheckGroup({ name, items, base, selected, onPick }: { name: string; items: Alert[]; base: BaseAnalysis; selected: (a: Alert) => boolean; onPick: (a: Alert) => void }) {
const [open, setOpen] = useState(false);
const total = items.reduce((s, a) => s + lengthM(base, a), 0);
const id = `check-${items[0].idx}`;
const worst = items.some((a) => a.severity === 'attention') ? 'attention' : 'info';
const km = base.distance || 1;
return (
<li className={`alert-group alert-${worst}`}>
<button type="button" className="alert-group-btn" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)}>
<span className="alert-name">{name}</span>
<span className="alert-len">
{items.length} tronçon{items.length > 1 ? 's' : ''}, {fmtLen(total)}
</span>
<svg className="fold-chevron" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5.5 8l4.5 4.5L14.5 8" />
</svg>
</button>
<div id={id} className="alert-group-body" hidden={!open}>
<span className="alert-strip" aria-hidden="true">
{items.map((a) => (
<i key={a.idx} style={{ left: `${(base.d[a.idx] / km) * 100}%`, width: `${(lengthM(base, a) / km) * 100}%` }} />
))}
</span>
<p className="alert-detail">{items[0].detail}</p>
<span className="alert-kms">
{items.map((a) => (
<button key={a.idx} type="button" className={selected(a) ? 'selected' : ''} aria-pressed={selected(a)} onClick={() => onPick(a)} aria-label={`${a.title}, km ${fmt(a.km, 1)}`}>
km {fmt(a.km, 1)}
</button>
))}
</span>
</div>
</li>
);
}
Tokens : --panel --text --muted --detour --detour-ink --line --accent --chip --exigeant --ease-out --tech-black --tech-black-ink
Utilisation
import AlertList from './components/AlertList';
<AlertList base={base} rider={rider} detours={detours} isSelected={isSelected} onFocus={focusRange} applyFor={applyFor} applied={appliedCards} />Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | En-tête | Le km, le type de passage et sa longueur. |
| 2 | Coupe | Le profil du passage coloré, ou la coupe du balcon (BalconyFigure). |
| 3 | Puces | Les types de voie rencontrés. |
États
selectedCarte choisie ; la carte et le profil cadrent le passage.Guidelines
- MUSTRanger les points dans l’ordre du parcours et compter les entrées affichées, pas les alertes brutes.
- MUSTRegrouper les obstacles identiques proches en une seule entrée.
- SHOULDMontrer l’alternative d’un passage dur dans sa carte, au plus près du problème qu’elle résout.
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
base | BaseAnalysis | requis |
rider | RiderAnalysis | requis |
detoursalternatives aux passages durs, par `alertKey` | Record<string, DetourResult> | requis |
isSelected | (start: number, end: number) => boolean | requis |
onFocus | (start: number, end: number, label: string) => void | requis |
applyForappliquer ou retirer une alternative trouvée ; absent tant que l'analyse d'origine n'est pas finie | (found: DetourFound, alert?: Pick<Alert, 'name' | 'severity'>) => DetourApply | undefined | — |
appliedalternatives appliquées dont le passage a disparu de l'analyse : leur carte reste, cochée | AppliedCardView[] | [] |