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.
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
- MUSTDire le statut de chaque pays en mots et l’expliquer (complet, bêta, dégradé).
- SHOULDOuvrir sur le pays d’où l’on vient (
initial) quand on arrive depuis l’accueil.
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
onShowSources | () => void | — |
initial | CountryCode | — |