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.

src/components/Welcome.tsx
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&nbsp;: 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&nbsp;: 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&nbsp;: 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&nbsp;: 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&nbsp;?
            </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émentRôle
1Marque« Pathou » et la mention Bêta, avec les liens vers les pays couverts et la méthode.
2QuestionLe titre principal, posé sur les courbes de niveau en fond.
3RéglagesPratique et niveau, deux Segmented, avec le bouton « i » des niveaux.
4DépôtZone 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

  • MUST
    Garder les réglages avant le dépôt ; le verdict dépend de la pratique et du niveau.
  • SHOULD
    Montrer ce que fait Pathou (démonstration, exemples) avant de demander un fichier.

Référence de l’API

PropTypeDéfaut
practicePracticerequis
levelLevelrequis
onPractice(p: Practice) => voidrequis
onLevel(l: Level) => voidrequis
onFile(f: File) => voidrequis
progress{ step: string; ratio?: number } | nullrequis
errorstring | nullrequis