| name | cartography |
| description | Implemente des cartes interactives avec Leaflet et react-leaflet. Couvre markers, clusters, popups, geolocalisation et integration Mapbox. |
Cartographie / Maps
Guide pour implementer des cartes interactives dans admin-next/ avec Leaflet, react-leaflet et Mapbox.
Stack technique
- leaflet : 1.9.4 - Librairie de cartographie
- react-leaflet : 4.2.1 - Binding React pour Leaflet
- react-leaflet-markercluster : Clustering de markers
- Mapbox : Tiles et geocoding
Structure recommandee
components/
└── MyFeature/
└── Map/
├── MyFeatureMap.tsx # Wrapper avec Suspense
├── MyFeatureMapContainer.tsx # MapContainer + logique
├── MyFeatureMapMarkers.tsx # Markers avec pagination
├── MyFeatureMarker.tsx # Marker individuel
└── MapControls.tsx # Controles custom
Composant Map de base
// MyFeatureMap.tsx
import { Suspense } from 'react'
import { Spinner, Box } from '@cap-collectif/ui'
import dynamic from 'next/dynamic'
// IMPORTANT: Leaflet ne supporte pas le SSR
const MyFeatureMapContainer = dynamic(
() => import('./MyFeatureMapContainer'),
{ ssr: false }
)
type Props = {
query: MyFeatureMap_query$key
}
export const MyFeatureMap: React.FC<Props> = ({ query }) => {
return (
<Box height="500px" width="100%" position="relative">
<Suspense fallback={<MapSkeleton />}>
<MyFeatureMapContainer query={query} />
</Suspense>
</Box>
)
}
const MapSkeleton = () => (
<Box
height="100%"
width="100%"
bg="gray.100"
display="flex"
alignItems="center"
justifyContent="center"
>
<Spinner />
</Box>
)
MapContainer
// MyFeatureMapContainer.tsx
import 'leaflet/dist/leaflet.css'
import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet'
import { graphql, useFragment } from 'react-relay'
import { CapcoTileLayer, getMapboxUrl } from '@utils/leaflet'
import { useAppContext } from '@components/BackOffice/AppProvider/App.context'
const FRAGMENT = graphql`
fragment MyFeatureMapContainer_query on Query
@argumentDefinitions(
bounds: { type: "String" }
# ... autres filtres
) {
...MyFeatureMapMarkers_query @arguments(bounds: $bounds)
}
`
const DEFAULT_CENTER: [number, number] = [46.603354, 1.888334] // France
const DEFAULT_ZOOM = 6
const MAX_ZOOM = 18
type Props = {
query: MyFeatureMapContainer_query$key
}
export const MyFeatureMapContainer: React.FC<Props> = ({ query: queryRef }) => {
const data = useFragment(FRAGMENT, queryRef)
const { mapTokens } = useAppContext()
return (
<MapContainer
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
maxZoom={MAX_ZOOM}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={true}
zoomControl={false} // On utilise des controles custom
>
<CapcoTileLayer mapTokens={mapTokens} />
<MapEventHandler />
<MyFeatureMapMarkers query={data} />
<MapControls />
</MapContainer>
)
}
// Hook pour ecouter les events de la map
const MapEventHandler: React.FC = () => {
const map = useMapEvents({
moveend: () => {
const bounds = map.getBounds()
const boundsString = `${bounds.getSouthWest().lat},${bounds.getSouthWest().lng},${bounds.getNorthEast().lat},${bounds.getNorthEast().lng}`
// Mettre a jour les filtres URL
},
zoomend: () => {
// Logique au changement de zoom
},
})
return null
}
Markers avec clustering
// MyFeatureMapMarkers.tsx
import { Marker, Popup } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-markercluster'
import { graphql, usePaginationFragment } from 'react-relay'
import L from 'leaflet'
const FRAGMENT = graphql`
fragment MyFeatureMapMarkers_query on Query
@argumentDefinitions(
count: { type: "Int!", defaultValue: 100 }
cursor: { type: "String" }
bounds: { type: "String" }
)
@refetchable(queryName: "MyFeatureMapMarkersPaginationQuery") {
items(first: $count, after: $cursor, bounds: $bounds)
@connection(key: "MyFeatureMapMarkers_items") {
edges {
node {
id
title
address {
lat
lng
}
}
}
}
}
`
// Configuration du clustering
const CLUSTER_OPTIONS = {
spiderfyOnMaxZoom: true,
zoomToBoundsOnClick: true,
maxClusterRadius: 30,
spiderfyDistanceMultiplier: 4,
showCoverageOnHover: false,
}
export const MyFeatureMapMarkers: React.FC<Props> = ({ query: queryRef }) => {
const { data, loadNext, hasNext } = usePaginationFragment(FRAGMENT, queryRef)
// Charger plus de markers si necessaire
React.useEffect(() => {
if (hasNext) {
loadNext(100)
}
}, [hasNext, loadNext])
const markers = data.items.edges
.map(edge => edge.node)
.filter(node => node.address?.lat && node.address?.lng)
return (
<MarkerClusterGroup {...CLUSTER_OPTIONS}>
{markers.map(item => (
<MyFeatureMarker key={item.id} item={item} />
))}
</MarkerClusterGroup>
)
}
Marker custom avec icone
// MyFeatureMarker.tsx
import { Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import { renderToString } from 'react-dom/server'
import { Icon, CapUIIcon } from '@cap-collectif/ui'
type Props = {
item: {
id: string
title: string
address: { lat: number; lng: number }
category?: { color: string; icon?: string } | null
}
}
export const MyFeatureMarker: React.FC<Props> = ({ item }) => {
const { address, category } = item
// Creer une icone custom avec React
const icon = React.useMemo(() => {
const color = category?.color ?? '#1E88E5'
return L.divIcon({
className: 'custom-marker', // Important: evite les styles par defaut
html: renderToString(
<div style={{ position: 'relative' }}>
<Icon
name={CapUIIcon.Pin}
size="xl"
color={color}
/>
</div>
),
iconSize: [30, 40],
iconAnchor: [15, 40], // Point d'ancrage en bas au centre
popupAnchor: [0, -40], // Popup au-dessus du marker
})
}, [category?.color])
return (
<Marker
position={[address.lat, address.lng]}
icon={icon}
eventHandlers={{
click: () => {
// Analytics, navigation, etc.
},
}}
>
<Popup>
<MarkerPopupContent item={item} />
</Popup>
</Marker>
)
}
const MarkerPopupContent: React.FC<{ item: Props['item'] }> = ({ item }) => (
<div style={{ minWidth: 200 }}>
<strong>{item.title}</strong>
{/* Contenu du popup */}
</div>
)
Controles custom
// MapControls.tsx
import { useMap } from 'react-leaflet'
import { Flex, Button, Icon, CapUIIcon } from '@cap-collectif/ui'
export const MapControls: React.FC = () => {
const map = useMap()
const handleZoomIn = () => map.zoomIn()
const handleZoomOut = () => map.zoomOut()
const handleLocate = () => {
map.locate({ setView: true, maxZoom: 16 })
}
return (
<Flex
direction="column"
position="absolute"
top={4}
right={4}
zIndex={1000}
gap={2}
>
<Button
variant="secondary"
size="small"
onClick={handleLocate}
aria-label="Ma position"
>
<Icon name={CapUIIcon.Location} />
</Button>
<Button
variant="secondary"
size="small"
onClick={handleZoomIn}
aria-label="Zoom avant"
>
<Icon name={CapUIIcon.Add} />
</Button>
<Button
variant="secondary"
size="small"
onClick={handleZoomOut}
aria-label="Zoom arriere"
>
<Icon name={CapUIIcon.Remove} />
</Button>
</Flex>
)
}
Geolocalisation et recherche d'adresse
import { useMap } from 'react-leaflet'
// Hook pour gerer la geolocalisation
const useGeolocation = () => {
const map = useMap()
const [isLocating, setIsLocating] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const locate = React.useCallback(() => {
setIsLocating(true)
setError(null)
map.locate({
setView: true,
maxZoom: 16,
enableHighAccuracy: true,
})
map.once('locationfound', (e) => {
setIsLocating(false)
// e.latlng contient la position
})
map.once('locationerror', (e) => {
setIsLocating(false)
setError(e.message)
})
}, [map])
return { locate, isLocating, error }
}
Synchronisation URL (filtres geographiques)
import { parseAsString, useQueryState } from 'nuqs'
import { useMap, useMapEvents } from 'react-leaflet'
const MapUrlSync: React.FC = () => {
const map = useMap()
const [bounds, setBounds] = useQueryState('bounds')
const [center, setCenter] = useQueryState('center')
// Mettre a jour l'URL quand la map bouge
useMapEvents({
moveend: () => {
const mapBounds = map.getBounds()
const boundsStr = [
mapBounds.getSouth(),
mapBounds.getWest(),
mapBounds.getNorth(),
mapBounds.getEast(),
].join(',')
setBounds(boundsStr)
const mapCenter = map.getCenter()
setCenter(`${mapCenter.lat},${mapCenter.lng}`)
},
})
// Restaurer la vue depuis l'URL au chargement
React.useEffect(() => {
if (center) {
const [lat, lng] = center.split(',').map(Number)
if (!isNaN(lat) && !isNaN(lng)) {
map.setView([lat, lng], map.getZoom())
}
}
}, []) // Seulement au montage
return null
}
Bonnes pratiques
Performance
- Limiter le nombre de markers : Utiliser la pagination Relay et charger par lots
- Clustering obligatoire : Toujours utiliser MarkerClusterGroup pour > 50 markers
- Lazy loading : Charger les markers seulement dans les bounds visibles
- Memoization :
useMemopour les icones custom (evite les re-renders)
Accessibilite
- Labels ARIA sur tous les boutons de controle
- Alt text pour les markers si possible
- Navigation clavier : Les popups doivent etre accessibles
SSR / Hydration
// TOUJOURS utiliser dynamic import avec ssr: false
const MapComponent = dynamic(() => import('./MapComponent'), {
ssr: false,
loading: () => <MapSkeleton />,
})
Gestion des erreurs
const MapWithErrorBoundary: React.FC<Props> = (props) => (
<ErrorBoundary
fallback={
<Box p="lg" bg="gray.100" textAlign="center">
<Text>Impossible de charger la carte</Text>
<Button onClick={() => window.location.reload()}>
Reessayer
</Button>
</Box>
}
>
<MyFeatureMap {...props} />
</ErrorBoundary>
)
Utilitaires disponibles
// admin-next/utils/leaflet.tsx
// Generer l'URL des tiles Mapbox
import { getMapboxUrl, CapcoTileLayer } from '@utils/leaflet'
// Parser les coordonnees depuis l'URL
import { parseLatLng, parseLatLngBounds } from '@utils/leaflet'
// Formater les GeoJSON avec styles
import { formatGeoJsons, convertToGeoJsonStyle } from '@utils/leaflet'
Exemples du projet
- Map complete : VoteStepMapContainer.tsx
- Markers avec clustering : VoteStepMapMarkers.tsx
- Marker custom : ProposalMarker.tsx
- Controles : LocateAndZoomControl.tsx
Checklist
- Import dynamique avec
ssr: false -
leaflet/dist/leaflet.cssimporte - MarkerClusterGroup pour les listes de markers
- Icones custom avec
L.divIconetclassName: 'custom-marker' - Controles avec labels ARIA
- Gestion des erreurs (ErrorBoundary)
- Pagination Relay pour les markers
- Synchronisation URL si necessaire