Profile

←→

Profil d’altitude de la trace (uPlot), coloré par la difficulté, la pente ou le type de voie ; le survol suit la carte et une sélection cadre une portion.

stableÀ valider
// Titre : Profil coloré par la difficulté
import { useState } from 'react';
import Profile from '@pathou/components/Profile';
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({ mode = 'difficulte' }: { mode?: ColorMode }) {
  const [cursor, setCursor] = useState<number | null>(null);
  const [focus, setFocus] = useState<Focus | null>(null);
  return (
    <div style={{ width: 720, height: 220 }}>
      <Profile
        base={base}
        rider={rider}
        mode={mode}
        cursorIdx={cursor}
        focus={focus}
        onHover={setCursor}
        onSelect={(start, end) => setFocus({ start, end, key: Date.now() })}
        onReset={() => setFocus(null)}
        onPick={setCursor}
      />
    </div>
  );
}

Installation

Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.

src/components/Profile.tsx
import { useEffect, useMemo, useRef } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import type { BaseAnalysis, RiderAnalysis } from '../lib/analyze';
import { WAY_COLOR, type ColorMode } from '../lib/colors';
import { isWholeTrack, sampleColors, type Focus } from '../lib/display';
import { FONT_STACK } from '../lib/fonts';

interface Props {
  base: BaseAnalysis;
  rider: RiderAnalysis;
  mode: ColorMode;
  cursorIdx: number | null;
  focus: Focus | null;
  onHover: (idx: number | null) => void;
  onSelect: (start: number, end: number) => void;
  onReset: () => void;
  /** clic simple (sans glisser) sur le profil */
  onPick: (idx: number) => void;
  /** repères posés sur le profil (point culminant, pente max), cliquables */
  marks?: ProfileMark[];
  /** clic sur un repère : cadrer sa portion sur la carte */
  onMark?: (start: number, end: number, label: string) => void;
}

/** Chiffre posé sur le profil, au-dessus d'un point (sommet) ou d'une portion (pente), qui cadre sa portion au clic. */
export interface ProfileMark {
  key: string;
  kind: 'point' | 'range';
  /** point d'ancrage : sommet, ou milieu de la portion */
  idx: number;
  /** portion cadrée au clic, et bornes de l'accolade pour une pente */
  start: number;
  end: number;
  value: string;
  /** précision, cachée sur un profil étroit */
  text: string;
  focusLabel: string;
  title: string;
}

const STRIP = 5;
/** Repères départ / arrivée, mêmes couleurs que sur la carte */
const ENDPOINTS: [string, string][] = [
  ['A', '#16a34a'],
  ['B', '#111827'],
];

/** écart entre la courbe et le bas d'une étiquette ; l'accolade d'une pente se loge dedans */
const MARK_GAP = 12;

