简介:面向前端开发与数据可视化从业者的 ECharts 世界地图数据包,重点提供以中国为地理中心的世界地图脚本,并配套中国省级区域地图数据,适合制作涉华数据分析、全球业务看板与地理关系展示。压缩包共 420 个文件,以 380 个 JSON 地图数据和 40 个 JS 脚本为主,整体约 4.34MB;JSON 便于按区域加载与复用,JS 可直接在页面中引入并注册地图。已有 3155 人学习下载,说明其在 ECharts 地图类资源中具有一定参考价值。读者可据此快速完成地图容器初始化、geo 中心点与缩放平移配置、标签高亮和区域样式调整,并将业务数据绑定到 series 进行渲染,减少自行搜集世界及省级边界数据、处理坐标与名称匹配的工作量,适合搭建中国视角的全球数据可视化页面。
1. 让中国落在画面正中:一份替换即生效的 echart 世界地图数据
大屏做到第三版,业务方指着屏幕说了一句:能不能让中国在中间。默认的 echart 世界地图 是拿本初子午线当中心线的,中国被挤到画面右侧三分之一的位置,太平洋对岸那块大陆横在最左边,怎么看怎么别扭。这份以中国为中心的 world.js 就解决这一件事——它是一份已经registerMap好的世界地图数据,覆盖掉原来的文件,option 几乎不用动,中国就落到画面正中。
它适合正在做大屏、做海外节点分布图、又不想为了一个投影去啃整套 GIS 的前端同学。需要提前说清楚:这是一份数据文件,不是框架,不含任何组件封装;它的价值在于省掉你查投影、算偏移、处理断点那一整天的活。
2. 拆开 world.js:registerMap 一行调用背后的三套坐标
很多人第一次打开这个文件,看到几万行[116.4, 39.9]这样的数组就关掉了。其实抛掉坐标,剩下的骨架不到二十行,看懂它就知道这份文件该怎么改、能改成什么样。
2.1 文件骨架:一个 UMD 包装裹着一份 FeatureCollection
把坐标数组整段折叠,文件长这样:
// world.js 骨架,coordinates 用 ... 省略 (function (root, factory) { if (typeof define === 'function' && define.amd) { define(['exports'], factory); // AMD } else if (typeof exports === 'object' && typeof module !== 'undefined') { factory(exports); // CommonJS / webpack } else { factory((root.echarts = root.echarts || {})); // 浏览器全局 } })(this, function (exports) { var type = 'world'; // 注册名,必须和 option 里 geo.map 完全一致 var mapData = { type: 'FeatureCollection', features: [ { type: 'Feature', properties: { name: 'China' }, // 国家名字段,tooltip / visualMap 靠它匹配 geometry: { type: 'Polygon', coordinates: [ /* 一圈闭合的 [经度, 纬度] */ ] } } // ... 剩下两百多个国家和地区 ] }; // 真正干活的只有这一行:把 GeoJSON 塞进 echarts 内部的 mapData 仓库 exports.registerMap(type, mapData); });UMD 那三个分支跟地图本身没关系,只是为了让同一个文件在 script 标签、webpack、require.js 下都能跑。真正有意义的只有最后一个registerMap(type, mapData),它把整份 FeatureCollection 挂到 ECharts 内部一个以type为键的仓库里。后面geo: { map: 'world' }或series: [{ type: 'map', map: 'world' }]取值时,就是拿这个名字去仓库里捞。
mapData是标准 GeoJSON,features里每个元素的geometry.type可能是Polygon(单块陆地)或MultiPolygon(群岛、飞地)。properties.name是渲染侧做数据匹配的唯一凭据,后面要讲的 visualMap 不上色、tooltip 显示 undefined,八成都是这个名字对不上。
2.2 一次注册之后:经纬度是怎么变成屏幕像素的
registerMap只负责把数据放好,真正把经纬度变成像素发生在渲染阶段,中间要过三道换算,这是理解「为什么中国能居中」的关键。
第一道是包围盒。ECharts 会扫一遍所有坐标,算出最小的[minLon, minLat]到[maxLon, maxLat]矩形;如果你在 option 里写了boundingCoords,就用你给的,跳过自动计算。
第二道是投影。ECharts 的 geo 默认不做墨卡托,就是把经纬度线性映射到画布矩形上——经度值域[-180, 180]线性拉到横向像素,纬度值域[90, -90]线性拉到纵向像素,再乘一个aspectScale修正长宽比。正因为是线性的,你往前推一步就能得出一个很重要的结论:谁落在画面中央,完全由经度的值域决定,跟center参数写多少没关系。原始数据值域是[-180, 180],中间点就是 0°,也就是本初子午线,中国自然被推到右边。
第三道是图形生成。每个环变成一条闭合 Path,填充走itemStyle.areaColor,描边走borderColor,鼠标命中检测也是按 Path 做的。
所以「以中国为中心」这件事,本质上就是把经度值域从[-180, 180]改成[-75, 285]之类——让 105°E 落在中间。而改法只有两种:改 option 里的center(只移动视野,两边会露白),或者改数据本身(把经度整体挪一遍)。这份 world.js 用的是后者,所以它是「数据级」的居中,不存在露白问题。
2.3 ECharts 4 和 5 的引入方式不一样,别照抄旧教程
这一条踩坑的人最多。ECharts 4.x 时代,echarts/map/js/world.js是随 npm 包一起发的,require('echarts/map/js/world')直接能用。5.0 之后地图数据从主包里剥离了,那条路径压根不存在,import 'echarts/map/js/world'会直接报 Module not found。
新项目里常见做法是两条:一是把这份 world.js 拷到src/geo/下当本地模块,用副作用引入(import './geo/world.js'),代价是打包体积多出几百 KB;二是只留 GeoJSON,运行时fetch回来再手动registerMap,加载时机可控,还能按需拆包。前者省事,后者适合大屏首屏有性能要求的情况。
提示:不管走哪条路,
registerMap必须发生在echarts.init()之前,否则第一次setOption时仓库还是空的,图表直接空白。
3. 自己造一份:用 Node 脚本把 105°E 搬到画布中央
拿到手的 world.js 未必符合你的中心经线要求,也不太可能和你的数据字段完全对得上。与其改别人的成品,不如花二十分钟写个脚本自己生成,后面维护心里有底。
3.1 先统一字段:源数据里国家名可能不叫 name
公开的国家边界 GeoJSON,properties里的字段名五花八门:有的叫NAME,有的叫ADMIN,有的干脆是本地语言。ECharts 默认只认小写name,不统一就会在渲染阶段静默失败——地图能画出来,但着色、tooltip、点击事件全哑火。
我一般会在生成脚本里先做一层规整,顺手把用不上的字段丢掉,能省不少体积:
// tools/make-world.js —— 第一步:字段规整 const fs = require('fs'); const path = require('path'); const SRC = path.resolve(__dirname, '../geo/source.geo.json'); const raw = JSON.parse(fs.readFileSync(SRC, 'utf8')); const features = raw.features.map((f) => ({ type: 'Feature', // 只留一个 name,大小写和多语言字段全部收敛到这里 properties: { name: f.properties.NAME || f.properties.name || f.properties.ADMIN }, geometry: f.geometry, }));SRC指向你下载的原始 GeoJSON,NAME || name || ADMIN是个兜底链,实际数据里通常只有一个字段有值。这一步做完,后面所有逻辑都只跟properties.name打交道,不用再到处判断。
选数据源的时候有个取舍:110m 精度那份几十万顶点,够大屏用;50m 精度细节好但体积翻好几倍,roam缩放的时候容易卡。我一般先用 110m 把功能跑通,真觉得边缘太糙再换。
3.2 经度整体平移:让 105°E 变成 0°
核心就三行数学。把每个经度减去中心经线,超出[-180, 180]的部分做一次回绕,纬度不动:
// tools/make-world.js —— 第二步:经度平移 const CENTER_LON = 105; // 中国大致落在这条经线上 const OFFSET = -CENTER_LON; function wrapLon(lon) { let v = lon + OFFSET; while (v > 180) v -= 360; // 回绕到左半边 while (v < -180) v += 360; // 回绕到右半边 return v; } // 递归处理任意深度的坐标环 function shiftRing(ring) { return ring.map(([lon, lat]) => [wrapLon(lon), lat]); } function shiftGeometry(geometry) { const { type, coordinates } = geometry; if (type === 'Polygon') { return { type, coordinates: coordinates.map(shiftRing) }; } if (type === 'MultiPolygon') { return { type, coordinates: coordinates.map((poly) => poly.map(shiftRing)) }; } if (type === 'LineString') return { type, coordinates: shiftRing(coordinates) }; if (type === 'Point') return { type, coordinates: shiftRing([coordinates])[0] }; return geometry; }CENTER_LON这个值决定版式,值得单独说一下:
- 给
105,中国正落在画布中央(0 度位置),断点在 75°W 附近,会在美洲中部切开一块; - 给
150,复刻的是国内纸质世界地图那种版式——大西洋断开,欧非在左、美洲在右,中国偏左三分之一处,视觉上更符合大多数人看地图的习惯。
我在大屏上这两种都试过,150那种客户接受度更高,因为它「看着像地图」;105那种在强调「国内节点 + 全球辐射」的拓扑图里更直观。脚本里把CENTER_LON提成一个常量,就是为了随时切换。
shiftGeometry用类型分支而不是无脑递归,是因为coordinates的嵌套层数跟type强相关:Polygon 是「环的数组」,MultiPolygon 是「多边形的数组,每个多边形又是环的数组」,层数差一层结果就是灾难。显式写分支虽然啰嗦,但不会错。
3.3 断点切环:极区数据为什么会被拉成一条横线
平移之后最典型的翻车现象:地图上横着一条贯穿全图的细线,位置看纬度大概在最底下。
原因不复杂。一个环从 179° 跨到 -179°,本来是两个挨着的点;经度平移加回绕之后,这两个点可能变成 179 和 181→-179 这种「一头一尾」的坐标。渲染器不知道它是绕地球一圈,只看到两个相距 358 度的点,于是直接连一条直线过去——那条横线就是这么来的。极区数据最惨,因为它本来就围了一圈,切成什么样都难看;跨经线的岛国也经常中招。
标准的修法是在断点处把环拆开,每段自己闭合:
// tools/make-world.js —— 第三步:断点切环 function splitRing(ring) { const parts = []; let cur = []; for (let i = 0; i < ring.length - 1; i++) { const a = ring[i]; const b = ring[i + 1]; cur.push(a); // 相邻两点的经度差超过 180°,说明它们分居断点两侧 if (Math.abs(b[0] - a[0]) > 180) { const edge = a[0] > 0 ? 180 : -180; cur.push([edge, a[1]]); // 在断点上补一个点收口 parts.push(cur); cur = [[-edge, b[1]]]; // 新的一段从对侧断点起步 } } cur.push(ring[ring.length - 1]); parts.push(cur); return parts.filter((r) => r.length >= 4); // 少于 4 个点构不成多边形 }判定条件Math.abs(b[0] - a[0]) > 180是这段逻辑的全部依据:地球一圈 360 度,两个相邻顶点在真实地理上不可能相隔超过 180 度,超过了必然是回绕造成的假象。补的两个点纬度分别取 a 和 b 的纬度,是为了让切口是竖直的,视觉上贴着画布边缘,不会被看出来。
filter(r => r.length >= 4)那行别省,切完之后经常产生两三个点的碎片,留着会在图上渲染出一堆小毛刺。
MultiPolygon 切完可能一个变两个,要摊平再拼回去:
function shiftMultiPolygon(polygons) { const out = []; polygons.forEach((poly) => { poly.forEach((ring) => { splitRing(ring).forEach((piece) => out.push([piece])); }); }); return { type: 'MultiPolygon', coordinates: out }; }这里有个简化:把所有环都当外环处理。世界地图的国家边界基本没有内环(洞),这个简化够用;真要换成带湖泊、带飞地的数据,得保留「第一个环是外环,其余是洞」的约定,否则湖会被填成陆地。
极区那块我一般直接砍掉:生成时判断纬度,所有顶点都小于 -60 的 feature 直接丢弃。大屏没人看那块,留着只会引来那条横线。
3.4 落盘:拼成 world.js
最后一步,把处理完的 GeoJSON 套上注册调用,写成文件:
// tools/make-world.js —— 第四步:输出 const geo = { type: 'FeatureCollection', features }; const banner = `/* generated, center=${CENTER_LON}E, do not edit by hand */\n`; const body = `echarts.registerMap('world', ${JSON.stringify(geo)});\n`; fs.writeFileSync(path.resolve(__dirname, '../src/geo/world.js'), banner + body); console.log('features:', features.length, 'bytes:', (banner + body).length);JSON.stringify(geo)会把整份数据压成一行,文件打开很难看,但浏览器解析最快,也省掉格式化带来的几十 KB。真需要看结构,源数据还在,不用看产物。
打进 package.json 的 script 里,npm run build:geo一条命令重生成,后面换数据源、换中心经线都只动常量。console.log打出来的 feature 数和字节数很有用——特征数突然少了,说明某一步过滤太狠;字节数暴涨,通常是切环切出了碎片。
4. 接进项目:geo 打底、散点叠加与四个必调参数
数据准备好之后,剩下的就是让它和业务数据在一个坐标系里说话。这块代码不长,但参数没配对就是各种别扭。
4.1 最小可跑:geo 打底 + effectScatter + lines
先用geo组件铺底图,再挂两个 series,一个画节点一个画飞线:
// map-init.js import * as echarts from 'echarts'; import './geo/world.js'; // 副作用引入,内部已调用 registerMap import { toMapCoord } from './geo/project.js'; const chart = echarts.init(document.getElementById('map')); const NODES = [ { name: '国内主节点', lnglat: [116.4, 39.9], value: 120 }, { name: '海外节点', lnglat: [-58.4, -34.6], value: 80 }, ]; chart.setOption({ backgroundColor: '#0a1a2b', geo: { map: 'world', // 与 registerMap 的第一个参数一致 roam: true, zoom: 1.15, aspectScale: 1, // 世界地图必须显式给 1 boundingCoords: [[-180, 90], [180, -90]], itemStyle: { areaColor: '#12314f', borderColor: '#2c5f88', borderWidth: 0.6 }, emphasis: { itemStyle: { areaColor: '#1d4f7c' } }, }, series: [ { type: 'effectScatter', coordinateSystem: 'geo', // 声明用 geo 的坐标系 data: NODES.map((n) => ({ name: n.name, value: [...toMapCoord(n.lnglat), n.value], // 关键:和地图走同一套平移 })), symbolSize: (v) => Math.max(6, Math.sqrt(v[2]) * 1.8), rippleEffect: { scale: 3, brushType: 'stroke' }, label: { show: true, formatter: '{b}', position: 'right', color: '#cfe6ff' }, }, { type: 'lines', coordinateSystem: 'geo', effect: { show: true, period: 4, trailLength: 0.25, symbol: 'arrow', symbolSize: 6 }, lineStyle: { color: '#39d0d8', width: 1, opacity: 0.55, curveness: 0.22 }, data: [{ coords: [toMapCoord([116.4, 39.9]), toMapCoord([-58.4, -34.6])] }], }, ], });geo和series是两层结构:geo只画底图和交互,series才是数据。想让底图跟着数据变颜色,得把type: 'map'的 series 挂在同一个map上,而不是改geo——这两者分管视觉和数据,混着用就会出现「改了没反应」。
coordinateSystem: 'geo'是 series 能找到坐标系的关键,漏了这一行,散点会按 canvas 像素去定位,全挤在左上角。
symbolSize用Math.sqrt(value)而不是value,是因为人眼对面积敏感、对半径不敏感,直接线性映射会让大值看起来夸张得离谱。这是可视化里的常识,但写代码时特别容易忘。
4.2 四个必调参数:boundingCoords、aspectScale、zoom、center
同一份世界地图,参数不同视觉效果能差一倍。这张表是我踩完坑之后固化的默认值:
| 参数 | 作用 | 世界地图上的建议值 |
|---|---|---|
aspectScale | 长宽比修正系数 | 1;默认0.75会把高纬地区压扁 |
boundingCoords | 锁死经纬度可视范围 | [[-180, 90], [180, -90]],避免 roam 之后跑飞 |
zoom | 初始缩放 | 0.9 ~ 1.2,视容器宽高比微调 |
center | 视野中心经纬度 | 平移后写[0, 0]就是中国 |
roam | 拖拽与滚轮缩放 | 只读大屏给false |
aspectScale默认是0.75,这个值是为中国省级地图准备的,套到世界地图上整个人类居住带都像被踩了一脚。boundingCoords的作用更隐蔽:不写它,用户滚轮放大再一拖,地图能整个飞出容器,回不来的感觉非常糟糕,配了之后视野被夹在给定范围内,怎么作都还在框里。
center值得单独提一句。做了数据级平移之后,中国的经度是 0,所以center: [0, 0]就能让中国居中——不需要再写[105, 35]这种看着像黑魔法的数字。反过来,如果某天你把CENTER_LON改成 150,center也要跟着改,否则会跑偏。这两个值应该从同一个配置项导出,别硬编码两处。
4.3 数据侧也要平移:把 project 抽成共享模块
这一步是整篇文章最容易被忽略、又最容易出错的地方。地图平移了,业务数据没平移,散点就会跑到屏幕另一半去。解决办法只有一个:把平移逻辑抽成模块,生成脚本和渲染代码共用。
// geo/project.js —— 生成地图和渲染数据共用这一份 export const CENTER_LON = 105; // 改这里,生成脚本和页面一起生效 export function toMapCoord([lon, lat]) { let x = lon - CENTER_LON; while (x > 180) x -= 360; while (x < -180) x += 360; return [x, lat]; } // 反解:点击地图拿到坐标后换回真实经纬度 export function fromMapCoord([x, lat]) { let lon = x + CENTER_LON; while (lon > 180) lon -= 360; while (lon < -180) lon += 360; return [lon, lat]; }fromMapCoord是给交互用的。chart.on('click')在 geo 上拿到的是屏幕坐标,需要chart.convertFromPixel({ geoIndex: 0 }, [x, y])转成地图坐标,再反解回真实经纬度才能拿去查库。少了这一步,用户点哪儿都是
本文还有配套的精品资源,点击获取