cartography
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCartographie / Maps
地图功能实现
Guide pour implementer des cartes interactives dans avec Leaflet, react-leaflet et Mapbox.
admin-next/本指南介绍如何在项目中使用Leaflet、react-leaflet和Mapbox实现交互式地图。
admin-next/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
- leaflet : 1.9.4 - 地图基础库
- react-leaflet : 4.2.1 - Leaflet的React绑定库
- react-leaflet-markercluster : 标记点聚合组件
- Mapbox : 瓦片地图与地理编码服务
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 customcomponents/
└── MyFeature/
└── Map/
├── MyFeatureMap.tsx # 包含Suspense的外层组件
├── MyFeatureMapContainer.tsx # MapContainer与核心逻辑
├── MyFeatureMapMarkers.tsx # 带分页的标记点组件
├── MyFeatureMarker.tsx # 单个标记点组件
└── MapControls.tsx # 自定义控制组件Composant Map de base
基础地图组件
typescript
// 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>
)typescript
// MyFeatureMap.tsx
import { Suspense } from 'react'
import { Spinner, Box } from '@cap-collectif/ui'
import dynamic from 'next/dynamic'
// 重要提示:Leaflet不支持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
MapContainer组件
typescript
// 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
}typescript
// 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" }
# ... 其他筛选条件
) {
...MyFeatureMapMarkers_query @arguments(bounds: $bounds)
}
`
const DEFAULT_CENTER: [number, number] = [46.603354, 1.888334] // 法国坐标
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} // 使用自定义控制组件
>
<CapcoTileLayer mapTokens={mapTokens} />
<MapEventHandler />
<MyFeatureMapMarkers query={data} />
<MapControls />
</MapContainer>
)
}
// 监听地图事件的Hook
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}`
// 更新URL中的筛选参数
},
zoomend: () => {
// 缩放变化时的逻辑
},
})
return null
}Markers avec clustering
带聚合功能的标记点组件
typescript
// 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>
)
}typescript
// 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
}
}
}
}
}
`
// 聚合配置
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)
// 按需加载更多标记点
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
自定义图标标记点组件
typescript
// 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>
)typescript
// 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
// 使用React创建自定义图标
const icon = React.useMemo(() => {
const color = category?.color ?? '#1E88E5'
return L.divIcon({
className: 'custom-marker', // 重要:覆盖默认样式
html: renderToString(
<div style={{ position: 'relative' }}>
<Icon
name={CapUIIcon.Pin}
size="xl"
color={color}
/>
</div>
),
iconSize: [30, 40],
iconAnchor: [15, 40], // 锚点位于图标底部中心
popupAnchor: [0, -40], // 弹出窗口显示在图标上方
})
}, [category?.color])
return (
<Marker
position={[address.lat, address.lng]}
icon={icon}
eventHandlers={{
click: () => {
// 埋点统计、页面跳转等逻辑
},
}}
>
<Popup>
<MarkerPopupContent item={item} />
</Popup>
</Marker>
)
}
const MarkerPopupContent: React.FC<{ item: Props['item'] }> = ({ item }) => (
<div style={{ minWidth: 200 }}>
<strong>{item.title}</strong>
{/* 弹出窗口内容 */}
</div>
)Controles custom
自定义控制组件
typescript
// 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>
)
}typescript
// 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="定位到我的位置"
>
<Icon name={CapUIIcon.Location} />
</Button>
<Button
variant="secondary"
size="small"
onClick={handleZoomIn}
aria-label="放大地图"
>
<Icon name={CapUIIcon.Add} />
</Button>
<Button
variant="secondary"
size="small"
onClick={handleZoomOut}
aria-label="缩小地图"
>
<Icon name={CapUIIcon.Remove} />
</Button>
</Flex>
)
}Geolocalisation et recherche d'adresse
地理定位与地址搜索
typescript
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 }
}typescript
import { useMap } from 'react-leaflet'
// 处理地理定位的Hook
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包含当前定位坐标
})
map.once('locationerror', (e) => {
setIsLocating(false)
setError(e.message)
})
}, [map])
return { locate, isLocating, error }
}Synchronisation URL (filtres geographiques)
URL同步(地理筛选)
typescript
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
}typescript
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')
// 地图移动时更新URL参数
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}`)
},
})
// 页面加载时从URL恢复地图视图
React.useEffect(() => {
if (center) {
const [lat, lng] = center.split(',').map(Number)
if (!isNaN(lat) && !isNaN(lng)) {
map.setView([lat, lng], map.getZoom())
}
}
}, []) // 仅在组件挂载时执行
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 : pour les icones custom (evite les re-renders)
useMemo
- 限制标记点数量:使用Relay分页功能,分批加载标记点
- 强制使用聚合:当标记点数量超过50个时,必须使用MarkerClusterGroup
- 懒加载:仅加载当前可视范围内的标记点
- 缓存优化:使用缓存自定义图标,避免不必要的重渲染
useMemo
Accessibilite
可访问性
- Labels ARIA sur tous les boutons de controle
- Alt text pour les markers si possible
- Navigation clavier : Les popups doivent etre accessibles
- 所有控制按钮添加ARIA标签
- 尽可能为标记点添加替代文本
- 键盘导航支持:弹出窗口需支持键盘访问
SSR / Hydration
SSR/水合处理
typescript
// TOUJOURS utiliser dynamic import avec ssr: false
const MapComponent = dynamic(() => import('./MapComponent'), {
ssr: false,
loading: () => <MapSkeleton />,
})typescript
// 务必使用动态导入并设置ssr: false
const MapComponent = dynamic(() => import('./MapComponent'), {
ssr: false,
loading: () => <MapSkeleton />,
})Gestion des erreurs
错误处理
typescript
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>
)typescript
const MapWithErrorBoundary: React.FC<Props> = (props) => (
<ErrorBoundary
fallback={
<Box p="lg" bg="gray.100" textAlign="center">
<Text>地图加载失败</Text>
<Button onClick={() => window.location.reload()}>
重新加载
</Button>
</Box>
}
>
<MyFeatureMap {...props} />
</ErrorBoundary>
)Utilitaires disponibles
可用工具函数
typescript
// 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'typescript
// admin-next/utils/leaflet.tsx
// 生成Mapbox瓦片地图URL
import { getMapboxUrl, CapcoTileLayer } from '@utils/leaflet'
// 解析URL中的坐标参数
import { parseLatLng, parseLatLngBounds } from '@utils/leaflet'
// 格式化GeoJSON并添加样式
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
- 完整地图组件:VoteStepMapContainer.tsx
- 带聚合的标记点组件:VoteStepMapMarkers.tsx
- 自定义标记点组件:ProposalMarker.tsx
- 控制组件:LocateAndZoomControl.tsx
Checklist
检查清单
- Import dynamique avec
ssr: false - importe
leaflet/dist/leaflet.css - MarkerClusterGroup pour les listes de markers
- Icones custom avec et
L.divIconclassName: 'custom-marker' - Controles avec labels ARIA
- Gestion des erreurs (ErrorBoundary)
- Pagination Relay pour les markers
- Synchronisation URL si necessaire
- 使用动态导入并设置
ssr: false - 导入样式文件
leaflet/dist/leaflet.css - 标记点列表使用MarkerClusterGroup组件
- 使用创建自定义图标并设置
L.divIconclassName: 'custom-marker' - 控制按钮添加ARIA标签
- 为地图组件添加ErrorBoundary错误处理
- 使用Relay分页加载标记点
- 按需实现URL参数同步功能