Dock flottant en bas de la carte — distance, dénivelé et durée estimée, puis le profil repliable où le point culminant et la pente maximale sont posés là où ils se trouvent.
stableÀ valider
// Titre : Dock du parcours, Cévennes J1
import { useState } from 'react';
import RouteDock from '@pathou/components/RouteDock';
import type { ColorMode } from '@pathou/lib/colors';
import type { Focus } from '@pathou/lib/display';
import { base, riderOf } from '../_fixtures/base';
const rider = riderOf('vtt', 'confirme');
export default function Exemple() {
const [mode, setMode] = useState<ColorMode>('difficulte');
const [speed, setSpeed] = useState<number | null>(null);
const [cursor, setCursor] = useState<number | null>(null);
const [focus, setFocus] = useState<Focus | null>(null);
return (
<div style={{ width: 760 }}>
<RouteDock
base={base}
rider={rider}
mode={mode}
onMode={setMode}
avgSpeed={speed}
onAvgSpeed={setSpeed}
cursorIdx={cursor}
focus={focus}
onHover={setCursor}
onSelect={(start, end) => setFocus({ start, end, key: Date.now() })}
onReset={() => setFocus(null)}
onPick={setCursor}
onFocus={(start, end, label) => setFocus({ start, end, label, key: Date.now() })}
onHeight={() => {}}
onToggle={() => {}}
/>
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { BaseAnalysis, RiderAnalysis } from '../lib/analyze';
import { RATING_COLOR, WAY_COLOR, type ColorMode } from '../lib/colors';
import type { Focus } from '../lib/display';
import { RATINGS, RATING_LABEL } from '../lib/difficulty/rules';
import { WAY_CLASS_LABEL } from '../lib/osm/classify';
import { fmt } from '../lib/format';
import { formatHours } from '../lib/time';
import type { WayClass } from '../lib/types';
import Profile, { type ProfileMark } from './Profile';
import Segmented from './Segmented';
import { GradeLegend } from './Sidebar';
interface Props {
base: BaseAnalysis;
rider: RiderAnalysis;
mode: ColorMode;
onMode: (m: ColorMode) => void;
/** vitesse moyenne saisie (km/h), null = estimation du modèle */
avgSpeed: number | null;
onAvgSpeed: (v: number | null) => void;
cursorIdx: number | null;
focus: Focus | null;
onHover: (idx: number | null) => void;
onSelect: (start: number, end: number) => void;
onReset: () => void;
onPick: (idx: number) => void;
/** met en avant une portion de trace sur la carte et le profil */
onFocus: (start: number, end: number, label?: string) => void;
/** hauteur occupée par le panneau, pour dégager le bas de la carte */
onHeight: (px: number) => void;
/** ouverture ou repli lancé : hauteur que le panneau aura une fois l'animation finie */
onToggle: (px: number) => void;
}
const MODES: [ColorMode, string][] = [
['pente', 'Pente'],
['difficulte', 'Difficulté'],
['surface', 'Type de voie'],
];
const MIN_SPEED = 2;
const MAX_SPEED = 60;
/**
* Panneau flottant en bas de la carte : les trois chiffres du parcours, puis le profil, où le point culminant et la
* pente max sont posés là où ils se trouvent. Le verdict (difficulté, poussage) est dans le panneau de droite.
*/
export default function RouteDock(props: Props) {
const { base, rider, mode, onMode, avgSpeed, onAvgSpeed, onHeight, onToggle, onFocus } = props;
const [collapsed, setCollapsed] = useState(false);
// Le profil reste monté le temps du repli, puis il est retiré : il se redessine à chaque mouvement du curseur.
const [plotMounted, setPlotMounted] = useState(true);
const [animating, setAnimating] = useState(false);
const animatingRef = useRef(false);
const ref = useRef<HTMLElement>(null);
const more = useRef<HTMLDivElement>(null);
// Pendant l'animation, la hauteur n'est pas suivie image par image : la carte se recadrerait et
// les commandes flottantes bougeraient à chaque frame.
useLayoutEffect(() => {
const el = ref.current!;
const ro = new ResizeObserver(() => {
if (!animatingRef.current) onHeight(el.offsetHeight);
});
ro.observe(el);
return () => ro.disconnect();
}, [onHeight]);
// La carte se recadre dès le départ, vers la hauteur finale, et les commandes flottantes y glissent avec
// la même durée et la même courbe que le panneau (styles.css) : elles restent collées à son bord haut.
useLayoutEffect(() => {
if (!animatingRef.current) return;
const inner = more.current!.firstElementChild as HTMLElement | null;
const rest = ref.current!.offsetHeight - (inner?.offsetHeight ?? 0);
const target = collapsed ? rest : rest + inner!.scrollHeight;
onToggle(target);
onHeight(target);
}, [collapsed, onHeight, onToggle]);
const finish = () => {
if (!animatingRef.current) return;
animatingRef.current = false;
setAnimating(false);
if (collapsed) setPlotMounted(false);
onHeight(ref.current!.offsetHeight);
};
// filet de sécurité : sans animation de hauteur (mouvements réduits), transitionend ne vient pas
useEffect(() => {
if (!animating) return;
const t = setTimeout(finish, 400);
return () => clearTimeout(t);
// le panneau se redessine à chaque mouvement du curseur : sans ces dépendances, le délai repartirait de zéro
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animating, collapsed]);
const marks = useMemo<ProfileMark[]>(() => {
const last = base.d.length - 1;
const s500 = base.sustained.find((x) => x.m === 500)!;
const s1k = base.sustained.find((x) => x.m === 1000)!;
const out: ProfileMark[] = [
{
key: 'sommet',
kind: 'point',
idx: base.maxEleIdx,
start: Math.max(0, base.maxEleIdx - 60),
end: Math.min(last, base.maxEleIdx + 60),
value: `${fmt(base.maxEle)}\u00a0m`,
text: 'point culminant',
focusLabel: 'Point culminant',
title: 'Voir le point culminant sur la carte',
},
];
if (s500.grade > 0 && s500.endIdx > s500.startIdx)
out.push({
key: 'pente',
kind: 'range',
idx: Math.round((s500.startIdx + s500.endIdx) / 2),
start: s500.startIdx,
end: s500.endIdx,
value: `${fmt(s500.grade, 1)}\u00a0%`,
text: 'pente max 500 m',
focusLabel: 'Pente max sur 500 m',
title: `Voir ce passage sur la carte · ${fmt(s1k.grade, 1)}\u00a0% au plus sur 1 km`,
});
return out;
}, [base]);
const toggle = () => {
animatingRef.current = true;
setAnimating(true);
if (collapsed) setPlotMounted(true);
setCollapsed(!collapsed);
};
return (
<section ref={ref} className={`route-dock${collapsed ? ' collapsed' : ''}${animating ? ' animating' : ''}`} aria-label="Profil et chiffres clés" data-tour="dock">
<button className="dock-tab" onClick={toggle} aria-expanded={!collapsed} title={collapsed ? 'Afficher le profil' : 'Réduire le profil'}>
<Chevron />
</button>
<DockHead base={base} rider={rider} avgSpeed={avgSpeed} onAvgSpeed={onAvgSpeed} />
<div
ref={more}
className="dock-more"
inert={collapsed}
onTransitionEnd={(e) => {
if (e.target === e.currentTarget && e.propertyName === 'grid-template-rows') finish();
}}
>
{plotMounted && (
<div>
<div className="dock-plot" title="Survoler pour suivre la trace · clic pour y aller · glisser pour zoomer · double-clic pour tout revoir">
<Profile
base={base}
rider={rider}
mode={mode}
cursorIdx={props.cursorIdx}
focus={props.focus}
onHover={props.onHover}
onSelect={props.onSelect}
onReset={props.onReset}
onPick={props.onPick}
marks={marks}
onMark={onFocus}
/>
</div>
<div className="dock-foot">
<Segmented label="Couleurs du profil et de la trace" value={mode} options={MODES} onChange={onMode} />
<ModeLegend mode={mode} />
</div>
</div>
)}
</div>
</section>
);
}
type HeadProps = Pick<Props, 'base' | 'rider' | 'avgSpeed' | 'onAvgSpeed'>;
/**
* Les trois chiffres du parcours. Mémorisés : le panneau se redessine à chaque mouvement du curseur
* (il le transmet au profil), et ces chiffres n'en dépendent pas.
* Sur un panneau étroit, la descente passe sous la montée et « En roulant » disparaît (styles.css).
*/
const DockHead = memo(function DockHead({ base, rider, avgSpeed, onAvgSpeed }: HeadProps) {
const total = base.distance / 1000;
const hours = avgSpeed ? total / avgSpeed : rider.hours;
const modelSpeed = total / rider.hours;
const perKm = base.gain / total;
const relief = perKm > 25 ? 'très vallonné' : perKm > 15 ? 'vallonné' : 'roulant';
let hh = Math.floor(hours);
let mm = Math.round((hours - hh) * 60);
if (mm === 60) [hh, mm] = [hh + 1, 0];
const timeInfo = avgSpeed
? `À ${fmt(avgSpeed, 1)} km/h, en roulant, hors pauses. Estimation du modèle\u00a0: ${formatHours(rider.hours)}.`
: 'En roulant, hors pauses. Tient compte du relief, du type de voie et de ton niveau.';
return (
<div className="dock-head">
<div className="dock-figure">
<span className="dock-value">
<b>{fmt(total, 1)}</b>
<small>km</small>
</span>
<span className="dock-label">Distance</span>
</div>
<div className="dock-figure">
<span className="dock-value">
<Slope dir="up" />
<span className="sr-only">Montée</span>
<b>{fmt(base.gain)}</b>
<small>m</small>
<span className="dock-loss">
<Slope dir="down" />
<span className="sr-only">Descente</span>
<b>{fmt(base.loss)}</b>
<small>m</small>
</span>
</span>
{/* une seule boîte : les espaces du libellé flexible s'intercaleraient avant la virgule */}
<span className="dock-label">
<span>
<span className="dock-loss-alt">
<Slope dir="down" />
<span className="sr-only">Descente</span>
{fmt(base.loss)} m ·
</span>{' '}
<span className="dock-wide">Dénivelé · </span>
{fmt(perKm)} m/km<span className="dock-wide">, {relief}</span>
</span>
</span>
</div>
<div className="dock-figure">
<span className="dock-value" title={timeInfo}>
<b>{hh}</b>
<small>h</small>
<b>{String(mm).padStart(2, '0')}</b>
</span>
<SpeedLabel value={avgSpeed} model={modelSpeed} onChange={onAvgSpeed} />
</div>
</div>
);
});
function Slope({ dir }: { dir: 'up' | 'down' }) {
return (
<svg className="dock-slope" viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={dir === 'up' ? 'M4 12L12 4M6 4h6v6' : 'M4 4l8 8M12 6v6H6'} />
</svg>
);
}
/** Vitesse moyenne, dans la ligne du temps : estimation du modèle, ou valeur saisie par l'utilisateur. */
function SpeedLabel({ value, model, onChange }: { value: number | null; model: number; onChange: (v: number | null) => void }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) input.current?.select();
}, [editing]);
const parsed = parseFloat(draft.replace(',', '.'));
const valid = draft.trim() === '' || (Number.isFinite(parsed) && parsed >= MIN_SPEED && parsed <= MAX_SPEED);
const commit = () => {
if (!valid) return;
onChange(draft.trim() === '' ? null : Math.round(parsed * 10) / 10);
setEditing(false);
};
if (editing)
return (
<span className="dock-label">
<span className={`dock-speed editing${valid ? '' : ' invalid'}`}>
<input
ref={input}
value={draft}
inputMode="decimal"
autoComplete="off"
aria-label="Ta vitesse moyenne en km/h (vide = estimation)"
placeholder={fmt(model, 1)}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setEditing(false);
}}
onBlur={() => (valid ? commit() : setEditing(false))}
/>
km/h
</span>
{!valid && (
<span className="dock-error">
entre {MIN_SPEED} et {MAX_SPEED}
</span>
)}
</span>
);
return (
<span className="dock-label">
<span className="dock-wide">En roulant</span> à
<button
className="dock-speed"
onClick={() => {
setDraft(value === null ? '' : fmt(value, 1));
setEditing(true);
}}
title={value === null ? 'Vitesse estimée : saisir ta vitesse moyenne' : 'Ta vitesse moyenne : la modifier'}
>
{fmt(value ?? model, 1)} km/h
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden="true">
<path d="M11 2.5l2.5 2.5L6 12.5H3.5V10z" />
</svg>
</button>
{value !== null && (
<button className="link dock-speed-reset" onClick={() => onChange(null)} title="Revenir à la vitesse estimée">
estimée
</button>
)}
</span>
);
}
function ModeLegend({ mode }: { mode: ColorMode }) {
if (mode === 'pente') return <GradeLegend />;
const entries: [string, string][] =
mode === 'surface'
? (Object.keys(WAY_COLOR) as WayClass[]).map((c) => [WAY_COLOR[c], WAY_CLASS_LABEL[c]])
: RATINGS.map((r) => [RATING_COLOR[r], RATING_LABEL[r]]);
return (
<div className="legend-inline">
{entries.map(([c, l]) => (
<span key={l}>
<i style={{ background: c }} />
{l}
</span>
))}
</div>
);
}
function Chevron() {
return (
<svg viewBox="0 0 20 20" width="20" height="20" 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>
);
}
Tokens : --panel --text --muted --detour-ink --line --grid --accent --chip --ok --engage --exigeant --inadapte --ease-out
Utilisation
import RouteDock from './components/RouteDock';
<RouteDock base={base} rider={rider} mode={mode} onMode={setMode} avgSpeed={speed} onAvgSpeed={setSpeed} cursorIdx={cursor} focus={focus} … />Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | Chiffres du parcours | Distance, montée et descente, durée en roulant avec la vitesse moyenne modifiable. |
| 2 | Profil | Le profil d’altitude coloré selon le mode, avec ses repères (point culminant, pente max sur 500 m). |
| 3 | Mode de couleur | Un Segmented « Pente, Difficulté, Type de voie » et la légende du mode choisi. |
| 4 | Onglet | Replie ou déplie le profil. |
États
collapsedProfil replié par l’onglet du haut ; seuls les trois chiffres restent.Guidelines
- SHOULDGarder trois chiffres seulement dans le dock ; le verdict et ses raisons vivent dans le panneau de droite.
- SHOULDPoser les repères du profil à leur place réelle sur la courbe plutôt que dans une liste à part.
Clavier
| Touche | Effet |
|---|---|
| Entrée | Valide la vitesse moyenne saisie. |
| Échap | Annule la saisie de la vitesse. |
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
base | BaseAnalysis | requis |
rider | RiderAnalysis | requis |
mode | ColorMode | requis |
onMode | (m: ColorMode) => void | requis |
avgSpeedvitesse moyenne saisie (km/h), null = estimation du modèle | number | null | requis |
onAvgSpeed | (v: number | null) => void | requis |
cursorIdx | number | null | requis |
focus | Focus | null | requis |
onHover | (idx: number | null) => void | requis |
onSelect | (start: number, end: number) => void | requis |
onReset | () => void | requis |
onPick | (idx: number) => void | requis |
onFocusmet en avant une portion de trace sur la carte et le profil | (start: number, end: number, label?: string) => void | requis |
onHeighthauteur occupée par le panneau, pour dégager le bas de la carte | (px: number) => void | requis |
onToggleouverture ou repli lancé : hauteur que le panneau aura une fois l'animation finie | (px: number) => void | requis |