RouteDock

←→

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.

src/components/RouteDock.tsx
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)}&nbsp;m&nbsp;·
            </span>{' '}
            <span className="dock-wide">Dénivelé · </span>
            {fmt(perKm)}&nbsp;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)}&nbsp;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émentRôle
1Chiffres du parcoursDistance, montée et descente, durée en roulant avec la vitesse moyenne modifiable.
2ProfilLe profil d’altitude coloré selon le mode, avec ses repères (point culminant, pente max sur 500 m).
3Mode de couleurUn Segmented « Pente, Difficulté, Type de voie » et la légende du mode choisi.
4OngletReplie ou déplie le profil.

États

collapsedProfil replié par l’onglet du haut ; seuls les trois chiffres restent.

Guidelines

  • SHOULD
    Garder trois chiffres seulement dans le dock ; le verdict et ses raisons vivent dans le panneau de droite.
  • SHOULD
    Poser les repères du profil à leur place réelle sur la courbe plutôt que dans une liste à part.

Clavier

ToucheEffet
EntréeValide la vitesse moyenne saisie.
ÉchapAnnule la saisie de la vitesse.

Référence de l’API

PropTypeDéfaut
baseBaseAnalysisrequis
riderRiderAnalysisrequis
modeColorModerequis
onMode(m: ColorMode) => voidrequis
avgSpeed
vitesse moyenne saisie (km/h), null = estimation du modèle
number | nullrequis
onAvgSpeed(v: number | null) => voidrequis
cursorIdxnumber | nullrequis
focusFocus | nullrequis
onHover(idx: number | null) => voidrequis
onSelect(start: number, end: number) => voidrequis
onReset() => voidrequis
onPick(idx: number) => voidrequis
onFocus
met en avant une portion de trace sur la carte et le profil
(start: number, end: number, label?: string) => voidrequis
onHeight
hauteur occupée par le panneau, pour dégager le bas de la carte
(px: number) => voidrequis
onToggle
ouverture ou repli lancé : hauteur que le panneau aura une fois l'animation finie
(px: number) => voidrequis