CameraControl

←→

Boussole unique de la caméra 3D — la couronne tourne la carte, le centre l’incline, un clic remet le nord, la pastille bascule entre vue de dessus et vue inclinée.

stableÀ valider
// Titre : Vue inclinée, orientée au nord-ouest (carte simulée)
import { useMemo } from 'react';
import CameraControl from '@pathou/components/CameraControl';
import { fakeMap } from '../_fixtures/fakeMap';

// dans Pathou, `map` est la carte MapLibre ; ici une carte simulée qui ne garde que l’orientation et l’inclinaison
export default function Exemple() {
  const map = useMemo(() => fakeMap(-30, 50), []);
  return (
    <div style={{ position: 'relative', width: 120, height: 120 }}>
      <CameraControl map={map} onInteract={() => {}} />
    </div>
  );
}

Installation

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

src/components/CameraControl.tsx
import { useEffect, useRef, useState } from 'react';
import type { Map as MlMap } from 'maplibre-gl';

interface Props {
  map: MlMap | null;
  /** appelé dès que l'utilisateur prend la main (pour couper le survol animé) */
  onInteract: () => void;
}

const MAX_PITCH = 85;
/** au-delà de cette fraction du rayon, on est sur la couronne (rotation) ; en dessous, au centre (inclinaison) */
const RING = 0.62;
const norm = (deg: number) => ((((deg + 180) % 360) + 360) % 360) - 180;
const clampPitch = (p: number) => Math.min(MAX_PITCH, Math.max(0, p));

type Drag = { mode: 'rotate' | 'tilt'; x: number; y: number; angle: number; bearing: number; pitch: number; moved: boolean };

/**
 * Boussole unique pour la caméra :
 * - couronne : glisser en tournant pour orienter la carte ;
 * - centre : glisser vers le haut / le bas pour incliner, à gauche / à droite pour tourner ;
 * - clic : retour au nord ; pastille du bas : bascule vue de dessus ↔ vue inclinée.
 * La rose est dessinée inclinée comme la carte, pour voir l'angle d'un coup d'œil.
 */
