Carte d’une alternative à un point d’attention difficile — praticable, moins dure ou en cours de recherche — avec son écart de distance et de dénivelé, et le bouton pour l’appliquer à la trace.
stableÀ valider
// Titre : Alternative praticable, applicable à la trace
import { useState } from 'react';
import DetourBlock from '@pathou/components/DetourBlock';
import type { DetourFound } from '@pathou/lib/detour/result';
// distances cumulées de la trace (m) : seules celles des points de départ et d’arrivée servent ici
const d = Array.from({ length: 200 }, (_, i) => i * 100);
const found: DetourFound = {
status: 'praticable',
path: [],
ele: [],
leaveIdx: 124,
rejoinIdx: 131,
extraM: 380,
extraGain: 22,
mainClass: 'chemin_naturel',
hardLeftM: 0,
worst: 'bleu',
approxRelief: false,
alsoAvoids: [],
};
export default function Exemple() {
const [applied, setApplied] = useState(false);
return (
<div style={{ width: 340, ['--sev' as string]: 'var(--exigeant)' }}>
<DetourBlock
r={found}
d={d}
shown
apply={{ canApply: true, applied, onApply: () => (setApplied(true), { id: 'a', replacedKm: [] }), onRemove: () => setApplied(false) }}
/>
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { useState } from 'react';
import type { DetourResult } from '../lib/detour/result';
import { formatDelta } from '../lib/detour/result';
import { RATING_COLOR, WAY_COLOR } from '../lib/colors';
import { RATING_LABEL } from '../lib/difficulty/rules';
import { WAY_CLASS_LABEL } from '../lib/osm/classify';
import { fmt } from '../lib/format';
import DetourGlyph from './DetourGlyph';
export const fmtLen = (m: number) => (m >= 995 ? `${fmt(m / 1000, 1)} km` : `${Math.max(10, Math.round(m / 10) * 10)} m`);
export const WalkIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="13" cy="4" r="1" />
<path d="M7 21l3-4M16 21l-2-4-3-3 1-6M6 12l2-3 4-1 3 3 3 1" />
</svg>
);
/** Appliquer une alternative à la trace, ou la retirer tant que la réanalyse ne l'a pas intégrée. */
export interface DetourApply {
/** faux : ne se rattache pas à l'origine (démarre sur une alternative appliquée) */
canApply: boolean;
applied: boolean;
onApply: () => { id: string; replacedKm: number[] } | null;
onRemove: () => void;
}
/** Alternative d'un point d'attention dur : trouvée, moins dure ou en cours ; rien s'il n'y en a pas. `shown` : elle est tracée sur la carte. */
export default function DetourBlock({ r, d, shown, apply }: { r: DetourResult; d: number[]; shown: boolean; apply?: DetourApply }) {
const [note, setNote] = useState<string | null>(null);
if (r.status === 'loading') return <div className="detour ld">Recherche d’une alternative…</div>;
if (r.status === 'timeout') return <div className="detour ld">Recherche d’alternative abandonnée (trop longue)</div>;
// sans alternative, rien à montrer : la carte du passage dit déjà la distance à pied
if (r.status === 'none') return null;
const gain = Math.round(r.extraGain);
const from = fmt(d[Math.min(r.leaveIdx, r.rejoinIdx)] / 1000, 1);
const to = fmt(d[Math.max(r.leaveIdx, r.rejoinIdx)] / 1000, 1);
return (
<div className={`detour ${r.status}${shown ? ' on' : ''}${apply?.applied ? ' applied' : ''}`}>
<div className="detour-top">
<DetourGlyph kind={r.status} worst={r.worst} />
<div className="detour-head">
{r.status === 'praticable' ? 'Alternative praticable' : 'Alternative moins dure'}
<small>
du km {from} au km {to}
</small>
</div>
{apply && <ApplyButton apply={apply} onReplaced={setNote} />}
</div>
<div className="detour-stats">
<div>
<b>{formatDelta(r.extraM)}</b>
<span>distance</span>
</div>
<div>
<b>
{gain < 0 ? '−' : '+'}
{Math.abs(gain)} m
</b>
<span>D+</span>
</div>
<span className="detour-chip">
<i style={{ background: WAY_COLOR[r.mainClass] }} />
{r.mainClass === 'route' ? 'Route' : WAY_CLASS_LABEL[r.mainClass]}
</span>
{r.status === 'moins-dur' && (
<span className="detour-chip">
<i style={{ background: r.worst === 'noir' ? 'var(--text)' : RATING_COLOR[r.worst] }} />
{RATING_LABEL[r.worst]} sur {fmtLen(r.hardLeftM)}
</span>
)}
</div>
{(r.approxRelief || r.alsoAvoids.length > 0 || note || (apply && !apply.canApply && !apply.applied)) && (
<div className="detour-note">
{r.alsoAvoids.length > 0 && <div>Évite aussi le passage du km {r.alsoAvoids.map((k) => fmt(k, 1)).join(', ')}</div>}
{r.approxRelief && <div>Relief approximatif : altitude précise indisponible.</div>}
{apply && !apply.canApply && !apply.applied && <div>Démarre sur une alternative déjà appliquée : retire-la pour appliquer celle-ci.</div>}
{note && apply?.applied && <div>{note}</div>}
</div>
)}
</div>
);
}
/** Bouton rond du coin de la carte : ajoute l'alternative à la trace, puis la retire. Le clic ne sélectionne pas la carte du point d'attention. */
function ApplyButton({ apply, onReplaced }: { apply: DetourApply; onReplaced: (note: string | null) => void }) {
// juste après le clic, la coche reste visible sous le pointeur : le × de retrait n'apparaît qu'au survol suivant
const [fresh, setFresh] = useState(false);
const blocked = !apply.canApply && !apply.applied;
const label = apply.applied ? 'Retirer l’alternative de la trace' : blocked ? 'Impossible d’appliquer : démarre sur une alternative déjà appliquée' : 'Appliquer l’alternative à la trace';
const click = (e: React.MouseEvent) => {
e.stopPropagation();
setFresh(true);
if (apply.applied) {
onReplaced(null);
return apply.onRemove();
}
const r = apply.onApply();
onReplaced(r && r.replacedKm.length > 0 ? `Remplace l’alternative du km ${r.replacedKm.map((k) => fmt(k, 1)).join(', ')}` : null);
};
return (
<button type="button" className={`detour-apply${fresh ? ' fresh' : ''}`} onMouseLeave={() => setFresh(false)} aria-pressed={apply.applied} aria-label={label} title={label} disabled={blocked} onClick={click}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{apply.applied ? (
<>
<path className="ic-on" d="M3.5 8.5l3 3 6-7" />
<path className="ic-off" d="M4.5 4.5l7 7M11.5 4.5l-7 7" />
</>
) : (
<path d="M8 3v10M3 8h10" />
)}
</svg>
</button>
);
}
Tokens : --panel --text --muted --detour --detour-ink --accent
Utilisation
import DetourBlock from './components/DetourBlock';
<DetourBlock r={detours[key]} d={base.d} shown={isSelected} apply={applyFor(found, alert)} />Exemples
Alternative moins dure, qui évite aussi un autre passage
// Titre : Alternative moins dure, qui évite aussi un autre passage
import DetourBlock from '@pathou/components/DetourBlock';
import type { DetourFound } from '@pathou/lib/detour/result';
const d = Array.from({ length: 400 }, (_, i) => i * 100);
const found: DetourFound = {
status: 'moins-dur',
path: [],
ele: [],
leaveIdx: 210,
rejoinIdx: 236,
extraM: 1400,
extraGain: 85,
mainClass: 'piste_cassante',
hardLeftM: 180,
worst: 'rouge',
approxRelief: false,
alsoAvoids: [24.1],
};
export default function Exemple() {
return (
<div style={{ width: 340, ['--sev' as string]: 'var(--inadapte)' }}>
<DetourBlock r={found} d={d} shown={false} />
</div>
);
}
Recherche en cours
// Titre : Recherche en cours
import DetourBlock from '@pathou/components/DetourBlock';
export default function Exemple() {
return (
<div style={{ width: 340 }}>
<DetourBlock r={{ status: 'loading' }} d={[]} shown={false} />
</div>
);
}
Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | En-tête | Le pictogramme, le type d’alternative et la portion contournée (du km … au km …). |
| 2 | Appliquer | Bouton rond à bascule (aria-pressed), nommé selon l’action ; son clic ne sélectionne pas la carte du point d’attention. |
| 3 | Bilan | Écart de distance, écart de D+, type de voie principal et, pour « moins dure », la cotation restante. |
États
statusRésultat de la recherche d’alternative (r.status).appliedL’alternative est appliquée à la trace ; le bouton devient une coche, puis une croix au survol pour la retirer.Guidelines
- MUSTNe rien afficher quand il n’y a pas d’alternative ; la carte du passage dit déjà la distance à pied.
- MUSTDire pourquoi une alternative ne peut pas s’appliquer (elle démarre sur une alternative déjà appliquée) au lieu de seulement désactiver le bouton.
- SHOULDSignaler un relief approximatif (altitude précise indisponible) ; la vérification est alors moins fiable.
Référence de l’API
DetourBlock (export par défaut)
| Prop | Type | Défaut |
|---|---|---|
r | DetourResult | requis |
d | number[] | requis |
shown | boolean | requis |
apply | DetourApply | — |
WalkIcon
Aucune prop.