Bouton flottant « Contenu de la carte » et son menu d’interrupteurs — trace, sens, bornes, cotation VTT, points d’eau, photos, obstacles… — groupés par thème.
stableÀ valider
// Titre : Bouton fermé
import { useState } from 'react';
import MapContentMenu from '@pathou/components/MapContentMenu';
import type { Overlays } from '@pathou/lib/mapOptions';
// les réglages par défaut de Pathou (App.tsx)
const DEFAULT: Overlays = { arrows: true, km: true, tech: true, mtbRoutes: false, water: false, pois: false, photos: true, obstacles: true, gpsTraces: false, labels: true };
export default function Exemple() {
const [overlays, setOverlays] = useState(DEFAULT);
const [hidden, setHidden] = useState(false);
return (
<div style={{ position: 'relative', width: 360, minHeight: 60 }}>
<MapContentMenu overlays={overlays} onOverlays={setOverlays} trackHidden={hidden} onTrackHidden={setHidden} pois={null} poiStatus="idle" />
</div>
);
}
Installation
Copie le fichier dans ton projet, ainsi que les tokens qu’il utilise.
import { useRef, useState, type ReactNode } from 'react';
import { useDismiss } from '../hooks/useDismiss';
import type { Overlays } from '../lib/mapOptions';
import { WATER_KINDS, type Poi } from '../lib/osm/pois';
import { usePresence } from '../hooks/usePresence';
export type PoiStatus = 'idle' | 'loading' | 'ok' | 'error';
interface Props {
overlays: Overlays;
onOverlays: (o: Overlays) => void;
trackHidden: boolean;
onTrackHidden: (hidden: boolean) => void;
pois: Poi[] | null;
poiStatus: PoiStatus;
}
const icon = (children: ReactNode) => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{children}
</svg>
);
const ICONS = {
track: icon(<path d="M4 19c3-1 3-6 7-7s5-5 9-7" />),
arrows: icon(
<>
<path d="M4 12h14" />
<path d="M13 7l5 5-5 5" />
</>,
),
km: icon(
<>
<path d="M6 21V4" />
<path d="M6 4h11l-2.5 4L17 12H6" />
</>,
),
mtbRoutes: icon(
<>
<circle cx="6" cy="17" r="3" />
<circle cx="18" cy="17" r="3" />
<path d="M6 17l4-8h5l3 8M10 9l-1.5-3H6M15 9l-2 8" />
</>,
),
water: icon(<path d="M12 3.5c-3 4-5.5 7.2-5.5 10a5.5 5.5 0 0 0 11 0c0-2.8-2.5-6-5.5-10z" />),
pois: icon(
<>
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z" />
<path d="M9.5 10h5M12 7.5v5" />
</>,
),
photos: icon(
<>
<path d="M4 8h3l1.5-2h7L17 8h3v11H4z" />
<circle cx="12" cy="13" r="3.5" />
</>,
),
obstacles: icon(
<>
<path d="M4 20V9M20 20V9M4 12h16M4 16h16" />
<path d="M8 12l-2 4M13 12l-2 4M18 12l-2 4" />
</>,
),
gpsTraces: icon(
<>
<path d="M3 17c3-4 5 1 8-3s4-6 10-6" />
<path d="M3 12c4-2 6 2 9-1s5-5 9-4" opacity="0.5" />
</>,
),
labels: icon(
<>
<path d="M5 19L10 5h1l5 14" />
<path d="M7 14h7" />
<path d="M17 9h3M18.5 9v10" />
</>,
),
tech: icon(
<>
<path d="M3 19l6-10 4 6 2-3 6 7z" />
<path d="M9 9l1.5 2.5" />
</>,
),
};
/** Bouton flottant « Contenu de la carte » et son menu des éléments affichés, façon Komoot. */
export default function MapContentMenu({ overlays, onOverlays, trackHidden, onTrackHidden, pois, poiStatus }: Props) {
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
useDismiss(open, root, () => setOpen(false));
// reste affiché le temps de l'animation de fermeture
const menu = usePresence(open, 150);
const nWater = pois?.filter((p) => WATER_KINDS.includes(p.kind)).length ?? 0;
const nOther = (pois?.length ?? 0) - nWater;
// état du chargement OpenStreetMap, affiché sous la description quand le calque est actif
const poiNote = (on: boolean, n: number) =>
!on ? undefined : poiStatus === 'loading' ? 'Chargement…' : poiStatus === 'error' ? 'OpenStreetMap ne répond pas\u00a0: désactive puis réactive pour réessayer' : poiStatus === 'ok' ? `${n} le long de la trace` : undefined;
type Row = { key: string; title: string; desc: string; note?: string; on: boolean; set: (v: boolean) => void; icon: ReactNode };
const toggle = (k: keyof Overlays) => (v: boolean) => onOverlays({ ...overlays, [k]: v });
const groups: [string, Row[]][] = [
[
'Sur la trace',
[
{ key: 'track', title: 'Trace', desc: 'Masquer pour voir le chemin réel sur le fond de carte (raccourci T)', on: !trackHidden, set: (v) => onTrackHidden(!v), icon: ICONS.track },
{ key: 'arrows', title: 'Sens de la trace', desc: 'Flèches indiquant le sens de parcours', on: overlays.arrows, set: toggle('arrows'), icon: ICONS.arrows },
{ key: 'km', title: 'Repères de distance', desc: 'Une borne tous les 10 km', on: overlays.km, set: toggle('km'), icon: ICONS.km },
{ key: 'tech', title: 'Cotation VTT', desc: 'Difficulté technique des sentiers (S0 à S5), d’après OpenStreetMap', on: overlays.tech, set: toggle('tech'), icon: ICONS.tech },
{
key: 'obstacles',
title: 'Obstacles',
desc: 'Échaliers et chicanes (vélo à porter), gués, barrières et grilles à bétail, d’après OpenStreetMap',
on: overlays.obstacles,
set: toggle('obstacles'),
icon: ICONS.obstacles,
},
{
key: 'photos',
title: 'Photos au sol',
desc: 'Pastilles vertes là où Panoramax a des photos du terrain\u00a0; clic sur la trace, le profil ou un passage pour les voir',
on: overlays.photos,
set: toggle('photos'),
icon: ICONS.photos,
},
],
],
[
'Autour de la trace',
[
{
key: 'labels',
title: 'Noms',
desc: 'Chemins, routes, villages, sommets et cols autour de la trace, d’après OpenStreetMap. Sur la photo IGN et le satellite\u00a0; les autres fonds affichent déjà les leurs',
on: overlays.labels,
set: toggle('labels'),
icon: ICONS.labels,
},
{ key: 'mtbRoutes', title: 'Itinéraires VTT balisés', desc: 'Circuits VTT officiels (Waymarked Trails, d’après OpenStreetMap)', on: overlays.mtbRoutes, set: toggle('mtbRoutes'), icon: ICONS.mtbRoutes },
{
key: 'gpsTraces',
title: 'Traces GPS publiques',
desc: 'Là où les contributeurs OpenStreetMap passent réellement\u00a0: utile pour vérifier un passage hors réseau',
on: overlays.gpsTraces,
set: toggle('gpsTraces'),
icon: ICONS.gpsTraces,
},
{
key: 'water',
title: 'Points d’eau',
desc: 'Eau potable, fontaines, robinets et sources à moins de 300 m de la trace',
note: poiNote(overlays.water, nWater),
on: overlays.water,
set: toggle('water'),
icon: ICONS.water,
},
{
key: 'pois',
title: 'Points d’intérêt',
desc: 'Ravitaillement, réparation vélo, abris, campings et points de vue à moins de 500 m',
note: poiNote(overlays.pois, nOther),
on: overlays.pois,
set: toggle('pois'),
icon: ICONS.pois,
},
],
],
];
return (
<div ref={root} className="map-content">
<button className={`map-fab map-content-btn${open ? ' open' : ''}`} onClick={() => setOpen((o) => !o)} aria-expanded={open} aria-haspopup="dialog" aria-controls="map-content-menu">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z" />
<circle cx="12" cy="10" r="2.3" />
</svg>
<span className="map-fab-label">Contenu de la carte</span>
<svg className="map-content-chevron" viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5.5 8l4.5 4.5L14.5 8" />
</svg>
</button>
{menu.mounted && (
<div id="map-content-menu" className={`map-menu map-content-menu${menu.closing ? ' closing' : ''}`} inert={menu.closing} role="dialog" aria-label="Contenu de la carte">
<div className="map-menu-head">
<h2>Contenu de la carte</h2>
<button className="map-menu-close" onClick={() => setOpen(false)} aria-label="Fermer">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
</div>
{groups.map(([heading, rows]) => (
<section key={heading}>
<h3>{heading}</h3>
<ul>
{rows.map((r) => (
<li key={r.key}>
<span className="map-menu-icon">{r.icon}</span>
<span className="map-menu-text" id={`mc-${r.key}`}>
<b>{r.title}</b>
<small>{r.desc}</small>
{r.note && <small className={`map-menu-note${poiStatus === 'error' ? ' error' : ''}`}>{r.note}</small>}
</span>
<button role="switch" aria-checked={r.on} aria-labelledby={`mc-${r.key}`} className="switch" onClick={() => r.set(!r.on)}>
<i />
</button>
</li>
))}
</ul>
</section>
))}
</div>
)}
</div>
);
}
Tokens : --panel --text --muted --accent --chip --shadow --exigeant --ease-out
Utilisation
import MapContentMenu from './components/MapContentMenu';
<MapContentMenu overlays={overlays} onOverlays={setOverlays} trackHidden={trackHidden} onTrackHidden={setTrackHidden} pois={pois} poiStatus={poiStatus} />Exemples
Menu ouvert
// Titre : Menu ouvert
import { useState } from 'react';
import MapContentMenu from '@pathou/components/MapContentMenu';
import type { Overlays } from '@pathou/lib/mapOptions';
import Clicked from '../../src/preview/Clicked';
const DEFAULT: Overlays = { arrows: true, km: true, tech: true, mtbRoutes: false, water: false, pois: false, photos: true, obstacles: true, gpsTraces: false, labels: true };
// <Clicked> ouvre le menu au chargement de l’aperçu ; dans Pathou, c’est le clic sur le bouton
export default function Exemple() {
const [overlays, setOverlays] = useState(DEFAULT);
const [hidden, setHidden] = useState(false);
return (
<div style={{ position: 'relative', width: 440, minHeight: 620 }}>
<Clicked selector=".map-content-btn">
<MapContentMenu overlays={overlays} onOverlays={setOverlays} trackHidden={hidden} onTrackHidden={setHidden} pois={null} poiStatus="idle" />
</Clicked>
</div>
);
}
Anatomie
| # | Élément | Rôle |
|---|---|---|
| 1 | Bouton | Pastille flottante avec chevron, aria-expanded et aria-controls vers le menu. |
| 2 | Menu | Panneau role="dialog" nommé « Contenu de la carte », avec en-tête et bouton Fermer. |
| 3 | Ligne | Icône, libellé et explication, et un interrupteur role="switch" nommé par le libellé. |
États
openMenu ouvert sous le bouton ; se ferme par la croix, Échap ou un clic à côté, avec une courte animation.Guidelines
- MUSTExpliquer chaque calque en une ligne sous son libellé (source, échelle) ; un nom seul ne suffit pas.
- SHOULDCharger les calques coûteux (points d’intérêt, eau) seulement quand on les active, et dire quand ils chargent ou échouent.
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
overlays | Overlays | requis |
onOverlays | (o: Overlays) => void | requis |
trackHidden | boolean | requis |
onTrackHidden | (hidden: boolean) => void | requis |
pois | Poi[] | null | requis |
poiStatus | 'idle' | 'loading' | 'ok' | 'error' | requis |