外卖小哥用ECharts自定义地图追踪配送区域 从下载JSON到地图渲染的完整步骤
嘿,你好啊!今天咱们来聊聊一个特别实用的小技能——怎么用ECharts做一个地图,专门用来追踪外卖配送区域。我猜你现在可能正坐在电动车上,或者刚送完一单回来,想着能不能有个更直观的工具来管理自己的配送范围。别急,这事儿真没你想的那么复杂,我一步步带你走完整个流程,保准你能照着做出来。
先说说为什么要用ECharts吧。以前我们用地图的时候,不是百度就是高德,但那都是现成的应用,改不了太多东西。ECharts是百度开源的一个可视化库,它的好处是你能完全控制地图长什么样、怎么交互、数据怎么展示。外卖小哥用这个,主要是想看清楚自己每天跑的区域,哪些地方单多、哪些地方单少,甚至能画出你负责配送的扇形范围。听起来挺酷的,对吧?好,咱们正式开始。
第一步:拿到地图的JSON数据
地图要画出来,首先得知道各个区域长什么样,也就是它们的边界在哪里。这个信息就藏在GeoJSON文件里。你可以从几个地方下载,我推荐你用阿里云的DataV.GeoAtlas,网址是datav.aliyun.com/portal/area_code.html。这个网站特别方便,它把全国省、市、区县的边界数据都整理好了,而且格式标准,ECharts直接能用。
打开网站之后,你会看到一张中国地图,左边有个搜索框,你可以输入”河南省”,它就会高亮显示。点进去之后,你会看到不同级别的数据:省、市、区县。对于外卖配送来说,建议你先下载市级的GeoJSON,这样地图精度合适,又不会太卡。比如你搜索”新乡市”,点进进去之后,页面下方会有一个”复制地址”的按钮,复制那个链接,然后用浏览器打开,或者直接点击”下载”按钮,把文件保存为xinxiang.json。
下载完之后,你可以用文本编辑器打开看看,内容其实就是一大串JSON数据,长这样:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"adcode": 410700,
"name": "新乡市",
"center": [113.931299, 35.299191],
"level": "city"
},
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[[113.5, 35.1], [113.6, 35.1], ...]]
}
}
]
}
你不用看懂每一行是什么意思,只需要知道,features数组里的每一个元素,就代表新乡市下面的一个区县,比如红旗区、卫滨区、凤泉区这些。geometry.coordinates里面存的就是这个区县的边界坐标点,ECharts会用这些点把地图画出来。
如果你找不到合适的下载链接,或者想要更细分的数据,也可以去GitHub上搜”china-json”或者”echarts-map”,那里也有不少人分享整理好的地图文件。不过说实话,阿里云那个最稳定,数据更新也比较及时。
第二步:搭建HTML页面,引入ECharts
JSON拿好了,下一步就是写代码把它展示出来。我们先创建一个index.html文件,把所有的内容都放在这里面。新手最容易犯的错误就是以为要引入一大堆东西,其实ECharts特别简单,只需要一个CDN链接就够了。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>外卖配送区域追踪地图</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; font-family: "Microsoft YaHei", sans-serif; }
#map-container {
width: 100vw;
height: 100vh;
}
.info-panel {
position: fixed;
top: 20px;
left: 20px;
background: rgba(0,0,0,0.7);
color: #fff;
padding: 15px 20px;
border-radius: 10px;
z-index: 100;
max-width: 280px;
}
.info-panel h2 {
font-size: 16px;
margin-bottom: 10px;
color: #00d4ff;
}
.info-panel p {
font-size: 13px;
line-height: 1.6;
color: #ccc;
}
.legend {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0,0,0,0.7);
padding: 12px 16px;
border-radius: 8px;
color: #fff;
font-size: 12px;
z-index: 100;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 6px;
}
.legend-color {
width: 14px;
height: 14px;
border-radius: 3px;
margin-right: 8px;
}
</style>
</head>
<body>
<div class="info-panel">
<h2>🛵 外卖配送区域追踪</h2>
<p>当前查看:新乡市全域</p>
<p>配送范围已标注完成</p>
<p style="margin-top:8px; color:#ffd700;">📍 点击区域查看详情</p>
</div>
<div id="map-container"></div>
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background:#00d4ff;"></div>
<span>核心配送区(3km)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background:#ff6b6b;"></div>
<span>常规配送区(5km)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background:#4ecdc4;"></div>
<span>远郊配送区(8km)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background:#45b7d1;"></div>
<span>正常区域</span>
</div>
</div>
<script src="map.js"></script>
</body>
</html>
看到没,其实页面结构很简单,一个大的地图容器#map-container,然后左边放了个小信息面板,右下角有个图例说明不同颜色代表什么配送范围。CSS样式我也给你写好了,用了深色背景,这样地图显示出来会更有科技感,看起来也像那么回事。
这里有个细节想跟你说,ECharts的版本我写的是5.4.3,这是目前比较稳定的版本。如果你担心网络问题,也可以把CDN换成国内的,比如用bootcdn的镜像,或者干脆把echarts.min.js文件下载到本地,然后用<script src="echarts.min.js"></script>引入。
接下来就是写地图逻辑的部分了,也就是map.js文件。这个文件才是真正干活的地方,咱们接着往下看。
第三步:加载GeoJSON并注册到ECharts
在map.js文件里,首先要做的事情就是把刚才下载的JSON数据加载进来,然后告诉ECharts”这是新乡市的地图数据,你用它来画地图”。这个过程叫注册,ECharts提供了专门的API来完成。
// map.js
// 第一步:注册地图数据
// 这里用fetch去加载本地的JSON文件
fetch('xinxiang.json')
.then(response => {
if (!response.ok) {
throw new Error('地图数据加载失败,请检查JSON文件路径');
}
return response.json();
})
.then(geometryData => {
// 注册到ECharts
echarts.registerMap('xinxiang', geometryData);
// 注册完地图之后,再初始化图表
initMap(geometryData);
})
.catch(error => {
console.error('加载地图数据出错:', error);
alert('地图数据加载失败,请确保xinxiang.json文件与index.html在同一目录下,并且使用了本地服务器运行。');
});
/**
* 初始化地图并配置各项参数
*/
function initMap(geometryData) {
const chartDom = document.getElementById('map-container');
const myChart = echarts.init(chartDom, 'dark');
// 从GeoJSON中提取所有区县的名称和中心点
const districts = geometryData.features.map(feature => {
return {
name: feature.properties.name,
adcode: feature.properties.adcode,
center: feature.properties.center
};
});
// 构建配置项
const option = buildMapOption(geometryData, districts);
myChart.setOption(option);
// 响应窗口大小变化
window.addEventListener('resize', () => {
myChart.resize();
});
}
这部分代码其实做了三件事。首先用fetch去加载JSON文件,这个方法和平时在浏览器里发请求是一样的。加载成功之后,调用echarts.registerMap('xinxiang', geometryData),第一个参数是你给这张地图取的名字,随便起,叫xinxiang、newMap、myCity都行,后面引用的时候用这个名字。第二个参数就是刚才解析出来的JSON数据。
注册完之后,就要初始化图表了。我用了echarts.init(chartDom, 'dark'),这里的'dark'是ECharts内置的一个暗色主题,看起来比默认的白底黑字更适合这种数据可视化的场景。你也可以换成'light'或者什么都不填用默认主题。
还有一个细节你可能注意到了,我从GeoJSON里提取了每个区县的名称、编码和中心点坐标,存到了districts数组里。这个在后面画配送区域的时候非常有用,因为我们要以某个中心点为圆心,画出不同半径的圆形覆盖范围。
第四步:绘制基础地图和区县高亮
地图注册好了,配置项也要写完整。咱们先做一个能正常显示的地图,然后再加配送区域的逻辑。
/**
* 构建地图配置项
*/
function buildMapOption(geometryData, districts) {
// 模拟外卖单量数据(实际使用时可以从API获取)
const orderData = [
{ name: '红旗区', value: 320, center: [113.93, 35.30] },
{ name: '卫滨区', value: 180, center: [113.88, 35.25] },
{ name: '凤泉区', value: 95, center: [113.98, 35.33] },
{ name: '牧野区', value: 260, center: [113.92, 35.28] },
{ name: '新乡县', value: 150, center: [114.05, 35.15] },
{ name: '延津县', value: 60, center: [114.30, 35.50] },
{ name: '封丘县', value: 45, center: [114.50, 35.10] },
{ name: '获嘉县', value: 70, center: [113.65, 35.25] },
{ name: '原阳县', value: 85, center: [113.80, 35.45] },
{ name: '辉县市', value: 110, center: [113.68, 35.45] },
{ name: '卫辉市', value: 75, center: [113.78, 35.60] },
{ name: '长垣市', value: 90, center: [114.55, 35.45] }
];
// 根据单量设置颜色映射
const maxOrders = Math.max(...orderData.map(d => d.value));
const colorScale = value => {
const ratio = value / maxOrders;
// 从深蓝到橙红的渐变色
const r = Math.round(30 + ratio * 200);
const g = Math.round(80 + ratio * 80);
const b = Math.round(150 - ratio * 100);
return `rgb(${r},${g},${b})`;
};
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(0,0,0,0.8)',
borderColor: '#00d4ff',
textStyle: { color: '#fff', fontSize: 13 },
formatter: params => {
if (params.componentType === 'series' && params.seriesType === 'map') {
const data = orderData.find(d => d.name === params.name);
if (data) {
return `<div style="padding:5px;">
<b style="color:#00d4ff;font-size:14px;">${params.name}</b><br/>
当日订单:<span style="color:#ffd700;">${data.value}单</span><br/>
配送覆盖:<span style="color:#4ecdc4;">约3-5公里</span>
</div>`;
}
return `<b>${params.name}</b><br/>暂无数据`;
}
return params.name + ': ' + params.value;
}
},
// 视觉映射:根据单量自动上色
visualMap: {
min: 0,
max: maxOrders,
left: 'left',
bottom: 30,
text: ['高单量', '低单量'],
textStyle: { color: '#aaa', fontSize: 11 },
inRange: {
color: ['#1a3a5c', '#0066cc', '#00aaff', '#ff8800', '#ff3300']
},
calculable: true,
outOfRange: {
colorAlpha: 0.3
}
},
// 地理坐标系
geo: {
map: 'xinxiang',
roam: true, // 支持缩放和平移
zoom: 1.1,
center: [113.93, 35.30], // 新乡市大致中心
label: {
show: true,
color: '#fff',
fontSize: 10
},
itemStyle: {
areaColor: '#1e3a5f',
borderColor: '#00d4ff',
borderWidth: 1
},
emphasis: {
itemStyle: {
areaColor: '#2a5a8f',
borderColor: '#ffd700',
borderWidth: 2
},
label: {
color: '#ffd700',
fontSize: 13,
fontWeight: 'bold'
}
},
select: {
itemStyle: { areaColor: '#ff6b6b' }
}
},
// 系列配置
series: [
// 基础地图层
{
name: '新乡市',
type: 'map',
geoIndex: 0,
data: orderData
},
// 配送覆盖区域(圆形热力)
{
name: '核心配送区',
type: 'effectScatter',
coordinateSystem: 'geo',
data: orderData.filter(d => d.value > 200),
symbolSize: val => Math.sqrt(val) * 1.5,
showEffectOn: 'render',
rippleEffect: {
brushType: 'stroke',
scale: 3
},
hoverAnimation: true,
label: {
show: true,
formatter: '{b}',
position: 'right',
color: '#fff',
fontSize: 11
},
itemStyle: {
color: '#00d4ff',
shadowBlur: 10,
shadowColor: '#00d4ff'
},
zlevel: 1
},
// 常规配送区标记
{
name: '常规配送区',
type: 'scatter',
coordinateSystem: 'geo',
data: orderData.filter(d => d.value <= 200 && d.value > 50),
symbolSize: val => Math.sqrt(val) * 1.2,
label: {
show: true,
formatter: '{b}',
position: 'top',
color: '#aaa',
fontSize: 10
},
itemStyle: {
color: '#ff6b6b',
shadowBlur: 5,
shadowColor: '#ff6b6b'
},
zlevel: 1
}
]
};
}
代码是不是看起来有点多?别怕,我跟你逐个拆解一下。
首先visualMap这个东西特别实用,它的作用是自动根据数据的大小来给地图上色。你传入的orderData里每个区县的value就是单量,visualMap会根据最大值和最小值自动生成一个渐变色,单量越多的区域颜色越深。你不需要手动写一堆if-else来判断颜色,ECharts帮你搞定了。
然后是geo配置,这是地图的核心。map: 'xinxiang'对应前面注册的地图名称,roam: true让你可以鼠标拖拽和平滑缩放,这对外卖小哥特别有用——你可以在手机上放大看某个小区附近的地图,缩小来看整个城市的配送范围。center和zoom控制地图默认显示的位置和缩放级别,我设的是新乡市的大致中心。
itemStyle控制地图本身的颜色,我用的是深蓝底色配青色边框,和整体暗色主题很搭。emphasis是鼠标悬停时的样式,边框会变成金色,文字加粗,这样你能清楚地看到自己当前关注的是哪个区。
series数组里我放了三个系列。第一个是基础的地图层,把orderData绑定上去,这样每个区县就能根据单量显示不同颜色。第二个是effectScatter,这是一种带涟漪动画效果的散点图,用来标记单量超过200单的核心配送区,点击之后会有一个扩散的波纹动画,视觉效果很醒目。第三个是普通散点,标记常规配送区。
第五步:叠加配送范围圆形覆盖层
画好基础地图之后,关键一步来了——画出你实际负责配送的范围。这个范围通常是围绕你店铺或者取餐点,以不同半径画的圆形区域。比如你可能在红旗区有个店,核心配送范围是3公里,常规是5公里,最远到8公里。
ECharts里没有直接画圆形的原生类型,但我们可以用custom系列自己定义渲染逻辑,或者用scatter配合symbolSize来模拟。不过最简单直观的方法是用effectScatter来画同心圆,或者直接用custom系列。
/**
* 添加配送范围圆形覆盖层
*/
function addDeliveryRadius() {
// 假设你的配送中心在红旗区,坐标是[113.93, 35.30]
const baseCenter = [113.93, 35.30];
// 三个不同半径的配送圈(单位:公里,ECharts的geo坐标系会自动转换)
const radiiKm = [3, 5, 8];
// 将公里数转换为地图上的视觉半径
// ECharts的geo坐标系会根据缩放级别自动计算像素距离
// 我们通过symbolSize来控制,这里用经验值:1km约等于25个symbolSize单位
const radiiSymbols = radiiKm.map(km => km * 25);
// 创建同心圆数据
const circleData = radiiSymbols.map((size, index) => ({
name: `配送圈${index + 1}`,
value: [baseCenter[0], baseCenter[1], size],
symbol: 'circle',
symbolSize: size * 2, // symbolSize是半径,所以要乘以2才是直径
itemStyle: {
color: 'transparent',
borderColor: index === 0 ? '#00d4ff' : index === 1 ? '#ff6b6b' : '#4ecdc4',
borderWidth: index === 0 ? 2 : 1.5,
borderType: 'solid',
opacity: 0.8
},
label: {
show: true,
formatter: index === 0 ? '🛵 核心3km' : index === 1 ? '常规5km' : '远郊8km',
color: index === 0 ? '#00d4ff' : index === 1 ? '#ff6b6b' : '#4ecdc4',
fontSize: 12,
fontWeight: 'bold',
position: 'insideTopLeft',
offset: [10, -10]
}
}));
return {
name: '配送范围',
type: 'scatter',
coordinateSystem: 'geo',
data: circleData,
symbolSize: 0, // 不显示中心点,只显示圆环
label: { show: false },
emphasis: { disabled: true },
zlevel: 2
};
}
等等,我刚才说的symbolSize可能有点绕,跟你解释一下。在ECharts里,scatter类型的symbolSize指的是标记的大小,对于圆形标记来说,它控制的是直径。但这里我真正想要画的是圆环,也就是一个空心的圆圈,所以我把symbolSize设为0,然后用itemStyle.borderColor和borderWidth来画圆环。这样看起来就像一个圆形的配送范围线。
不过说实话,用scatter画圆环有点hack的感觉,不太优雅。更正规的做法是用custom系列自己绘制。我后面会再给你展示用custom系列的写法,那个更灵活。
把刚才的addDeliveryRadius()函数添加到series数组里:
// 在buildMapOption函数中,series数组最后追加一行:
series: [
// ...前面的配置...
addDeliveryRadius() // 配送范围圆环
]
这样就多了一层配送范围的视觉覆盖。你可以根据实际需要调整baseCenter的坐标,改成你自己的店铺位置。
第六步:用Custom系列自定义绘制配送热力区域
如果你想要更精细的控制,比如让配送范围的颜色是渐变的、或者形状不是正圆而是根据道路实际延伸,那就需要用到custom系列来自定义渲染。这个功能很强,但用法稍微复杂一点。
/**
* 用Custom系列绘制配送热力覆盖区域
* 这比简单的圆形更真实,可以根据实际道路和区域边界来渲染
*/
function buildHeatmapSeries(geometryData) {
// 计算每个区县的配送覆盖热力值
// 这里模拟:以每个区县中心为圆心,根据单量决定覆盖强度
const heatmapData = geometryData.features.map(feature => {
const center = feature.properties.center; // [lng, lat]
const name = feature.properties.name;
// 从orderData中找对应的单量
const orderInfo = orderData.find(d => d.name === name);
const value = orderInfo ? orderInfo.value : 0;
// 将单量映射为热力值(0-100)
const heatValue = Math.min(100, Math.max(10, value * 0.5));
return {
name: name,
value: [center[0], center[1], heatValue],
// 为每个区县生成一些周边的采样点,让热力更自然
subPoints: generateSurroundingPoints(center, heatValue, 8)
};
});
return {
name: '配送热力',
type: 'custom',
coordinateSystem: 'geo',
// renderItem函数:自定义每个数据项的渲染方式
renderItem: (params, api) => {
const center = api.value(0); // 经度
const centerY = api.value(1); // 纬度
const intensity = api.value(2); // 热力强度
// 计算在地图坐标系中的像素位置
const point = api.coord([center, centerY]);
// 根据热力强度和地图缩放级别计算半径
const radius = (intensity / 100) * 60 * (api.size([1, 0])[0] / 100);
return {
type: 'circle',
shape: {
cx: point[0],
cy: point[1],
r: radius
},
style: {
fill: `rgba(0, 212, 255, ${intensity / 150})`,
stroke: `rgba(0, 212, 255, ${intensity / 200})`,
lineWidth: 1
},
z: 0
};
},
data: heatmapData.map(d => d.value),
zlevel: 1
};
}
/**
* 生成中心点周围的采样点,让热力效果更自然
*/
function generateSurroundingPoints(center, intensity, count) {
const points = [];
const spread = intensity * 0.5; // 扩散范围
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const distance = Math.random() * spread + 2;
points.push([
center[0] + Math.cos(angle) * distance * 0.01,
center[1] + Math.sin(angle) * distance * 0.01
]);
}
return points;
}
custom系列的核心是renderItem函数,这个函数会在每个数据点渲染时被调用。api对象提供了一系列工具方法:api.value(0)获取第一个维度的值(经度),api.coord()把地理坐标转换为屏幕像素坐标,api.size()获取元素的尺寸信息。
在renderItem里我画了一个圆形,填充色用的是半透明的青色,透明度根据热力强度调整。强度越高的区域颜色越浓,强度越低越淡。这样叠加在地图上,就能很直观地看到哪些区域是外卖密集区。
把buildHeatmapSeries(geometryData)加入到series里,地图效果就更丰富了。
第七步:交互功能——点击区县查看详情和配送路线
光好看没用,外卖小哥用的时候还需要能交互。比如你点击某个区县,它应该弹出详细信息,告诉你这个区有多少单、平均配送距离多少、建议的配送路线是什么。
/**
* 添加点击交互:点击区县弹出详情和配送建议
*/
function addInteraction(myChart, geometryData) {
myChart.on('click', params => {
if (params.componentType === 'series' && params.seriesType === 'map') {
handleDistrictClick(params.name, geometryData);
}
});
}
/**
* 处理区县点击事件
*/
function handleDistrictClick(districtName, geometryData) {
// 查找该区县的数据
const districtFeature = geometryData.features.find(
f => f.properties.name === districtName
);
const orderInfo = orderData.find(d => d.name === districtName);
if (!districtFeature) return;
const center = districtFeature.properties.center;
const adcode = districtFeature.properties.adcode;
const area = calculateDistrictArea(districtFeature); // 计算面积
// 模拟配送数据
const todayOrders = orderInfo ? orderInfo.value : 0;
const avgDeliveryTime = Math.round(todayOrders * 0.8 + 15); // 模拟平均配送时间
const distanceRange = `${Math.round(area / 10)}-${Math.round(area / 10) + 5}km`;
const suggestedRoutes = generateSuggestedRoutes(districtName, center);
// 弹出详情面板(可以用modal或者tooltip)
showDistrictInfo({
name: districtName,
adcode: adcode,
center: center,
orders: todayOrders,
avgTime: avgDeliveryTime,
distanceRange: distanceRange,
routes: suggestedRoutes,
area: area
});
}
/**
* 显示区县详情
*/
function showDistrictInfo(info) {
// 创建一个浮动的详情面板
let panel = document.getElementById('district-detail-panel');
if (!panel) {
panel = document.createElement('div');
panel.id = 'district-detail-panel';
panel.style.cssText = `
position:fixed; top:50%; left:50%; transform:translate(-50%,-50%);
background:rgba(10,15,30,0.95); border:1px solid #00d4ff;
border-radius:12px; padding:20px 25px; z-index:1000;
color:#fff; min-width:300px; max-width:400px;
box-shadow:0 0 30px rgba(0,212,255,0.3);
`;
document.body.appendChild(panel);
// 点击外部关闭
panel.addEventListener('click', e => {
if (e.target === panel) panel.style.display = 'none';
});
}
const routeHtml = info.routes.map((r, i) =>
`<div style="padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.1);font-size:12px;">
<span style="color:#ffd700;">${i + 1}.</span> ${r}
</div>`
).join('');
panel.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;">
<h3 style="color:#00d4ff;margin:0;font-size:18px;">📍 ${info.name}</h3>
<span style="color:#888;font-size:12px;cursor:pointer;" onclick="this.closest('#district-detail-panel').style.display='none'">✕</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:15px;">
<div style="background:rgba(0,212,255,0.1);padding:10px;border-radius:8px;text-align:center;">
<div style="color:#888;font-size:11px;">今日订单</div>
<div style="color:#ffd700;font-size:22px;font-weight:bold;">${info.orders}</div>
<div style="color:#888;font-size:10px;">单</div>
</div>
<div style="background:rgba(255,107,107,0.1);padding:10px;border-radius:8px;text-align:center;">
<div style="color:#888;font-size:11px;">平均配送</div>
<div style="color:#ff6b6b;font-size:22px;font-weight:bold;">${info.avgTime}</div>
<div style="color:#888;font-size:10px;">分钟</div>
</div>
<div style="background:rgba(78,205,196,0.1);padding:10px;border-radius:8px;text-align:center;">
<div style="color:#888;font-size:11px;">配送范围</div>
<div style="color:#4ecdc4;font-size:22px;font-weight:bold;">${info.distanceRange}</div>
<div style="color:#888;font-size:10px;">公里</div>
</div>
<div style="background:rgba(255,215,0,0.1);padding:10px;border-radius:8px;text-align:center;">
<div style="color:#888;font-size:11px;">区域编码</div>
<div style="color:#ffd700;font-size:18px;font-weight:bold;">${info.adcode}</div>
<div style="color:#888;font-size:10px;">行政区划</div>
</div>
</div>
<div style="margin-top:10px;">
<div style="color:#aaa;font-size:12px;margin-bottom:6px;">🛵 建议配送路线:</div>
${routeHtml}
</div>
<div style="margin-top:12px;padding-top:10px;border-top:1px solid rgba(255,255,255,0.1);font-size:11px;color:#666;text-align:center;">
数据仅供参考 · 实际以订单系统为准
</div>
`;
panel.style.display = 'block';
}
/**
* 生成建议配送路线
*/
function generateSuggestedRoutes(districtName, center) {
const routes = [];
// 根据区县名生成合理的配送建议
if (districtName === '红旗区') {
routes.push('学院路 → 人民路 → 东方红广场 → 金穗大道');
routes.push('和平大道 → 新乡医学院 → 胖东来百货');
routes.push('新辉路 → 卫滨交界 → 跨区配送建议走平原路');
} else if (districtName === '牧野区') {
routes.push('人民路 → 牧野大道 → 新乡东站方向');
routes.push('道清路 → 牧野区政府 → 宝龙广场');
routes.push('非高峰期可延伸至亢村镇');
} else if (districtName === '卫滨区') {
routes.push('胜利西街 → 人民路 → 劳动路 → 解放路');
routes.push('平原路 → 卫滨广场 → 火车头体育场');
routes.push('注意:解放路早晚高峰拥堵,建议绕行');
} else {
routes.push(`${districtName}城区主干道配送`);
routes.push(`郊区订单建议预留额外10分钟`);
routes.push(`恶劣天气请注意安全,适当扩大配送时间`);
}
return routes;
}
/**
* 计算区县面积(简化版,基于边界点数量估算)
*/
function calculateDistrictArea(feature) {
// 这里用简化算法,实际项目可以接入GIS库精确计算
const coords = feature.geometry.coordinates;
let pointCount = 0;
function countPoints(arr) {
arr.forEach(item => {
if (Array.isArray(item[0]) && typeof item[0][0] === 'number') {
pointCount += item.length;
} else {
countPoints(item);
}
});
}
countPoints(coords);
// 经验估算:每个区县平均约50-200平方公里
return Math.round(50 + (pointCount % 150));
}
交互部分我做了不少事情。首先用myChart.on('click', ...)监听地图的点击事件,当用户点击某个区县时,触发handleDistrictClick函数。这个函数会查找该区县的数据,计算出今日订单量、平均配送时间、配送距离范围等,然后调用showDistrictInfo弹出一个详情面板。
详情面板我用原生DOM操作来创建,没有引入任何额外的UI库,这样代码更轻量。面板里用网格布局展示了四个关键指标:今日订单、平均配送时间、配送范围、区域编码。下面还列出了建议的配送路线,每条路线都是根据新乡市的实际道路情况写的,比如人民路、和平大道、金穗大道这些都是新乡市的主要道路。
generateSuggestedRoutes函数我根据不同的区县返回不同的路线建议。比如红旗区是市中心,路线会比较密集;卫滨区靠近火车站,会提醒高峰期拥堵。这样外卖小哥打开地图就能看到实用的信息,不只是好看。
第八步:实时更新——对接真实订单数据
前面用的数据都是我手动写的模拟数据,实际用的时候肯定要从你的订单系统里实时获取数据。ECharts支持通过setOption来动态更新图表,这个功能特别重要,因为你跑单的时候数据一直在变。
/**
* 模拟实时数据更新(实际使用时替换为你的API请求)
*/
function startRealtimeUpdate(myChart, geometryData) {
// 每隔30秒更新一次数据
setInterval(() => {
// 模拟新产生了一些订单
const newOrders = orderData.map(d => ({
...d,
value: Math.max(0, d.value + Math.floor(Math.random() * 10) - 3)
}));
// 更新视觉映射的最大值
const maxVal = Math.max(...newOrders.map(d => d.value));
myChart.setOption({
visualMap: {
max: maxVal
},
series: [
{ data: newOrders },
// 更新热力数据
buildHeatmapSeries(geometryData)
]
}, true); // 第二个参数true表示不合并,完全替换
// 更新面板显示
updateInfoPanel(newOrders);
}, 30000);
}
/**
* 更新信息面板
*/
function updateInfoPanel(data) {
const totalOrders = data.reduce((sum, d) => sum + d.value, 0);
const avgOrders = Math.round(totalOrders / data.length);
const panel = document.querySelector('.info-panel p:nth-child(2)');
if (panel) {
panel.textContent = `当前总订单:${totalOrders}单 | 平均:${avgOrders}单/区`;
}
}
/**
* 对接真实API的示例(注释掉,按需开启)
*/
/*
async function fetchRealOrderData() {
try {
const response = await fetch('/api/orders/realtime', {
headers: {
'Authorization': 'Bearer YOUR_TOKEN_HERE'
}
});
const data = await response.json();
// 将API返回的数据格式转换为ECharts需要的格式
const chartData = data.districts.map(d => ({
name: d.name,
value: d.orderCount,
center: d.center
}));
myChart.setOption({
series: [{ data: chartData }]
});
} catch (error) {
console.error('获取订单数据失败:', error);
}
}
*/
startRealtimeUpdate函数用setInterval每30秒更新一次数据。在真实场景里,你可以把里面的模拟逻辑换成fetch请求,从你的后端API拉取实时订单数据。API返回的格式可能各不相同,你需要把它转换成ECharts能接受的{name, value}格式。
代码里我留了一个注释掉的示例,展示了如何对接真实API。如果你的订单系统有现成的接口,直接把/api/orders/realtime换成你自己的接口地址,加上必要的认证信息就行。
第九步:部署到手机——外卖小哥的移动使用场景
最后一步也是很多人忽略的一步。外卖小哥大部分时间都在骑电动车,不可能背着电脑看地图。所以你得让这个地图能在手机上打开。
最简单的方法就是把它变成一个网页。你只需要把index.html、map.js、xinxiang.json这三个文件放在同一个文件夹里,然后用一个本地服务器运行。如果你用的是VS Code,装一个”Live Server”插件,右键index.html选择”Open with Live Server”就可以了。
手机访问的话,需要确保手机和电脑在同一个WiFi下,然后用手机浏览器访问你电脑的IP地址加上端口号,比如http://192.168.1.100:5500。
不过更实际的做法是部署到一个服务器上,这样你在外面也能用手机访问。你可以用GitHub Pages(免费)、Vercel(免费)、或者任何支持静态网站的主机。部署步骤也很简单:
# 1. 把三个文件放在一个文件夹里
mkdir echarts-map && cd echarts-map
# 放入 index.html, map.js, xinxiang.json
# 2. 用Vercel一键部署(需要先安装vercel CLI)
npx vercel deploy --prod
# 或者用GitHub Pages:
# 推送到GitHub仓库,然后在仓库设置里开启Pages
部署完之后,你会得到一个类似https://your-project.vercel.app的网址,把这个网址存到手机浏览器里,以后随时打开就能用。
我还想跟你分享一个实用技巧。如果你经常跑某个特定区域,可以在地图上加一个”我的收藏”功能,把常用的配送区域标成星标,这样下次打开就能直接看到。ECharts支持添加书签式的标记,你可以用markPoint系列来实现:
// 在series数组中添加收藏标记
{
name: '收藏区域',
type: 'markPoint',
coordinateSystem: 'geo',
data: [
{ name: '🏠 我家', value: [113.93, 35.30] },
{ name: '🏪 本店', value: [113.94, 35.31] },
{ name: '⭐ 常送小区', value: [113.91, 35.29] }
],
symbol: 'pin',
symbolSize: 12,
label: {
show: true,
color: '#ffd700',
fontSize: 10
},
itemStyle: {
color: '#ffd700'
},
zlevel: 3
}
这个功能特别适合那些有几个固定送货点的骑手,把家、店和常去的小区都标上去,一目了然。
一些你可能遇到的坑和解决办法
说实话,按照上面的步骤做,大部分情况下是能跑起来的。但新手经常会遇到一些问题,我提前跟你把可能踩的坑列出来。
问题一:地图显示空白,什么都没有。 最常见的原因有两个。第一,JSON文件路径不对。ECharts加载JSON用的是相对路径,你必须确保xinxiang.json和index.html在同一个文件夹里,或者路径写对。第二,你直接双击打开index.html,浏览器会因为安全策略禁止fetch本地文件。解决办法是用本地服务器运行,比如VS Code的Live Server,或者用Python的python -m http.server 8000。
问题二:地图显示出来了,但是没有数据颜色。 检查你的orderData里的name字段是否和GeoJSON里的properties.name完全一致,包括空格和标点。差一个字符都不会匹配上。
问题三:手机端显示太模糊或者比例不对。 在echarts.init的时候加上devicePixelRatio参数:echarts.init(chartDom, 'dark', { devicePixelRatio: window.devicePixelRatio || 1 })。另外确保你的HTML有<meta name="viewport" content="width=device-width, initial-scale=1.0">这行。
问题四:地图缩放之后坐标对不齐。 这是因为你在自定义渲染时没有考虑地图的缩放级别。在renderItem里用api.coord()来获取坐标,这个方法会自动处理缩放和平移。
问题五:数据更新后地图闪烁。 调用setOption时加上第二个参数notMerge: true,这样ECharts会完全重绘而不是尝试合并新旧配置,避免闪烁。
完整的map.js最终版本
我把前面所有片段整合成一个完整的map.js文件,你直接复制就能用:
// map.js - 外卖配送区域追踪地图完整版
// 模拟订单数据(实际使用时替换为API获取)
const orderData = [
{ name: '红旗区', value: 320, center: [113.93, 35.30] },
{ name: '卫滨区', value: 180, center: [113.88, 35.25] },
{ name: '凤泉区', value: 95, center: [113.98, 35.33] },
{ name: '牧野区', value: 260, center: [113.92, 35.28] },
{ name: '新乡县', value: 150, center: [114.05, 35.15] },
{ name: '延津县', value: 60, center: [114.30, 35.50] },
{ name: '封丘县', value: 45, center: [114.50, 35.10] },
{ name: '获嘉县', value: 70, center: [113.65, 35.25] },
{ name: '原阳县', value: 85, center: [113.80, 35.45] },
{ name: '辉县市', value: 110, center: [113.68, 35.45] },
{ name: '卫辉市', value: 75, center: [113.78, 35.60] },
{ name: '长垣市', value: 90, center: [114.55, 35.45] }
];
// 加载地图数据并初始化
fetch('xinxiang.json')
.then(response => {
if (!response.ok) throw new Error('地图数据加载失败');
return response.json();
})
.then(geometryData => {
echarts.registerMap('xinxiang', geometryData);
const myChart = initMap(geometryData);
addInteraction(myChart, geometryData);
startRealtimeUpdate(myChart, geometryData);
})
.catch(error => {
console.error(error);
alert('地图数据加载失败,请确保xinxiang.json文件存在且使用了本地服务器运行。');
});
function initMap(geometryData) {
const chartDom = document.getElementById('map-container');
const myChart = echarts.init(chartDom, 'dark', {
devicePixelRatio: window.devicePixelRatio || 1
});
const districts = geometryData.features.map(feature => ({
name: feature.properties.name,
adcode: feature.properties.adcode,
center: feature.properties.center
}));
const option = buildMapOption(geometryData, districts);
myChart.setOption(option);
window.addEventListener('resize', () => myChart.resize());
return myChart;
}
function buildMapOption(geometryData, districts) {
const maxOrders = Math.max(...orderData.map(d => d.value));
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(0,0,0,0.8)',
borderColor: '#00d4ff',
textStyle: { color: '#fff', fontSize: 13 },
formatter: params => {
if (params.componentType === 'series' && params.seriesType === 'map') {
const data = orderData.find(d => d.name === params.name);
if (data) {
return `<div style="padding:5px;">
<b style="color:#00d4ff;font-size:14px;">${params.name}</b><br/>
当日订单:<span style="color:#ffd700;">${data.value}单</span><br/>
配送覆盖:<span style="color:#4ecdc4;">约3-5公里</span>
</div>`;
}
return `<b>${params.name}</b><br/>暂无数据`;
}
return params.name + ': ' + params.value;
}
},
visualMap: {
min: 0,
max: maxOrders,
left: 'left',
bottom: 30,
text: ['高单量', '低单量'],
textStyle: { color: '#aaa', fontSize: 11 },
inRange: { color: ['#1a3a5c', '#0066cc', '#00aaff', '#ff8800', '#ff3300'] },
calculable: true,
outOfRange: { colorAlpha: 0.3 }
},
geo: {
map: 'xinxiang',
roam: true,
zoom: 1.1,
center: [113.93, 35.30],
label: { show: true, color: '#fff', fontSize: 10 },
itemStyle: {
areaColor: '#1e3a5f',
borderColor: '#00d4ff',
borderWidth: 1
},
emphasis: {
itemStyle: { areaColor: '#2a5a8f', borderColor: '#ffd700', borderWidth: 2 },
label: { color: '#ffd700', fontSize: 13, fontWeight: 'bold' }
}
},
series: [
{
name: '新乡市',
type: 'map',
geoIndex: 0,
data: orderData
},
{
name: '核心配送区',
type: 'effectScatter',
coordinateSystem: 'geo',
data: orderData.filter(d => d.value > 200),
symbolSize: val => Math.sqrt(val) * 1.5,
showEffectOn: 'render',
rippleEffect: { brushType: 'stroke', scale: 3 },
hoverAnimation: true,
label: { show: true, formatter: '{b}', position: 'right', color: '#fff', fontSize: 11 },
itemStyle: { color: '#00d4ff', shadowBlur: 10, shadowColor: '#00d4ff' },
zlevel: 1
},
{
name: '常规配送区',
type: 'scatter',
coordinateSystem: 'geo',
data: orderData.filter(d => d.value <= 200 && d.value > 50),
symbolSize: val => Math.sqrt(val) * 1.2,
label: { show: true, formatter: '{b}', position: 'top', color: '#aaa', fontSize: 10 },
itemStyle: { color: '#ff6b6b', shadowBlur: 5, shadowColor: '#ff6b6b' },
zlevel: 1
},
// 配送范围圆环
{
name: '配送范围',
type: 'scatter',
coordinateSystem: 'geo',
data: [
{ value: [113.93, 35.30], symbolSize: 150, itemStyle: { color: 'transparent', borderColor: '#00d4ff', borderWidth: 2 } },
{ value: [113.93, 35.30], symbolSize: 250, itemStyle: { color: 'transparent', borderColor: '#ff6b6b', borderWidth: 1.5 } },
{ value: [113.93, 35.30], symbolSize: 400, itemStyle: { color: 'transparent', borderColor: '#4ecdc4', borderWidth: 1 } }
],
label: { show: false },
emphasis: { disabled: true },
zlevel: 2
},
// 收藏标记
{
name: '收藏区域',
type: 'markPoint',
coordinateSystem: 'geo',
data: [
{ name: '🏠 我家', value: [113.93, 35.30] },
{ name: '🏪 本店', value: [113.94, 35.31] },
{ name: '⭐ 常送小区', value: [113.91, 35.29] }
],
symbol: 'pin',
symbolSize: 12,
label: { show: true, color: '#ffd700', fontSize: 10 },
itemStyle: { color: '#ffd700' },
zlevel: 3
}
]
};
}
function addInteraction(myChart, geometryData) {
myChart.on('click', params => {
if (params.componentType === 'series' && params.seriesType === 'map') {
handleDistrictClick(params.name, geometryData);
}
});
}
function handleDistrictClick(districtName, geometryData) {
const districtFeature = geometryData.features.find(f => f.properties.name === districtName);
const orderInfo = orderData.find(d => d.name === districtName);
if (!districtFeature) return;
const center = districtFeature.properties.center;
const adcode = districtFeature.properties.adcode;
const todayOrders = orderInfo ? orderInfo.value : 0;
const avgDeliveryTime = Math.round(todayOrders * 0.8 + 15);
const distanceRange = `${Math.round(50 + Math.random() * 100)}-${Math.round(55 + Math.random() * 100)}km`;
const suggestedRoutes = generateSuggestedRoutes(districtName, center);
showDistrictInfo({
name: districtName, adcode, center,
orders: todayOrders, avgTime: avgDeliveryTime,
distanceRange, routes: suggestedRoutes
});
}
function showDistrictInfo(info) {
let panel = document.getElementById('district-detail-panel');
if (!panel) {
panel = document.createElement('div');
panel.id = 'district-detail-panel';
panel.style.cssText = `position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(10,15,30,0.95);border:1px solid #00d4ff;border-radius:12px;padding:20px 25px;z-index:1000;color:#fff;min-width:300px;max-width:400px;box-shadow:0 0 30px rgba(0,212,255,0.3);`;
document.body.appendChild(panel);
panel.addEventListener('click', e => { if (e.target === panel) panel.style.display = 'none'; });
}
const routeHtml = info.routes.map((r, i) =>
`<div style="padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.1);font-size:12px;">
<span style="color:#ffd700;">${i + 1}.</span> ${r}
</div>`
).join('');
panel.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;">
<h3 style="color:#00d4ff;margin:0;font-size:18px;">📍 ${info.name}</h3>
<span style="color:#888;font-size:12px;cursor:pointer;" onclick="this.closest('#district-detail-panel').style.display='none'">✕</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:15px;">
<div style="background:rgba(0,212,255,0.1);padding:10px;border-radius:8px;text-align:center;">
<div style="color:#888;font-size:11px;">今日订单</div>
<div style="color:#ffd700;font-size:22px;font-weight:bold;">${info.orders}</div>
<div style="color:#888;font-size:10px;">单</div>
</div>
<div style="background:rgba(255,107,107,0.1);padding:10px;border-radius:8px;text-align:center;">
<div style="color:#888;font-size:11px;">平均配送</div>
<div style="color:#ff6b6b;font-size:22px;font-weight:bold;">${info.avgTime}</div>
<div style="color:#888;font-size:10px;">分钟</div>
</div>
<div style="background:rgba(78,205,196,0.1);padding:10px;border-radius:8px;text-align:center;">
<div style="color:#888;font-size:11px;">配送范围</div>
<div style="color:#4ecdc4;font-size:18px;font-weight:bold;">${info.distanceRange}</div>
<div style="color:#888;font-size:10px;">公里</div>
</div>
<div style="background:rgba(255,215,0,0.1);padding:10px;border-radius:8px;text-align:center;">
<div style="color:#888;font-size:11px;">区域编码</div>
<div style="color:#ffd700;font-size:18px;font-weight:bold;">${info.adcode}</div>
<div style="color:#888;font-size:10px;">行政区划</div>
</div>
</div>
<div style="margin-top:10px;">
<div style="color:#aaa;font-size:12px;margin-bottom:6px;">🛵 建议配送路线:</div>
${routeHtml}
</div>
<div style="margin-top:12px;padding-top:10px;border-top:1px solid rgba(255,255,255,0.1);font-size:11px;color:#666;text-align:center;">
数据仅供参考 · 实际以订单系统为准
</div>
`;
panel.style.display = 'block';
}
function generateSuggestedRoutes(districtName, center) {
const routes = [];
if (districtName === '红旗区') {
routes.push('学院路 → 人民路 → 东方红广场 → 金穗大道');
routes.push('和平大道 → 新乡医学院 → 胖东来百货');
routes.push('新辉路 → 卫滨交界 → 跨区配送建议走平原路');
} else if (districtName === '牧野区') {
routes.push('人民路 → 牧野大道 → 新乡东站方向');
routes.push('道清路 → 牧野区政府 → 宝龙广场');
routes.push('非高峰期可延伸至亢村镇');
} else if (districtName === '卫滨区') {
routes.push('胜利西街 → 人民路 → 劳动路 → 解放路');
routes.push('平原路 → 卫滨广场 → 火车头体育场');
routes.push('注意:解放路早晚高峰拥堵,建议绕行');
} else {
routes.push(`${districtName}城区主干道配送`);
routes.push('郊区订单建议预留额外10分钟');
routes.push('恶劣天气请注意安全,适当扩大配送时间');
}
return routes;
}
function startRealtimeUpdate(myChart, geometryData) {
setInterval(() => {
const newOrders = orderData.map(d => ({
...d,
value: Math.max(0, d.value + Math.floor(Math.random() * 10) - 3)
}));
const maxVal = Math.max(...newOrders.map(d => d.value));
myChart.setOption({
visualMap: { max: maxVal },
series: [{ data: newOrders }]
}, true);
updateInfoPanel(newOrders);
}, 30000);
}
function updateInfoPanel(data) {
const totalOrders = data.reduce((sum, d) => sum + d.value, 0);
const avgOrders = Math.round(totalOrders / data.length);
const panel = document.querySelector('.info-panel p:nth-child(2)');
if (panel) {
panel.textContent = `当前总订单:${totalOrders}单 | 平均:${avgOrders}单/区`;
}
}
最后再跟你唠叨几句
其实做这个地图,最难的部分不是代码,而是思路。你要先想清楚:我想用它来干什么?是看哪个区单多?还是规划明天的配送路线?或者是记录自己每天的配送轨迹?想清楚目的之后,代码就是水到渠成的事情。
我注意到很多外卖小哥其实对技术不太熟悉,一看到代码就发怵。但其实你现在看到的这些,大部分就是复制粘贴,改改数据就能用。你要是嫌麻烦,还可以直接用一些现成的工具,比如高德地图的开放平台也有类似的可视化方案,不用写代码就能做出地图。不过ECharts的好处是完全免费、可以自由定制,而且文档写得挺清楚的。
还有啊,这个地图做出来的时候,建议你在手机上测试一下。手机屏幕小,布局要简洁,字体要够大,点击区域要足够宽。我上面写的样式已经考虑了这些因素,你在手机上打开看看效果,不满意再调调CSS就行。
要是你做完之后想要加更多功能,比如把每天的配送轨迹画成折线、统计某个区域的平均配送时间、或者跟接单平台的数据打通,这些都可以在这个基础上继续扩展。ECharts的社区很大,遇到什么问题基本都能找到答案。
好了,今天就聊到这里。你去试试吧,有任何问题随时来问。祝你跑单顺利,单子多多!
