用 ECharts 实现高性能地图可视化的实战经验分享

Mc.琪航 交互 阅读 644
赞 14 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

上周临时接了个需求:在后台展示全国门店的分布,还要能按区域筛选、点击弹窗查看详情。本来以为就是套个地图组件完事,结果搞了两天才上线——中间踩的坑真不少。

用 ECharts 实现高性能地图可视化的实战经验分享

最终用的是 Leaflet + GeoJSON 的方案,轻量、灵活,适合我们这种不需要 3D 效果、也不需要 Google Maps 的场景。国内很多开发者喜欢直接上高德或百度 API,但说实话,如果你只是做静态可视化或者内网系统,Leaflet 真的够用,而且不卡。

先放一段核心初始化代码,这是我每次写地图都复制粘贴的模板:

const map = L.map('map').setView([35.8617, 104.1954], 5); // 中国中心

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 18,
  attribution: '© OpenStreetMap'
}).addTo(map);

注意这里 setView 的坐标是中国的地理中心,缩放级别设为 5 刚好能完整看到全国。如果你一开始 zoom 设太大(比如 8),用户打开地图会感觉“怎么只看到一块”;太小又看不清细节。这个值我调了三次才定下来。

点标记怎么做才不卡

最开始我用普通的 L.marker() 循环添加上千个门店点位,页面直接卡成幻灯片。后来换成 L.circleMarker(),并配合 Leaflet.markercluster 插件做了聚合,体验立马顺滑了。

代码长这样:

// 安装插件:npm install leaflet.markercluster
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster';

const markers = L.markerClusterGroup();

data.forEach(item => {
  const marker = L.circleMarker([item.lat, item.lng], {
    radius: 6,
    fillColor: '#f39c12',
    color: '#c97f0d',
    weight: 1,
    opacity: 1,
    fillOpacity: 0.8
  });

  marker.bindPopup(<h3>${item.name}</h3><p>地址:${item.address}</p>);
  markers.addLayer(marker);
});

map.addLayer(markers);

这里的关键是:别把上千个标记直接 add 到 map 上,一定要用 cluster 包一层。不然你浏览器内存占用蹭蹭往上涨,尤其是低配电脑,用户点开就怀疑人生。

另外 circleMarker 比默认图标性能更好,因为它是 SVG 渲染的,缩放也清晰。你要是非要用图片图标,记得压缩尺寸,别塞个 100×100 的 png 进去。

GeoJSON 渲染省级区域边界

另一个常见需求是按省份着色,比如热力图那种。这时候就得上 GeoJSON 了。我在网上找了个中国省级边界的 GeoJSON 文件(choropleth 数据),加载进去后用 L.geoJSON 渲染。

fetch('https://jztheme.com/data/china-provinces.geojson')
  .then(res => res.json())
  .then(data => {
    L.geoJSON(data, {
      style: feature => ({
        fillColor: getColorByData(feature.properties.name),
        weight: 1,
        color: '#666',
        fillOpacity: 0.7
      }),
      onEachFeature: (feature, layer) => {
        layer.bindPopup(<strong>省份:</strong>${feature.properties.name});
      }
    }).addTo(map);
  });

其中 getColorByData() 是你自己写的逻辑,根据某个数值(比如销售额)返回颜色。我用了简单的条件判断,没上 d3-scale,毕竟项目急,没必要过度设计。

function getColorByData(provinceName) {
  const value = salesData[provinceName] || 0;
  return value > 1000 ? '#800026' :
         value > 500  ? '#BD0026' :
         value > 200  ? '#E31A1C' :
         value > 100  ? '#FC4E2A' :
                      '#FEB24C';
}

这里有个巨坑:GeoJSON 文件里的省份名称和你数据里的 key 必须完全一致!我一开始拿的文件里是 “内蒙古自治区”,而我的销售数据 key 是 “内蒙古”,直接匹配不上,查了半天才发现是这问题。建议统一前处理一下名字映射表。

移动端手势差点翻车

测试时发现,在 iPad 上双指缩放特别不跟手,touchmove 响应慢半拍。排查半天发现是被页面其他事件干扰了。最后在 map 容器上加了 CSS 强制启用硬件加速:

#map {
  height: 600px;
  width: 100%;
  touch-action: pan-x pan-y;
  -webkit-transform: translateZ(0);
  transform: translateZ(0);
}