export default function CameraControl({ map, onInteract }: Props) {
  const [cam, setCam] = useState({ bearing: 0, pitch: 0 });
  const ref = useRef<HTMLDivElement>(null);
  const drag = useRef<Drag | null>(null);

  useEffect(() => {
    if (!map) return;
    const update = () => setCam({ bearing: map.getBearing(), pitch: map.getPitch() });
    update();
    map.on('move', update);
    return () => void map.off('move', update);
  }, [map]);

  if (!map) return null;
  const { bearing, pitch } = cam;

  const geom = (e: React.PointerEvent) => {
    const r = ref.current!.getBoundingClientRect();
    const dx = e.clientX - (r.left + r.width / 2);
    const dy = e.clientY - (r.top + r.height / 2);
    // angle en degrés, sens horaire depuis le haut ; distance en fraction du rayon
    return { angle: (Math.atan2(dx, -dy) * 180) / Math.PI, dist: Math.hypot(dx, dy) / (r.width / 2) };
  };

  const onDown = (e: React.PointerEvent<HTMLDivElement>) => {
    e.currentTarget.setPointerCapture(e.pointerId);
    const g = geom(e);
    drag.current = { mode: g.dist > RING ? 'rotate' : 'tilt', x: e.clientX, y: e.clientY, angle: g.angle, bearing: map.getBearing(), pitch: map.getPitch(), moved: false };
    onInteract();
  };
  const onMove = (e: React.PointerEvent) => {
    const d = drag.current;
    if (!d) return;
    const dx = e.clientX - d.x;
    const dy = e.clientY - d.y;
    if (!d.moved && Math.hypot(dx, dy) < 4) return;
    d.moved = true;
    map.stop();
    if (d.mode === 'rotate') map.jumpTo({ bearing: norm(d.bearing - (geom(e).angle - d.angle)) });
    else map.jumpTo({ pitch: clampPitch(d.pitch - dy * 0.8), bearing: norm(d.bearing - dx * 0.6) });
  };
  const onUp = () => {
    if (drag.current && !drag.current.moved) map.easeTo({ bearing: 0, duration: 300 });
    drag.current = null;
  };
  const onKey = (e: React.KeyboardEvent) => {
    const big = e.shiftKey;
    const to: { bearing?: number; pitch?: number } = {};
    if (e.key === 'ArrowLeft') to.bearing = norm(bearing + (big ? 45 : 15));
    else if (e.key === 'ArrowRight') to.bearing = norm(bearing - (big ? 45 : 15));
    else if (e.key === 'ArrowUp') to.pitch = clampPitch(pitch + (big ? 20 : 5));
    else if (e.key === 'ArrowDown') to.pitch = clampPitch(pitch - (big ? 20 : 5));
    else if (e.key === 'Home' || e.key === 'n' || e.key === 'N') to.bearing = 0;
    else return;
    e.preventDefault();
    e.stopPropagation();
    onInteract();
    // au clavier, souvent touche maintenue : changement immédiat, sans animation
    map.jumpTo(to);
  };
  const togglePitch = () => {
    onInteract();
    map.easeTo({ pitch: pitch > 5 ? 0 : 60, duration: 300 });
  };

  const ticks = Array.from({ length: 24 }, (_, i) => i * 15);
  return (
    <div className="camera-control">
      <div
        ref={ref}
        className="compass"
        role="application"
        tabIndex={0}
        aria-label={`Caméra\u00a0: orientation ${Math.round(((-bearing % 360) + 360) % 360)}° depuis le nord, inclinaison ${Math.round(pitch)}°. Flèches gauche / droite pour tourner, haut / bas pour incliner, N pour le nord.`}
        title={'Couronne\u00a0: glisser pour tourner\nCentre\u00a0: glisser ↑ ↓ pour incliner, ← → pour tourner\nClic\u00a0: retour au nord'}
        onPointerDown={onDown}
        onPointerMove={onMove}
        onPointerUp={onUp}
        onPointerCancel={onUp}
        onPointerMoveCapture={(e) => {
          // curseur adapté à la zone survolée
          if (drag.current) return;
          e.currentTarget.dataset.zone = geom(e).dist > RING ? 'ring' : 'center';
        }}
        onKeyDown={onKey}
      >
        <div className="compass-rose" style={{ transform: `perspective(240px) rotateX(${pitch * 0.62}deg)` }}>
          <svg viewBox="0 0 100 100" aria-hidden="true">
            <circle className="compass-face" cx="50" cy="50" r="48" />
            <circle className="compass-inner" cx="50" cy="50" r={48 * RING} />
            <g transform={`rotate(${-bearing} 50 50)`}>
              {ticks.map((a) => (
                <line
                  key={a}
                  className={a % 90 === 0 ? 'compass-tick major' : 'compass-tick'}
                  x1="50"
                  y1={a % 90 === 0 ? 3 : 4}
                  x2="50"
                  y2={a % 90 === 0 ? 9 : 7}
                  transform={`rotate(${a} 50 50)`}
                />
              ))}
              {(
                [
                  ['N', 0],
                  ['E', 90],
                  ['S', 180],
                  ['O', 270],
                ] as const
              ).map(([l, a]) => (
                <text key={l} className={l === 'N' ? 'compass-letter north' : 'compass-letter'} x="50" y="15" transform={`rotate(${a} 50 50)`}>
                  {l}
                </text>
              ))}
              <path className="needle-n" d="M50 24 L56.5 50 L43.5 50 Z" />
              <path className="needle-s" d="M50 76 L56.5 50 L43.5 50 Z" />
            </g>
            <circle className="compass-hub" cx="50" cy="50" r="3.5" />
          </svg>
        </div>
        <button
          className="compass-pitch"
          onPointerDown={(e) => e.stopPropagation()}
          onClick={togglePitch}
          title={pitch > 5 ? 'Passer en vue de dessus (2D)' : 'Passer en vue inclinée (3D)'}
          aria-label={pitch > 5 ? 'Passer en vue de dessus' : 'Passer en vue inclinée'}
        >
          {pitch > 5 ? `${Math.round(pitch)}°` : '2D'}
        </button>
      </div>
    </div>
  );
}

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

Utilisation

import CameraControl from './components/CameraControl';

<CameraControl map={map} onInteract={stopFlyover} />

Anatomie

#ÉlémentRôle
1CouronneGlisser en tournant oriente la carte ; la rose est dessinée inclinée comme la carte.
2AiguilleIndique le nord ; un clic sur la boussole, sans glisser, remet la carte au nord.
3Pastille d’inclinaisonAffiche l’inclinaison et bascule entre vue de dessus et vue inclinée.

Guidelines

  • MUST
    Garder le nom accessible qui donne l’orientation et l’inclinaison en cours, et les touches disponibles.
  • SHOULD
    Appeler onInteract dès que la personne prend la main, pour couper le survol animé.

Clavier

ToucheEffet
Flèche gaucheFlèche droiteTournent de 15° (45° avec Maj).
Flèche hautFlèche basInclinent de 5° (20° avec Maj).
NDébutRemettent la carte au nord.

Référence de l’API

PropTypeDéfaut
mapMlMap | nullrequis
onInteract
appelé dès que l'utilisateur prend la main (pour couper le survol animé)
() => voidrequis