Map3D

←→

La carte 3D (MapLibre) — relief, trace colorée par difficulté, pente ou surface, calques, curseur lié au profil, alternatives et survol animé de la trace.

stableÀ valider
Capture : Map3D
Capture du 19 octobre 2026, pas un rendu en direct

Installation

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

src/components/Map3D.tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { AttributionControl, LngLat, LngLatBounds, Map as MlMap, Marker, NavigationControl, Popup, ScaleControl, addProtocol, type GeoJSONSource, type LngLatLike, type StyleSpecification } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import '../lib/maplibreWorker';
import type { BaseAnalysis, RiderAnalysis } from '../lib/analyze';
import { DETOUR_COLOR, REPLACED_COLOR, mtbColor, type ColorMode } from '../lib/colors';
import { formatDelta, type DetourFound } from '../lib/detour/result';
import { coloredLines, isWholeTrack, kmMarkers, nearestSample, sectionSummary, techLabel, techLabels, trackLine, wayProvenance, type Focus } from '../lib/display';
import { ICON_PIXEL_RATIO, arrowIcon, kmIcon, photoDotIcon, poiIcon, techIcon, type MapIcon } from '../lib/mapIcons';
import { WATER_KINDS, type Poi, type PoiKind } from '../lib/osm/pois';
import type { GroundPhoto, PhotoDot } from '../lib/photos';
import { RELIEF_TILES, reliefTile } from '../lib/reliefTiles';
import { RATING_LABEL } from '../lib/difficulty/rules';
import { WAY_CLASS_LABEL } from '../lib/osm/classify';
import CameraControl from './CameraControl';
import { haversine } from '../lib/geo';
import { compositeTile } from '../lib/basemapComposite';
import { BASEMAPS, GPS_TRACES, MTB_ROUTES, hasPrintedNames, type Basemap, type Overlays } from '../lib/mapOptions';
import type { ObstacleKind } from '../lib/osm/obstacles';
import { fmt } from '../lib/format';
import type { Place, PlaceKind } from '../lib/osm/places';
import type { OsmWay } from '../lib/types';
// mêmes fichiers que la police de l'interface (déjà en cache) : MapLibre en tire les lettres des noms
import interLatin from '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url';
import interLatinExt from '@fontsource-variable/inter/files/inter-latin-ext-wght-normal.woff2?url';
import { easeOut } from '../lib/ease';

// fonds « Photo aérienne » et « Carte topo » : tuiles assemblées par pays (voir basemapComposite.ts)
addProtocol('pathou-fond', async (params, abort) => ({ data: await compositeTile(params.url, abort.signal) }));
// relief et ombrage : tuiles Terrarium aux trous bouchés (voir reliefTiles.ts)
addProtocol('pathou-relief', async (params, abort) => ({ data: await reliefTile(params.url, abort.signal) }));

/** vitesse du survol animé, en km de trace par seconde */
const FLY_SPEED_KM_S = 0.32;

const OVERLAY_LAYERS: Record<keyof Overlays, string[]> = {
  arrows: ['track-arrows'],
  km: ['km-markers'],
  tech: ['tech-labels'],
  mtbRoutes: ['mtb-routes'],
  water: ['poi-water'],
  pois: ['poi-other'],
  photos: ['photo-dots-major', 'photo-dots-minor'],
  obstacles: ['obstacles'],
  gpsTraces: ['gps-traces'],
  labels: ['way-names', 'place-names'],
};

/** Pictogramme (tracé SVG sur une grille de 24) et couleur des obstacles sur la trace ; échalier et gué comme dans la liste des points d'attention. */
const OBSTACLE_STYLE: Record<ObstacleKind, [string, string]> = {
  gate: ['M4 5v15M20 5v15M4 9h16M4 15h16M4 15l16-6', '#64748b'],
  stile: ['M6 4v17M18 4v17M3 9h18M3 15h18', '#dc2626'],
  cattle_grid: ['M4 7h16v10H4zM8 7v10M12 7v10M16 7v10', '#64748b'],
  ford: ['M3 9c2-1.5 4-1.5 6 0s4 1.5 6 0 4-1.5 6 0M3 15c2-1.5 4-1.5 6 0s4 1.5 6 0 4-1.5 6 0', '#0284c7'],
};

/** Pictogramme et couleur de chaque type de point (eau, ravitaillement…). */
const DROP = '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';
const POI_STYLE: Record<PoiKind, [string, string]> = {
  water: [DROP, '#0284c7'],
  spring: [DROP, '#94a3b8'],
  food: ['M5.5 8h13l-1 12h-11zM9 8V6.5a3 3 0 0 1 6 0V8', '#d97706'],
  cafe: ['M5 10h11v4a5 5 0 0 1-5 5h-1a5 5 0 0 1-5-5zM16 11.5h1a2.5 2.5 0 0 1 0 5h-1.3M9 4.5V7M12.5 4.5V7', '#b45309'],
  repair: ['M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z', '#475569'],
  shelter: ['M3.5 11.5L12 4.5l8.5 7M6 9.8v9.7h12V9.8M10 19.5v-5h4v5', '#15803d'],
  camp: ['M3 19.5L12 5l9 14.5zM9.5 19.5L12 15l2.5 4.5', '#15803d'],
  viewpoint: ['M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z', '#7c3aed'],
};
/** Calques qui recouvrent le chemin lui-même et disparaissent quand on masque la trace. */
// masquées avec la trace (touche T) : la trace, la mise en avant et le contournement proposé
const TRACK_LAYERS = ['track-casing', 'track-line', 'track-hatch', 'hl-casing', 'hl-line', 'hl-ends', 'detour-casing', 'detour-line', 'detour-ends', 'edit-replaced', 'edit-pending'];
const ON_TRACK_OVERLAYS: (keyof Overlays)[] = ['arrows', 'tech'];

/**
 * Atténuation de la trace sans fondu : en 3D, MapLibre fige les lignes posées sur le relief dans une image
 * qu'il ne redessine qu'au changement de données (voir updateHighlight). Avec le fondu de 300 ms par défaut,
 * l'image était refaite au début du fondu et la trace restait à moitié atténuée.
 */
const NO_FADE = { duration: 0, delay: 0 };

