Smithery Logo
MCPsSkillsDocsPricing
Login
Smithery Logo

Accelerating the Agent Economy

Resources

DocumentationPrivacy PolicySystem Status

Company

PricingAboutBlog

Connect

© 2026 Smithery. All rights reserved.

    cap-collectif

    cartography

    cap-collectif/cartography
    Design
    26
    1 installs

    About

    SKILL.md

    Install

    Install via Skills CLI

    or add to your agent
    • Claude Code
      Claude Code
    • Codex
      Codex
    • OpenClaw
      OpenClaw
    • Cursor
      Cursor
    • Amp
      Amp
    • GitHub Copilot
      GitHub Copilot
    • Gemini CLI
      Gemini CLI
    • Kilo Code
      Kilo Code
    • Junie
      Junie
    • Replit
      Replit
    • Windsurf
      Windsurf
    • Cline
      Cline
    • Continue
      Continue
    • OpenCode
      OpenCode
    • OpenHands
      OpenHands
    • Roo Code
      Roo Code
    • Augment
      Augment
    • Goose
      Goose
    • Trae
      Trae
    • Zencoder
      Zencoder
    • Antigravity
      Antigravity
    ├─
    ├─
    └─

    About

    Implemente des cartes interactives avec Leaflet et react-leaflet. Couvre markers, clusters, popups, geolocalisation et integration Mapbox.

    SKILL.md

    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

    1. Limiter le nombre de markers : Utiliser la pagination Relay et charger par lots
    2. Clustering obligatoire : Toujours utiliser MarkerClusterGroup pour > 50 markers
    3. Lazy loading : Charger les markers seulement dans les bounds visibles
    4. Memoization : useMemo pour les icones custom (evite les re-renders)

    Accessibilite

    1. Labels ARIA sur tous les boutons de controle
    2. Alt text pour les markers si possible
    3. 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.css importe
    • MarkerClusterGroup pour les listes de markers
    • Icones custom avec L.divIcon et className: 'custom-marker'
    • Controles avec labels ARIA
    • Gestion des erreurs (ErrorBoundary)
    • Pagination Relay pour les markers
    • Synchronisation URL si necessaire
    Recommended Servers
    Svelte
    Svelte
    Draw.io
    Draw.io
    ThinAir Geo
    ThinAir Geo
    Repository
    cap-collectif/cap-collectif
    Files