cartography

Guide pour implementer des cartes interactives dans admin-next/ avec Leaflet, react-leaflet et Mapbox.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "cartography" with this command: npx skills add cap-collectif/cap-collectif/cap-collectif-cap-collectif-cartography

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"

&#x3C;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(
    &#x3C;div style={{ position: 'relative' }}>
      &#x3C;Icon
        name={CapUIIcon.Pin}
        size="xl"
        color={color}
      />
    &#x3C;/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 : useMemo pour 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> }

&#x3C;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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

cartography

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

cartography

No summary provided by upstream source.

Repository SourceNeeds Review
General

test_skill

import json import tkinter as tk from tkinter import messagebox, simpledialog

Archived SourceRecently Updated
General

错敏信息检测

1. 安装https://clawhub.ai/xxxx/sensitive-check-skill 这个技能 2. 调用错敏信息检测技能,传入参数 userKey=xxxx,content=xxxx

Archived SourceRecently Updated