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.
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>
);
}
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ément | Rôle |
|---|---|---|
| 1 | Courbe | L’altitude le long de la distance, sur la grille --grid. |
| 2 | Couleurs | Bandes sous la courbe selon le mode. |
| 3 | Repères | Étiquettes posées sur la courbe (marks), cliquables pour cadrer le passage sur la carte. |
Guidelines
- MUSTGarder le curseur du profil et celui de la carte sur le même point.
- SHOULDAccompagner chaque mode d’une légende en mots ; la couleur seule ne suffit pas.
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
base | BaseAnalysis | requis |
rider | RiderAnalysis | requis |
mode | ColorMode | 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 |
onPickclic simple (sans glisser) sur le profil | (idx: number) => void | requis |
marksrepères posés sur le profil (point culminant, pente max), cliquables | ProfileMark[] | [] |
onMarkclic sur un repère : cadrer sa portion sur la carte | (start: number, end: number, label: string) => void | — |