CountriesInfo

←→

Contenu de la page /pays et de la fenêtre « Où Pathou fonctionne » — la carte des pays couverts colorée par statut, et pour chaque pays ce que Pathou y sait faire.

stableÀ valider

Écran ou section propre à Pathou, pas un composant du design system : à ne pas réutiliser ailleurs. Tous les écrans et sections

// Titre : Pays couverts
import CountriesInfo from '@pathou/components/CountriesInfo';

export default function Exemple() {
  return (
    <div className="ip-main" style={{ width: 720, maxHeight: 680, overflowY: 'auto' }}>
      <CountriesInfo />
    </div>
  );
}

Installation

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

src/components/CountriesInfo.tsx
import { useEffect, useRef, useState } from 'react';
import flagAd from '../assets/flags/ad.svg';
import flagBe from '../assets/flags/be.svg';
import flagCh from '../assets/flags/ch.svg';
import flagDe from '../assets/flags/de.svg';
import flagEs from '../assets/flags/es.svg';
import flagFr from '../assets/flags/fr.svg';
import flagIt from '../assets/flags/it.svg';
import flagPt from '../assets/flags/pt.svg';
import { REAL_PROFILE } from '../lib/sources/profile';
import outlines from '../lib/sources/outlines.data.json';
import { ringsOf } from '../lib/sources/regions';
import { DE_AUTRES_LANDER, REGIONS, REGION_COUNTRY, REGION_SHORT, type CountryCode, type Region, type Support } from '../lib/sources/types';
import { COUNTRY_ROWS, VG250_ATTRIBUTION, bestSupport } from './DataSources';
import { opensModal } from './opensModal';

type FeatureKey = 'altitude' | 'chemins' | 'fonds';
interface Feature {
  key: FeatureKey;
  label: string;
  on: boolean;
  /** source quand la donnée est là, raison quand elle manque */
  detail: string;
}
export interface CountryCard {
  code: CountryCode;
  regions: Region[];
  name: string;
  status: Support;
  features: Feature[];
  /** phrase courte sous les pictogrammes ; le détail reste au survol */
  line: string;
  /** ce que le pays a, en mots */
  has: string;
  /** ce qui lui manque, et ses réserves ; vide si rien */
  lacks: string;
  /** lignes propres à un pays à région de fond, affichées sous ce qu’il a */
  lines?: string[];
}

export const STATUS: Record<Support, { label: string; text: string }> = {
  complet: { label: 'Complet', text: 'Vérifié sur le terrain : verdict aussi éprouvé qu’en France.' },
  beta: { label: 'Bêta', text: 'Altitude vérifiée, verdict moins éprouvé qu’en France.' },
  degrade: { label: 'Dégradé', text: 'Pathou fonctionne, avec le relief mondial ou des données partielles.' },
};
const ORDER: Support[] = ['complet', 'beta', 'degrade'];
/** Drapeau de chaque pays ; sans drapeau, un espace garde l’alignement. */
export const FLAG: Partial<Record<CountryCode, string>> = { fr: flagFr, ch: flagCh, es: flagEs, ad: flagAd, it: flagIt, pt: flagPt, de: flagDe, be: flagBe };

