Welcome
La page d’accueil — la question « cette trace est-elle faisable ? », les réglages de pratique et de niveau, la démonstration qui y réagit, des exemples de ce que Pathou repère, puis le dépôt du fichier.
stableÀ valider
Écran ou section propre à Pathou, pas un composant du design system : à ne pas réutiliser ailleurs. Tous les écrans et sections
// Titre : Accueil, réglages VTT intermédiaire
import { useState } from 'react';
import Welcome from '@pathou/components/Welcome';
import type { Level, Practice } from '@pathou/lib/types';
// onFile reçoit le GPX déposé ; ici il ne fait rien (et « essayer un exemple » ne trouve pas /demo.gpx sur la doc)
export default function Exemple() {
const [practice, setPractice] = useState<Practice>('vtt');
const [level, setLevel] = useState<Level>('intermediaire');
return (
<div style={{ width: 1100, height: 900 }}>
<Welcome practice={practice} level={level} onPractice={setPractice} onLevel={setLevel} onFile={() => {}} progress={null} error={null} />
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { Suspense, useEffect, useRef, useState, type ComponentType } from 'react';
import topoAvif from '../assets/topo.avif';
import topoWebp from '../assets/topo.webp';
import { LEVELS, LEVEL_LABEL, PRACTICE_LABEL } from '../lib/difficulty/rules';
import type { Level, Practice } from '../lib/types';
import type { CountryCode } from '../lib/sources/types';
import { DEMO_FILE_NAME, track } from '../lib/analytics';
import { withRetry } from '../lib/chunks';
import { TRACK_ACCEPT } from '../lib/gpx/formats';
import HomeDemo from './HomeDemo';
import FootNote from './FootNote';
import { BalconyExample, DetourExample, PassageExample } from './HomeFeatures';
import LevelInfo from './LevelInfo';
import Modal from './Modal';
import { opensModal } from './opensModal';
import { preloadable } from './preloadable';
import Segmented from './Segmented';
/** Fichier séparé qui n'a pas pu se charger, même après les nouveaux essais. */
function Unavailable() {
return <p className="modal-unavailable">Cette partie n’a pas pu se charger (connexion coupée ou nouvelle version en ligne). Recharge la page.</p>;
}
/** Partie de l'accueil chargée à part ; un échec affiche `fallback` au lieu de casser la page. */
const part = <P extends object>(load: () => Promise<{ default: ComponentType<P> }>, fallback: ComponentType<P>) =>
preloadable<P>(() => withRetry(load)().catch(() => ({ default: fallback })));
// Les modales d'information et la section des pays tirent le catalogue des sources d'altitude et les contours des pays
// (plus de la moitié de l'accueil compressé) : chargés à part, préchargés quand le navigateur est libre.
const HomeCountries = part(() => import('./HomeCountries'), () => null);
const CountriesInfo = part(() => import('./CountriesInfo'), Unavailable);
const DataSources = part(() => import('./DataSources'), Unavailable);
const ServiceStatus = part(() => import('./ServiceStatus'), Unavailable);
const Privacy = part(() => import('./Privacy'), Unavailable);
const EngineInfo = part(() => import('./EngineInfo'), ({ onClose }: { onClose: () => void }) => (
<Modal title="Comment Pathou évalue la difficulté" onClose={onClose}>
<Unavailable />
</Modal>
));
const PRACTICES: Practice[] = ['vtt', 'gravel', 'route'];
const LOOKS: [term: string, text: string][] = [
['Nature des chemins', 'Route, piste ou sentier, d’après OpenStreetMap et les bases nationales des chemins (IGN, IGN España).'],
['Technicité S0 à S5', 'La cotation VTT d’OpenStreetMap, ou estimée d’après le type de sentier et l’état du sol.'],
['Montées à pousser', 'Les pentes que ton niveau ne monte pas à vélo, rampes courtes comprises.'],
['Sentiers exposés', 'Les passages en balcon ou au bord du vide, mesurés de part et d’autre du chemin.'],
['Alternatives', 'Un contournement quand un passage est trop dur, ou l’annonce qu’il n’y en a pas.'],
['Photos du terrain', 'Les photos au sol de Panoramax, pour voir le passage avant d’y aller.'],
];
const STEPS: [title: string, text: string][] = [
['Trace ton itinéraire', 'dans Komoot ou ton appli habituelle'],
['Exporte-le en GPX', 'le format standard de toutes les applis, ou en FIT depuis ton compteur'],
['Dépose-le ici', 'Pathou l’analyse en quelques secondes'],
];
interface Props {
practice: Practice;
level: Level;
onPractice: (p: Practice) => void;
onLevel: (l: Level) => void;
onFile: (f: File) => void;
progress: { step: string; ratio?: number } | null;
error: string | null;
}
/** Page d'accueil : la question, entourée d'une démonstration qui réagit aux réglages et d'exemples de ce que Pathou repère, puis le dépôt du GPX. */
export default function Welcome(props: Props) {
const [over, setOver] = useState(false);
const [sources, setSources] = useState(false);
// pays mis en avant à l'ouverture de la modale des pays ; null : fermée
const [countries, setCountries] = useState<{ focus?: CountryCode } | null>(null);
const [engine, setEngine] = useState(false);
const [status, setStatus] = useState(false);
const [privacy, setPrivacy] = useState(false);
const input = useRef<HTMLInputElement>(null);
const busy = props.progress !== null;
const [demoFailed, setDemoFailed] = useState(false);
// même moment que le moteur d'analyse (App) : après le chargement, quand le navigateur est libre
useEffect(() => {
const all = () => [HomeCountries, CountriesInfo, DataSources, ServiceStatus, EngineInfo, Privacy].forEach((c) => void c.preload());
if ('requestIdleCallback' in window) {
const id = requestIdleCallback(all, { timeout: 2000 });
return () => cancelIdleCallback(id);
}
const id = setTimeout(all, 500);
return () => clearTimeout(id);
}, []);
/** Lance l'app sur la trace d'exemple servie avec le site (public/demo.gpx), comme si le visiteur l'avait déposée. */
const tryDemo = async () => {
if (busy) return;
setDemoFailed(false);
track('trace-exemple');
try {
const res = await fetch('/demo.gpx');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
props.onFile(new File([await res.blob()], DEMO_FILE_NAME, { type: 'application/gpx+xml' }));
} catch {
setDemoFailed(true);
}
};
return (
<div className="welcome">
<header className="hp-hero">
{/* décor : priorité basse, pour ne pas retarder la police et le code de la page */}
<picture className="hp-topo" aria-hidden="true">
<source type="image/avif" srcSet={topoAvif} />
<img src={topoWebp} alt="" decoding="async" fetchPriority="low" />
</picture>
<div className="hp-inner">
<div className="hp-bar">
<span className="hp-brand">
Pathou<span className="hp-beta">Bêta</span>
</span>
<nav aria-label="Informations">
<a className="hp-link" href="/pays" onClick={opensModal(() => setCountries({}))}>
Pays couverts
</a>
<a className="hp-link" href="/methode" onClick={opensModal(() => setEngine(true))}>
Comment Pathou évalue la difficulté
</a>
</nav>
</div>
<div className="hp-center">
<h1>
<span className="nw">Analyse ta trace GPX</span> <span className="nw">VTT et gravel</span>
</h1>
<p className="hp-lead">
Charge un fichier GPX : Pathou montre son relief en 3D et dit, passage par passage, ce que ta pratique et ton niveau peuvent en faire.
</p>
<div className="hp-try">
<button type="button" className="secondary" onClick={tryDemo} disabled={busy}>
Essayer avec une trace d’exemple
</button>
<span>{demoFailed ? 'Trace d’exemple indisponible, réessaie dans un instant.' : 'Une étape VTT ou gravel de 62\u00a0km dans les Vosges\u00a0: rien à charger de ton côté.'}</span>
</div>
<div className="hp-feats">
{/* le profil réagit aux réglages ; l'alternative chevauche son coin haut */}
<div className="hp-stage">
<DetourExample />
<HomeDemo practice={props.practice} level={props.level} />
</div>
{/* en miroir à droite : un point d'attention, que le balcon chevauche par son coin bas */}
<div className="hp-stage hp-stage-right">
<PassageExample />
<BalconyExample />
</div>
</div>
</div>
<div className="hp-start" role="group" aria-label="Commencer : pratique, niveau et fichier GPX">
<div className="hp-start-row">
<div>
<div className="field-label">Pratique</div>
<Segmented label="Pratique" value={props.practice} options={PRACTICES.map((p) => [p, PRACTICE_LABEL[p]])} onChange={props.onPractice} />
</div>
<div className="hp-level">
<div className="field-label">Niveau</div>
<div className="level-field">
<Segmented label="Niveau" value={props.level} options={LEVELS.map((l) => [l, LEVEL_LABEL[l]])} onChange={props.onLevel} />
<LevelInfo practice={props.practice} />
</div>
</div>
<div
className={`hp-drop${over ? ' over' : ''}${busy ? ' busy' : ''}`}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const f = e.dataTransfer.files[0];
if (f && !busy) props.onFile(f);
}}
onClick={() => !busy && input.current?.click()}
>
{busy ? (
<div className="progress">
<div>{props.progress!.step}…</div>
{/* loader continu : un serveur lent ou muet ne laisse pas une barre vide */}
<div className="progress-loader" role="progressbar" aria-label={props.progress!.step}>
<span />
</div>
</div>
) : (
<>
<div className="hp-drop-text">
<svg viewBox="0 0 34 34" width="34" height="34" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 27l9-14 6 9 4-5 9 10z" />
<circle cx="26" cy="8" r="2.4" />
</svg>
<span>
<b>Dépose ton fichier GPX ici</b>
<small className="hp-drop-formats">ou FIT, TCX, KML</small>
</span>
</div>
<button className="primary" type="button">
Choisir un fichier
</button>
</>
)}
<input
ref={input}
type="file"
accept={TRACK_ACCEPT}
aria-label="Charger une trace (GPX, FIT, TCX ou KML)"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) props.onFile(f);
e.target.value = '';
}}
/>
</div>
</div>
<p className="hp-priv">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 018 0v3" />
</svg>
Tout se calcule dans ton navigateur : ton fichier ne part nulle part.
</p>
{props.error && <div className="error">{props.error}</div>}
</div>
</div>
</header>
<section className="hp-steps" aria-labelledby="hp-komoot">
<div className="hp-inner hp-split">
<h2 id="hp-komoot">Pathou ne remplace pas Komoot : il pousse l’analyse plus loin.</h2>
<ol className="hp-path">
{STEPS.map(([title, text], i) => (
<li key={title}>
<span className="hp-n" aria-hidden="true">
{i + 1}
</span>
<b>{title}</b>
<span>{text}</span>
</li>
))}
</ol>
</div>
</section>
<section className="hp-sees" aria-labelledby="hp-sees">
<div className="hp-inner">
<h2 id="hp-sees">Ce que Pathou regarde</h2>
<dl className="hp-dl">
{LOOKS.map(([term, text]) => (
<div key={term}>
<dt>{term}</dt>
<dd>{text}</dd>
</div>
))}
</dl>
</div>
</section>
<Suspense fallback={<div className="hp-countries-wait" />}>
<HomeCountries onOpen={(focus) => setCountries({ focus })} />
</Suspense>
<footer className="hp-foot">
<div className="hp-inner hp-foot-row">
<FootNote />
<nav className="hp-links" aria-label="Pied de page">
<a className="hp-link" href="/methode" onClick={opensModal(() => setEngine(true))}>
Comment Pathou évalue la difficulté
</a>
<a className="hp-link" href="/sources" onClick={opensModal(() => setSources(true))}>
Sources des données
</a>
<button className="hp-link" onClick={() => setStatus(true)}>
État des services
</button>
<a className="hp-link" href="/confidentialite" onClick={opensModal(() => setPrivacy(true))}>
Vie privée
</a>
<a className="hp-link" href="/trace-gpx-faisable">
Trace faisable à ton niveau ?
</a>
<a className="hp-link" href="/denivele-trace-gpx">
Dénivelé selon les applis
</a>
<a className="hp-link" href="/fichier-trace-gps">
Formats de fichier
</a>
<a className="hp-link" href="/duree-sortie-velo">
Durée et heure d’arrivée
</a>
<a className="hp-link" href="/a-propos">
À propos et mentions légales
</a>
</nav>
</div>
</footer>
{engine && (
<Suspense fallback={null}>
<EngineInfo onClose={() => setEngine(false)} />
</Suspense>
)}
{countries && (
<Modal title="Où Pathou fonctionne" onClose={() => setCountries(null)}>
<Suspense fallback={<ModalWait />}>
<CountriesInfo
initial={countries.focus}
onShowSources={() => {
setCountries(null);
setSources(true);
}}
/>
</Suspense>
</Modal>
)}
{sources && (
<Modal title="Sources des données" onClose={() => setSources(false)}>
<Suspense fallback={<ModalWait />}>
<DataSources />
</Suspense>
</Modal>
)}
{status && (
<Modal title="État des services" onClose={() => setStatus(false)}>
<Suspense fallback={<ModalWait />}>
<ServiceStatus />
</Suspense>
</Modal>
)}
{privacy && (
<Modal title="Vie privée" onClose={() => setPrivacy(false)}>
<Suspense fallback={<ModalWait />}>
<Privacy
onShowSources={() => {
setPrivacy(false);
setSources(true);
}}
/>
</Suspense>
</Modal>
)}
</div>
);
}
const ModalWait = () => <p className="modal-wait">Chargement…</p>;
Tokens : --bg --panel --text --muted --detour-ink --line --accent --accent-ink --chip --exigeant --ease-out --ease-in-out --font --display
Utilisation
import Welcome from './components/Welcome';
<Welcome practice={practice} level={level} onPractice={setPractice} onLevel={setLevel} onFile={analyze} progress={progress} error={error} />Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | Marque | « Pathou » et la mention Bêta, avec les liens vers les pays couverts et la méthode. |
| 2 | Question | Le titre principal, posé sur les courbes de niveau en fond. |
| 3 | Réglages | Pratique et niveau, deux Segmented, avec le bouton « i » des niveaux. |
| 4 | Dépôt | Zone de dépôt du fichier (GPX, FIT, TCX, KML…) et l’essai avec une trace d’exemple. |
États
busyAnalyse en cours (progress non nul) ; l’étape et l’avancement remplacent le dépôt.Guidelines
- MUSTGarder les réglages avant le dépôt ; le verdict dépend de la pratique et du niveau.
- SHOULDMontrer ce que fait Pathou (démonstration, exemples) avant de demander un fichier.
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
practice | Practice | requis |
level | Level | requis |
onPractice | (p: Practice) => void | requis |
onLevel | (l: Level) => void | requis |
onFile | (f: File) => void | requis |
progress | { step: string; ratio?: number } | null | requis |
error | string | null | requis |