con un clic
antv-l7
// 基于 WebGL 的大规模地理空间数据可视化引擎。适用于: (1) 创建交互式 WebGL 地图应用 (2) 可视化地理空间数据(点、线、面、热力图) (3) 构建位置数据驾驶舱 (4) 添加地图图层、交互和动画效果 (5) 处理并展示 GeoJSON、CSV 等空间数据
// 基于 WebGL 的大规模地理空间数据可视化引擎。适用于: (1) 创建交互式 WebGL 地图应用 (2) 可视化地理空间数据(点、线、面、热力图) (3) 构建位置数据驾驶舱 (4) 添加地图图层、交互和动画效果 (5) 处理并展示 GeoJSON、CSV 等空间数据
| name | antv-l7 |
| title | AntV L7 地理空间可视化 |
| description | 基于 WebGL 的大规模地理空间数据可视化引擎。适用于: (1) 创建交互式 WebGL 地图应用 (2) 可视化地理空间数据(点、线、面、热力图) (3) 构建位置数据驾驶舱 (4) 添加地图图层、交互和动画效果 (5) 处理并展示 GeoJSON、CSV 等空间数据 |
| license | MIT |
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', // 不要这样做!
});
优先使用 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 | 内置支持 | 需额外配置 |
使用 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 + L7 代码时必须遵守:
new Scene() 的 useEffect 依赖必须是 []。selectedId、筛选条件、分页参数。onSelect、onClick、onChange。destroy + new Scene。loaded 回调里若需要最新 props,使用 latestXxxRef.current,不要依赖闭包旧值。scene.destroy() 只能放在初始化 effect 的 cleanup 中,不能由状态变化触发。Scene/Layer 首建,更新负责 setData、更新 Marker 或样式。anchor: 'bottom',让弹窗显示在点位上方。scene.addPopup(...),禁止与 marker.setPopup(...)、popup.addTo(scene) 混用。scene.removePopup(oldPopup),避免页面出现多个弹窗。anchor 必须使用 'bottom',禁止使用 'center' 导致点位偏移。max-width 与 word-break,且尖角保持 margin: 0 auto 居中,防止尖角偏离点位。z-index >= 1000,确保显示在地图 logo 和其他地图固定元素之上。closeButton: true,允许用户点击关闭按钮关闭弹窗,提升交互体验。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 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))。'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 与地图不同步scene.addPopup(...),不要和 marker.setPopup(...) 混用anchor: 'bottom',确保弹窗显示在 marker 上方markersRef.current 逐一 remove()'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' }}
/>
);
}
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 |
.shape('circle') // 圆形
.shape('square') // 方形
.shape('triangle') // 三角形
.shape('text') // 文本标注
.shape('image') // 图片图标
.shape('cylinder') // 3D 圆柱
.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 } |
.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.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,手动控制视图 |