/** « a, b et c » */
const list = (xs: string[]) => (xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} et ${xs[xs.length - 1]}`);
/** « a, b et c », sauf si un nom contient déjà « et » : « Wallonie, Flandre et Bruxelles ». */
const names = (xs: string[]) => (xs.some((x) => / et /.test(x)) ? xs.join(', ') : list(xs));
const capital = (t: string) => t.charAt(0).toUpperCase() + t.slice(1);

const join = (...parts: (string | undefined | false)[]) => parts.filter(Boolean).join(' · ');

/** Contours entiers des pays (l'Allemagne, la Belgique) : sans région de fond, leur présence dit que seules certaines régions sont couvertes. */
const OUTLINES = outlines as Partial<Record<CountryCode, [number, number][][]>>;
/** Région de fond qui couvre tout un pays sous ses régions fines (l’Allemagne : basemap.de sous les trois Länder de G1). */
const WHOLE: Partial<Record<CountryCode, Region>> = { de: 'de-autres' };
const SUPPORT_TITLE: Record<Support, string> = { complet: 'Complet', beta: 'Bêta', degrade: 'Dégradé' };

/** Deux lignes de la carte « Allemagne » : les Länder de G1 et leur statut, puis les 13 autres Länder, nommés. */
export function germanyLines(regions: Region[]): string[] | undefined {
  const whole = WHOLE.de!;
  if (!regions.includes(whole)) return undefined;
  const fine = regions.filter((r) => r !== whole);
  const res = fine.map((r) => REAL_PROFILE.regions[r]!.elevation!.resolutionM);
  const [lo, hi] = [Math.min(...res), Math.max(...res)];
  const p = REAL_PROFILE.regions[whole]!;
  return [
    `${SUPPORT_TITLE[bestSupport(fine)]}\u00a0: ${fine.map((r) => REGION_SHORT[r]).join(', ')} (${lo === hi ? lo : `${lo} à ${hi}`} m).`,
    `${p.support === 'degrade' ? 'Dégradé, terrain' : 'Terrain'} à ${p.elevation!.resolutionM} m (basemap.de)\u00a0: ${DE_AUTRES_LANDER.join(', ')}.`,
  ];
}

/** Une carte par pays du profil, tirée du tableau des sources (même source de vérité). */
export const COUNTRY_CARDS: CountryCard[] = COUNTRY_ROWS.flatMap((r): CountryCard[] => {
  if (!r.code) return [];
  const [e] = r.elevation;
  const several = r.regions.length > 1;
  // pays couvert en partie (contour entier affiché, sans région de fond) : on nomme les régions couvertes
  const partial = !!OUTLINES[r.code] && !WHOLE[r.code];
  const covered = r.regions.map((x) => REGION_SHORT[x]);
  const lines = r.code === 'de' ? germanyLines(r.regions) : undefined;
  const line = lines
    ? lines.join(' ')
    : partial
      ? [`${covered.join(', ')}.`, r.reasons.elsewhere && `${r.reasons.elsewhere}.`].filter(Boolean).join(' ')
      : e
        ? join(`Altitude ${e.label}, ${e.resolutionM} m`, r.note)
        : 'Altitude : relief mondial';
  // fournisseurs des fonds nationaux (« IGN : plan » → « IGN »), sans doublon
  const providers = [...new Set(r.basemaps.map((b) => b.split('\u00a0:')[0]))];
  const has = [
    // l’Allemagne : précise partout (G1 ou basemap.de), les Länder sont nommés dans les deux lignes de la carte
    e && (WHOLE[r.code] ? 'altitude précise dans les 16 Länder' : several ? `altitude précise en ${names(r.regions.filter((x) => REAL_PROFILE.regions[x]?.elevation).map((x) => REGION_SHORT[x]))}` : `altitude ${e.label} à ${e.resolutionM} m${partial ? ` en ${names(covered)}` : ''}`),
    r.extraWays && `chemins hors OpenStreetMap (${r.extraWays.label})`,
    providers.length > 0 && `fonds ${list(providers)}`,
  ].filter((x): x is string => !!x);
  const missing = [!e && 'altitude précise (relief mondial)', !r.extraWays && 'chemins hors OpenStreetMap', !providers.length && 'fonds nationaux'].filter((x): x is string => !!x);
  const reserves = [r.note && capital(r.note), partial && r.reasons.elsewhere].filter((x): x is string => !!x);
  const lacks = [missing.length > 0 && `Sans ${missing.join(' ni ')}`, ...reserves].filter(Boolean).join('. ');
  return [
    {
      code: r.code,
      regions: r.regions,
      name: r.country,
      status: bestSupport(r.regions),
      line,
      has: capital(has.join(', ')),
      lacks,
      ...(lines && { lines }),
      features: [
        {
          key: 'altitude',
          label: 'Altitude précise',
          on: !!e,
          detail: e
            ? join(...r.elevation.map((x) => `${x.land ? `${x.land}\u00a0: ` : ''}${x.label}, ${x.resolutionM} m`), r.note, partial && r.reasons.elevation)
            : join('altitude du GPX ou relief mondial', r.reasons.elevation),
        },
        { key: 'chemins', label: 'Chemins hors OpenStreetMap', on: !!r.extraWays, detail: r.extraWays ? join(r.extraWays.label, r.reasons.extraWays) : r.reasons.extraWays ?? '—' },
        {
          key: 'fonds',
          label: 'Fonds nationaux',
          on: r.basemaps.length > 0,
          detail: r.basemaps.length ? join(r.basemaps.join(', '), r.reasons.basemaps) : join('fonds mondiaux', r.reasons.basemaps),
        },
      ],
    },
  ];
});

/** Pays regroupés par statut, du plus sûr au moins sûr ; un groupe vide n'apparaît pas. */
export const COUNTRY_GROUPS: { status: Support; cards: CountryCard[] }[] = ORDER.map((status) => ({
  status,
  cards: COUNTRY_CARDS.filter((c) => c.status === status),
})).filter((g) => g.cards.length > 0);

/** Emprise de la carte (degrés) et projection équirectangulaire corrigée à la latitude moyenne. */
const W = -10.2;
const E = 19.2;
const S = 35.4;
const N = 55.2;
const WIDTH = 320;
const KX = Math.cos((((S + N) / 2) * Math.PI) / 180);
const SCALE = WIDTH / ((E - W) * KX);
const HEIGHT = Math.round((N - S) * SCALE);
const x = (lon: number) => (lon - W) * KX * SCALE;
const y = (lat: number) => (N - lat) * SCALE;

/** Tracé SVG de contours ([longitude, latitude]). */
const ringPath = (rings: [number, number][][]) => rings.map((ring) => `M${ring.map(([lon, lat]) => `${x(lon).toFixed(1)},${y(lat).toFixed(1)}`).join('L')}Z`).join('');

/**
 * Tracés SVG des pays du profil, avec leur statut ; règle pair-impair pour les enclaves. La région de fond d’abord, avec le
 * contour simplifié du pays, puis les régions fines par-dessus (leur trait les distingue même à statut égal).
 */
export function mapPaths(): { region: Region; status: Support; d: string }[] {
  const regions = REGIONS.filter((r) => REAL_PROFILE.regions[r]);
  const under = regions.filter((r) => Object.values(WHOLE).includes(r));
  return [...under, ...regions.filter((r) => !under.includes(r))].map((r) => {
    const c = REGION_COUNTRY[r];
    const rings = WHOLE[c] === r && OUTLINES[c] ? OUTLINES[c]! : ringsOf(r);
    return { region: r, status: REAL_PROFILE.regions[r]!.support, d: ringPath(rings) };
  });
}

/** Surface sous laquelle un pays n'est pas nommé sur la carte (unités SVG²) : Andorre. */
const LABEL_MIN_AREA = 40;
/** Décalage manuel du nom quand le centre de l'emprise tombe mal (unités SVG). */
const LABEL_SHIFT: Partial<Record<CountryCode, [number, number]>> = { it: [-14, -26], fr: [0, 4], es: [4, 0] };

/**
 * Contour entier d'un pays dont seules certaines régions sont couvertes (la Belgique) : dessiné en fond, pour situer les
 * régions en couleur. Affichage seulement : l'aiguillage des sources ne s'en sert pas.
 */
export function mapOutlines(): { code: CountryCode; d: string }[] {
  return COUNTRY_CARDS.flatMap((c) => (OUTLINES[c.code] && !WHOLE[c.code] ? [{ code: c.code, d: ringPath(OUTLINES[c.code]!) }] : []));
}

/** Nom de chaque pays assez grand, au centre de l'emprise de son plus grand contour (le contour entier s'il y en a un). */
export function mapLabels(): { code: CountryCode; name: string; x: number; y: number }[] {
  return COUNTRY_CARDS.flatMap((c) => {
    const rings = OUTLINES[c.code] ?? c.regions.flatMap((r) => ringsOf(r));
    let best: { area: number; box: [number, number, number, number] } | null = null;
    for (const ring of rings) {
      const xs = ring.map(([lon]) => x(lon));
      const ys = ring.map(([, lat]) => y(lat));
      const box: [number, number, number, number] = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
      const area = (box[2] - box[0]) * (box[3] - box[1]);
      if (!best || area > best.area) best = { area, box };
    }
    if (!best || best.area < LABEL_MIN_AREA) return [];
    const [x0, y0, x1, y1] = best.box;
    const [dx, dy] = LABEL_SHIFT[c.code] ?? [0, 0];
    return [{ code: c.code, name: c.name, x: (x0 + x1) / 2 + dx, y: (y0 + y1) / 2 + dy }];
  });
}

/** Pastille du statut, avec le même motif que la carte : le statut se lit sans la couleur. */
function Swatch({ status }: { status: Support }) {
  return (
    <svg className="ci-swatch" viewBox="0 0 14 14" aria-hidden="true">
      <rect x="0.5" y="0.5" width="13" height="13" rx="3" className={`ci-fill status-${status}`} />
    </svg>
  );
}

/** `onShowSources` : ouvre les sources dans la modale ; sans lui (page /pays), le lien mène à la page /sources. */
export default function CountriesInfo({ onShowSources, initial }: { onShowSources?: () => void; initial?: CountryCode }) {
  const [active, setActive] = useState<CountryCode | null>(initial ?? null);
  const groups = useRef<HTMLDivElement>(null);
  // ouverte depuis un pays de l’accueil : sa ligne vient au milieu de la modale
  useEffect(() => {
    if (initial) groups.current?.querySelector(`[data-code="${initial}"]`)?.scrollIntoView({ block: 'center' });
  }, [initial]);
  const paths = mapPaths();
  const labels = mapLabels();
  const outlinesD = mapOutlines();
  return (
    <section className="countries">
      <div className="ci-layout" onMouseLeave={() => setActive(null)}>
        <svg className="ci-map" viewBox={`0 0 ${WIDTH} ${HEIGHT}`} role="img" aria-label="Carte des pays couverts par Pathou, selon leur statut">
          <defs>
            <pattern id="ci-beta" width="4" height="4" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
              <rect width="4" height="4" className="ci-pat-bg status-beta" />
              <line x1="0" y1="0" x2="0" y2="4" className="ci-pat-ink status-beta" />
            </pattern>
            <pattern id="ci-degrade" width="4" height="4" patternUnits="userSpaceOnUse">
              <rect width="4" height="4" className="ci-pat-bg status-degrade" />
              <circle cx="2" cy="2" r="1" className="ci-pat-dot status-degrade" />
            </pattern>
          </defs>
          {outlinesD.map((o) => (
            <path
              key={`outline-${o.code}`}
              d={o.d}
              fillRule="evenodd"
              className={`ci-outline${active === o.code ? ' is-active' : ''}${active && active !== o.code ? ' is-dim' : ''}`}
              onMouseEnter={() => setActive(o.code)}
              onClick={() => setActive(o.code)}
            />
          ))}
          {paths.map((p) => {
            const code = REGION_COUNTRY[p.region];
            return (
              <path
                key={p.region}
                d={p.d}
                fillRule="evenodd"
                className={`ci-shape ci-fill status-${p.status}${active === code ? ' is-active' : ''}${active && active !== code ? ' is-dim' : ''}`}
                onMouseEnter={() => setActive(code)}
                onClick={() => setActive(code)}
              />
            );
          })}
          {labels.map((l) => (
            <text key={l.code} x={l.x.toFixed(1)} y={l.y.toFixed(1)} className={`ci-label${active === l.code ? ' is-active' : ''}`} aria-hidden="true">
              {l.name}
            </text>
          ))}
        </svg>
        <div className="ci-groups" ref={groups}>
          {COUNTRY_GROUPS.map((g) => (
            <section key={g.status} className="ci-group" aria-labelledby={`ci-${g.status}`}>
              <h3 id={`ci-${g.status}`} className="ci-group-head">
                <Swatch status={g.status} />
                {STATUS[g.status].label}
              </h3>
              <p className="ci-group-text">{STATUS[g.status].text}</p>
              <ul className="ci-rows">
                {g.cards.map((c) => (
                  <li key={c.code}>
                    <button
                      type="button"
                      data-code={c.code}
                      className={`ci-row${active === c.code ? ' is-active' : ''}`}
                      aria-pressed={active === c.code}
                      onMouseEnter={() => setActive(c.code)}
                      onFocus={() => setActive(c.code)}
                      onClick={() => setActive(active === c.code ? null : c.code)}
                    >
                      <b className="ci-name">
                        {FLAG[c.code] ? <img className="ci-flag" src={FLAG[c.code]} alt="" width={22} height={22} /> : <span className="ci-flag" aria-hidden="true" />}
                        {c.name}
                      </b>
                      <span className="ci-has">{c.has}</span>
                      {c.lines?.map((l) => (
                        <span key={l} className="ci-has">
                          {l}
                        </span>
                      ))}
                      {c.lacks && <span className="ci-lacks">{c.lacks}</span>}
                    </button>
                  </li>
                ))}
              </ul>
            </section>
          ))}
        </div>
      </div>
      <p className="ci-elsewhere">Ailleurs, Pathou fonctionne avec l’altitude du GPX ou le relief mondial.</p>
      <p className="muted small">Contours de l’Allemagne et des Länder{'\u00a0'}: VG250, {VG250_ATTRIBUTION}, dl-de/by-2-0.</p>
      <a className="hp-link countries-sources" href="/sources" onClick={onShowSources && opensModal(onShowSources)}>
        Détail des sources
      </a>
    </section>
  );
}

Tokens : --panel --text --muted --line --accent --chip --ease-out --font --status-complet (non déterminé) --status-beta --status-degrade

Utilisation

import CountriesInfo from './components/CountriesInfo';

<CountriesInfo initial="fr" onShowSources={openSources} />

Guidelines

  • MUST
    Dire le statut de chaque pays en mots et l’expliquer (complet, bêta, dégradé).
  • SHOULD
    Ouvrir sur le pays d’où l’on vient (initial) quand on arrive depuis l’accueil.

Référence de l’API

PropTypeDéfaut
onShowSources() => void—
initialCountryCode—