Pattern UX classique dans une app cartographique avec une cible mobile (objet à retrouver, contact à rejoindre, point d’intérêt à proximité) : quand la cible sort du viewport visible, afficher un petit edge marker sur le bord de la carte, dans la direction de la cible, avec sa distance. Tap → recentre.
Math : intersection du segment [user, cible] avec la bbox du viewport, clamp à l’intérieur avec un padding pour ne pas déborder.
function segmentBoundsIntersection(map, userLatLng, targetLatLng) {
const u = map.latLngToContainerPoint(userLatLng);
const t = map.latLngToContainerPoint(targetLatLng);
const size = map.getSize();
// ... calcul du t∈[0,1] de l'intersection segment×rectangle
const PAD = 24;
const cx = Math.max(PAD, Math.min(size.x - PAD, intersectX));
const cy = Math.max(PAD, Math.min(size.y - PAD, intersectY));
return map.containerPointToLatLng([cx, cy]);
}
Test fonctionnel : on zoome, la cible sort du viewport, l’edge marker apparaît. On clique pour recentrer.
Symptôme
Test E2E qui devrait passer, mais le click sur .edge-marker timeout :
locator resolved to <div class="edge-marker">241m</div>
attempting click action
element is visible, enabled and stable
<a class="" title="Recentrer..." aria-label="Recentrer...">📍</a> from
<div class="leaflet-control-container">…</div> subtree intercepts pointer events
retrying click action ...
Le browser confirme : un autre élément (un control Leaflet) intercepte les pointer events au-dessus du marker. En manuel sur le device, on ne voit même pas l’edge marker à cet endroit — il est caché derrière le bouton de zoom.
Cause
Hiérarchie des z-index Leaflet par défaut :
| Élément | z-index |
|---|---|
.leaflet-pane (markers, polylines) | 200-600 |
.leaflet-marker-pane | 600 |
.leaflet-control-container | 1000 |
Les contrôles Leaflet (zoom, attribution, custom buttons) sont systématiquement au-dessus des markers. Et ils capturent les pointer events pour empêcher la map de drag dessus.
Quand la cible est en direction nord-ouest, le segment user→cible traverse le coin top-left du viewport. L’edge marker s’y place. C’est exactement la zone où vivent les controls Leaflet (zoom + 3-4 boutons custom = ~60px × 200px).
Résultat : edge marker placé sous les contrôles → invisible (caché) et intappable (events interceptés).
Le clamp PAD = 24 ne suffit pas : 24px de padding ne sort pas de la zone des controls qui font 60px de large.
Solution : padding asymétrique + zones d’évitement
Au lieu d’un PAD uniforme, identifier les zones où vivent les UI elements et y interdire le placement du marker. Quand le marker y atterrit, on shift sur l’axe approprié pour préserver la direction visuelle (l’utilisateur doit toujours sentir « la cible est par là-bas »).
const PAD = 24;
let cx = Math.max(PAD, Math.min(size.x - PAD, px));
let cy = Math.max(PAD, Math.min(size.y - PAD, py));
// Zones d'évitement (en pixels) :
const CTRL_W = 60, CTRL_H = 215; // controls Leaflet top-left
const OVERLAY_H = 60; // notre overlay distance top-centre
if (cx < CTRL_W && cy < CTRL_H) cx = CTRL_W + 6; // shift à droite des contrôles
if (cy < OVERLAY_H) cy = OVERLAY_H + 6; // shift sous l'overlay distance
// re-clamp dans la viewport au cas où on a shifté hors bounds
cx = Math.min(size.x - PAD, cx);
cy = Math.min(size.y - PAD, cy);
Le shift cx = CTRL_W + 6 est volontairement directionnel : on push horizontalement pour libérer X tout en gardant Y le plus possible (la direction « haut » reste perceptible). Si on shiftait diagonalement, on perdrait la sémantique « la cible est au nord-ouest ».
Lessons learned
L.Controlz-index 1000 > marker pane z-index 600 — tout marker placé sous un control est mort en UX. Vérifier visuellement, pas juste programmatiquement- Les contrôles Leaflet capturent les pointer events (pour ne pas drag la map dessus). Élever le z-index d’un marker au-dessus n’aide pas si l’élément above intercepte avant le hit-test
- Les zones d’évitement doivent être nommées en pixels container, pas en LatLng — c’est l’espace écran qui est contraint, indépendamment du zoom et de la projection
- Le shift doit être directionnel pour préserver la sémantique du edge marker. Shifter aléatoirement ou clamper agressivement (PAD = 100) casse la lisibilité de « la cible est dans cette direction »
- Bug invisible en dev « regard humain » : on zoome, le marker apparaît ailleurs, on ne pense pas à tester le coin nord-ouest spécifiquement. Le test E2E systématique (placer la cible NW + zoom max + assert click) l’a révélé immédiatement
- Patrons d’évitement à inventorier en amont : liste des UI elements top-level de l’app (controls leaflet, overlay flottant, modal, banner, attribution) avec leurs rect approximatifs. Sinon on découvre le problème un par un
- Diagnostic playwright : si le click timeout sur un élément « visible, enabled, stable » mais qu’un autre
intercepts pointer events, c’est ce pattern. La trace contient le sélecteur de l’intercepteur
#leaflet #frontend #ux #mapping #z-index #playwright