const STYLE: StyleSpecification = {
  version: 8,
  // sans `glyphs` : les noms sont dessinés directement avec Inter, plages latin et latin étendu de Fontsource
  'font-faces': {
    Inter: [
      { url: interLatin, 'unicode-range': ['U+0000-00FF', 'U+0131', 'U+0152-0153', 'U+02BB-02BC', 'U+02C6', 'U+02DA', 'U+02DC', 'U+2000-206F', 'U+20AC', 'U+2122', 'U+2212'] },
      { url: interLatinExt, 'unicode-range': ['U+0100-02BA', 'U+02BD-02C5', 'U+02C7-02CC', 'U+02CE-02D7', 'U+02DD-02FF', 'U+1E00-1E9F', 'U+1EF2-1EFF', 'U+2C60-2C7F', 'U+A720-A7FF'] },
    ],
  },
  sources: {
    ...Object.fromEntries(BASEMAPS.map((b) => [b.id, { type: 'raster' as const, tiles: b.tiles, tileSize: 256, maxzoom: b.maxzoom, attribution: b.attribution }])),
    'gps-traces': { type: 'raster', tiles: GPS_TRACES.tiles, tileSize: 256, maxzoom: GPS_TRACES.maxzoom, attribution: GPS_TRACES.attribution },
    'mtb-routes': { type: 'raster', tiles: MTB_ROUTES.tiles, tileSize: 256, maxzoom: MTB_ROUTES.maxzoom, attribution: MTB_ROUTES.attribution },
    // Une seule source pour le relief 3D et l'ombrage. Deux sources demandaient les mêmes tuiles AWS à deux zooms différents
    // (2 tuiles communes sur 45) : 7,0 Mo de relief pour une vue rapprochée en montagne, 2,6 Mo à source unique (mesuré le
    // 13 octobre 2026 sur cvn-j1). L'ombrage perd un peu de finesse : 1,9 niveau sur 255 d'écart moyen au zoom 13,6, 0,1 au
    // zoom 15,5, pour 8 à 9 niveaux d'effet de l'ombrage lui-même. MapLibre le signale dans la console ; l'écart reste invisible ici.
    terrain: { type: 'raster-dem', tiles: [RELIEF_TILES], encoding: 'terrarium', tileSize: 256, maxzoom: 15, attribution: 'Relief\u00a0: Mapzen/AWS Terrain Tiles' },
  },
  layers: [
    { id: 'bg', type: 'background', paint: { 'background-color': '#dfe6dc' } },
    ...BASEMAPS.map(({ id }) => ({
      id: `base-${id}`,
      type: 'raster' as const,
      source: id,
      layout: { visibility: id === 'ortho' ? ('visible' as const) : ('none' as const) },
    })),
    { id: 'hillshade', type: 'hillshade', source: 'terrain', paint: { 'hillshade-exaggeration': 0.35, 'hillshade-shadow-color': '#1f2937' } },
    // au-dessus du fond et du relief, sous la trace (ajoutée ensuite)
    // tuiles d'origine en couleurs pâles, peu visibles sur les fonds clairs : assombries et saturées
    {
      id: 'gps-traces',
      type: 'raster',
      source: 'gps-traces',
      layout: { visibility: 'none' },
      paint: { 'raster-opacity': 0.95, 'raster-brightness-max': 0.55, 'raster-saturation': 0.8, 'raster-contrast': 0.4 },
    },
    { id: 'mtb-routes', type: 'raster', source: 'mtb-routes', layout: { visibility: 'none' }, paint: { 'raster-opacity': 0.9 } },
  ],
  terrain: { source: 'terrain', exaggeration: 1.5 },
  sky: {
    'sky-color': '#7fb2e5',
    'horizon-color': '#dbe8f5',
    'fog-color': '#e8eef4',
    'sky-horizon-blend': 0.6,
    'horizon-fog-blend': 0.6,
    'fog-ground-blend': 0.8,
    'atmosphere-blend': ['interpolate', ['linear'], ['zoom'], 0, 1, 12, 0],
  },
};

interface Props {
  base: BaseAnalysis;
  rider: RiderAnalysis;
  mode: ColorMode;
  basemap: Basemap;
  exaggeration: number;
  cursorIdx: number | null;
  focus: Focus | null;
  /** point de la trace vers lequel voler (clic sur le profil) */
  pick: { idx: number; key: number } | null;
  overlays: Overlays;
  /** masque la trace pour voir le chemin réel sur le fond de carte */
  trackHidden: boolean;
  flying: boolean;
  /** hauteur masquée en bas de la carte par le panneau flottant du profil (px) */
  bottomInset: number;
  /** le panneau du profil s'ouvre ou se replie : le recadrage l'accompagne au lieu de sauter */
  insetEase?: boolean;
  /** largeur masquée à droite par la sidebar flottante (px) */
  rightInset: number;
  /** points d'eau et d'intérêt le long de la trace (null tant qu'ils ne sont pas demandés) */
  pois: Poi[] | null;
  /** villages, sommets et cols autour de la trace (null tant qu'ils ne sont pas demandés) */
  places: Place[] | null;
  /** photo au sol affichée dans la fiche, repérée sur la carte avec sa direction de prise de vue */
  photo: GroundPhoto | null;
  /** clic sur la trace (pour chercher les photos de ce point) */
  onTrackClick: (idx: number) => void;
  /** endroits de la trace qui ont des photos au sol (pastilles vertes) */
  photoDots: PhotoDot[] | null;
  onHover: (idx: number | null) => void;
  onFlyProgress: (idx: number) => void;
  onFlyEnd: () => void;
  /** contexte WebGL perdu : la carte doit être recréée */
  onContextLost: () => void;
  onClearFocus: () => void;
  /** alternative du point d'attention sélectionné, dessinée en pointillés */
  detour: DetourFound | null;
  /** alternatives appliquées : portions de l'origine remplacées (pointillés roses), tracés pas encore réanalysés */
  edit: { replaced: { lat: number; lon: number }[][]; pending: { lat: number; lon: number }[][] };
}