同时确保没有其他元素覆盖在地图上方,否则 touch 事件会被拦截。我还见过有人给 body 加了 overflow: hidden 导致地图拖不动的……这种低级错误自己调试的时候根本想不到。

异步数据加载顺序要小心

还有一个容易忽略的问题:地图初始化完成后再加载数据。如果你在 map.setView() 之前就执行 addLayer(),可能会报错说 map not ready。

稳妥做法是在 map.on('load', ...) 或者至少等 DOM ready 后再请求数据:

map.whenReady(() => {
  fetch('/api/stores').then(...).then(addToMap);
});

或者更简单粗暴:把数据加载逻辑放在 setTimeout 里延后 100ms 执行(别笑,生产环境真有人这么干,我也干过)。虽然不够优雅,但比白屏强。

自定义控件其实很简单

产品非要加个“重置视图”按钮,不想用手势滚回原状。其实 Leaflet 提供了 L.control 接口,可以轻松扩展:

const resetControl = L.Control.extend({
  options: { position: 'bottomright' },

  onAdd: function() {
    const container = L.DomUtil.create('div', 'leaflet-bar');
    container.innerHTML = '<a href="#" title="重置">⟲</a>';
    L.DomEvent.on(container, 'click', () => {
      map.setView([35.8617, 104.1954], 5);
    });
    return container;
  }
});

map.addControl(new resetControl());

CSS 也得配套写一点,让按钮看起来像原生样式:

.leaflet-bar a {
  background-color: white;
  border-bottom: 1px solid #ccc;
  text-align: center;
  line-height: 30px;
  width: 30px;
  height: 30px;
  text-decoration: none;
  color: #333;
}

.leaflet-bar a:hover {
  background-color: #f4f4f4;
}

别忘了阻止默认跳转行为,加一行 L.DomEvent.disableClickPropagation(container); 防止地图误触。

踩坑提醒:这三点一定注意

  • 坐标顺序别搞反:GeoJSON 和 Leaflet 都是 [纬度, 经度],不是 [经度, 纬度]。我有一次从接口拿到的数据是 lng-lat,直接塞进去导致整个地图偏移几千公里,定位到西伯利亚去了……
  • 大量数据分页或懒加载:超过 2000 个点建议做分区域加载,或者用 WebGL 方案如 Mapbox with deck.gl。Leaflet 不是万能的。
  • 避免频繁 re-render:比如你在定时轮询更新位置,记得先 markers.clearLayers() 再重新添加,不要每次都 new 一个 cluster group,否则内存泄漏。

还有个小技巧:开发时可以用浏览器的 Memory Snapshot 工具看看地图组件是否释放干净,特别是你做 SPA 路由跳转的时候。

进阶玩法:飞线动画试试看

最近在搞个新功能,想展示用户从 A 市到 B 市的流动路径。用了 L.polyline 加了个过渡动画:

function drawFlyLine(start, end) {
  const line = L.polyline([start], {
    color: '#00f',
    opacity: 0.8,
    weight: 2
  }).addTo(map);

  let progress = 0;
  const duration = 2000;
  const interval = 16;
  const timer = setInterval(() => {
    progress += interval / duration;
    if (progress >= 1) {
      line.setLatLngs([start, end]);
      clearInterval(timer);
      setTimeout(() => map.removeLayer(line), 1000);
      return;
    }

    const currentLat = start[0] + (end[0] - start[0]) * progress;
    const currentLng = start[1] + (end[1] - start[1]) * progress;
    line.setLatLngs([start, [currentLat, currentLng]]);
  }, interval);
}

视觉效果还行,就是 CPU 占用有点高,十几个动画同时跑就会卡。后续打算改成 CSS transform 动画或者用 canvas 自绘路径。但这已经是目前最快能上线的方案了。

这个技术的拓展用法还有很多

以上是我踩坑后的总结,希望对你有帮助。地图可视化不只是“放几个点”那么简单,性能、交互、数据结构都要考虑。不过 Leaflet 确实是个不错的起点,API 清晰,插件生态丰富,文档虽然英文但还算友好。

这个技巧的拓展用法还有很多,比如结合 ECharts 做迁徙图、用 Turf.js 做空间分析、甚至集成 Three.js 实现 3D 地形。后续会继续分享这类博客,包括怎么优化大数据量下的渲染效率,以及如何搭建离线地图服务。

以上是我个人对这个功能的完整讲解,有更优的实现方式欢迎评论区交流。毕竟前端这行,谁也不是一开始就写对的,都是改着改着就上线了……

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论