export default function Profile({ base, rider, mode, cursorIdx, focus, onHover, onSelect, onReset, onPick, marks = [], onMark }: Props) {
  const wrap = useRef<HTMLDivElement>(null);
  const markEls = useRef<(HTMLButtonElement | null)[]>([]);
  const marksRef = useRef(marks);
  marksRef.current = marks;
  const plotRef = useRef<uPlot | null>(null);
  const colorsRef = useRef<string[]>([]);
  const cb = useRef({ onHover, onSelect, onReset, onPick, rider, focus });
  cb.current = { onHover, onSelect, onReset, onPick, rider, focus };
  const internalCursor = useRef(false);

  // une couleur par point (7 000 sur 70 km) : recalculée seulement quand elles changent, pas à chaque mouvement du curseur
  colorsRef.current = useMemo(() => sampleColors(base, rider, mode), [base, rider, mode]);

  useEffect(() => {
    const el = wrap.current!;
    const km = base.d.map((m) => m / 1000);
    // Échelle verticale ajustée à la portion visible, avec un écart minimal pour ne pas exagérer le moindre replat.
    const yRange = (_u: uPlot, lo: number, hi: number): [number, number] => {
      const span = Math.max(80, hi - lo);
      const mid = (lo + hi) / 2;
      const a = Math.min(lo, mid - span / 2) - span * 0.08;
      const b = Math.max(hi, mid + span / 2) + span * 0.15;
      const step = span > 600 ? 50 : 10;
      return [Math.floor(a / step) * step, Math.ceil(b / step) * step];
    };

    // le canvas ne comprend pas var(--x) : on lit la couleur du thème à chaque dessin
    const css = (name: string) => () => getComputedStyle(el).getPropertyValue(name).trim();

    const drawFill = (u: uPlot) => {
      const ctx = u.ctx;
      const { left, top, width, height } = u.bbox;
      const colors = colorsRef.current;
      const [i0, i1] = [u.series[0].idxs![0], u.series[0].idxs![1]];
      const bottom = top + height;
      ctx.save();
      ctx.beginPath();
      ctx.rect(left, top, width, height);
      ctx.clip();
      // Remplissage pastel colonne par colonne selon la couleur du mode, puis ligne de crête colorée (façon Komoot)
      const pxPerIdx = width / Math.max(1, i1 - i0);
      const stride = Math.max(1, Math.floor(1 / pxPerIdx));
      ctx.globalAlpha = 0.3;
      for (let i = i0; i < i1; i += stride) {
        const x0 = u.valToPos(km[i], 'x', true);
        const x1 = u.valToPos(km[Math.min(i1, i + stride)], 'x', true);
        const y = u.valToPos(base.ele[i], 'y', true);
        ctx.fillStyle = colors[i];
        ctx.fillRect(x0, y, Math.max(1, x1 - x0 + 0.5), bottom - y - STRIP * devicePixelRatio);
      }
      ctx.globalAlpha = 1;
      ctx.lineWidth = 2.5 * devicePixelRatio;
      ctx.lineJoin = 'round';
      ctx.lineCap = 'round';
      for (let i = i0; i < i1; ) {
        // un tracé par série de points de même couleur
        const c = colors[i];
        ctx.strokeStyle = c;
        ctx.beginPath();
        ctx.moveTo(u.valToPos(km[i], 'x', true), u.valToPos(base.ele[i], 'y', true));
        let j = i;
        while (j < i1 && (colors[j] === c || j === i)) {
          j = Math.min(i1, j + stride);
          ctx.lineTo(u.valToPos(km[j], 'x', true), u.valToPos(base.ele[j], 'y', true));
        }
        ctx.stroke();
        i = j;
      }
      // Bande « nature de la voie » en bas
      ctx.globalAlpha = 1;
      for (let i = i0; i < i1; i += stride) {
        const x0 = u.valToPos(km[i], 'x', true);
        const x1 = u.valToPos(km[Math.min(i1, i + stride)], 'x', true);
        ctx.fillStyle = WAY_COLOR[base.ways[i].cls];
        ctx.fillRect(x0, bottom - STRIP * devicePixelRatio, Math.max(1, x1 - x0 + 0.5), STRIP * devicePixelRatio);
      }
      // Section sélectionnée : on voile le reste du profil et on borne la section
      const f = cb.current.focus;
      if (f && !isWholeTrack(base, f)) {
        const xa = u.valToPos(km[f.start], 'x', true);
        const xb = u.valToPos(km[f.end], 'x', true);
        ctx.fillStyle = getComputedStyle(u.root).getPropertyValue('--panel').trim() || '#ffffff';
        ctx.globalAlpha = 0.7;
        ctx.fillRect(left, top, Math.max(0, xa - left), height);
        ctx.fillRect(xb, top, Math.max(0, left + width - xb), height);
        ctx.globalAlpha = 1;
        ctx.strokeStyle = css('--text')();
        ctx.lineWidth = 1.5 * devicePixelRatio;
        ctx.beginPath();
        ctx.moveTo(xa, top);
        ctx.lineTo(xa, top + height);
        ctx.moveTo(xb, top);
        ctx.lineTo(xb, top + height);
        ctx.stroke();
      }
      // Repères des alertes
      for (const a of cb.current.rider.alerts) {
        if (a.idx < i0 || a.idx > i1) continue;
        const x = u.valToPos(km[a.idx], 'x', true);
        ctx.fillStyle = a.severity === 'bloquant' ? '#c026d3' : a.severity === 'attention' ? '#dc2626' : '#64748b';
        ctx.beginPath();
        ctx.moveTo(x, top + 12 * devicePixelRatio);
        ctx.lineTo(x - 5 * devicePixelRatio, top + 2 * devicePixelRatio);
        ctx.lineTo(x + 5 * devicePixelRatio, top + 2 * devicePixelRatio);
        ctx.fill();
      }
      ctx.restore();
      // Départ (A) et arrivée (B) au-dessus du profil
      const dpr = devicePixelRatio;
      ctx.save();
      ctx.font = `600 ${10 * dpr}px ${FONT_STACK}`;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      [0, km.length - 1].forEach((i, k) => {
        if (i < i0 || i > i1) return;
        const x = Math.min(left + width - 9 * dpr, Math.max(left + 9 * dpr, u.valToPos(km[i], 'x', true)));
        const y = top - 13 * dpr;
        ctx.beginPath();
        ctx.arc(x, y, 9 * dpr, 0, Math.PI * 2);
        ctx.fillStyle = ENDPOINTS[k][1];
        ctx.fill();
        ctx.lineWidth = 1.5 * dpr;
        ctx.strokeStyle = '#ffffff';
        ctx.stroke();
        ctx.fillStyle = '#ffffff';
        ctx.fillText(ENDPOINTS[k][0], x, y + 0.5 * dpr);
      });
      ctx.restore();
      placeMarks(u, ctx);
    };

    /**
     * Étiquettes des repères (boutons posés sur le canvas) : au-dessus de la courbe, sans sortir du profil ni se
     * chevaucher, masquées hors de la portion visible. Le trait qui les relie à la courbe est dessiné ici.
     */
    const placeMarks = (u: uPlot, ctx: CanvasRenderingContext2D) => {
      const dpr = devicePixelRatio;
      const left = u.bbox.left / dpr;
      const top = u.bbox.top / dpr;
      const width = u.bbox.width / dpr;
      const [xmin, xmax] = [u.scales.x.min ?? 0, u.scales.x.max ?? km[km.length - 1]];
      const placed: { x: number; y: number; w: number; h: number }[] = [];
      const ink = css('--text')();
      marksRef.current.forEach((m, k) => {
        const el = markEls.current[k];
        if (!el) return;
        if (km[m.idx] < xmin || km[m.idx] > xmax) {
          el.style.visibility = 'hidden';
          return;
        }
        let peak = m.idx;
        if (m.kind === 'range') for (let i = m.start; i <= m.end; i++) if (base.ele[i] > base.ele[peak]) peak = i;
        const ax = u.valToPos(km[m.idx], 'x');
        const ay = u.valToPos(base.ele[peak], 'y');
        const w = el.offsetWidth;
        const h = el.offsetHeight;
        let x = Math.min(left + width - w, Math.max(left, left + ax - w / 2));
        // la ligne des départ/arrivée (au-dessus du profil) accueille l'étiquette d'un sommet tout en haut
        const y = Math.max(2, top + ay - MARK_GAP - h);
        // chevauchement avec une étiquette déjà posée : on la décale du côté où est son ancre
        for (const p of placed) {
          if (y >= p.y + p.h || y + h <= p.y || x >= p.x + p.w + 6 || x + w + 6 <= p.x) continue;
          x = left + ax < p.x + p.w / 2 ? p.x - w - 6 : p.x + p.w + 6;
        }
        if (x < left - 1 || x + w > left + width + 1) {
          el.style.visibility = 'hidden';
          return;
        }
        placed.push({ x, y, w, h });
        el.style.visibility = 'visible';
        el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
        ctx.save();
        ctx.strokeStyle = ink;
        ctx.globalAlpha = 0.6;
        ctx.lineWidth = 1.25 * dpr;
        if (m.kind === 'point') {
          // pointillé du bas de l'étiquette jusqu'au sommet
          ctx.setLineDash([2 * dpr, 2 * dpr]);
          ctx.beginPath();
          ctx.moveTo((left + ax) * dpr, (y + h) * dpr);
          ctx.lineTo((left + ax) * dpr, (top + ay - 3) * dpr);
          ctx.stroke();
        } else {
          // accolade au-dessus de la portion, reliée à l'étiquette
          const xa = (left + u.valToPos(km[m.start], 'x')) * dpr;
          const xb = (left + u.valToPos(km[m.end], 'x')) * dpr;
          const by = (top + ay - 5) * dpr;
          ctx.beginPath();
          ctx.moveTo(xa, by + 4 * dpr);
          ctx.lineTo(xa, by);
          ctx.lineTo(xb, by);
          ctx.lineTo(xb, by + 4 * dpr);
          ctx.moveTo((left + ax) * dpr, by);
          ctx.lineTo((left + ax) * dpr, (y + h) * dpr);
          ctx.stroke();
        }
        ctx.restore();
      });
    };

    const opts: uPlot.Options = {
      width: el.clientWidth,
      height: el.clientHeight,
      padding: [28, 14, 0, 0],
      legend: { show: false },
      cursor: {
        drag: { x: true, y: false, setScale: false },
        points: { size: 9, fill: css('--panel'), stroke: css('--text'), width: 2 },
        y: false,
      },
      select: { show: true, left: 0, top: 0, width: 0, height: 0 },
      scales: { x: { time: false }, y: { range: yRange } },
      axes: [
        { stroke: css('--muted'), grid: { stroke: css('--grid'), width: 1, dash: [4, 4] }, ticks: { show: false }, values: (_u, v) => v.map((x) => `${x.toLocaleString('fr-FR')} km`), font: `11px ${FONT_STACK}`, size: 28 },
        { stroke: css('--muted'), grid: { stroke: css('--grid'), width: 1, dash: [4, 4] }, ticks: { show: false }, values: (_u, v) => v.map((x) => `${x.toLocaleString('fr-FR')} m`), font: `11px ${FONT_STACK}`, size: 54 },
      ],
      series: [{}, { stroke: 'transparent', width: 0, points: { show: false } }],
      hooks: {
        drawClear: [drawFill],
        setCursor: [
          (u) => {
            if (internalCursor.current) return;
            cb.current.onHover(u.cursor.idx ?? null);
          },
        ],
        setSelect: [
          (u) => {
            if (u.select.width < 4) return;
            const a = u.posToIdx(u.select.left);
            const b = u.posToIdx(u.select.left + u.select.width);
            u.setScale('x', { min: km[a], max: km[b] });
            u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false);
            cb.current.onSelect(a, b);
          },
        ],
      },
    };
    const u = new uPlot(opts, [km, base.ele], el);
    plotRef.current = u;
    if (import.meta.env.DEV) (window as unknown as { __plot: uPlot }).__plot = u;
    u.over.addEventListener('mouseleave', () => cb.current.onHover(null));
    // Clic simple : aller à ce point sur la carte. On attend un peu pour ne pas le confondre
    // avec un double-clic (retour à la vue complète) ni avec un glisser (zoom sur un passage).
    let downX = 0;
    let pickTimer: ReturnType<typeof setTimeout> | undefined;
    u.over.addEventListener('mousedown', (e) => (downX = e.clientX));
    u.over.addEventListener('click', (e) => {
      if (Math.abs(e.clientX - downX) > 3) return;
      const idx = u.posToIdx(e.offsetX);
      clearTimeout(pickTimer);
      pickTimer = setTimeout(() => cb.current.onPick(idx), 220);
    });
    u.over.addEventListener('dblclick', () => {
      clearTimeout(pickTimer);
      u.setScale('x', { min: km[0], max: km[km.length - 1] });
      cb.current.onReset();
    });
    const ro = new ResizeObserver(() => u.setSize({ width: el.clientWidth, height: el.clientHeight }));
    ro.observe(el);
    return () => {
      clearTimeout(pickTimer);
      ro.disconnect();
      u.destroy();
      plotRef.current = null;
    };
  }, [base]);

  useEffect(() => {
    plotRef.current?.redraw(false);
  }, [rider, mode]);

  // thème clair ou sombre changé pendant l'affichage : le canvas relit ses couleurs
  useEffect(() => {
    const mq = window.matchMedia('(prefers-color-scheme: dark)');
    const redraw = () => plotRef.current?.redraw(false);
    mq.addEventListener('change', redraw);
    return () => mq.removeEventListener('change', redraw);
  }, []);

  // Zoom synchronisé avec le passage cadré sur la carte
  useEffect(() => {
    const u = plotRef.current;
    if (!u) return;
    if (!focus) {
      u.setScale('x', { min: 0, max: base.distance / 1000 });
      return;
    }
    const whole = focus.start === 0 && focus.end === base.d.length - 1;
    const margin = whole ? 0 : Math.max(300, (base.d[focus.end] - base.d[focus.start]) * 0.4);
    u.setScale('x', {
      min: Math.max(0, base.d[focus.start] - margin) / 1000,
      max: Math.min(base.distance, base.d[focus.end] + margin) / 1000,
    });
  }, [focus, base]);

  // Curseur piloté depuis la carte
  useEffect(() => {
    const u = plotRef.current;
    if (!u) return;
    if (u.cursor.idx === cursorIdx) return;
    internalCursor.current = true;
    if (cursorIdx === null) u.setCursor({ left: -10, top: -10 }, false);
    else {
      const left = u.valToPos(base.d[cursorIdx] / 1000, 'x');
      const top = u.valToPos(base.ele[cursorIdx], 'y');
      u.setCursor({ left, top }, false);
    }
    internalCursor.current = false;
  }, [cursorIdx, base]);

  const isFocused = (m: ProfileMark) => focus?.start === m.start && focus?.end === m.end;
  return (
    <div className="profile">
      <div ref={wrap} className="profile-plot" />
      {marks.map((m, k) => (
        <button
          key={m.key}
          ref={(el) => {
            markEls.current[k] = el;
          }}
          type="button"
          className={`profile-mark${isFocused(m) ? ' selected' : ''}`}
          style={{ visibility: 'hidden' }}
          title={m.title}
          aria-label={`${m.value.replace('\u00a0', ' ')}, ${m.text}`}
          aria-pressed={isFocused(m)}
          onClick={() => onMark?.(m.start, m.end, m.focusLabel)}
        >
          <b>{m.value}</b>
          <span className="profile-mark-text"> {m.text}</span>
        </button>
      ))}
    </div>
  );
}

