24年Blog改版,从Typecho迁移到Hexo时我使用 jVectorMap 制作了“足迹”页面,当时jVectorMap官方案例的中国地图里连台湾省都没有,经过一番查找,我才在 amcharts 网站里找到了一份相对完善的中国地图(仍缺少一些海上岛屿😥)。受限于个人技术能力,摸索了很久都没法做的更好了,最终只能将就着用,并写了一篇《jVectorMap入门使用教程》分享出来。
初版的足迹页面
一转眼都快两年了,前天有个匿名小伙伴在网站上留言了一条评论“博主大爱,参考了您的地图工具,请问有最新的方案吗,缺失地图总是心病。”。这两年AI发展很快,我觉得是时候借助AI的能力,去尝试补全这个地图缺陷了😁。
![]()
整个与AI对话的过程很长,中途因为聊天限制也切换了像ChatGPT、Gemini等多个AI平台。如果把对话内容全部贴出来,估计大家也看得一知半解,所以我让AI总结精简了对话内容和中途的一些技术方案调整内容,对过程不感兴趣的直接跳到04章节结尾查看效果即可!
01、无限接近:与ChatGPT对话生成SVG
我:我在用 jVectorMap,这个库很老了,文档也没了。我想做一个自定义 SVG 地图,该怎么做?
ChatGPT:可以用 GeoJSON → mapshaper → SVG → 再转换成 jVectorMap 的路径数据。
GPT最初推荐了 GeoJSON → SVG → jVectorMap的方案。从阿里DataV获取到了GeoJSON数据,然后通过 mapshaper 进行JSON到SVG的转换
1 2 3 4 5 6 7 8
| npm init
npm i mapshaper
npx mapshaper 100000_full.json -simplify dp 8% -filter-fields name,adcode -o format=svg china.svg
|
通过mapshaper读取JSON文件并转换成SVG。效果见下图,生成的地图是斜着的,但好像这方案可行?
![]()
我:生成的 SVG 看起来是斜的,是数据问题吗?
ChatGPT:是投影问题,需要用 mapshaper 做投影(如 webmercator)
GPT告诉我“地图 = 数据 + 投影”,GeoJSON 使用“地理坐标(经纬度)”,SVG 是“平面坐标”,中间缺了“地图投影(projection)”这一步。我直接把球面坐标摊平了(没有投影)就导致图片看起来是斜的,通过添加-proj mercator属性就可以生成带有投影信息的SVG图片。
1 2 3 4 5
| npx mapshaper 100000_full.json -simplify dp 8% -filter-fields name,adcode -o format=svg china.svg
npx mapshaper 100000_full.json -proj webmercator -simplify weighted 0.08 -filter-fields name,adcode -o format=svg china.svg
|
重新运行命令后,SVG看起来就正常了。此时我觉得胜券在握,没想到只是踩坑的开始😭。
![]()
我:有些小岛不见了(如海南下面的南沙群岛,台湾省右上方的钓鱼岛等信息😐)
ChatGPT:是 simplify 过度,需要调整参数或保留小形状
GPT告诉我-simplify weighted 0.08这行属性会删减面积很小的polygon(岛屿)来简化复杂度和缩小地图体积,不使用simplify则会生成完整的地图。
1 2
| npx mapshaper 100000_full.json -proj webmercator -filter-fields name,adcode -o format=svg china.svg
|
经过测试确实可以显示出完整地图了,但文件体积也从 32.7 KB 暴涨至 334 KB。我在这个问题上尝试过不同精度的配置项输出,最后也没有降低下来体积。
![]()
创建转换脚本(我命名为了parse.js),它将把SVG转换为jVectorMap所需的js文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
| #!/usr/bin/env node
import fs from 'fs' import path from 'path' import { parse } from 'node-html-parser'
const args = process.argv.slice(2)
if (args.length === 0) { console.error('❌ 请提供 SVG 文件路径,例如: npx parse.js china.svg') process.exit(1) }
const inputPath = args[0] const outputPath = args[1] || path.join( path.dirname(inputPath), path.basename(inputPath, '.svg') + '.js' )
if (!fs.existsSync(inputPath)) { console.error('❌ 文件不存在:', inputPath) process.exit(1) }
const svgContent = fs.readFileSync(inputPath, 'utf-8') const root = parse(svgContent)
const svgTag = root.querySelector('svg')
if (!svgTag) { console.error('❌ 非法 SVG 文件') process.exit(1) }
let width = 1000 let height = 600
const viewBox = svgTag.getAttribute('viewBox') if (viewBox) { const parts = viewBox.split(/\s+/) width = parseFloat(parts[2]) height = parseFloat(parts[3]) }
const paths = {}
const elements = root.querySelectorAll('path')
if (elements.length === 0) { console.error('❌ SVG 中未找到 path 元素') process.exit(1) }
elements.forEach((el, index) => { const d = el.getAttribute('d') if (!d) return
const name = el.getAttribute('name') || el.getAttribute('data-name') || `region_${index}`
const adcode = el.getAttribute('adcode') || el.getAttribute('data-adcode') || index
const id = `CN_${adcode}`
paths[id] = { path: d, name } })
const mapName = path.basename(outputPath, '.js')
const mapData = { width, height, paths }
const output = `/*! * Generated by parse.js */ jQuery.fn.vectorMap('addMap', '${mapName}', ${JSON.stringify(mapData)}); `
fs.writeFileSync(outputPath, output)
console.log(`✅ 转换完成:`) console.log(`📥 输入: ${inputPath}`) console.log(`📤 输出: ${outputPath}`) console.log(`🧩 区域数: ${Object.keys(paths).length}`)
|
修改目录下的package.json文件,以支持import写法:
1 2 3 4 5 6 7 8
| { "name": "parse-svg-map", "version": "1.0.0", "bin": { "parse.js": "./parse.js" }, "type": "module" }
|
接着运行转换命令:
它确实生成了js文件,但是出现了新的问题“地图的省份code和name丢失了”,我折腾了一会才发现,SVG的path标签中没有拷贝到对应的name和code值。这个问题ChatGPT折腾了很久都无法解决,我也有点崩溃了😭(那时候觉得离成功只差临门一脚了)。
对应省份code和名称丢失了
最后,我不得不放弃了GeoJSON → SVG → jVectorMap这种方案,转而询问GPT是否有直接从GeoJSON转换为jVectorMap所需JS的方案,GPT说:有的。
02、重头再来:GeoJSON → map.js之路
我:能不能直接用 Node 读取 GeoJSON,生成 map.js?
ChatGPT:可以,直接 GeoJSON → path
这一回,流程变成了 GeoJSON → Node → jVectorMap,跳过了SVG的生成和处理步骤。脚本也有一些变化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132
| #!/usr/bin/env node
import fs from 'fs' import path from 'path'
const input = process.argv[2] if (!input) { console.error('❌ 用法: node parse.js xxx.json') process.exit(1) }
const output = process.argv[3] || input.replace('.json', '.js')
const geojson = JSON.parse(fs.readFileSync(input, 'utf-8'))
const features = geojson.features || []
let minX = Infinity, minY = Infinity let maxX = -Infinity, maxY = -Infinity
features.forEach(f => { scanCoords(f.geometry.coordinates) })
function scanCoords(coords) { coords.forEach(c => { if (Array.isArray(c[0])) { scanCoords(c) } else { const [lng, lat] = c const [x, y] = mercator([lng, lat])
minX = Math.min(minX, x) minY = Math.min(minY, y) maxX = Math.max(maxX, x) maxY = Math.max(maxY, y) } }) }
features.forEach(f => scanCoords(f.geometry.coordinates))
const width = maxX - minX const height = maxY - minY
const paths = {}
features.forEach((f, index) => { const props = f.properties || {} const coords = f.geometry.coordinates
const name = props.name || `region_${index}` const adcode = String(props.adcode || index)
const d = geoToPath(f.geometry)
paths[adcode] = { path: d, name } })
const mapName = path.basename(output, '.js')
const mapData = { width, height,
projection: { type: 'merc', centralMeridian: 0 },
insets: [{ width, height, top: 0, left: 0, bbox: [ { x: minX, y: minY }, { x: maxX, y: maxY } ] }],
paths }
const js = ` $.fn.vectorMap('addMap', '${mapName}', ${JSON.stringify(mapData)}); `
fs.writeFileSync(output, js)
console.log('✅ 完成:', output)
function geoToPath(geometry) { let d = ''
function projectRing(ring) { ring.forEach(([lng, lat], i) => { const [x, y] = mercator([lng, lat]) d += (i === 0 ? 'M' : 'L') + x + ',' + y }) d += 'Z' }
if (geometry.type === 'Polygon') { geometry.coordinates.forEach(projectRing) }
if (geometry.type === 'MultiPolygon') { geometry.coordinates.forEach(polygon => { polygon.forEach(projectRing) }) }
return d }
function mercator([lng, lat]) { const x = lng const y = Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI / 180) / 2)) * 180 / Math.PI return [x, y] }
|
省份code和名称获取到了
终于,生成的数据成功获取到path对应的省份信息了,赶紧添加到网站里看一看。Emm,好像直接炸了….😭。于是GPT建议转换时对坐标进行归一化(计算 bbox、平移、缩放到固定宽高、Y 轴翻转),最后给GPT都干出“窗口对话量上限”提示了也没解决问题。
窝铐,地图直接炸了
小技巧:
虽然GPT现在有跨对话记忆功能,但是新开对话直接问的效果依然不好。可以让AI总结一下这个对话的具体内容,整理出一套合适的提示词,再去开新对话询问的效果会好很多👍!下面是GPT帮我总结的对话提示词:
我在做一个 jvectormap 自定义地图生成工具,需求如下:
输入:GeoJSON(例如阿里 DataV 的中国省级数据 100000_full.json)
输出:jVectorMap 使用的 map.js(包含 insets / paths / projection)
技术限制:
1. 必须使用经纬度(因为我要用 markers: latLng 打点)
2. 使用 merc 投影(jVectorMap 内置)
3. 不使用 SVG 中间态
当前处理流程: GeoJSON → Node 脚本 → 转换为 SVG path → 注入 jVectorMap
请帮我编写这个node脚本
后续在新对话中与GPT沟通了多轮,地图总算能够显示出来了,但是省份的标注字段都聚拢到左上方了,地图的缩放、移动功能也彻底坏了😂。
我:地图显示出来了,但布局乱、位置错乱
ChatGPT:问题在于“投影后的坐标 和 bbox 不匹配”
我:现在出现两个问题:
- labels 全跑左上角
- zoomMin / zoomMax 失效
ChatGPT:直接指出核心问题“重复做了 jVectorMap 内部 already 会做的 transform”
GPT说 jVectorMap 内部流程是:
1
| latLng → projection → 自动 scale → 自动 translate
|
而我在做的是:
1
| GeoJSON → 自己投影 → 自己 scale → 再交给 jVectorMap
|
这导致地图路径是处理后的,labels和markers则是原始投影,因为两者的坐标系统不一样,最后全部错位了。
与GPT的最终成果:地图展示OK,省份标注和地图缩放功能全坏了
在此之后我又进行了多轮对话,甚至让它参考官方的Python转换工具去修改,但问题依旧越改越坏…
![]()
03、绝处逢生:Gemini的救赎
当我察觉到GPT已经无法解决的时候,我只能先跳出GPT转而寻求其他AI的帮助了,于是我把发给GPT的总结提示词发给了Gemini,并让它参考官方的Python工具提供一套Node.js脚本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
| const fs = require('fs-extra');
function projectMercator(lng, lat) { const x = (lng + 180) / 360; const sinLat = Math.sin(lat * Math.PI / 180); const y = 0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI); return { x, y }; }
async function convert(inputPath, outputPath, mapName) { const geojson = await fs.readJson(inputPath); const width = 1000; const height = 800;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const features = geojson.features.map(feature => { const coords = feature.geometry.coordinates; const type = feature.geometry.type;
const projectedCoords = processCoords(coords, type, (lng, lat) => { const p = projectMercator(lng, lat); if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; return p; });
return { id: feature.properties.adcode || feature.properties.id || feature.properties.name, name: feature.properties.name, projectedCoords }; });
const mapW = maxX - minX; const mapH = maxY - minY; const scale = width / mapW; const finalHeight = mapH * scale;
const paths = {}; features.forEach(f => { const pathString = f.projectedCoords.map(polygon => { return polygon.map((ring, i) => { const points = ring.map(p => { const tx = (p.x - minX) * scale; const ty = (p.y - minY) * scale; return `${tx.toFixed(2)},${ty.toFixed(2)}`; }); return `M${points.join('L')}Z`; }).join(' '); }).join(' ');
paths[f.id] = { path: pathString, name: f.name }; });
const result = { paths: paths, width: width, height: finalHeight, projection: { type: 'merc', centralMeridian: 0 }, insets: [{ left: 0, top: 0, width: width, height: finalHeight, bbox: [ { x: minX * 360 - 180, y: latFromY(maxY) }, { x: maxX * 360 - 180, y: latFromY(minY) } ] }] };
const finalJS = `jQuery.fn.vectorMap('addMap', '${mapName}', ${JSON.stringify(result, null, 2)});`; await fs.writeFile(outputPath, finalJS); console.log(`✅ 成功生成: ${outputPath}`); }
function processCoords(coords, type, transformFn) { if (type === 'Polygon') return [coords.map(ring => ring.map(p => transformFn(p[0], p[1])))]; if (type === 'MultiPolygon') return coords.map(poly => poly.map(ring => ring.map(p => transformFn(p[0], p[1])))); return []; }
function latFromY(y) { const latRad = Math.atan(Math.sinh((0.5 - y) * (2 * Math.PI))); return latRad * 180 / Math.PI; }
convert('./100000_full.json', './china_merc.js', 'china_merc');
|
当我运行脚本,Gemini一次就完成了地图展示效果,并且缩放、移动功能都不受影响!太振奋了,现在只差地图标点问题了😀。
Gemini的脚本一次就完成了地图展示
但我没想到的是,围绕这个标点问题Gemini改了N次依旧没法解决,难道就要卡在这了吗?
巧合的是,当我某次去查看博客线上的js代码,发现线上的地图的bbox值范围非常大(6位数),而Gemini、ChatGPT生成的都只有-2d到2的范围之间。于是我就问Gemini,它回答到“ 因为目标网站使用的是墨卡托投影的原始投影坐标(单位通常是米),而不是我们之前脚本中为了方便计算而归一化(0 到 1000)的像素坐标。 ”。于是我感觉这可能涉及到jVectorMap的配置项问题,当我把jVectorMap的初始配置也一并发给Gemini时,它成功得解决了问题!👍
爽哉爽哉😁
04、再进一步:压缩地图体积
打开F12控制台,可以看到目前的中国地图JS文件体积是355KB,相比之前暴涨了4倍多。
新版的地图体积暴涨
于是我开始寻找有没有压缩地图体积的办法,在我查看之前的JS内容时,发现两者的格式不太一样,询问Gemini才得知原有的图形路径是相对路径,而新方案中采用的是绝对路径,这俩在体积上会有一些差别。
1 2 3 4 5
| # 绝对路径: M569.49,249.35L570.03,249.69L570.55,249.44L570.33....
# 相对路径12 M551.45,250.07l0.21,0.2l-0.43,0.35l0.04,1.16l-0.61...
|
于是我让Gemini将脚本中的路径计算改为相对路径,整体文件体积从 355KB 降至 271KB(缩减23.6%)。
生成的相对路径体积小了不少
查看生成的JS文件,发现生成的路径类似-0.39 -0.78中间都包含空格,如果能把这些空格删掉,是不是又能缩小一些?
我:-0.39 -0.78,这中间的空格可以移除吗?
Gemini:在 SVG 的 path 语法中,空格确实是可以移除的,但必须满足一个前提条件:**后续数字自带负号 (-)**。
这是因为负号本身就可以充当坐标之间的分隔符,解析器(如 jVectorMap 内部的 SVG 引擎)能够识别 0.39-0.78 是两个独立的数值。
我们可以通过正则或简单的字符串拼接逻辑,判断如果下一个数字是负数,就不添加空格。
再次生成后,文件体积从 271KB 降至 241KB(缩减11%),相比最初已经缩减了32%。加上服务器的Gzip压缩,整体应该可以控制在100 KB左右,我已经满足了😁。
再次生成的Path,负数间的空格被删除了
05、结尾复盘:我+AI的能力边界
最近“AI替代程序员”的话题愈演愈烈,不少大厂也传出大批量裁员的消息,加上一些自媒体不怀好意的鼓吹AI最近又具备了怎样的能力,躁动的氛围似乎今天就已经是程序员的末日。又恰逢这两天出来个“养虾潮”,OpenClaw+Agent以及各种各样的MCP服务,似乎AI已经能全方位替代各行各业了。
大多数人养出好用的龙虾了嘛?😂
我能理解有些人希望借助AI打破知识壁垒,在别的领域发表内容、抢占一定的话语权。但还是觉得这样的AI造神风气有点过了。搞得现在社交平台上一群小白在喊AI能做这个、也能做那个,还有说AI能写GTA6的👍。
最后一条给我看笑了哈哈哈
回到主题,如果没有AI我可能根本解决不了这个问题。我承认AI确实拓宽了我的能力边界,帮助我搞定了GeoJSON → map.js的转换过程,但这也需要一个前提:首先我得知道些什么。就像GPT帮我整理的提示词总结,一个什么都不懂的小白,能进行下面这样精准的提问吗?
我在做一个 jvectormap 自定义地图生成工具,需求如下:
输入:GeoJSON(例如阿里 DataV 的中国省级数据 100000_full.json)
输出:jVectorMap 使用的 map.js(包含 insets / paths / projection)
技术限制:
1. 必须使用经纬度(因为我要用 markers: latLng 打点)
2. 使用 merc 投影(jVectorMap 内置)
3. 不使用 SVG 中间态
当前处理流程: GeoJSON → Node 脚本 → 转换为 SVG path → 注入 jVectorMap
请帮我编写这个node脚本
所以,借助AI拓宽自己能力的同时,依旧要保持冷静思考和决策的能力呐☺。