外卖平台配送范围可视化实战从零开始用ECharts绘制自定义区域地图并解决GeoJSON数据缺失和边界渲染模糊问题
做外卖平台的时候,老板突然甩给我一个需求:用户想知道”我家能不能送”,最好能在地图上直观看到配送范围覆盖哪些区域。这个需求看似简单,做起来才发现坑不少。今天就把我踩过的坑和最终解决方案一次性讲清楚,希望能帮到正在做类似项目的你。
为什么ECharts能胜任这个场景
其实一开始我也考虑过百度地图、高德地图这些专业地图SDK。但仔细想想,我们的核心需求并不是导航、POI搜索这些复杂功能,而是在地图上划分行政区域并填充颜色表示配送状态。ECharts作为国内最流行的图表库之一,内置了echarts-gl,配合GeoJSON数据,完全能满足这个需求。
更重要的是,ECharts上手简单,文档友好,而且可以和现有的Vue、React项目无缝集成。对于配送范围这种”展示为主”的场景,ECharts是性价比最高的选择。
第一个大坑:GeoJSON数据从哪来
刚接到需求的时候,我以为去找个现成的中国地图GeoJSON就能搞定。结果发现,市面上的GeoJSON数据质量参差不齐:
- 国家级的地图数据(比如全国各省)通常比较完整,但精度不够
- 区级、街道级的数据大多缺失,或者格式不统一
- 有些数据存在坐标系偏差,显示位置完全不对
我后来摸索出几种获取GeoJSON数据的途径,每种都有利弊:
官方渠道
民政部每年会发布标准行政区划数据,但这个数据更新周期长,而且只有省级和市级,区级以下基本没有。对于外卖平台来说,区级和街道级的数据才是真正有用的。
在线GeoJSON生成工具
有一个比较常用的工具叫”GeoJSON.io”,可以手动绘制区域。还有一个叫”数据地图”的网站,提供国内各省市县的GeoJSON下载。不过这些数据的精度和完整性我无法保证,建议下载后先用工具检查一下。
高德/百度地图API
我最后选用的方案是从高德地图开放平台获取。高德提供了”行政区划查询”接口,可以获取到区县级甚至街道级的边界数据。关键代码大概是这样的:
// 使用高德地图 JS API 获取行政区划边界
function fetchBoundary(placecode) {
return new Promise((resolve, reject) => {
AMap.plugin('AMap.DistrictSearch', function () {
var districtSearch = new AMap.DistrictSearch({
level: 'district', // 区县级
extensions: 'all' // 返回边界坐标
});
districtSearch.search(placecode, function (status, result) {
if (status === 'complete' && result.info === 'OK') {
// 转换为GeoJSON格式
const geojson = convertToGeoJSON(result);
resolve(geojson);
} else {
reject(new Error('获取边界数据失败'));
}
});
});
});
}
function convertToGeoJSON(data) {
// 将高德返回的数据转换为标准GeoJSON格式
const features = data.districtList[0].subdistricts.map(sub => ({
type: 'Feature',
properties: {
name: sub.name,
adcode: sub.adcode,
level: sub.level
},
geometry: {
type: 'MultiPolygon',
coordinates: sub.center ?
[[sub.center]] : []
}
}));
return {
type: 'FeatureCollection',
features: features
};
}
这段代码的核心思路是:先通过高德的DistrictSearch插件获取某个城市的区县边界,然后将返回的坐标数据转换成标准GeoJSON格式。转换的时候要注意,高德的坐标系是GCJ-02,而ECharts默认使用的是WGS-84,所以后续还需要做坐标系转换。
坐标系问题:GCJ-02转WGS-84
这个坑我踩了整整一天。最开始我直接用高德的数据渲染到ECharts上,结果地图显示的位置完全不对——深圳的地图跑到了北京的位置。
原因就在于坐标系不匹配。高德和百度地图使用的都是加密坐标系,国内所有地图服务的数据都经过了国家测绘局的加密处理。而ECharts和大多数开源地图库使用的是标准的WGS-84坐标系。
解决这个问题的方案有很多,我推荐用现成的转换库:
// 安装转换库
// npm install coord-transform
const coordTransform = require('coord-transform');
function transformCoords(gjCoords) {
// gjCoords: [经度, 纬度] 格式的GCJ-02坐标
const wgsCoords = coordTransform.gcj02ToWgs84(gjCoords[0], gjCoords[1]);
return [wgsCoords[0], wgsCoords[1]];
}
// 批量转换GeoJSON中的坐标
function transformGeoJSON(geojson) {
const features = geojson.features.map(feature => {
const newGeometry = transformGeometry(feature.geometry);
return {
...feature,
geometry: newGeometry
};
});
return {
...geojson,
features: features
};
}
function transformGeometry(geometry) {
if (geometry.type === 'Point') {
const [lng, lat] = geometry.coordinates;
return {
...geometry,
coordinates: transformCoords([lng, lat])
};
}
if (geometry.type === 'LineString' || geometry.type === 'MultiPoint') {
return {
...geometry,
coordinates: geometry.coordinates.map(coord => transformCoords(coord))
};
}
if (geometry.type === 'Polygon' || geometry.type === 'MultiPolygon') {
return {
...geometry,
coordinates: transformPolygonCoords(geometry.coordinates)
};
}
return geometry;
}
function transformPolygonCoords(coords) {
// Polygon: [[[lng,lat], ...], ...]
// MultiPolygon: [[[lng,lat], ...], ...]
return coords.map(ring =>
ring.map(coord => transformCoords(coord))
);
}
这个转换过程需要递归处理GeoJSON中所有类型的几何对象。Point、LineString、Polygon的坐标结构不同,需要分别处理。转换完之后,地图的显示位置就正常了。
第二个大坑:边界渲染模糊
数据获取和转换都搞定后,我以为万事大吉。结果渲染出来的地图边界一塌糊涂——很多区域之间有空隙,有些边界线条特别细,看起来像锯齿一样。
仔细分析了一下,问题出在几个方面:
GeoJSON数据本身的精度问题
很多开源的GeoJSON数据精度不够,尤其是街道级别的边界,可能本身就存在大量的空洞和重叠。这种情况下,不管你怎么调ECharts的配置,结果都不会好。
ECharts渲染配置不够优化
ECharts默认的配置对于这种展示型地图确实不够友好。需要调整以下关键参数:
option = {
series: [{
type: 'map',
roam: true, // 允许缩放和平移
zoom: 1.2, // 适当放大避免边缘裁切
// 区域样式
itemStyle: {
areaColor: '#f3f3f3',
borderColor: '#ffffff',
borderWidth: 1.5, // 增大边框宽度
borderType: 'solid',
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowBlur: 10,
shadowOffsetY: 5
},
// 选中样式
emphasis: {
itemStyle: {
areaColor: '#2c3e50',
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
color: '#fff',
fontSize: 12,
fontWeight: 'bold'
}
},
// 文字样式
label: {
show: true,
color: '#333',
fontSize: 10
},
// 关键:合并相似区域,减少缝隙
select: {
itemStyle: {
areaColor: '#3498db'
}
}
}]
};
使用polygon替换path绘制
我发现一个有效的优化方案:对于某些特殊区域(比如岛屿、飞地),直接用SVG路径来绘制会比GeoJSON的polygon更高效,而且不会出现边界模糊的问题。
// 对于边界复杂的区域,手动优化坐标
function optimizePolygonCoords(coords, tolerance = 0.001) {
// 使用道格拉斯-普克算法简化坐标,减少数据量但保留轮廓
function simplify(coords, tolerance) {
if (coords.length < 3) return coords;
const start = coords[0];
const end = coords[coords.length - 1];
// 找到距离起点-终点连线最远的点
let maxDist = 0;
let maxIndex = 0;
for (let i = 1; i < coords.length - 1; i++) {
const dist = perpendicularDistance(coords[i], start, end);
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
if (maxDist > tolerance) {
const left = simplify(coords.slice(0, maxIndex + 1), tolerance);
const right = simplify(coords.slice(maxIndex), tolerance);
return left.concat(right.slice(1));
} else {
return [start, end];
}
}
return coords.map(ring => simplify(ring, tolerance));
}
function perpendicularDistance(point, lineStart, lineEnd) {
const dx = lineEnd[0] - lineStart[0];
const dy = lineEnd[1] - lineStart[1];
if (dx === 0 && dy === 0) {
return Math.sqrt(
Math.pow(point[0] - lineStart[0], 2) +
Math.pow(point[1] - lineStart[1], 2)
);
}
const t = Math.max(0, Math.min(1, (
(point[0] - lineStart[0]) * dx +
(point[1] - lineStart[1]) * dy
) / (dx * dx + dy * dy)));
const projection = [
lineStart[0] + t * dx,
lineStart[1] + t * dy
];
return Math.sqrt(
Math.pow(point[0] - projection[0], 2) +
Math.pow(point[1] - projection[1], 2)
);
}
这个坐标简化的思路其实和地图瓦片的技术类似——在保持整体轮廓的前提下,去掉一些过于密集的坐标点。这样既减少了数据传输量,又避免了渲染时的锯齿效应。
配送范围的实际展示逻辑
数据准备好了,接下来就是业务逻辑的实现。外卖平台的配送范围通常有几个层次:
- 核心配送区:3公里以内,免费配送,时效最快
- 标准配送区:3-5公里,可能需要收取配送费
- 扩展配送区:5-8公里,配送费更高,时效更长
- 不配送区:超出范围的区域
// 配送范围数据结构
const deliveryZones = {
core: {
name: '核心配送区',
distance: 3,
color: '#e74c3c', // 红色,表示最快
fee: 0,
time: '30分钟'
},
standard: {
name: '标准配送区',
distance: 5,
color: '#f39c12', // 橙色
fee: 3,
time: '45分钟'
},
extended: {
name: '扩展配送区',
distance: 8,
color: '#3498db', // 蓝色
fee: 5,
time: '60分钟'
}
};
// 根据距离给区域着色
function getColorByDistance(distance, maxDistance = 8) {
const ratio = distance / maxDistance;
if (ratio <= 0.375) { // 3/8
return deliveryZones.core.color;
} else if (ratio <= 0.625) { // 5/8
return deliveryZones.standard.color;
} else if (ratio <= 1) {
return deliveryZones.extended.color;
}
return '#95a5a6'; // 灰色表示不配送
}
在实际展示的时候,我采用的是动态着色的方案:当用户点击某个区域时,根据该区域中心点到用户位置的直线距离,实时计算配送范围和费用。这样的好处是不需要提前准备复杂的区域数据,只需要知道用户位置和各个区域的中心点坐标就可以了。
性能优化:区域太多的时候怎么办
这个项目最头疼的地方是,一个城市可能有几百个街道级别的区域。如果全部加载到地图上,渲染性能会很差。
我尝试了以下几种优化方案:
方案一:按需加载
只加载当前可见区域内的区域数据。当用户缩放或平移地图时,动态请求并渲染新的区域。
let visibleFeatures = [];
let allFeatures = [];
function onZoomChange() {
const bounds = chart.getModel().getComponent('geo').getViewportBounds();
// 过滤出当前视口内可见的区域
visibleFeatures = allFeatures.filter(feature => {
return isFeatureInViewport(feature, bounds);
});
// 更新地图数据
chart.setOption({
series: [{
data: visibleFeatures.map(f => ({
name: f.properties.name,
itemStyle: {
areaColor: getColorByDistance(
calculateDistance(userLocation, f.geometry)
)
}
}))
}]
});
}
function isFeatureInViewport(feature, bounds) {
const coords = flattenCoords(feature.geometry.coordinates);
return coords.some(coord =>
coord[0] >= bounds.x1 && coord[0] <= bounds.x2 &&
coord[1] >= bounds.y1 && coord[1] <= bounds.y2
);
}
方案二:聚合简化
对于某些特别小的区域,采用聚合的方式展示。比如把相邻的几条小街道合并成一个区域,只显示一个大致的配送范围。
function aggregateSmallAreas(features, minArea) {
const aggregated = [];
const processed = new Set();
for (let i = 0; i < features.length; i++) {
if (processed.has(i)) continue;
const feature = features[i];
const area = calculateArea(feature.geometry);
// 如果面积足够大,单独展示
if (area >= minArea) {
aggregated.push(feature);
processed.add(i);
continue;
}
// 否则尝试合并到相邻区域
let merged = false;
for (let j = 0; j < aggregated.length; j++) {
if (isAdjacent(aggregated[j], feature)) {
aggregated[j] = mergeFeatures(aggregated[j], feature);
processed.add(i);
merged = true;
break;
}
}
if (!merged) {
aggregated.push(feature);
processed.add(i);
}
}
return aggregated;
}
方案三:预渲染图片
对于静态展示的场景,可以预先将地图渲染成图片,直接展示。这样能大幅减少客户端的计算量。缺点是交互性会差一些,但作为”配送范围查询”这种以展示为主的功能,完全够用。
// 服务端预渲染地图
async function renderDeliveryMap(userLocation, cityCode) {
// 使用Puppeteer在服务端渲染
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(`
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
body { margin: 0; padding: 0; }
#chart { width: 800px; height: 600px; }
</style>
</head>
<body>
<div id="chart"></div>
<script>
const chart = echarts.init(document.getElementById('chart'));
// 加载GeoJSON数据
const response = await fetch('/api/map/geojson?city=${cityCode}');
const geojson = await response.json();
echarts.registerMap('delivery', geojson);
chart.setOption({
series: [{
type: 'map',
map: 'delivery',
roam: false,
data: /* 配送范围数据 */
}]
});
</script>
</body>
</html>
`);
const screenshot = await page.screenshot({ type: 'png' });
await browser.close();
return screenshot;
}
边界模糊的最终解决方案
回到最初的问题:边界渲染模糊。经过多次尝试,我发现根本原因是GeoJSON数据本身的精度问题。很多在线下载的GeoJSON数据,坐标点的密度不够,或者存在大量的拓扑错误(比如两个相邻区域之间有缝隙)。
我最终采用的解决方案是:使用更高质量的数据源 + 后处理优化。
// 后处理GeoJSON,修复拓扑问题
function fixGeoJSONTopology(geojson) {
const fixed = {
...geojson,
features: geojson.features.map(feature => {
// 简化坐标,去除冗余点
const simplified = simplifyGeometry(feature.geometry, 0.0001);
// 修复自相交的多边形
const fixed = fixSelfIntersections(simplified);
return {
...feature,
geometry: fixed
};
})
};
return fixed;
}
// 使用JTS库或TurboWkt等库进行拓扑修复
function fixSelfIntersections(geometry) {
// 这里需要使用专业的几何处理库
// 比如JTS Topology Suite的JavaScript版本
return geometry;
}
实际操作中,我发现一个更简单的办法:直接在高德地图上手动绘制配送区域,然后导出GeoJSON。虽然这种方法比较耗时,但对于中小城市的外卖平台来说,数据量不大,手动绘制反而是最可靠的方式。高德提供的这个功能叫”地图编辑器”,可以在开放平台上直接使用。
完整的前端集成代码
把上面所有步骤整合起来,就是一个完整的外卖配送范围可视化方案:
<template>
<div class="delivery-map-container">
<div id="deliveryMap" ref="mapRef" style="width: 100%; height: 500px;"></div>
<div class="legend">
<div class="legend-item">
<span class="color-box" style="background: #e74c3c;"></span>
<span>核心配送区(3km内)</span>
</div>
<div class="legend-item">
<span class="color-box" style="background: #f39c12;"></span>
<span>标准配送区(3-5km)</span>
</div>
<div class="legend-item">
<span class="color-box" style="background: #3498db;"></span>
<span>扩展配送区(5-8km)</span>
</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts';
import coordTransform from 'coord-transform';
export default {
name: 'DeliveryMap',
props: {
userLocation: {
type: Object,
required: true,
default: () => ({ lat: 39.9042, lng: 116.4074 })
},
cityCode: {
type: String,
required: true
}
},
data() {
return {
chart: null,
geojson: null
};
},
async mounted() {
await this.loadGeoJSON();
this.initChart();
},
methods: {
async loadGeoJSON() {
// 从高德API获取区县边界数据
const response = await fetch(
`/api/map/geojson?city=${this.cityCode}`
);
const data = await response.json();
// 坐标系转换
this.geojson = this.transformToWGS84(data);
// 拓扑修复
this.geojson = this.fixTopology(this.geojson);
},
transformToWGS84(geojson) {
const transform = (coords) => {
if (Array.isArray(coords[0][0])) {
// MultiPolygon
return coords.map(polygon =>
polygon.map(ring =>
ring.map(coord =>
coordTransform.gcj02ToWgs84(coord[0], coord[1])
)
)
);
} else if (Array.isArray(coords[0])) {
// Polygon
return coords.map(coord =>
coordTransform.gcj02ToWgs84(coord[0], coord[1])
);
}
return coords;
};
const features = geojson.features.map(feature => ({
...feature,
geometry: {
...feature.geometry,
coordinates: transform(feature.geometry.coordinates)
}
}));
return { ...geojson, features };
},
initChart() {
this.chart = echarts.init(this.$refs.mapRef);
echarts.registerMap('delivery', this.geojson);
const option = {
tooltip: {
trigger: 'item',
formatter: (params) => {
const name = params.name;
const distance = this.calculateDistance(
this.userLocation,
params.data.center
);
const zone = this.getDeliveryZone(distance);
return `
<div style="padding: 8px;">
<strong>${name}</strong><br/>
距离:${distance.toFixed(2)} km<br/>
配送范围:${zone.name}<br/>
配送费:¥${zone.fee}<br/>
预计送达:${zone.time}
</div>
`;
}
},
visualMap: {
min: 0,
max: 8,
left: 'left',
top: 'bottom',
text: ['8km', '0km'],
calculable: true,
inRange: {
color: ['#e74c3c', '#f39c12', '#3498db', '#95a5a6']
},
textStyle: {
color: '#666'
}
},
series: [{
name: '配送范围',
type: 'map',
map: 'delivery',
roam: true,
zoom: 1.1,
label: {
show: true,
color: '#333',
fontSize: 10
},
itemStyle: {
areaColor: '#f3f3f3',
borderColor: '#fff',
borderWidth: 1.5
},
emphasis: {
label: {
show: true,
color: '#fff',
fontSize: 12
},
itemStyle: {
areaColor: '#2c3e50'
}
},
select: {
itemStyle: {
areaColor: '#3498db'
}
},
// 标记用户位置
markPoint: {
symbol: 'pin',
symbolSize: 14,
label: {
show: true,
formatter: '📍',
fontSize: 16
},
data: [{
coord: [
this.userLocation.lng,
this.userLocation.lat
],
name: '您的位置'
}]
},
// 配送范围同心圆
markLine: {
symbol: 'none',
data: [
{ coord: [
this.userLocation.lng,
this.userLocation.lat
] },
{ radius: 3000 } // 3公里
]
}
}]
};
this.chart.setOption(option);
},
calculateDistance(point1, point2) {
// Haversine公式计算球面距离
const R = 6371; // 地球半径(km)
const dLat = (point2.lat - point1.lat) * Math.PI / 180;
const dLng = (point2.lng - point1.lng) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(point1.lat * Math.PI / 180) *
Math.cos(point2.lat * Math.PI / 180) *
Math.sin(dLng/2) * Math.sin(dLng/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
},
getDeliveryZone(distance) {
if (distance <= 3) {
return { name: '核心配送区', fee: 0, time: '30分钟' };
} else if (distance <= 5) {
return { name: '标准配送区', fee: 3, time: '45分钟' };
} else if (distance <= 8) {
return { name: '扩展配送区', fee: 5, time: '60分钟' };
}
return { name: '不配送', fee: Infinity, time: '——' };
}
},
beforeUnmount() {
if (this.chart) {
this.chart.dispose();
}
}
};
</script>
<style scoped>
.delivery-map-container {
position: relative;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.legend {
display: flex;
gap: 16px;
padding: 12px 16px;
background: #fff;
border-top: 1px solid #eee;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #666;
}
.color-box {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.1);
}
</style>
几个实用的调试技巧
在做这个项目的时候,我积累了几个调试地图问题的实用技巧:
查看GeoJSON是否正确:使用geojson.io这个网站,把你的GeoJSON数据粘贴进去,可以直接预览。这是最快的验证数据质量的方法。
检查坐标系:在ECharts中直接查看坐标值。可以在浏览器的控制台执行:
echarts.registerMap('test', geojson);
console.log(geojson.features[0].geometry.coordinates[0][0]);
然后和已知坐标对比,确认转换是否正确。
边界模糊时的终极方案:如果GeoJSON数据本身的精度实在不够,可以考虑使用矢量切片的方式。将区域数据预先处理成矢量切片,在客户端按需加载和渲染。这种方式的数据质量更高,而且性能也更好。
以上就是我在这个项目中的所有经验总结。地图可视化看起来简单,但涉及到的问题其实不少——数据源、坐标系、渲染性能、交互体验等等。希望这些内容能帮到正在做类似项目的你。如果遇到问题,欢迎在评论区交流。