Tokens : --panel --text --muted --line --grid --accent

Utilisation

import Profile from './components/Profile';

<Profile base={base} rider={rider} mode={mode} cursorIdx={cursor} focus={focus} onHover={setCursor} onSelect={select} onReset={reset} onPick={pick} />

Exemples

Profil coloré par la pente

// Titre : Profil coloré par la pente
import Exemple from './default';

export default function Pente() {
  return <Exemple mode="pente" />;
}

Anatomie

#ÉlémentRôle
1CourbeL’altitude le long de la distance, sur la grille --grid.
2CouleursBandes sous la courbe selon le mode.
3RepèresÉtiquettes posées sur la courbe (marks), cliquables pour cadrer le passage sur la carte.

Guidelines

  • MUST
    Garder le curseur du profil et celui de la carte sur le même point.
  • SHOULD
    Accompagner chaque mode d’une légende en mots ; la couleur seule ne suffit pas.

Référence de l’API

PropTypeDéfaut
baseBaseAnalysisrequis
riderRiderAnalysisrequis
modeColorModerequis
cursorIdxnumber | nullrequis
focusFocus | nullrequis
onHover(idx: number | null) => voidrequis
onSelect(start: number, end: number) => voidrequis
onReset() => voidrequis
onPick
clic simple (sans glisser) sur le profil
(idx: number) => voidrequis
marks
repères posés sur le profil (point culminant, pente max), cliquables
ProfileMark[][]
onMark
clic sur un repère : cadrer sa portion sur la carte
(start: number, end: number, label: string) => void—