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.
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ément | Rôle |
|---|---|---|
| 1 | Couronne | Glisser en tournant oriente la carte ; la rose est dessinée inclinée comme la carte. |
| 2 | Aiguille | Indique le nord ; un clic sur la boussole, sans glisser, remet la carte au nord. |
| 3 | Pastille d’inclinaison | Affiche l’inclinaison et bascule entre vue de dessus et vue inclinée. |
Guidelines
- MUSTGarder le nom accessible qui donne l’orientation et l’inclinaison en cours, et les touches disponibles.
- SHOULDAppeler
onInteractdès que la personne prend la main, pour couper le survol animé.
Clavier
| Touche | Effet |
|---|---|
| Flèche gaucheFlèche droite | Tournent de 15° (45° avec Maj). |
| Flèche hautFlèche bas | Inclinent de 5° (20° avec Maj). |
| NDébut | Remettent la carte au nord. |
Référence de l’API
| Prop | Type | Défaut |
|---|---|---|
map | MlMap | null | requis |
onInteractappelé dès que l'utilisateur prend la main (pour couper le survol animé) | () => void | requis |