- name
- antv-l7
- title
- AntV L7 地理空间可视化
- description
- 基于 WebGL 的大规模地理空间数据可视化引擎。适用于:
(1) 创建交互式 WebGL 地图应用
(2) 可视化地理空间数据(点、线、面、热力图)
(3) 构建位置数据驾驶舱
(4) 添加地图图层、交互和动画效果
(5) 处理并展示 GeoJSON、CSV 等空间数据
- license
- MIT
# AntV L7 地理空间可视化
AntV L7 是基于 WebGL 的大规模地理空间数据可视化引擎,支持多种地图底图和丰富的可视化图层类型。
## ⚠️ 重要约束
**作用域声明:以下约束仅适用于 WeaveFox(本仓库)环境。**
**在 WeaveFox 环境下,高德地图配置禁止传入 token 参数。**
在 WeaveFox 项目中,L7 已配置默认 token。在该环境下,`GaodeMap` 配置中**不要**设置 `token` 字段,否则会导致地图加载失败。
```javascript
// ✅ 正确 - 不传 token
new GaodeMap({
center: [120.19, 30.26],
zoom: 10,
style: 'light',
});
// ❌ 错误 - 禁止传入 token
new GaodeMap({
center: [120.19, 30.26],
zoom: 10,
style: 'light',
token: 'xxx', // 不要这样做!
});
```
## 选型指南
### 简单点位展示(< 100 个点)
**优先使用 Marker**,而非 PointLayer:
```javascript
import { Marker, Popup } from '@antv/l7';
let currentPopup = null;
// ✅ 推荐 - 少量点位使用 Marker
data.forEach((point) => {
const marker = new Marker({
color: '#5B8FF9',
draggable: false,
}).setLnglat([point.lng, point.lat]);
marker.on('click', () => {
// 统一单实例 popup:先移除旧 popup,再创建新 popup
if (currentPopup) scene.removePopup(currentPopup);
currentPopup = new Popup({
anchor: 'bottom',
className: 'outing-popup',
lngLat: { lng: point.lng, lat: point.lat },
html: `<div>${point.name}</div>`,
});
scene.addPopup(currentPopup);
});
// 必须使用 scene.addMarker,否则地图缩放/平移时 Marker 不会更新位置
scene.addMarker(marker);
});
```
**Marker vs PointLayer 对比:**
| 特性 | Marker | PointLayer |
| -------- | ------------------- | ----------------------- |
| 适用场景 | < 100 个点位 | > 100 个点位/大规模数据 |
| 性能 | DOM 渲染,轻量 | WebGL 渲染,高性能 |
| 交互 | 原生 DOM 事件,灵活 | 需绑定图层事件 |
| 自定义 | 支持任意 HTML/CSS | 受限于 L7 样式配置 |
| Popup | 内置支持 | 需额外配置 |
### 大规模数据可视化(> 100 个点)
使用 PointLayer 进行 WebGL 渲染:
```javascript
import { PointLayer } from '@antv/l7';
// ✅ 推荐 - 大量点位使用 PointLayer
const pointLayer = new PointLayer()
.source(data, {
parser: { type: 'json', x: 'lng', y: 'lat' },
})
.shape('circle')
.size(10)
.color('#5B8FF9');
scene.addLayer(pointLayer);
```
## React 最佳实践
### 核心原则
1. **单次初始化**:地图场景只初始化一次
2. **图层分离**:数据更新时只更新图层,不重建地图
3. **完整清理**:组件卸载时清理所有资源
4. **错误处理**:捕获初始化异常
### 硬性规则(防止重复初始化)
以下规则为强约束,生成 React + L7 代码时必须遵守:
1. **初始化 effect 必须空依赖**:创建 `new Scene()` 的 `useEffect` 依赖必须是 `[]`。
2. **禁止在初始化 effect 依赖中放业务状态**:例如 `selectedId`、筛选条件、分页参数。
3. **禁止在初始化 effect 依赖中放易变回调**:例如来自父组件的 `onSelect`、`onClick`、`onChange`。
4. **状态更新走独立 effect**:选中态、数据源、样式变化只能更新图层/Marker,不得 `destroy + new Scene`。
5. **最新值使用 ref**:`loaded` 回调里若需要最新 props,使用 `latestXxxRef.current`,不要依赖闭包旧值。
6. **清理只在卸载时执行**:`scene.destroy()` 只能放在初始化 effect 的 cleanup 中,不能由状态变化触发。
7. **严格区分初始化与更新职责**:初始化负责 `Scene/Layer` 首建,更新负责 `setData`、更新 Marker 或样式。
8. **Popup 锚点默认规则**:点位/Marker 场景默认使用 `anchor: 'bottom'`,让弹窗显示在点位上方。
9. **Popup 方案必须单一**:统一使用 `scene.addPopup(...)`,禁止与 `marker.setPopup(...)`、`popup.addTo(scene)` 混用。
10. **Popup 必须单实例**:创建新 popup 前先 `scene.removePopup(oldPopup)`,避免页面出现多个弹窗。
11. **Marker 形态优先尖角样式**:优先使用“气泡主体 + 尖角(pointer)”图标,尖角必须指向真实经纬度点。
12. **Marker 锚点必须与尖角一致**:尖角在元素底部时,`anchor` 必须使用 `'bottom'`,禁止使用 `'center'` 导致点位偏移。
13. **Marker 文案对齐规则**:文字描述必须在 Marker 主体内水平居中,主体中心与实际点位保持视觉对称。
14. **多行文案稳定规则**:文案允许换行时,主体必须设置 `max-width` 与 `word-break`,且尖角保持 `margin: 0 auto` 居中,防止尖角偏离点位。
15. **应用 UI 层级规则**:地图应用的信息面板、Sidebar、模态框等非地图组件必须设置 `z-index >= 1000`,确保显示在地图 logo 和其他地图固定元素之上。
16. **Popup 关闭按钮规则**:创建 Popup 时必须启用 `closeButton: true`,允许用户点击关闭按钮关闭弹窗,提升交互体验。
17. **Popup 相对位置规则**:Popup 必须显示在 Marker/点位上方,需使用 `offset: [0, -40]` 向上移动,避免与 Marker 气泡重叠遮挡。
反例(禁止):
```typescript
// 会导致 selectedId 变化时反复销毁并重建 Scene
useEffect(() => {
const scene = new Scene(...);
return () => scene.destroy();
}, [selectedId, onSelectLocation]);
```
正例(推荐):
```typescript
// 只初始化一次
useEffect(() => {
const scene = new Scene(...);
return () => scene.destroy();
}, []);
// 状态变化只更新图层,不重建 Scene
useEffect(() => {
pointLayerRef.current?.setData(nextData);
}, [nextData]);
```
说明:React 18 开发环境 `StrictMode` 会对 mount 做额外检查,可能看到两次初始化日志。只要初始化 effect 为空依赖且职责正确,生产环境不会因此重复重建。
### Marker 样式约束(尖角指向点位)
当使用自定义 Marker DOM 时,默认采用“主体 + 尖角”结构:
```typescript
function createMarkerElement(point: { name: string; color: string }) {
const el = document.createElement('div');
el.style.cssText = 'position: relative; transform: translateY(-8px);';
el.innerHTML = `
<div style="
min-width: 88px;
padding: 6px 10px;
border-radius: 999px;
background: ${point.color};
color: #fff;
font-size: 12px;
font-weight: 600;
text-align: center;
line-height: 1.2;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
position: relative;
max-width: 160px;
white-space: normal;
word-break: break-word;
">${point.name}</div>
<div style="
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid ${point.color};
margin: 0 auto;
"></div>
`;
return el;
}
const marker = new Marker({
element: createMarkerElement(point),
anchor: 'bottom',
}).setLnglat([point.lng, point.lat]);
```
约束说明:
- 尖角是点位指示器,必须位于元素底部中心。
- 文案容器使用 `text-align: center`,并保持左右内边距对称。
- 多行文案时必须限制最大宽度并允许换行,避免单行过长导致主体偏斜。
- 为避免遮挡点位,元素整体可上移少量像素(例如 `translateY(-8px)`)。
### 使用 Marker(< 100 个点)
```typescript
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { Scene, Marker, Popup } from '@antv/l7';
import { GaodeMap } from '@antv/l7-maps';
interface Location {
id: string;
lng: number;
lat: number;
name: string;
}
export function MarkerMap({ locations }: { locations: Location[] }) {
const mapContainerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<Scene | null>(null);
const markersRef = useRef<Marker[]>([]);
const popupRef = useRef<Popup | null>(null);
const isInitializedRef = useRef(false);
// 创建/更新 Markers
const updateMarkers = useCallback((scene: Scene, data: Location[]) => {
// 1. 清理旧 marker(避免重复渲染和内存泄漏)
markersRef.current.forEach(marker => {
marker.remove();
});
markersRef.current = [];
// 2. 创建新 marker
data.forEach(point => {
const marker = new Marker({ color: '#5B8FF9' })
.setLnglat([point.lng, point.lat]);
marker.on('click', () => {
if (popupRef.current) {
scene.removePopup(popupRef.current);
}
popupRef.current = new Popup({
anchor: 'bottom',
closeButton: true,
offset: [0, -40],
lngLat: { lng: point.lng, lat: point.lat },
html: `<div>${point.name}</div>`,
});
scene.addPopup(popupRef.current);
});
// 必须使用 scene.addMarker,否则地图缩放/平移时 Marker 不会联动
scene.addMarker(marker);
markersRef.current.push(marker);
});
}, []);
useEffect(() => {
if (!mapContainerRef.current || isInitializedRef.current) return;
const scene = new Scene({
id: mapContainerRef.current,
map: new GaodeMap({
center: [105, 35],
zoom: 4,
style: 'light',
}),
});
sceneRef.current = scene;
isInitializedRef.current = true;
scene.on('loaded', () => {
updateMarkers(scene, locations);
});
return () => {
if (sceneRef.current) {
if (popupRef.current) {
sceneRef.current.removePopup(popupRef.current);
popupRef.current = null;
}
markersRef.current.forEach(marker => marker.remove());
markersRef.current = [];
sceneRef.current.destroy();
sceneRef.current = null;
isInitializedRef.current = false;
}
};
}, []);
// 数据更新时重建 markers
useEffect(() => {
if (isInitializedRef.current && sceneRef.current) {
updateMarkers(sceneRef.current, locations);
}
}, [locations]);
return (
<div
ref={mapContainerRef}
style={{ width: '100%', height: 'min(70vh, 600px)', minHeight: '360px' }}
/>
);
}
```
**Marker 关键注意点:**
- `markersRef` 保存实例数组,用于数据更新时清理旧 marker
- `scene.addMarker(marker)` 必须用它,`.addTo(scene)` 不会注册相机事件,导致 marker 与地图不同步
- Popup 统一使用 `scene.addPopup(...)`,不要和 `marker.setPopup(...)` 混用
- 点位弹窗默认 `anchor: 'bottom'`,确保弹窗显示在 marker 上方
- 组件卸载时遍历 `markersRef.current` 逐一 `remove()`
### 使用 PointLayer(> 100 个点)
```typescript
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { Scene, PointLayer, Popup } from '@antv/l7';
import { GaodeMap } from '@antv/l7-maps';
interface Location {
id: string;
lng: number;
lat: number;
name: string;
color: string;
}
interface MapProps {
locations: Location[];
onLocationClick?: (location: Location) => void;
}
export function TravelMap({ locations, onLocationClick }: MapProps) {
const mapContainerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<Scene | null>(null);
const pointLayerRef = useRef<PointLayer | null>(null);
const popupRef = useRef<Popup | null>(null);
const isInitializedRef = useRef(false);
const latestLocationsRef = useRef<Location[]>(locations);
// 保持最新数据,避免 scene loaded 回调拿到旧闭包数据
useEffect(() => {
latestLocationsRef.current = locations;
}, [locations]);
// 初始化地图(只执行一次)
const initMap = useCallback(() => {
if (!mapContainerRef.current || isInitializedRef.current) return;
try {
const scene = new Scene({
id: mapContainerRef.current,
map: new GaodeMap({
center: [105, 35],
zoom: 4,
style: 'light',
}),
});
sceneRef.current = scene;
isInitializedRef.current = true;
return scene;
} catch (error) {
console.error('地图初始化失败:', error);
throw error;
}
}, []);
// 创建图层
const createLayer = useCallback((scene: Scene, data: Location[]) => {
const pointData = data.map(loc => ({
lng: loc.lng,
lat: loc.lat,
name: loc.name,
color: loc.color,
id: loc.id,
}));
const pointLayer = new PointLayer()
.source(pointData, {
parser: { type: 'json', x: 'lng', y: 'lat' },
})
.shape('circle')
.size(20)
.color('color', (color: string) => color)
.style({
opacity: 0.9,
strokeWidth: 2,
stroke: '#ffffff',
});
View on GitHub