export default function Map3D(props: Props) {
  const { base, rider, mode, basemap, exaggeration, cursorIdx, focus, pick, overlays, trackHidden, flying } = props;
  const container = useRef<HTMLDivElement>(null);
  const mapRef = useRef<MlMap | null>(null);
  const [mapInst, setMapInst] = useState<MlMap | null>(null);
  const markerRef = useRef<Marker | null>(null);
  const detourLabelRef = useRef<Marker | null>(null);
  const readyRef = useRef(false);
  /** l'utilisateur a déplacé la carte : on ne recadre plus la trace de lui-même */
  const userMoved = useRef(false);
  /** trace atténuée autour d'une section mise en avant */
  const dimmedRef = useRef(false);
  const cb = useRef(props);
  cb.current = props;

  // Création de la carte
  useEffect(() => {
    // Fond, calques et relief déjà réglés dans le style : on ne télécharge pas les tuiles d'un fond qu'on masquerait ensuite.
    const { basemap: bm, exaggeration: ex, overlays: ov } = cb.current;
    const style = structuredClone(STYLE);
    for (const layer of style.layers) {
      if (layer.id.startsWith('base-')) layer.layout = { visibility: layer.id === `base-${bm}` ? 'visible' : 'none' };
      if (layer.id === 'mtb-routes') layer.layout = { visibility: ov.mtbRoutes ? 'visible' : 'none' };
      if (layer.id === 'gps-traces') layer.layout = { visibility: ov.gpsTraces ? 'visible' : 'none' };
    }
    style.terrain = { source: 'terrain', exaggeration: ex };
    const map = new MlMap({
      container: container.current!,
      style,
      center: [base.samples[0].lon, base.samples[0].lat],
      zoom: 11,
      pitch: 60,
      maxPitch: 85,
      attributionControl: false,
      // relief 3D + photo aérienne : sur un écran 3×, dessiner en 2× divise presque par deux le travail du GPU, sans différence visible
      pixelRatio: Math.min(window.devicePixelRatio, 2),
    });
    mapRef.current = map;
    setMapInst(map);
    if (import.meta.env.DEV) (window as unknown as { __map: MlMap }).__map = map;
    // zoom au-dessus de la boussole (voir .maplibregl-ctrl-bottom-right dans styles.css)
    map.addControl(new NavigationControl({ showCompass: false }), 'bottom-right');
    map.addControl(new ScaleControl({ unit: 'metric' }), 'bottom-left');
    // Crédits réduits à un bouton « i », placé au-dessus du bouton « Fond » (voir styles.css)
    map.addControl(new AttributionControl({ compact: true }), 'bottom-left');
    // MapLibre les déplie à l'arrivée des premières sources (bien avant « idle ») : on les replie aussitôt,
    // dans le même évènement, avant qu'ils ne s'affichent
    const attribEvents = ['styledata', 'sourcedata', 'terrain'] as const;
    const collapseAttribution = () => {
      const el = map.getContainer().querySelector('.maplibregl-ctrl-attrib');
      if (!el?.classList.contains('maplibregl-compact')) return;
      el.classList.remove('maplibregl-compact-show');
      for (const t of attribEvents) map.off(t, collapseAttribution);
    };
    for (const t of attribEvents) map.on(t, collapseAttribution);

    const el = document.createElement('div');
    el.className = 'cursor-marker';
    const start = cb.current.base.samples[0];
    markerRef.current = new Marker({ element: el }).setLngLat([start.lon, start.lat]);

    // Cadrage immédiat sur la trace, avant le premier rendu : pas de tuiles chargées pour une vue intermédiaire.
    const f0 = cb.current.focus;
    if (f0) frame(f0.start, f0.end, false);
    else fitAll(false);

    // 'style.load' plutôt que 'load' : la trace s'affiche dès que le style est prêt,
    // sans attendre que toutes les tuiles (photo, relief) de la vue soient téléchargées.
    map.once('style.load', () => {
      map.addSource('track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
      const lineLayout = { 'line-join': 'round' as const, 'line-cap': 'round' as const };
      map.addLayer({ id: 'track-casing', type: 'line', source: 'track', layout: lineLayout, paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 5, 13, 8, 16, 12], 'line-opacity': 0.9, 'line-opacity-transition': NO_FADE } });
      map.addLayer({ id: 'track-line', type: 'line', source: 'track', layout: lineLayout, paint: { 'line-color': ['get', 'color'], 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 3, 13, 5, 16, 8], 'line-opacity-transition': NO_FADE } });
      map.addLayer({
        id: 'track-hatch',
        type: 'line',
        source: 'track',
        filter: ['==', ['get', 'hatch'], true],
        paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1.5, 16, 3], 'line-dasharray': [1.5, 1.5], 'line-opacity-transition': NO_FADE },
      });
      // Section mise en avant (montée, alerte, sélection du profil), dessinée par-dessus la trace atténuée
      map.addSource('highlight', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
      map.addLayer({
        id: 'hl-casing',
        type: 'line',
        source: 'highlight',
        filter: ['==', ['geometry-type'], 'LineString'],
        layout: lineLayout,
        paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 9, 13, 13, 16, 18], 'line-blur': 1 },
      });
      map.addLayer({
        id: 'hl-line',
        type: 'line',
        source: 'highlight',
        filter: ['==', ['geometry-type'], 'LineString'],
        layout: lineLayout,
        paint: { 'line-color': ['get', 'color'], 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 5, 13, 8, 16, 11] },
      });
      map.addLayer({
        id: 'hl-ends',
        type: 'circle',
        source: 'highlight',
        filter: ['==', ['geometry-type'], 'Point'],
        paint: { 'circle-radius': 6, 'circle-color': '#ffffff', 'circle-stroke-color': '#111827', 'circle-stroke-width': 3 },
      });
      addLabelLayers(map);
      addOverlays(map);
      map.addSource('endpoints', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
      map.addLayer({
        id: 'endpoints',
        type: 'circle',
        source: 'endpoints',
        paint: {
          'circle-radius': 7,
          'circle-color': ['match', ['get', 'kind'], 'Départ', '#16a34a', '#111827'],
          'circle-stroke-color': '#fff',
          'circle-stroke-width': 2.5,
        },
      });
      addDetourLayers(map);
      addEditLayers(map);
      addPoiLayers(map);
      addPhotoDotLayers(map);
      readyRef.current = true;
      setBaseData(map, cb.current.base);
      setPoiData(map, cb.current.pois);
      setPlaceData(map, cb.current.places);
      setPhotoDotData(map, cb.current.photoDots);
      setDetourData(map, cb.current.detour);
      setEditData(map, cb.current.edit);
      applyBasemap();
      map.setTerrain({ source: 'terrain', exaggeration: cb.current.exaggeration });
      updateTrack();
      updateHighlight();
    });

    // Geste de l'utilisateur (glisser, molette, pincer, boutons +/−) : il reprend la main, le survol s'arrête.
    const takeOver = () => {
      userMoved.current = true;
      if (cb.current.flying) cb.current.onFlyEnd();
    };
    map.on('movestart', (e) => {
      if ((e as { originalEvent?: Event }).originalEvent) takeOver();
    });
    map.on('dragstart', takeOver);
    // Contexte graphique perdu (mise en veille, mémoire GPU saturée sur mobile) : la restauration de MapLibre laisse
    // des textures corrompues, on préfère recréer la carte (TrackView change sa `key`).
    map.on('webglcontextlost', () => {
      readyRef.current = false;
      cb.current.onContextLost();
    });
    map.getCanvasContainer().addEventListener('wheel', takeOver, { passive: true });
    map.getContainer().querySelector('.maplibregl-ctrl-zoom-in')?.addEventListener('click', takeOver);
    map.getContainer().querySelector('.maplibregl-ctrl-zoom-out')?.addEventListener('click', takeOver);
    // Le premier cadrage se fait sur une carte plate ; une fois le relief chargé, la trace remonte un peu à l'écran.
    // On recadre une fois, en douceur, si l'utilisateur n'a encore rien touché.
    map.once('idle', () => {
      if (!userMoved.current && !cb.current.focus && !cb.current.pick && !cb.current.flying) frame(0, cb.current.base.samples.length - 1, 'settle');
    });

    const hover = (e: { lngLat: LngLat }) => {
      if (cb.current.flying) return;
      cb.current.onHover(nearestSample(cb.current.base, e.lngLat.lng, e.lngLat.lat));
    };
    map.on('mousemove', 'track-casing', hover);
    map.on('mouseenter', 'track-casing', () => (map.getCanvas().style.cursor = 'pointer'));
    map.on('mouseleave', 'track-casing', () => {
      map.getCanvas().style.cursor = '';
      cb.current.onHover(null);
    });
    for (const layer of ['photo-dots-major', 'photo-dots-minor']) {
      map.on('mouseenter', layer, () => (map.getCanvas().style.cursor = 'pointer'));
      map.on('mouseleave', layer, () => (map.getCanvas().style.cursor = ''));
      map.on('click', layer, (e) => {
        const f = e.features?.[0];
        if (!f) return;
        e.preventDefault();
        cb.current.onTrackClick((f.properties as { idx: number }).idx);
      });
    }
    map.on('mouseenter', 'obstacles', () => (map.getCanvas().style.cursor = 'pointer'));
    map.on('mouseleave', 'obstacles', () => (map.getCanvas().style.cursor = ''));
    map.on('click', 'obstacles', (e) => {
      const f = e.features?.[0];
      if (!f) return;
      e.preventDefault();
      const p = f.properties as { label: string; idx: number };
      const km = (cb.current.base.d[p.idx] / 1000).toFixed(1).replace('.', ',');
      const [lon, lat] = (f.geometry as GeoJSON.Point).coordinates;
      new Popup({ maxWidth: '260px' }).setLngLat([lon, lat]).setHTML(`<div class="popup"><strong>${esc(p.label)}</strong><div class="muted">km ${km} · d’après OpenStreetMap</div></div>`).addTo(map);
    });
    for (const layer of ['poi-water', 'poi-other']) {
      map.on('mouseenter', layer, () => (map.getCanvas().style.cursor = 'pointer'));
      map.on('mouseleave', layer, () => (map.getCanvas().style.cursor = ''));
      map.on('click', layer, (e) => {
        const f = e.features?.[0];
        if (!f) return;
        e.preventDefault();
        const p = f.properties as { label: string; name?: string; idx: number; dist: number };
        const [lon, lat] = (f.geometry as GeoJSON.Point).coordinates;
        new Popup({ maxWidth: '280px' }).setLngLat([lon, lat]).setHTML(poiPopupHtml(cb.current.base, p)).addTo(map);
      });
    }
    map.on('click', 'track-casing', (e) => {
      if (e.defaultPrevented) return;
      const { base: b, rider: r } = cb.current;
      const i = nearestSample(b, e.lngLat.lng, e.lngLat.lat);
      cb.current.onTrackClick(i);
      new Popup({ maxWidth: '320px' }).setLngLat([b.samples[i].lon, b.samples[i].lat]).setHTML(popupHtml(b, r, i)).addTo(map);
    });

    return () => {
      readyRef.current = false;
      map.remove();
      mapRef.current = null;
      setMapInst(null);
    };
    // une carte par trace (App la recrée via `key`) : quand l'analyse se complète, on met à jour ses données
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    const map = mapRef.current;
    if (map && readyRef.current) setPoiData(map, props.pois);
  }, [props.pois]);

  useEffect(() => {
    const map = mapRef.current;
    if (map && readyRef.current) setPhotoDotData(map, props.photoDots);
  }, [props.photoDots]);

  useEffect(() => {
    const map = mapRef.current;
    if (map && readyRef.current) setPlaceData(map, props.places);
  }, [props.places]);

  // Alternatives appliquées : portions remplacées en pointillés roses, tracés en attente de réanalyse
  useEffect(() => {
    const map = mapRef.current;
    if (map && readyRef.current) setEditData(map, props.edit);
  }, [props.edit]);

  // Contournement du point d'attention sélectionné : tracé, extrémités et étiquette « Alternative +1,4 km »
  useEffect(() => {
    const map = mapRef.current;
    if (map && readyRef.current) setDetourData(map, props.detour);
    detourLabelRef.current?.remove();
    detourLabelRef.current = null;
    const dt = props.detour;
    if (!map || !dt || dt.path.length < 2 || trackHidden) return;
    const mid = dt.path[Math.floor(dt.path.length / 2)];
    const el = document.createElement('div');
    el.className = 'detour-label';
    el.textContent = `Alternative ${formatDelta(dt.extraM)}`;
    detourLabelRef.current = new Marker({ element: el }).setLngLat([mid.lon, mid.lat]).addTo(map);
  }, [props.detour, trackHidden]);

  // Repère de la photo au sol affichée : point + cône orienté dans la direction de prise de vue
  const photoMarker = useRef<Marker | null>(null);
  useEffect(() => {
    const map = mapRef.current;
    const p = props.photo;
    if (!map || !p) {
      photoMarker.current?.remove();
      photoMarker.current = null;
      return;
    }
    if (!photoMarker.current) {
      const el = document.createElement('div');
      el.className = 'photo-marker';
      photoMarker.current = new Marker({ element: el, rotationAlignment: 'map', pitchAlignment: 'map' });
    }
    const el = photoMarker.current.getElement();
    el.classList.toggle('no-dir', p.azimuth === null);
    photoMarker.current.setLngLat([p.lon, p.lat]).setRotation(p.azimuth ?? 0).addTo(map);
  }, [props.photo]);

  // Analyse complétée (chemins OSM, positions recalées) : mise à jour sur place, sans recréer la carte
  useEffect(() => {
    const map = mapRef.current;
    if (!map || !readyRef.current) return;
    setBaseData(map, base);
    updateTrack();
    updateHighlight();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [base]);

  function updateTrack() {
    const map = mapRef.current;
    if (!map || !readyRef.current) return;
    const src = map.getSource('track') as GeoJSONSource | undefined;
    src?.setData(coloredLines(cb.current.base, cb.current.rider, cb.current.mode));
  }

  /** Cadre une portion de trace en vue 3D inclinée, orientée perpendiculairement au passage. */
  function frame(start: number, end: number, animate: boolean | 'settle' | 'dock', extra?: { lat: number; lon: number }[]) {
    const map = mapRef.current;
    if (!map) return;
    const b = new LngLatBounds();
    for (let i = start; i <= end; i++) b.extend([base.samples[i].lon, base.samples[i].lat]);
    for (const p of extra ?? []) b.extend([p.lon, p.lat]);
    const whole = start === 0 && end === base.samples.length - 1;
    // Caméra orientée dans le sens de la marche : on découvre le passage comme on va le rouler
    const bearing = whole ? -15 : travelBearing(base, start, end);
    const { bottomInset, rightInset } = cb.current;
    const narrow = map.getContainer().clientWidth - rightInset < 700;
    // En sélection, un peu plus d'air en haut pour la carte récapitulative de la section.
    const padding = narrow
      ? { top: 40, bottom: 40 + bottomInset, left: 40, right: 40 + rightInset }
      : { top: whole ? 60 : 90, bottom: 40 + bottomInset, left: 70, right: 70 + rightInset };
    const cam = map.cameraForBounds(b, { padding, bearing, maxZoom: 16.5 });
    if (!cam) return;
    const target = fitPitched(map, framingPoints(base, start, end, extra), { ...cam, pitch: whole ? 55 : 58, bearing }, padding, 16.5);
    // sans `essential` : MapLibre saute directement à la vue quand le système demande moins d'animations
    if (animate === 'settle') map.easeTo({ ...target, duration: 300 });
    // même durée et même courbe que le repli du dock (styles.css, .dock-more) : les deux bougent d'un seul geste
    else if (animate === 'dock') map.easeTo({ ...target, duration: 250, easing: easeOut });
    else if (animate) map.flyTo({ ...target, duration: 1600 });
    else map.jumpTo(target);
  }

  function fitAll(animate: boolean | 'dock' = true) {
    frame(0, base.samples.length - 1, animate);
  }

  function applyBasemap() {
    const map = mapRef.current;
    if (!map) return;
    for (const b of BASEMAPS) map.setLayoutProperty(`base-${b.id}`, 'visibility', b.id === cb.current.basemap ? 'visible' : 'none');
    applyOverlays();
  }

  function applyOverlays() {
    const map = mapRef.current;
    if (!map || !readyRef.current) return;
    const { overlays: o, trackHidden: hidden, basemap: bm } = cb.current;
    const vis = (on: boolean) => (on ? 'visible' : 'none');
    for (const layer of TRACK_LAYERS) map.setLayoutProperty(layer, 'visibility', vis(!hidden));
    for (const [key, layers] of Object.entries(OVERLAY_LAYERS) as [keyof Overlays, string[]][]) {
      // les noms de Pathou s'effacent sur les fonds qui impriment déjà les leurs
      const on = o[key] && !(hidden && ON_TRACK_OVERLAYS.includes(key)) && !(key === 'labels' && hasPrintedNames(bm));
      for (const layer of layers) map.setLayoutProperty(layer, 'visibility', vis(on));
    }
  }

  /** Surligne la section sélectionnée et atténue le reste de la trace. */
  function updateHighlight() {
    const map = mapRef.current;
    if (!map || !readyRef.current) return;
    const { base: b, rider: r, mode: m, focus: f } = cb.current;
    const active = !!f && !isWholeTrack(b, f);
    const src = map.getSource('highlight') as GeoJSONSource | undefined;
    map.setPaintProperty('track-line', 'line-opacity', active ? 0.35 : 1);
    map.setPaintProperty('track-casing', 'line-opacity', active ? 0.35 : 0.9);
    map.setPaintProperty('track-hatch', 'line-opacity', active ? 0.35 : 1);
    // En 3D, MapLibre garde les lignes dessinées sur le relief en cache et ne les redessine que si leurs données
    // changent : sans ce renvoi, la trace restait atténuée après la fermeture d'une section, jusqu'au prochain mouvement.
    if (dimmedRef.current !== active) {
      dimmedRef.current = active;
      updateTrack();
    }
    if (!active || !f) {
      src?.setData({ type: 'FeatureCollection', features: [] });
      return;
    }
    const lines = coloredLines(b, r, m, [f.start, f.end]);
    const point = (i: number): GeoJSON.Feature => ({
      type: 'Feature',
      properties: {},
      geometry: { type: 'Point', coordinates: [b.samples[i].lon, b.samples[i].lat] },
    });
    src?.setData({ type: 'FeatureCollection', features: [...lines.features, point(f.start), point(f.end)] });

  }

  useEffect(updateTrack, [rider, mode]);
  useEffect(updateHighlight, [focus, rider, mode]);
  useEffect(applyOverlays, [overlays, trackHidden]);

  useEffect(() => {
    // isStyleLoaded() est faux tant qu'une tuile charge, et 'load' ne se déclenche qu'une fois :
    // on applique dès que nos couches existent, sinon 'style.load' reprendra la valeur courante.
    if (mapRef.current && readyRef.current) applyBasemap();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [basemap]);

  useEffect(() => {
    const map = mapRef.current;
    if (map && readyRef.current) map.setTerrain({ source: 'terrain', exaggeration });
  }, [exaggeration]);

  useEffect(() => {
    const map = mapRef.current;
    const marker = markerRef.current;
    if (!map || !marker) return;
    if (cursorIdx === null) {
      marker.remove();
      return;
    }
    const s = base.samples[cursorIdx];
    marker.setLngLat([s.lon, s.lat]);
    if (!marker.getElement().isConnected) marker.addTo(map);
  }, [cursorIdx, base]);

  // Le dock et le panneau latéral sont mesurés après la création de la carte : on recadre la trace entière
  // dès que leur taille est connue (ou change), tant que l'utilisateur n'a pas bougé la carte ni choisi de section.
  useEffect(() => {
    if (!mapRef.current || userMoved.current || cb.current.focus || cb.current.pick || cb.current.flying) return;
    fitAll(props.insetEase ? 'dock' : false);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [props.bottomInset, props.rightInset]);

  useEffect(() => {
    if (focus) frame(focus.start, focus.end, true, cb.current.detour?.path);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [focus]);

  // Vol vers un point : caméra inclinée, orientée dans le sens de la marche pour voir ce qui arrive.
  useEffect(() => {
    const map = mapRef.current;
    if (!map || !pick) return;
    // vue choisie par l'utilisateur : plus de recadrage automatique sur la trace entière
    userMoved.current = true;
    const { samples } = base;
    const p = samples[pick.idx];
    const bearing = travelBearing(base, Math.max(0, pick.idx - 10), Math.min(samples.length - 1, pick.idx + 30));
    // décalé vers le haut pour rester dans la partie visible au-dessus du profil flottant
    map.flyTo({ center: [p.lon, p.lat], offset: [-cb.current.rightInset / 2, -cb.current.bottomInset / 2], zoom: Math.max(map.getZoom(), 15), pitch: 62, bearing, duration: 1400 });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pick]);

  // Survol animé : la caméra suit la trace à distance constante, derrière le cycliste.
  useEffect(() => {
    const map = mapRef.current;
    if (!map || !flying) return;
    userMoved.current = true;
    const { samples, ele, d } = base;
    const total = d[d.length - 1];
    let dist = cursorIdx !== null && cursorIdx < samples.length - 5 ? d[cursorIdx] : 0;
    let last = performance.now();
    let camBearing: number | null = null;
    let raf = 0;
    const idxAt = (m: number) => Math.max(0, Math.min(samples.length - 1, Math.round(m / (d[1] - d[0]))));
    const tick = (now: number) => {
      const dt = Math.min(0.1, (now - last) / 1000);
      last = now;
      dist += dt * FLY_SPEED_KM_S * 1000;
      if (dist >= total) {
        cb.current.onFlyEnd();
        return;
      }
      const i = idxAt(dist);
      const ahead = samples[idxAt(dist + 400)];
      const p = samples[i];
      const target = Math.atan2((ahead.lon - p.lon) * Math.cos((p.lat * Math.PI) / 180), ahead.lat - p.lat);
      if (camBearing === null) camBearing = target;
      let delta = target - camBearing;
      while (delta > Math.PI) delta -= 2 * Math.PI;
      while (delta < -Math.PI) delta += 2 * Math.PI;
      camBearing += delta * Math.min(1, dt * 1.2);
      const back = 900;
      const mPerDegLat = 111320;
      const camLat = p.lat - (Math.cos(camBearing) * back) / mPerDegLat;
      const camLon = p.lon - (Math.sin(camBearing) * back) / (mPerDegLat * Math.cos((p.lat * Math.PI) / 180));
      const ex = map.getTerrain()?.exaggeration ?? 1;
      const groundCam = (map.queryTerrainElevation([camLon, camLat]) ?? ele[i] * ex);
      const targetAlt = ele[i] * ex;
      const camAlt = Math.max(targetAlt + 380, groundCam + 150);
      const opts = map.calculateCameraOptionsFromTo(new LngLat(camLon, camLat), camAlt, new LngLat(p.lon, p.lat), targetAlt);
      map.jumpTo(opts);
      cb.current.onFlyProgress(i);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [flying, base]);

  return (
    <>
      <div ref={container} className="map" />
      <CameraControl
        map={mapInst}
        onInteract={() => {
          userMoved.current = true;
          if (cb.current.flying) cb.current.onFlyEnd();
        }}
      />
      {focus && !isWholeTrack(base, focus) && <SectionCard base={base} focus={focus} onClose={props.onClearFocus} />}
    </>
  );
}

/** Récapitulatif de la section sélectionnée, affiché en haut de la carte. */
function SectionCard({ base, focus, onClose }: { base: BaseAnalysis; focus: Focus; onClose: () => void }) {
  // la carte se rend à chaque survol du profil : le bilan de la section n'est refait que si elle change
  const sum = useMemo(() => sectionSummary(base, focus.start, focus.end), [base, focus.start, focus.end]);
  const fr = (n: number, d = 1) => fmt(n, d);
  return (
    <div className="section-card" role="status">
      <div className="section-card-head">
        <strong>{focus.label ?? 'Section sélectionnée'}</strong>
        <button type="button" onClick={onClose} aria-label="Désélectionner la section" title="Désélectionner (Échap)">
          <svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
            <path d="M5 5l10 10M15 5L5 15" />
          </svg>
        </button>
      </div>
      <div className="section-card-stats">
        <span className="section-card-km">
          km {fr(sum.fromKm)} → {fr(sum.toKm)}
        </span>
        <span>
          <b>{fr(sum.lengthKm)}</b> km
        </span>
        <span>
          <b>+{Math.round(sum.gain)}</b> m
        </span>
        {sum.loss >= 10 && (
          <span>
            <b>−{Math.round(sum.loss)}</b> m
          </span>
        )}
        <span>
          <b>{fr(sum.avgGrade)}</b> % moy.
        </span>
        <span>
          <b>{Math.round(sum.maxGrade)}</b> % max
        </span>
      </div>
    </div>
  );
}

function addPoiLayers(map: MlMap) {
  for (const [kind, [path, color]] of Object.entries(POI_STYLE)) addIcon(map, poiIcon(`poi-${kind}`, path, color));
  map.addSource('pois', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  const layout = {
    visibility: 'none' as const,
    'icon-image': ['concat', 'poi-', ['get', 'kind']] as ['concat', string, ['get', string]],
    'icon-anchor': 'center' as const,
  };
  map.addLayer({
    id: 'poi-other',
    type: 'symbol',
    source: 'pois',
    minzoom: 10,
    filter: ['!', ['in', ['get', 'kind'], ['literal', WATER_KINDS]]],
    layout: { ...layout, 'icon-padding': 4, 'icon-size': ['interpolate', ['linear'], ['zoom'], 10, 0.75, 14, 1] },
  });
  // l'eau passe au-dessus et n'est jamais masquée par un autre repère
  map.addLayer({
    id: 'poi-water',
    type: 'symbol',
    source: 'pois',
    minzoom: 9,
    filter: ['in', ['get', 'kind'], ['literal', WATER_KINDS]],
    layout: { ...layout, 'icon-allow-overlap': true, 'icon-size': ['interpolate', ['linear'], ['zoom'], 9, 0.75, 14, 1] },
  });
}

/** Pastilles vertes « photos au sol » posées sur la trace, au-dessus d'elle. */
function addPhotoDotLayers(map: MlMap) {
  addIcon(map, photoDotIcon());
  map.addSource('photo-dots', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  const layout = { visibility: 'none' as const, 'icon-image': 'photo-dot', 'icon-allow-overlap': true };
  map.addLayer({ id: 'photo-dots-major', type: 'symbol', source: 'photo-dots', minzoom: 10, filter: ['==', ['get', 'major'], true], layout });
  map.addLayer({ id: 'photo-dots-minor', type: 'symbol', source: 'photo-dots', minzoom: 14, filter: ['==', ['get', 'major'], false], layout });
}

function setPhotoDotData(map: MlMap, dots: PhotoDot[] | null) {
  (map.getSource('photo-dots') as GeoJSONSource | undefined)?.setData({
    type: 'FeatureCollection',
    features: (dots ?? []).map((d) => ({
      type: 'Feature',
      properties: { idx: d.idx, major: d.major },
      geometry: { type: 'Point', coordinates: [d.lon, d.lat] },
    })),
  });
}

function setPoiData(map: MlMap, pois: Poi[] | null) {
  (map.getSource('pois') as GeoJSONSource | undefined)?.setData({
    type: 'FeatureCollection',
    features: (pois ?? []).map((p) => ({
      type: 'Feature',
      properties: { kind: p.kind, label: p.label, name: p.name ?? '', idx: p.idx, dist: Math.round(p.dist) },
      geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
    })),
  });
}

function poiPopupHtml(b: BaseAnalysis, p: { label: string; name?: string; idx: number; dist: number }): string {
  const km = (b.d[Math.min(p.idx, b.d.length - 1)] / 1000).toFixed(1).replace('.', ',');
  const where = p.dist < 30 ? 'sur la trace' : `à ${p.dist} m de la trace`;
  return `<div class="popup">
    <strong>${esc(p.label)}</strong>
    ${p.name ? `<div>${esc(p.name)}</div>` : ''}
    <div class="muted">km ${km} · ${where}</div>
  </div>`;
}

function popupHtml(b: BaseAnalysis, r: RiderAnalysis, i: number): string {
  const w = b.ways[i];
  const rating = r.ratings[i];
  const fr = (n: number, digits: number) => n.toFixed(digits).replace('.', ',');
  const name = w.tags.name ? ` · ${esc(w.tags.name)}` : '';
  const width = parseFloat(w.tags.width ?? '');
  const widthTxt = Number.isFinite(width) && width > 0 ? ` · largeur ~${fr(width, width < 2 ? 1 : 0)} m` : '';
  const provenance = wayProvenance(w);
  const ign = provenance ? ` <span class="muted">(${esc(provenance)})</span>` : '';
  const reasons = rating.reasons.length ? `<ul>${rating.reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : '';
  const tl = techLabel(w);
  const tech = tl ? ` · <span class="pill-tech" style="--c:${esc(mtbColor(tl.scale))}">${esc(tl.text)}</span>${tl.estimated && tl.scale !== null ? ` <span class="muted">(estimée)</span>` : ''}` : '';
  const side = b.sideSlope?.[i] ?? null;
  const osmLink = w.wayId ? ` · <a href="https://www.openstreetmap.org/way/${w.wayId}" target="_blank" rel="noopener">voir sur OpenStreetMap</a>` : '';
  return `<div class="popup">
    <div class="popup-head"><strong>km ${fr(b.d[i] / 1000, 2)}</strong> · ${Math.round(b.ele[i])} m · pente ${fr(b.grade50[i], 1)} %</div>
    <div><span class="pill pill-${rating.rating}">${RATING_LABEL[rating.rating]}</span> ${WAY_CLASS_LABEL[w.cls]}${ign}${name}${widthTxt}${tech}${osmLink}</div>
    ${side !== null && Math.abs(side) >= 30 ? `<div>Pente latérale ${Math.round(Math.abs(side))} % · vide ${side > 0 ? 'à gauche' : 'à droite'}</div>` : ''}
    ${reasons}
  </div>`;
}

const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);

/** Flèches de sens, bornes kilométriques et cotations VTT : dépendent seulement de la trace. */
/**
 * Cap du sens de la marche entre deux points de la trace (degrés depuis le nord).
 * Si le passage revient presque sur lui-même (lacets, boucle), la corde ne veut plus rien dire :
 * on prend alors la direction des 200 premiers mètres.
 */
function travelBearing(base: BaseAnalysis, start: number, end: number): number {
  const s = base.samples;
  const heading = (i: number, j: number) =>
    (Math.atan2((s[j].lon - s[i].lon) * Math.cos((s[i].lat * Math.PI) / 180), s[j].lat - s[i].lat) * 180) / Math.PI;
  const chord = haversine(s[start].lat, s[start].lon, s[end].lat, s[end].lon);
  const path = base.d[end] - base.d[start];
  if (path > 0 && chord / path >= 0.3) return heading(start, end);
  return heading(start, Math.min(end, start + 20));
}

/**
 * Cadre des points dans la zone visible (hors marges) avec une caméra inclinée.
 * `cameraForBounds` raisonne en vue de dessus : avec l'inclinaison et le relief, la trace tombe plus bas et déborde
 * sous le dock. On place donc la caméra, on mesure où les points tombent vraiment à l'écran, puis on corrige
 * le centre et le zoom ; inclinée, la caméra a besoin de 3 à 4 passes pour se stabiliser.
 * La caméra d'origine est rétablie : rien n'est dessiné entre-temps.
 */
/**
 * Points qui décident du cadrage, au lieu de toute la trace : chaque projection lit l'altitude du relief et coûte cher.
 * L'enveloppe convexe donne les bords de l'image (la perspective conserve la convexité) ; une quarantaine de points
 * répartis et le point le plus haut couvrent les sommets que le relief fait monter à l'écran.
 */
function framingPoints(base: BaseAnalysis, start: number, end: number, extra?: { lat: number; lon: number }[]): [number, number][] {
  const all: { lon: number; lat: number }[] = [];
  for (let i = start; i <= end; i++) all.push(base.samples[i]);
  for (const p of extra ?? []) all.push(p);
  const out: [number, number][] = convexHull(all).map((p) => [p.lon, p.lat]);
  const step = Math.max(1, Math.floor((end - start) / 40));
  let top = start;
  for (let i = start; i <= end; i++) if (base.ele[i] > base.ele[top]) top = i;
  for (let i = start; i <= end; i += step) out.push([base.samples[i].lon, base.samples[i].lat]);
  out.push([base.samples[top].lon, base.samples[top].lat]);
  return out;
}

/** Enveloppe convexe (chaîne monotone d'Andrew), en coordonnées locales pour ne pas déformer l'est-ouest. */
function convexHull<T extends { lat: number; lon: number }>(pts: T[]): T[] {
  if (pts.length < 4) return pts.slice();
  const k = Math.cos(((pts[0].lat ?? 0) * Math.PI) / 180);
  const sorted = pts.slice().sort((a, b) => a.lon - b.lon || a.lat - b.lat);
  const cross = (o: T, a: T, b: T) => (a.lon - o.lon) * k * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon) * k;
  const half = (list: T[]) => {
    const h: T[] = [];
    for (const p of list) {
      while (h.length >= 2 && cross(h[h.length - 2], h[h.length - 1], p) <= 0) h.pop();
      h.push(p);
    }
    h.pop();
    return h;
  };
  return [...half(sorted), ...half(sorted.reverse())];
}

function fitPitched(
  map: MlMap,
  pts: [number, number][],
  start: { center?: LngLatLike; zoom?: number; pitch: number; bearing: number },
  pad: { top: number; bottom: number; left: number; right: number },
  maxZoom: number,
): { center: LngLat; zoom: number; pitch: number; bearing: number } {
  // la carte ne suit la taille de son conteneur qu'au rendu suivant : sans ça, le calcul part d'une taille périmée
  map.resize();
  const before = { center: map.getCenter(), zoom: map.getZoom(), pitch: map.getPitch(), bearing: map.getBearing() };
  const { clientWidth: w, clientHeight: h } = map.getContainer();
  const box = { left: pad.left, top: pad.top, right: w - pad.right, bottom: h - pad.bottom };
  map.jumpTo(start);
  for (let pass = 0; pass < 4; pass++) {
    let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
    for (const p of pts) {
      const s = map.project(p);
      x0 = Math.min(x0, s.x);
      x1 = Math.max(x1, s.x);
      y0 = Math.min(y0, s.y);
      y1 = Math.max(y1, s.y);
    }
    if (!Number.isFinite(x0)) break;
    const scale = Math.min((box.right - box.left) / Math.max(1, x1 - x0), (box.bottom - box.top) / Math.max(1, y1 - y0));
    const dx = (box.left + box.right) / 2 - (x0 + x1) / 2;
    const dy = (box.top + box.bottom) / 2 - (y0 + y1) / 2;
    // déjà cadré au pixel près : les passes suivantes ne changeraient rien
    if (Math.abs(dx) < 1 && Math.abs(dy) < 1 && Math.abs(scale - 1) < 0.005) break;
    const center = map.unproject([w / 2 - dx, h / 2 - dy]);
    map.jumpTo({ center, zoom: Math.min(maxZoom, map.getZoom() + Math.log2(scale)) });
  }
  const fitted = { center: map.getCenter(), zoom: map.getZoom(), pitch: map.getPitch(), bearing: map.getBearing() };
  map.jumpTo(before);
  return fitted;
}

/** Sources et calques des repères (flèches, cotations, bornes km), remplis ensuite par setBaseData. */
function addOverlays(map: MlMap) {
  addIcon(map, arrowIcon());
  map.addSource('track-full', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({
    id: 'track-arrows',
    type: 'symbol',
    source: 'track-full',
    layout: {
      'symbol-placement': 'line',
      'symbol-spacing': 70,
      'icon-image': 'arrow',
      'icon-size': ['interpolate', ['linear'], ['zoom'], 9, 0.75, 14, 1, 17, 1.25],
      'icon-rotation-alignment': 'map',
      'icon-pitch-alignment': 'map',
      'icon-allow-overlap': true,
      'icon-ignore-placement': true,
    },
  });

  map.addSource('tech-labels', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({
    id: 'tech-labels',
    type: 'symbol',
    source: 'tech-labels',
    minzoom: 11,
    layout: {
      'icon-image': ['get', 'icon'],
      'icon-anchor': 'bottom',
      'icon-offset': [0, -5],
      'icon-padding': 6,
      // les cotations les plus difficiles gagnent en cas de chevauchement
      'symbol-sort-key': ['-', 10, ['get', 'rank']],
    },
  });

  for (const [kind, [path, color]] of Object.entries(OBSTACLE_STYLE)) addIcon(map, poiIcon(`obstacle-${kind}`, path, color));
  map.addSource('obstacles', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({
    id: 'obstacles',
    type: 'symbol',
    source: 'obstacles',
    minzoom: 11,
    layout: {
      'icon-image': ['concat', 'obstacle-', ['get', 'kind']],
      'icon-allow-overlap': true,
      'icon-size': ['interpolate', ['linear'], ['zoom'], 11, 0.7, 15, 0.95],
    },
  });

  map.addSource('km-markers', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({
    id: 'km-markers',
    type: 'symbol',
    source: 'km-markers',
    layout: {
      'icon-image': ['concat', 'km-', ['to-string', ['get', 'km']]],
      'icon-anchor': 'bottom',
      'icon-allow-overlap': true,
    },
  });
}

/** Voies dont les noms sont déjà sur chaque carte. */
const namedWaysFor = new WeakMap<MlMap, OsmWay[]>();

/** Zoom d'apparition et priorité (0 = placé en premier) des noms de voies, selon leur importance. */
function wayRank(highway: string): [minzoom: number, rank: number] {
  if (/^(trunk|primary|secondary)$/.test(highway)) return [10, 0];
  if (/^(tertiary|unclassified|residential|living_street)$/.test(highway)) return [12, 1];
  return [14, 2];
}

/** Voies OSM nommées autour de la trace (déjà chargées pour l'analyse) : nom, ou numéro pour une route. */
function wayNames(ways: OsmWay[]): GeoJSON.FeatureCollection {
  const features: GeoJSON.Feature[] = [];
  for (const w of ways) {
    const name = w.tags.name ?? w.tags.ref;
    if (!name || w.geometry.length < 2) continue;
    const [minz, rank] = wayRank(w.tags.highway ?? '');
    features.push({ type: 'Feature', properties: { name, minz, rank }, geometry: { type: 'LineString', coordinates: w.geometry.map((p) => [p.lon, p.lat]) } });
  }
  return { type: 'FeatureCollection', features };
}

/** Zoom d'apparition, taille de texte et priorité de chaque type de lieu. */
const PLACE_STYLE: Record<PlaceKind, [minzoom: number, size: number, rank: number]> = {
  city: [7, 17, 0],
  town: [9, 15, 1],
  village: [11, 13.5, 2],
  peak: [12, 12, 3],
  pass: [12, 12, 3],
  hamlet: [13, 12, 4],
  locality: [14, 11.5, 5],
};

function setPlaceData(map: MlMap, places: Place[] | null) {
  (map.getSource('places') as GeoJSONSource | undefined)?.setData({
    type: 'FeatureCollection',
    features: (places ?? []).map((p) => {
      const [minz, size, rank] = PLACE_STYLE[p.kind];
      const relief = p.kind === 'peak' || p.kind === 'pass';
      const name = relief && p.ele !== undefined ? `${p.name}\n${fmt(p.ele)}\u00a0m` : p.name;
      return { type: 'Feature', properties: { name, minz, size, rank, relief }, geometry: { type: 'Point', coordinates: [p.lon, p.lat] } };
    }),
  });
}

/** Noms des voies (le long de leur tracé) et des lieux, sous les repères de la trace qui restent prioritaires. */
function addLabelLayers(map: MlMap) {
  const halo = { 'text-halo-color': 'rgba(255, 255, 255, 0.92)', 'text-halo-width': 1.4, 'text-halo-blur': 0.3 };
  map.addSource('way-names', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({
    id: 'way-names',
    type: 'symbol',
    source: 'way-names',
    minzoom: 10,
    filter: ['>=', ['zoom'], ['get', 'minz']],
    layout: {
      'symbol-placement': 'line',
      'symbol-spacing': 320,
      'symbol-sort-key': ['get', 'rank'],
      'text-field': ['get', 'name'],
      'text-font': ['Inter'],
      'text-size': ['interpolate', ['linear'], ['zoom'], 12, 11, 16, 13],
      'text-max-angle': 30,
      'text-padding': 4,
      // droit face à l'écran même caméra inclinée, mais orienté le long du chemin
      'text-pitch-alignment': 'viewport',
      'text-rotation-alignment': 'map',
    },
    paint: { 'text-color': '#1c2321', ...halo },
  });
  map.addSource('places', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({
    id: 'place-names',
    type: 'symbol',
    source: 'places',
    filter: ['>=', ['zoom'], ['get', 'minz']],
    layout: {
      'symbol-sort-key': ['get', 'rank'],
      'text-field': ['get', 'name'],
      'text-font': ['Inter'],
      'text-size': ['get', 'size'],
      'text-max-width': 9,
      'text-line-height': 1.15,
      'text-padding': 6,
    },
    // relief en brun, comme sur les cartes topographiques
    paint: { 'text-color': ['case', ['get', 'relief'], '#6b4a22', '#1c2321'], ...halo },
  });
}

function addIcon(map: MlMap, icon: MapIcon) {
  if (!map.hasImage(icon.id)) map.addImage(icon.id, icon.image, { pixelRatio: ICON_PIXEL_RATIO });
}

/** Données liées à l'analyse : ligne complète, cotations S, bornes km, départ et arrivée. */
function setBaseData(map: MlMap, base: BaseAnalysis) {
  const src = (id: string) => map.getSource(id) as GeoJSONSource;
  src('track-full').setData(trackLine(base));
  // les chemins OSM ne changent pas quand l'affinage LiDAR remplace l'analyse : inutile de retailler 1 000+ lignes
  if (namedWaysFor.get(map) !== base.osmWays) {
    namedWaysFor.set(map, base.osmWays);
    src('way-names').setData(wayNames(base.osmWays));
  }

  const labels = techLabels(base);
  for (const f of labels.features) {
    const id = f.properties!.icon as string;
    if (map.hasImage(id)) continue;
    const rank = f.properties!.rank as number;
    addIcon(map, techIcon(id, f.properties!.text as string, rank < 0 ? null : rank, !id.endsWith('-m')));
  }
  src('tech-labels').setData(labels);

  const km = kmMarkers(base);
  for (const f of km.features) addIcon(map, kmIcon(f.properties!.km as number));
  src('km-markers').setData(km);

  src('obstacles').setData({
    type: 'FeatureCollection',
    features: base.obstacles.map((o) => ({
      type: 'Feature',
      properties: { kind: o.kind, label: o.label, idx: o.idx },
      geometry: { type: 'Point', coordinates: [o.lon, o.lat] },
    })),
  });

  const a = base.samples[0];
  const z = base.samples[base.samples.length - 1];
  src('endpoints').setData({
    type: 'FeatureCollection',
    features: [
      { type: 'Feature', properties: { kind: 'Départ' }, geometry: { type: 'Point', coordinates: [a.lon, a.lat] } },
      { type: 'Feature', properties: { kind: 'Arrivée' }, geometry: { type: 'Point', coordinates: [z.lon, z.lat] } },
    ],
  });
}

function addDetourLayers(map: MlMap) {
  map.addSource('detour', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({
    id: 'detour-casing',
    type: 'line',
    source: 'detour',
    filter: ['==', ['geometry-type'], 'LineString'],
    layout: { 'line-join': 'round', 'line-cap': 'round' },
    paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 6, 16, 11] },
  });
  map.addLayer({
    id: 'detour-line',
    type: 'line',
    source: 'detour',
    filter: ['==', ['geometry-type'], 'LineString'],
    paint: { 'line-color': DETOUR_COLOR, 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 3.5, 16, 7], 'line-dasharray': [2, 1.2] },
  });
  map.addLayer({
    id: 'detour-ends',
    type: 'circle',
    source: 'detour',
    filter: ['==', ['geometry-type'], 'Point'],
    paint: { 'circle-radius': 5, 'circle-color': DETOUR_COLOR, 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2 },
  });
}

function addEditLayers(map: MlMap) {
  map.addSource('edit', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({
    id: 'edit-replaced',
    type: 'line',
    source: 'edit',
    filter: ['==', ['get', 'kind'], 'replaced'],
    layout: { 'line-join': 'round', 'line-cap': 'butt' },
    paint: { 'line-color': REPLACED_COLOR, 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 3, 16, 6], 'line-dasharray': [1.5, 1.5] },
  });
  map.addLayer({
    id: 'edit-pending',
    type: 'line',
    source: 'edit',
    filter: ['==', ['get', 'kind'], 'pending'],
    layout: { 'line-join': 'round', 'line-cap': 'round' },
    paint: { 'line-color': DETOUR_COLOR, 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 3.5, 16, 7] },
  });
}

function setEditData(map: MlMap, edit: Props['edit']) {
  const src = map.getSource('edit') as GeoJSONSource | undefined;
  if (!src) return;
  const line = (kind: string, pts: { lat: number; lon: number }[]): GeoJSON.Feature => ({
    type: 'Feature',
    properties: { kind },
    geometry: { type: 'LineString', coordinates: pts.map((p) => [p.lon, p.lat]) },
  });
  src.setData({
    type: 'FeatureCollection',
    features: [...edit.replaced.filter((p) => p.length > 1).map((p) => line('replaced', p)), ...edit.pending.filter((p) => p.length > 1).map((p) => line('pending', p))],
  });
}

function setDetourData(map: MlMap, dt: DetourFound | null) {
  const src = map.getSource('detour') as GeoJSONSource | undefined;
  if (!src) return;
  if (!dt || dt.path.length < 2) {
    src.setData({ type: 'FeatureCollection', features: [] });
    return;
  }
  const coords = dt.path.map((p) => [p.lon, p.lat]);
  const point = (c: number[]): GeoJSON.Feature => ({ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: c } });
  src.setData({
    type: 'FeatureCollection',
    features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }, point(coords[0]), point(coords[coords.length - 1])],
  });
}

Tokens : --panel --text --muted --line --accent --accent-ink --chip --shadow

Anatomie

#ÉlémentRôle
1Relief et fondLe terrain en 3D (exagération réglable) sous le fond de carte choisi.
2TraceColorée selon le mode du profil (difficulté, pente ou surface) ; un clic sur la trace place le curseur.
3CalquesSens, bornes, cotation VTT, itinéraires balisés, eau, points d’intérêt, photos, obstacles, selon le menu Contenu de la carte.
4AlternativesLes contournements proposés, en pointillés --detour, et la portion remplacée en --replaced une fois appliqués.

Guidelines

  • MUST
    Garder la carte synchronisée avec le profil (curseur, sélection) ; c’est le même point vu de deux façons.
  • SHOULD
    Laisser la personne reprendre la main à tout moment pendant le survol animé.

Référence de l’API

PropTypeDéfaut
baseBaseAnalysisrequis
riderRiderAnalysisrequis
modeColorModerequis
basemapBasemaprequis
exaggerationnumberrequis
cursorIdxnumber | nullrequis
focusFocus | nullrequis
pick
point de la trace vers lequel voler (clic sur le profil)
{ idx: number; key: number } | nullrequis
overlaysOverlaysrequis
trackHidden
masque la trace pour voir le chemin réel sur le fond de carte
booleanrequis
flyingbooleanrequis
bottomInset
hauteur masquée en bas de la carte par le panneau flottant du profil (px)
numberrequis
insetEase
le panneau du profil s'ouvre ou se replie : le recadrage l'accompagne au lieu de sauter
boolean—
rightInset
largeur masquée à droite par la sidebar flottante (px)
numberrequis
pois
points d'eau et d'intérêt le long de la trace (null tant qu'ils ne sont pas demandés)
Poi[] | nullrequis
places
villages, sommets et cols autour de la trace (null tant qu'ils ne sont pas demandés)
Place[] | nullrequis
photo
photo au sol affichée dans la fiche, repérée sur la carte avec sa direction de prise de vue
GroundPhoto | nullrequis
onTrackClick
clic sur la trace (pour chercher les photos de ce point)
(idx: number) => voidrequis
photoDots
endroits de la trace qui ont des photos au sol (pastilles vertes)
PhotoDot[] | nullrequis
onHover(idx: number | null) => voidrequis
onFlyProgress(idx: number) => voidrequis
onFlyEnd() => voidrequis
onContextLost
contexte WebGL perdu : la carte doit être recréée
() => voidrequis
onClearFocus() => voidrequis
detour
alternative du point d'attention sélectionné, dessinée en pointillés
DetourFound | nullrequis
edit
alternatives appliquées : portions de l'origine remplacées (pointillés roses), tracés pas encore réanalysés
{ replaced: { lat: number; lon: number }[][]; pending: { lat: number; lon: number }[][] }requis