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 字段,否则会导致地图加载失败。
// ✅ 正确 - 不传 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:
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 渲染:
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 最佳实践
核心原则
- 单次初始化:地图场景只初始化一次
- 图层分离:数据更新时只更新图层,不重建地图
- 完整清理:组件卸载时清理所有资源
- 错误处理:捕获初始化异常
硬性规则(防止重复初始化)
以下规则为强约束,生成 React + L7 代码时必须遵守:
- 初始化 effect 必须空依赖:创建
new Scene()的useEffect依赖必须是[]。 - 禁止在初始化 effect 依赖中放业务状态:例如
selectedId、筛选条件、分页参数。 - 禁止在初始化 effect 依赖中放易变回调:例如来自父组件的
onSelect、onClick、onChange。 - 状态更新走独立 effect:选中态、数据源、样式变化只能更新图层/Marker,不得
destroy + new Scene。 - 最新值使用 ref:
loaded回调里若需要最新 props,使用latestXxxRef.current,不要依赖闭包旧值。 - 清理只在卸载时执行:
scene.destroy()只能放在初始化 effect 的 cleanup 中,不能由状态变化触发。 - 严格区分初始化与更新职责:初始化负责
Scene/Layer首建,更新负责setData、更新 Marker 或样式。 - Popup 锚点默认规则:点位/Marker 场景默认使用
anchor: 'bottom',让弹窗显示在点位上方。 - Popup 方案必须单一:统一使用
scene.addPopup(...),禁止与marker.setPopup(...)、popup.addTo(scene)混用。 - Popup 必须单实例:创建新 popup 前先
scene.removePopup(oldPopup),避免页面出现多个弹窗。 - Marker 形态优先尖角样式:优先使用“气泡主体 + 尖角(pointer)”图标,尖角必须指向真实经纬度点。
- Marker 锚点必须与尖角一致:尖角在元素底部时,
anchor必须使用'bottom',禁止使用'center'导致点位偏移。 - Marker 文案对齐规则:文字描述必须在 Marker 主体内水平居中,主体中心与实际点位保持视觉对称。
- 多行文案稳定规则:文案允许换行时,主体必须设置
max-width与word-break,且尖角保持margin: 0 auto居中,防止尖角偏离点位。 - 应用 UI 层级规则:地图应用的信息面板、Sidebar、模态框等非地图组件必须设置
z-index >= 1000,确保显示在地图 logo 和其他地图固定元素之上。 - Popup 关闭按钮规则:创建 Popup 时必须启用
closeButton: true,允许用户点击关闭按钮关闭弹窗,提升交互体验。 - Popup 相对位置规则:Popup 必须显示在 Marker/点位上方,需使用
offset: [0, -40]向上移动,避免与 Marker 气泡重叠遮挡。
反例(禁止):
// 会导致 selectedId 变化时反复销毁并重建 Scene
useEffect(() => {
const scene = new Scene(...);
return () => scene.destroy();
}, [selectedId, onSelectLocation]);
正例(推荐):
// 只初始化一次
useEffect(() => {
const scene = new Scene(...);
return () => scene.destroy();
}, []);
// 状态变化只更新图层,不重建 Scene
useEffect(() => {
pointLayerRef.current?.setData(nextData);
}, [nextData]);
说明:React 18 开发环境 StrictMode 会对 mount 做额外检查,可能看到两次初始化日志。只要初始化 effect 为空依赖且职责正确,生产环境不会因此重复重建。
Marker 样式约束(尖角指向点位)
当使用自定义 Marker DOM 时,默认采用“主体 + 尖角”结构:
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 个点)
'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保存实例数组,用于数据更新时清理旧 markerscene.addMarker(marker)必须用它,.addTo(scene)不会注册相机事件,导致 marker 与地图不同步- Popup 统一使用
scene.addPopup(...),不要和marker.setPopup(...)混用 - 点位弹窗默认
anchor: 'bottom',确保弹窗显示在 marker 上方 - 组件卸载时遍历
markersRef.current逐一remove()
使用 PointLayer(> 100 个点)
'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',
});
// 点击事件
pointLayer.on('click', (e: any) => {
if (popupRef.current) scene.removePopup(popupRef.current);
const feature = e?.feature;
if (!feature) return;
const location: Location = {
id: feature.id,
lng: feature.lng,
lat: feature.lat,
name: feature.name,
color: feature.color,
};
onLocationClick?.(location);
const lng = e?.lngLat?.lng ?? feature.lng;
const lat = e?.lngLat?.lat ?? feature.lat;
if (typeof lng !== 'number' || typeof lat !== 'number') return;
popupRef.current = new Popup({
anchor: 'bottom',
closeButton: true,
offset: [0, -40],
lngLat: { lng, lat },
title: feature.name ?? '',
html: `<div style="padding: 12px;">${feature.name ?? ''}</div>`,
});
scene.addPopup(popupRef.current);
});
scene.addLayer(pointLayer);
pointLayerRef.current = pointLayer;
}, [onLocationClick]);
// 更新数据
const updateLayers = useCallback((data: Location[]) => {
const pointLayer = pointLayerRef.current;
if (!pointLayer) return;
const pointData = data.map(loc => ({
lng: loc.lng,
lat: loc.lat,
name: loc.name,
color: loc.color,
id: loc.id,
}));
pointLayer.setData(pointData);
}, []);
// 初始化 effect
useEffect(() => {
const scene = initMap();
if (!scene) return;
scene.on('loaded', () => {
createLayer(scene, latestLocationsRef.current);
});
return () => {
if (sceneRef.current) {
if (pointLayerRef.current) {
sceneRef.current.removeLayer(pointLayerRef.current);
}
if (popupRef.current) {
sceneRef.current.removePopup(popupRef.current);
}
sceneRef.current.destroy();
sceneRef.current = null;
isInitializedRef.current = false;
}
};
}, []);
// 数据更新 effect
useEffect(() => {
if (isInitializedRef.current && pointLayerRef.current) {
updateLayers(locations);
}
}, [locations]);
return (
<div
ref={mapContainerRef}
style={{ width: '100%', height: 'min(70vh, 600px)', minHeight: '360px' }}
/>
);
}
Core Workflow
L7 的典型开发流程:
1. 场景初始化 (Scene) → 2. 数据准备 → 3. 创建图层 (Layer) → 4. 添加交互 → 5. 优化性能
支持的图层类型
基础图层
| 图层类型 | 说明 | 适用场景 |
|---|---|---|
| PointLayer | 点图层 | 散点、气泡、符号、文本标注 |
| LineLayer | 线图层 | 路径、弧线、流向线、3D 弧墙 |
| PolygonLayer | 面图层 | 区域填充、3D 挤出、海面效果 |
| HeatmapLayer | 热力图 | 密度分布、聚合热力 |
高级图层
| 图层类型 | 说明 | 适用场景 |
|---|---|---|
| RasterLayer | 栅格图层 | 卫星影像、地形高程 |
| ImageLayer | 图片图层 | 单张图片叠加 |
| WindLayer | 风场图层 | 风向、气流可视化 |
| CityBuildingLayer | 建筑图层 | 3D 城市建筑、扫描光效果 |
| CanvasLayer | Canvas 图层 | 自定义 Canvas 绘制 |
| EarthLayer | 地球图层 | 3D 地球、大气层效果 |
| Marker | DOM 标注 | 少量点位、自定义 DOM |
PointLayer shape 类型
.shape('circle') // 圆形
.shape('square') // 方形
.shape('triangle') // 三角形
.shape('text') // 文本标注
.shape('image') // 图片图标
.shape('cylinder') // 3D 圆柱
LineLayer shape 类型
.shape('line') // 直线
.shape('arc') // 弧线
.shape('arc3d') // 3D 弧线
.shape('greatcircle') // 大圆航线
.shape('wall') // 墙
.shape('flowline') // 流向线
.shape('dash') // 虚线
数据格式
支持的解析器类型
| 类型 | parser.type | 数据格式 | 配置示例 |
|---|---|---|---|
| GeoJSON | 'geojson' |
FeatureCollection | { type: 'geojson' } |
| JSON | 'json' |
Array<Object> | { type: 'json', x: 'lng', y: 'lat' } |
| CSV | 'csv' |
CSV 字符串 | { type: 'csv', x: 'lng', y: 'lat' } |
| MVT | 'mvt' |
Vector Tile URL | { type: 'mvt', minZoom: 0, maxZoom: 18 } |
| Raster | 'raster' |
栅格数据 | { type: 'raster', width: 100, height: 100 } |
数据变换 (Transforms)
.source(data, {
parser: { type: 'json', x: 'lng', y: 'lat' },
transforms: [
// 聚合
{
type: 'cluster',
radius: 80,
method: 'sum',
field: 'value'
},
// 过滤
{
type: 'filter',
callback: (item) => item.value > 100
},
],
})
常用控件
// L7 2.x 推荐:统一从 @antv/l7 导入
import { Zoom, Fullscreen, LayerSwitch, ExportImage } from '@antv/l7';
// 等价写法:按子包导入(在按需拆包场景下可用)
// import { Zoom, Fullscreen, LayerSwitch, ExportImage } from '@antv/l7-component';
// 缩放控件
scene.addControl(
new Zoom({
position: 'bottomright',
zoomInText: '+',
zoomOutText: '-',
}),
);
// 全屏控件
scene.addControl(
new Fullscreen({
position: 'topleft',
}),
);
// 图层切换
scene.addControl(
new LayerSwitch({
layers: [layer1, layer2],
position: 'topright',
}),
);
// 导出图片
scene.addControl(
new ExportImage({
position: 'topright',
imageType: 'png',
}),
);
常用 Scene API
地图控制
// 设置中心点
scene.setCenter([120.19, 30.26]);
// 设置缩放
scene.setZoom(12);
// 适应边界 - 自动缩放地图以容纳指定范围
scene.fitBounds([
[minLng, minLat],
[maxLng, maxLat],
]);
// 完整参数
scene.fitBounds(
[
[minLng, minLat],
[maxLng, maxLat],
], // 边界范围 [西南角, 东北角]
{
padding: [50, 50, 50, 50], // 内边距 [上, 右, 下, 左],单位像素
duration: 500, // 动画时长(毫秒),默认无动画
maxZoom: 15, // 最大缩放级别限制
},
);
// 根据数据范围自动适配(配合 Layer 使用)
scene.fitBounds(pointLayer.getBounds());
// 切换地图样式
scene.setMapStyle('dark');
// 获取当前状态
const zoom = scene.getZoom();
const center = scene.getCenter();
导出功能
// 导出为 PNG
const png = await scene.exportPng('png');
// 导出地图(含底图)
const mapImage = await scene.exportMap('png');
交互方法
// 高亮
layer.active({ color: '#ff0000' });
// 选中
layer.select({ color: '#00ff00' });
// 框选
scene.enableBoxSelect(true);
常见问题
| 问题 | 解决方案 |
|---|---|
| 重复初始化 | 使用 isInitializedRef 标记 + 空依赖数组 |
| 初始化多次(React) | 初始化 useEffect 仅用 [];不要依赖 selectedId/父回调,状态变化走独立更新 effect |
| 弹窗重复出现 | 统一 scene.addPopup(...);新建前先移除旧 popup;禁止与 marker.setPopup(...) 混用 |
| 图层重复创建 | 分离初始化和更新逻辑 |
| Popup 累积 | 创建前先移除旧 popup |
| 清理不完整 | 完整的 cleanup 函数 |
| 缺少错误处理 | try-catch 包裹初始化 |
| autoFit 跳动 | 移除 autoFit,手动控制视图 |