MapContentMenu

←→

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.

src/components/MapContentMenu.tsx
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émentRôle
1BoutonPastille flottante avec chevron, aria-expanded et aria-controls vers le menu.
2MenuPanneau role="dialog" nommé « Contenu de la carte », avec en-tête et bouton Fermer.
3LigneIcô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

  • MUST
    Expliquer chaque calque en une ligne sous son libellé (source, échelle) ; un nom seul ne suffit pas.
  • SHOULD
    Charger 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

PropTypeDéfaut
overlaysOverlaysrequis
onOverlays(o: Overlays) => voidrequis
trackHiddenbooleanrequis
onTrackHidden(hidden: boolean) => voidrequis
poisPoi[] | nullrequis
poiStatus'idle' | 'loading' | 'ok' | 'error'requis