AlertList

←→

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.

src/components/AlertList.tsx
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émentRôle
1En-têteLe km, le type de passage et sa longueur.
2CoupeLe profil du passage coloré, ou la coupe du balcon (BalconyFigure).
3PucesLes types de voie rencontrés.

États

selectedCarte choisie ; la carte et le profil cadrent le passage.

Guidelines

  • MUST
    Ranger les points dans l’ordre du parcours et compter les entrées affichées, pas les alertes brutes.
  • MUST
    Regrouper les obstacles identiques proches en une seule entrée.
  • SHOULD
    Montrer 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

PropTypeDéfaut
baseBaseAnalysisrequis
riderRiderAnalysisrequis
detours
alternatives aux passages durs, par `alertKey`
Record<string, DetourResult>requis
isSelected(start: number, end: number) => booleanrequis
onFocus(start: number, end: number, label: string) => voidrequis
applyFor
appliquer 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—
applied
alternatives appliquées dont le passage a disparu de l'analyse : leur carte reste, cochée
AppliedCardView[][]