3
0

fetch-offline-map.mjs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805
  1. #!/usr/bin/env node
  2. /**
  3. * 离线地图资源下载与本地化样式生成脚本
  4. * ---------------------------------------------------------------------------
  5. * 把项目当前使用的外网地图资源(OpenFreeMap)一次性抓取到本地 public/map 下,
  6. * 并生成一份所有地址都指向本地的 style.json,使内网 / 离线环境也能正常显示地图,
  7. * 且样式与线上(dev / prod)完全一致。
  8. *
  9. * 抓取内容:
  10. * 1. 矢量瓦片 tiles/{z}/{x}/{y}.pbf (源自 tiles.openfreemap.org/planet)
  11. * 2. 字体字形 fonts/{fontstack}/{range}.pbf (glyphs,地图注记文字 / 点位数字依赖)
  12. * !!! 默认抓全部 256 个区间,不要裁剪 !!!
  13. * vjmap 解析字形时执行 `for (const g of parsed.glyphs)`,只要某个区间缺失、
  14. * 请求拿到空数据,glyphs 就是 undefined,直接抛 "glyphs is not iterable"。
  15. * 3. 图标雪碧 sprites/ofm.json|png|@2x.png (地图 POI 图标)
  16. * 4. 生成样式 shanxi.offline.json (源样式 shanxi.map.json 的本地化版本)
  17. *
  18. * 用法:
  19. * node scripts/fetch-offline-map.mjs # 默认:陕西范围 z0-z9(体积最小)
  20. * node scripts/fetch-offline-map.mjs --maxzoom=11 # 更细(+约 53MB),--maxzoom=12 再 +约 70MB
  21. * node scripts/fetch-offline-map.mjs --skip-existing # 断点续传,已存在的文件跳过
  22. * node scripts/fetch-offline-map.mjs --bbox=105,31,112,40 # 自定义范围 lonMin,latMin,lonMax,latMax
  23. * node scripts/fetch-offline-map.mjs --base=/map/ # 部署在子路径下时改写资源前缀
  24. * node scripts/fetch-offline-map.mjs --fonts="Noto Sans Regular" # 只保留一个字体栈(省约 78MB)
  25. * node scripts/fetch-offline-map.mjs --no-strip # 不精简瓦片(保留多语言 name 字段)
  26. * node scripts/fetch-offline-map.mjs --glyphs=all # 全量字形(最保险,体积大)
  27. *
  28. * 网络要求:只在能上网的机器上执行一次;执行完把 public/map 整个目录拷到内网即可。
  29. */
  30. import { mkdir, readFile, readdir, rm, writeFile, stat } from 'node:fs/promises';
  31. import path from 'node:path';
  32. // --------------------------------------------------------------------------
  33. // 参数
  34. // --------------------------------------------------------------------------
  35. const ROOT = process.cwd();
  36. const args = {};
  37. for (const rawArg of process.argv.slice(2)) {
  38. const m = /^--([^=]+)(?:=(.*))?$/.exec(rawArg);
  39. if (m) args[m[1]] = m[2] === undefined ? true : m[2];
  40. }
  41. // 陕西省大致范围,默认外扩 0.7 度,避免地图边缘缺瓦片
  42. const PADDING = Number(args.padding ?? 0.7);
  43. const [bLon, bLat, tLon, tLat] = String(args.bbox ?? '105.4,31.6,111.3,39.7')
  44. .split(',')
  45. .map(Number);
  46. // lonMin, latMin, lonMax, latMax
  47. const BBOX = [bLon - PADDING, bLat - PADDING, tLon + PADDING, tLat + PADDING];
  48. const MINZOOM = Number(args.minzoom ?? 0);
  49. const MAXZOOM = Number(args.maxzoom ?? 9);
  50. const OUT_DIR = path.resolve(ROOT, args.out ?? 'public/map');
  51. const BASE = String(args.base ?? '/map/');
  52. // 源样式:保留 OpenFreeMap 外网地址的原始样式(生成器输入)
  53. const STYLE_SRC = path.resolve(ROOT, args.style ?? 'scripts/map/shanxi.map.source.json');
  54. // 产物:直接覆盖前端引用的样式文件,这样前端代码一行都不用改
  55. const STYLE_OUT = path.resolve(ROOT, args['style-out'] ?? 'public/js/shanxi.map.json');
  56. const CONCURRENCY = Number(args.concurrency ?? 12);
  57. const SKIP_EXISTING = Boolean(args['skip-existing'] || args.resume);
  58. // used=按瓦片文字推导(默认,体积最小) | all-cjk=全量 CJK | all=全部 256 个区间(最保险)
  59. const GLYPH_MODE = String(args.glyphs ?? 'used');
  60. // 精简瓦片:删除样式用不到的 name:* 字段、未被引用的键值、样式不引用的图层(约省 28%)
  61. const STRIP_TILES = !args['no-strip'];
  62. const KEEP_NAME_KEYS = new Set(
  63. String(args['keep-names'] ?? 'name,name:latin,name:nonlatin')
  64. .split(',')
  65. .map((s) => s.trim())
  66. .filter(Boolean),
  67. );
  68. // vjmap(mapbox-gl 1.x) 的 normalizeSpriteURL 会无条件 parseUrl(),
  69. // 只接受 "协议://..." 形式的绝对地址,相对路径会抛 "Unable to parse URL object"。
  70. // 因此默认不写 sprite 字段(地图照常渲染,只是 POI 图标不显示);
  71. // 需要图标时用 --sprite-base=http://your-host/map/sprites/ofm 传入绝对地址。
  72. const SPRITE_BASE = args['sprite-base'] ? String(args['sprite-base']) : '';
  73. const UA =
  74. 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
  75. const VECTOR_SOURCE_ID = 'openmaptiles';
  76. // 样式里声明了但没有图层使用的源,本地化时直接移除
  77. const UNUSED_SOURCES = ['ne2_shaded'];
  78. // --------------------------------------------------------------------------
  79. // 工具函数
  80. // --------------------------------------------------------------------------
  81. const log = (...a) => console.log(...a);
  82. const fmtMB = (bytes) => (bytes / 1048576).toFixed(1) + 'MB';
  83. function lonLatToTile(lon, lat, z) {
  84. const n = 2 ** z;
  85. const latClamped = Math.min(Math.max(lat, -85.05112878), 85.05112878);
  86. const x = Math.floor(((lon + 180) / 360) * n);
  87. const rad = (latClamped * Math.PI) / 180;
  88. const y = Math.floor(((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n);
  89. return [Math.min(Math.max(x, 0), n - 1), Math.min(Math.max(y, 0), n - 1)];
  90. }
  91. /**
  92. * 分层范围:低层级看到的范围远大于省界(全屏地图在 z6 时横向能看到 40 多度经度),
  93. * 若只下载省内瓦片,屏幕上会出现大片空白。因此按层级给不同范围:
  94. * z0-z6 中国范围(张数很少,成本低)
  95. * z7-z8 陕西周边
  96. * z9+ 陕西省内
  97. * 可用 --tiers="0-6:73,3,136,54;7-8:98,24,122,45;9-11:104.7,30.9,112,40.4" 覆盖。
  98. */
  99. function buildTiers() {
  100. const custom = args.tiers ? String(args.tiers) : null;
  101. const parse = (s) => s.split(',').map(Number);
  102. if (custom) {
  103. return custom
  104. .split(';')
  105. .filter(Boolean)
  106. .map((seg) => {
  107. const [zooms, bbox] = seg.split(':');
  108. const [z0, z1] = zooms.split('-').map(Number);
  109. return { min: z0, max: z1, bbox: parse(bbox) };
  110. });
  111. }
  112. return [
  113. // z0-z6:中国范围(首页默认 z6)
  114. { min: 0, max: Math.min(6, MAXZOOM), bbox: parse(String(args['china-bbox'] ?? '73,3,136,54')) },
  115. // z7-z8:陕西周边
  116. { min: 7, max: Math.min(8, MAXZOOM), bbox: parse(String(args['region-bbox'] ?? '98,24,122,45')) },
  117. // z9+:陕西省内
  118. { min: 9, max: MAXZOOM, bbox: BBOX },
  119. ].filter((t) => t.min <= t.max);
  120. }
  121. const TIERS = buildTiers();
  122. function bboxForZoom(z) {
  123. const tier = TIERS.find((t) => z >= t.min && z <= t.max);
  124. return tier ? tier.bbox : BBOX;
  125. }
  126. function tileRangeForZoom(z) {
  127. const [lonMin, latMin, lonMax, latMax] = bboxForZoom(z);
  128. const [x0, y0] = lonLatToTile(lonMin, latMax, z);
  129. const [x1, y1] = lonLatToTile(lonMax, latMin, z);
  130. return { x0, x1, y0, y1, count: (x1 - x0 + 1) * (y1 - y0 + 1), bbox: bboxForZoom(z) };
  131. }
  132. async function ensureDir(p) {
  133. await mkdir(p, { recursive: true });
  134. }
  135. async function fileSize(p) {
  136. try {
  137. return (await stat(p)).size;
  138. } catch {
  139. return -1;
  140. }
  141. }
  142. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  143. async function fetchBuffer(url, retries = 4) {
  144. let lastErr;
  145. for (let i = 0; i <= retries; i++) {
  146. try {
  147. const res = await fetch(url, { headers: { 'User-Agent': UA } });
  148. if (res.status === 404) return { notFound: true, buffer: Buffer.alloc(0) };
  149. if (!res.ok) throw new Error('HTTP ' + res.status);
  150. const ab = await res.arrayBuffer();
  151. return { notFound: false, buffer: Buffer.from(ab) };
  152. } catch (e) {
  153. lastErr = e;
  154. if (i < retries) await sleep(300 * (i + 1) * (i + 1));
  155. }
  156. }
  157. throw new Error('下载失败 ' + url + ' : ' + (lastErr && lastErr.message));
  158. }
  159. /** 简单并发池 */
  160. async function pool(items, limit, worker, onProgress) {
  161. let cursor = 0;
  162. let done = 0;
  163. const results = new Array(items.length);
  164. const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
  165. while (cursor < items.length) {
  166. const idx = cursor++;
  167. results[idx] = await worker(items[idx], idx);
  168. done += 1;
  169. if (onProgress && (done % 100 === 0 || done === items.length)) onProgress(done, items.length);
  170. }
  171. });
  172. await Promise.all(runners);
  173. return results;
  174. }
  175. // --------------------------------------------------------------------------
  176. // 矢量瓦片精简(protobuf / MVT 原地重写,无第三方依赖)
  177. // - 删除样式用不到的 name:xx 字段(本样式只用 name:latin / name:nonlatin / ref)
  178. // - 删除因此不再被任何要素引用的 value,以及这些 key
  179. // - 删除样式没有引用的 source-layer(如 mountain_peak)
  180. // 对同一文件重复执行是幂等的。
  181. // --------------------------------------------------------------------------
  182. function pbReadVarint(buf, i) {
  183. let v = 0;
  184. let sh = 0;
  185. let c;
  186. do {
  187. if (i >= buf.length) throw new Error('truncated varint');
  188. c = buf[i++];
  189. v += (c & 0x7f) * Math.pow(2, sh);
  190. sh += 7;
  191. if (sh > 63) throw new Error('varint too long');
  192. } while (c & 0x80);
  193. return [v, i];
  194. }
  195. function pbWriteVarint(out, v) {
  196. while (v > 0x7f) {
  197. out.push((v & 0x7f) | 0x80);
  198. v = Math.floor(v / 128);
  199. }
  200. out.push(v & 0x7f);
  201. }
  202. function pbPush(out, bytes) {
  203. for (let i = 0; i < bytes.length; i++) out.push(bytes[i]);
  204. }
  205. function pbWriteBytes(out, field, bytes) {
  206. pbWriteVarint(out, field * 8 + 2);
  207. pbWriteVarint(out, bytes.length);
  208. pbPush(out, bytes);
  209. }
  210. function* pbFields(buf) {
  211. let i = 0;
  212. while (i < buf.length) {
  213. let tag;
  214. [tag, i] = pbReadVarint(buf, i);
  215. const field = Math.floor(tag / 8);
  216. const wire = tag % 8;
  217. if (wire === 0) {
  218. let v;
  219. [v, i] = pbReadVarint(buf, i);
  220. yield { field, wire, value: v };
  221. } else if (wire === 2) {
  222. let len;
  223. [len, i] = pbReadVarint(buf, i);
  224. if (i + len > buf.length) throw new Error('length overflow');
  225. yield { field, wire, value: buf.subarray(i, i + len) };
  226. i += len;
  227. } else if (wire === 5) {
  228. yield { field, wire, value: buf.subarray(i, i + 4) };
  229. i += 4;
  230. } else if (wire === 1) {
  231. yield { field, wire, value: buf.subarray(i, i + 8) };
  232. i += 8;
  233. } else {
  234. throw new Error('unsupported wire type ' + wire);
  235. }
  236. }
  237. }
  238. function encodeFeature(feat, keyRemap, valRemap) {
  239. const out = [];
  240. if (feat.id != null) {
  241. pbWriteVarint(out, 1 * 8 + 0);
  242. pbWriteVarint(out, feat.id);
  243. }
  244. const tags = [];
  245. for (let i = 0; i + 1 < feat.tags.length; i += 2) {
  246. const k = keyRemap.get(feat.tags[i]);
  247. if (k === undefined) continue;
  248. tags.push(k, valRemap.get(feat.tags[i + 1]));
  249. }
  250. if (tags.length) {
  251. const tb = [];
  252. tags.forEach((v) => pbWriteVarint(tb, v));
  253. pbWriteBytes(out, 2, tb);
  254. }
  255. if (feat.type != null) {
  256. pbWriteVarint(out, 3 * 8 + 0);
  257. pbWriteVarint(out, feat.type);
  258. }
  259. if (feat.geom) pbWriteBytes(out, 4, feat.geom);
  260. return Buffer.from(out);
  261. }
  262. function stripLayer(buf, keepLayers, stats) {
  263. let name = null;
  264. let extent = 4096;
  265. let version = 2;
  266. const keys = [];
  267. const values = [];
  268. const features = [];
  269. for (const f of pbFields(buf)) {
  270. if (f.field === 1 && f.wire === 2) name = f.value;
  271. else if (f.field === 2 && f.wire === 2) features.push(f.value);
  272. else if (f.field === 3 && f.wire === 2) keys.push(f.value);
  273. else if (f.field === 4 && f.wire === 2) values.push(f.value);
  274. else if (f.field === 5 && f.wire === 0) extent = f.value;
  275. else if (f.field === 15 && f.wire === 0) version = f.value;
  276. }
  277. const layerName = name ? name.toString('utf8') : '';
  278. if (keepLayers && keepLayers.size && !keepLayers.has(layerName)) {
  279. stats.droppedLayers += 1;
  280. return null;
  281. }
  282. const keyRemap = new Map();
  283. const keptKeys = [];
  284. keys.forEach((kb, idx) => {
  285. const k = kb.toString('utf8');
  286. // OpenMapTiles 里多语言字段有两套命名:name:xx(冒号)和 name_xx(下划线,如 name_en/name_de/name_int)
  287. const isName = k.startsWith('name:') || /^name_[a-z]{2,3}$/.test(k);
  288. if (!isName || KEEP_NAME_KEYS.has(k)) {
  289. keyRemap.set(idx, keptKeys.length);
  290. keptKeys.push(idx);
  291. } else {
  292. stats.droppedKeys += 1;
  293. }
  294. });
  295. const parsed = [];
  296. const usedValues = new Set();
  297. for (const fb of features) {
  298. const feat = { id: null, type: null, geom: null, tags: [] };
  299. for (const f of pbFields(fb)) {
  300. if (f.field === 1 && f.wire === 0) feat.id = f.value;
  301. else if (f.field === 3 && f.wire === 0) feat.type = f.value;
  302. else if (f.field === 4 && f.wire === 2) feat.geom = f.value;
  303. else if (f.field === 2 && f.wire === 2) {
  304. let i = 0;
  305. while (i < f.value.length) {
  306. let v;
  307. [v, i] = pbReadVarint(f.value, i);
  308. feat.tags.push(v);
  309. }
  310. }
  311. }
  312. for (let i = 0; i + 1 < feat.tags.length; i += 2) {
  313. if (keyRemap.has(feat.tags[i])) usedValues.add(feat.tags[i + 1]);
  314. }
  315. parsed.push(feat);
  316. }
  317. const valRemap = new Map();
  318. const keptValues = [];
  319. values.forEach((vb, idx) => {
  320. if (usedValues.has(idx)) {
  321. valRemap.set(idx, keptValues.length);
  322. keptValues.push(vb);
  323. } else {
  324. stats.droppedValues += 1;
  325. }
  326. });
  327. const out = [];
  328. pbWriteVarint(out, 15 * 8 + 0);
  329. pbWriteVarint(out, version);
  330. if (name) pbWriteBytes(out, 1, name);
  331. for (const feat of parsed) pbWriteBytes(out, 2, encodeFeature(feat, keyRemap, valRemap));
  332. for (const i of keptKeys) pbWriteBytes(out, 3, keys[i]);
  333. for (const vb of keptValues) pbWriteBytes(out, 4, vb);
  334. pbWriteVarint(out, 5 * 8 + 0);
  335. pbWriteVarint(out, extent);
  336. return Buffer.from(out);
  337. }
  338. function stripTile(buf, keepLayers) {
  339. const stats = { droppedKeys: 0, droppedValues: 0, droppedLayers: 0 };
  340. const out = [];
  341. for (const f of pbFields(buf)) {
  342. if (f.field === 3 && f.wire === 2) {
  343. const layer = stripLayer(f.value, keepLayers, stats);
  344. if (layer) pbWriteBytes(out, 3, layer);
  345. }
  346. }
  347. return { buffer: Buffer.from(out), stats };
  348. }
  349. /**
  350. * 清理不再需要的字体文件:
  351. * - 删掉不在 FONTSTACKS 里的字体栈目录(例如 --fonts 只留 Regular 时,删掉 Italic/Bold)
  352. * - 删掉不在 NEEDED_RANGES 里的区间文件
  353. * 保证反复执行 pnpm map:fetch 时体积会收敛,而不是越积越多。
  354. */
  355. async function pruneFonts() {
  356. const fontsDir = path.join(OUT_DIR, 'fonts');
  357. let dirs = [];
  358. try {
  359. dirs = await readdir(fontsDir, { withFileTypes: true });
  360. } catch {
  361. return;
  362. }
  363. const keepStacks = new Set(FONTSTACKS);
  364. const keepRanges = new Set([...NEEDED_RANGES].map((r) => r * 256 + '-' + (r * 256 + 255)));
  365. let removed = 0;
  366. let freed = 0;
  367. for (const d of dirs) {
  368. if (!d.isDirectory()) continue;
  369. const full = path.join(fontsDir, d.name);
  370. if (!keepStacks.has(d.name)) {
  371. for (const f of await readdir(full)) {
  372. const s = await fileSize(path.join(full, f));
  373. if (s > 0) freed += s;
  374. }
  375. await rm(full, { recursive: true, force: true });
  376. removed += 1;
  377. log(' 移除未使用的字体栈: ' + d.name);
  378. continue;
  379. }
  380. for (const f of await readdir(full)) {
  381. if (!f.endsWith('.pbf')) continue;
  382. if (!keepRanges.has(f.slice(0, -4))) {
  383. const s = await fileSize(path.join(full, f));
  384. if (s > 0) freed += s;
  385. await rm(path.join(full, f), { force: true });
  386. removed += 1;
  387. }
  388. }
  389. }
  390. if (removed) log(' 清理多余字体文件 ' + removed + ' 个,释放 ' + fmtMB(freed));
  391. }
  392. // --------------------------------------------------------------------------
  393. // 1. 解析源样式
  394. // --------------------------------------------------------------------------
  395. log('> 读取源样式:', path.relative(ROOT, STYLE_SRC));
  396. const style = JSON.parse(await readFile(STYLE_SRC, 'utf8'));
  397. const srcVec = style.sources && style.sources[VECTOR_SOURCE_ID];
  398. if (!srcVec) throw new Error('源样式里找不到矢量源 ' + VECTOR_SOURCE_ID);
  399. let tileTemplate;
  400. if (srcVec.url) {
  401. log(' 矢量源 TileJSON:', srcVec.url);
  402. const { buffer } = await fetchBuffer(srcVec.url);
  403. const tilejson = JSON.parse(buffer.toString('utf8'));
  404. tileTemplate = tilejson.tiles[0];
  405. } else {
  406. tileTemplate = srcVec.tiles[0];
  407. }
  408. log(' 瓦片模板:', tileTemplate);
  409. // 收集样式中实际使用的字体栈
  410. const fontstacks = new Set();
  411. for (const layer of style.layers || []) {
  412. const tf = layer.layout && layer.layout['text-font'];
  413. if (Array.isArray(tf)) tf.forEach((f) => fontstacks.add(f));
  414. }
  415. // 代码里 vjmap.Symbol 手动指定过的字体栈
  416. for (const extra of String(args['extra-fonts'] ?? 'Noto Sans Regular').split(',')) {
  417. if (extra.trim()) fontstacks.add(extra.trim());
  418. }
  419. let FONTSTACKS = [...fontstacks];
  420. // 字体是体积大头:字形是 SDF 位图,单个区间约 150KB,一个字体栈 150 个区间就约 25MB,
  421. // 样式里 Regular/Italic/Bold 三个栈全下会到 75MB —— 比瓦片还大。
  422. // 所以默认只保留一个字体栈(Noto Sans Regular),并把样式里所有 text-font 改写过去。
  423. // --fonts="Noto Sans Regular,Noto Sans Italic" 保留多个字体栈
  424. // --fonts= 留空则沿用样式里的全部字体栈
  425. const FONTS_ARG = args.fonts === undefined ? 'Noto Sans Regular' : String(args.fonts);
  426. if (FONTS_ARG) {
  427. const list = FONTS_ARG.split(',')
  428. .map((s) => s.trim())
  429. .filter(Boolean);
  430. if (list.length) FONTSTACKS = list;
  431. }
  432. log(' 字体栈:', FONTSTACKS.join(' | '));
  433. // 样式引用到的 source-layer,精简瓦片时只保留这些图层
  434. const KEEP_SOURCE_LAYERS = new Set((style.layers || []).map((l) => l['source-layer']).filter(Boolean));
  435. // --------------------------------------------------------------------------
  436. // 2. 下载矢量瓦片
  437. // --------------------------------------------------------------------------
  438. await ensureDir(path.join(OUT_DIR, 'tiles'));
  439. const tileJobs = [];
  440. for (let z = MINZOOM; z <= MAXZOOM; z++) {
  441. const r = tileRangeForZoom(z);
  442. for (let x = r.x0; x <= r.x1; x++) {
  443. for (let y = r.y0; y <= r.y1; y++) tileJobs.push({ z, x, y });
  444. }
  445. }
  446. log('> 矢量瓦片: z' + MINZOOM + '-z' + MAXZOOM + ',共 ' + tileJobs.length + ' 张');
  447. for (const t of TIERS) {
  448. log(
  449. ' z' + t.min + '-z' + t.max +
  450. ' lon[' + t.bbox[0] + ',' + t.bbox[2] + '] lat[' + t.bbox[1] + ',' + t.bbox[3] + ']',
  451. );
  452. }
  453. let tilesBytes = 0;
  454. let tilesSkipped = 0;
  455. const t0 = Date.now();
  456. await pool(
  457. tileJobs,
  458. CONCURRENCY,
  459. async (job) => {
  460. const dir = path.join(OUT_DIR, 'tiles', String(job.z), String(job.x));
  461. const file = path.join(dir, job.y + '.pbf');
  462. const existing = SKIP_EXISTING ? await fileSize(file) : -1;
  463. if (existing >= 0) {
  464. tilesBytes += existing;
  465. tilesSkipped += 1;
  466. return;
  467. }
  468. const url = tileTemplate
  469. .replace('{z}', job.z)
  470. .replace('{x}', job.x)
  471. .replace('{y}', job.y);
  472. const { buffer } = await fetchBuffer(url);
  473. await ensureDir(dir);
  474. // 空瓦片也写空文件,避免浏览器 404 反复报错
  475. await writeFile(file, buffer);
  476. tilesBytes += buffer.length;
  477. },
  478. (d, total) => {
  479. const elapsed = (Date.now() - t0) / 1000;
  480. log(
  481. ' ' +
  482. d +
  483. '/' +
  484. total +
  485. ' ' +
  486. fmtMB(tilesBytes) +
  487. ' 用时 ' +
  488. elapsed.toFixed(0) +
  489. 's (' +
  490. (d / Math.max(elapsed, 1)).toFixed(1) +
  491. ' 张/s)',
  492. );
  493. },
  494. );
  495. log(' OK 瓦片完成: ' + fmtMB(tilesBytes) + (tilesSkipped ? '(跳过已存在 ' + tilesSkipped + ' 张)' : ''));
  496. // --------------------------------------------------------------------------
  497. // 2.5 精简矢量瓦片(删除样式用不到的 name:xx / 值 / 图层)
  498. // --------------------------------------------------------------------------
  499. let strippedBytes = tilesBytes;
  500. if (STRIP_TILES) {
  501. log('> 精简矢量瓦片:删除样式未使用的 name:xx 字段与图层 …');
  502. const t1 = Date.now();
  503. let before = 0;
  504. let after = 0;
  505. let droppedLayers = 0;
  506. let droppedKeys = 0;
  507. let touched = 0;
  508. await pool(
  509. tileJobs,
  510. 32,
  511. async (job) => {
  512. const file = path.join(OUT_DIR, 'tiles', String(job.z), String(job.x), job.y + '.pbf');
  513. const size = await fileSize(file);
  514. if (size <= 0) return;
  515. const raw = await readFile(file);
  516. const { buffer, stats } = stripTile(raw, KEEP_SOURCE_LAYERS);
  517. droppedLayers += stats.droppedLayers;
  518. droppedKeys += stats.droppedKeys;
  519. if (buffer.length !== raw.length || stats.droppedLayers || stats.droppedKeys) {
  520. await writeFile(file, buffer);
  521. touched += 1;
  522. }
  523. before += raw.length;
  524. after += buffer.length;
  525. },
  526. (d, total) => {
  527. if (d % 1000 === 0 || d === total) log(' ' + d + '/' + total + ' 用时 ' + ((Date.now() - t1) / 1000).toFixed(0) + 's');
  528. },
  529. );
  530. strippedBytes = after;
  531. log(
  532. ' OK 精简完成: ' + fmtMB(before) + ' -> ' + fmtMB(after) +
  533. '(省 ' + fmtMB(before - after) + ',改写 ' + touched + ' 张;删除 name 键 ' + droppedKeys + ' 个、图层 ' + droppedLayers + ' 个)',
  534. );
  535. } else {
  536. log(' 跳过瓦片精简(--no-strip)');
  537. }
  538. // --------------------------------------------------------------------------
  539. // 3. 统计瓦片内文字字符 -> 推导需要的字形区间(避免下载 50MB+ 全量 CJK)
  540. // --------------------------------------------------------------------------
  541. const NEEDED_RANGES = new Set([0, 1]);
  542. if (GLYPH_MODE === 'all') {
  543. // 全量:0-65535 的 256 个区间。虽然体积最大,但可以彻底避免
  544. // “某个字符所在区间没下载 → 空响应 → vjmap 报 i5.glyphs is not iterable”。
  545. for (let r = 0; r <= 255; r++) NEEDED_RANGES.add(r);
  546. log(' 全量字形模式:每个字体栈 256 个区间');
  547. } else if (GLYPH_MODE === 'all-cjk') {
  548. for (let r = 78; r <= 159; r++) NEEDED_RANGES.add(r);
  549. [2, 3, 4, 48, 49, 255].forEach((r) => NEEDED_RANGES.add(r));
  550. } else {
  551. log('> 扫描瓦片文字,推导所需字形区间 …');
  552. // 按 MVT 规范只取真正的字符串:key(layer 的 field 3)和 value 里的 string_value(value 的 field 1)。
  553. // 不能用「把整个 value 消息当字符串解码」的做法——数字型 value 会被误解码成乱码字符,
  554. // 从而漏掉真正要用的区间(曾导致缺区间 → glyphs is not iterable)。
  555. const chars = new Set();
  556. const decoder = new TextDecoder('utf-8', { fatal: true });
  557. function addString(bytes) {
  558. let s = null;
  559. try {
  560. s = decoder.decode(bytes);
  561. } catch {
  562. return; // 不是合法 UTF-8,忽略
  563. }
  564. for (const ch of s) {
  565. const cp = ch.codePointAt(0);
  566. if (cp >= 0x20 && cp < 0x10000) chars.add(cp); // 字形只支持 BMP
  567. }
  568. }
  569. function collectTileStrings(buf) {
  570. for (const layerField of pbFields(buf)) {
  571. if (layerField.field !== 3 || layerField.wire !== 2) continue;
  572. for (const lf of pbFields(layerField.value)) {
  573. if (lf.field === 3 && lf.wire === 2) {
  574. addString(lf.value); // key
  575. } else if (lf.field === 4 && lf.wire === 2) {
  576. for (const vf of pbFields(lf.value)) {
  577. if (vf.field === 1 && vf.wire === 2) addString(vf.value); // Value.string_value
  578. }
  579. }
  580. }
  581. }
  582. }
  583. // 统计所有已下载瓦片里的文字:地图上渲染的每个字都来自这些瓦片,
  584. // 因此按瓦片字符集下载字形区间是完备的(精简瓦片后字符集会进一步变小)
  585. const scanMinzoom = Number(args['glyph-scan-minzoom'] ?? 0);
  586. let scanned = 0;
  587. for (const job of tileJobs) {
  588. if (job.z < scanMinzoom) continue;
  589. const fpath = path.join(OUT_DIR, 'tiles', String(job.z), String(job.x), job.y + '.pbf');
  590. const size = await fileSize(fpath);
  591. if (size <= 0) continue;
  592. collectTileStrings(await readFile(fpath));
  593. scanned++;
  594. }
  595. for (const cp of chars) NEEDED_RANGES.add(cp >> 8);
  596. // 兜底区间:ASCII、拉丁扩展、通用标点、CJK 标点、全角,以及数字(点位数量)
  597. [0, 1, 2, 3, 4, 32, 33, 48, 49, 255].forEach((r) => NEEDED_RANGES.add(r));
  598. log(' 扫描 ' + scanned + ' 张瓦片,命中 ' + chars.size + ' 个字符,需要 ' + NEEDED_RANGES.size + ' 个字形区间');
  599. }
  600. // --------------------------------------------------------------------------
  601. // 4. 下载 glyphs 字体
  602. // --------------------------------------------------------------------------
  603. log('> 下载 glyphs 字体 …');
  604. const glyphTemplate = style.glyphs.replace('{fontstack}', '__FONT__').replace('{range}', '__RANGE__');
  605. let glyphBytes = 0;
  606. const glyphJobs = [];
  607. for (const font of FONTSTACKS) {
  608. for (const r of [...NEEDED_RANGES].sort((a, b) => a - b)) {
  609. const lo = r * 256;
  610. glyphJobs.push({ font, range: lo + '-' + (lo + 255) });
  611. }
  612. }
  613. await pool(
  614. glyphJobs,
  615. CONCURRENCY,
  616. async ({ font, range }) => {
  617. const dir = path.join(OUT_DIR, 'fonts', font);
  618. const file = path.join(dir, range + '.pbf');
  619. if (SKIP_EXISTING && (await fileSize(file)) > 0) return;
  620. const url = glyphTemplate
  621. .replace('__FONT__', encodeURIComponent(font))
  622. .replace('__RANGE__', range);
  623. const { buffer, notFound } = await fetchBuffer(url);
  624. if (notFound || buffer.length === 0) return;
  625. await ensureDir(dir);
  626. await writeFile(file, buffer);
  627. glyphBytes += buffer.length;
  628. },
  629. (d, total) => {
  630. if (d % 30 === 0 || d === total) log(' ' + d + '/' + total + ' ' + fmtMB(glyphBytes));
  631. },
  632. );
  633. log(
  634. ' OK 字体完成: ' +
  635. fmtMB(glyphBytes) +
  636. '(' +
  637. FONTSTACKS.length +
  638. ' 个字体栈 x ' +
  639. NEEDED_RANGES.size +
  640. ' 个区间)',
  641. );
  642. await pruneFonts();
  643. // --------------------------------------------------------------------------
  644. // 5. 下载 sprite 图标
  645. // --------------------------------------------------------------------------
  646. log('> 下载 sprite 图标 …');
  647. await ensureDir(path.join(OUT_DIR, 'sprites'));
  648. const spriteBase = style.sprite;
  649. let spriteBytes = 0;
  650. for (const suffix of ['.json', '.png', '@2x.png']) {
  651. const { buffer } = await fetchBuffer(spriteBase + suffix);
  652. if (!buffer.length) continue;
  653. await writeFile(path.join(OUT_DIR, 'sprites', 'ofm' + suffix), buffer);
  654. spriteBytes += buffer.length;
  655. }
  656. log(' OK sprite 完成: ' + fmtMB(spriteBytes));
  657. // --------------------------------------------------------------------------
  658. // 6. 生成本地化样式
  659. // --------------------------------------------------------------------------
  660. log('> 生成本地化样式 …');
  661. const outStyle = JSON.parse(JSON.stringify(style));
  662. const usedSources = new Set((style.layers || []).map((l) => l.source).filter(Boolean));
  663. const sources = {};
  664. for (const [id, src] of Object.entries(style.sources)) {
  665. if (!usedSources.has(id) && UNUSED_SOURCES.includes(id)) {
  666. log(' 移除未被引用的瓦片源: ' + id);
  667. continue;
  668. }
  669. if (id === VECTOR_SOURCE_ID) {
  670. sources[id] = {
  671. type: 'vector',
  672. scheme: 'xyz',
  673. tiles: [BASE + 'tiles/{z}/{x}/{y}.pbf'],
  674. minzoom: MINZOOM,
  675. maxzoom: MAXZOOM,
  676. };
  677. if (src.attribution) sources[id].attribution = src.attribution;
  678. continue;
  679. }
  680. sources[id] = src;
  681. }
  682. outStyle.sources = sources;
  683. outStyle.glyphs = BASE + 'fonts/{fontstack}/{range}.pbf';
  684. if (FONTS_ARG && FONTSTACKS.length) {
  685. const only = FONTSTACKS[0];
  686. for (const l of outStyle.layers || []) {
  687. if (l.layout && Array.isArray(l.layout['text-font'])) l.layout['text-font'] = [only];
  688. }
  689. log(' 已将样式内全部 text-font 统一为: ' + only);
  690. }
  691. if (SPRITE_BASE) {
  692. outStyle.sprite = SPRITE_BASE;
  693. log(' sprite 使用绝对地址: ' + SPRITE_BASE);
  694. } else {
  695. delete outStyle.sprite;
  696. log(' 不写入 sprite 字段(vjmap 只接受绝对地址;地图正常,仅 POI 图标不显示)');
  697. log(' 需要图标请加 --sprite-base=http://<内网域名或IP>/map/sprites/ofm');
  698. }
  699. outStyle.name = (style.name || 'Custom') + ' (offline)';
  700. // 只记录 OpenFreeMap 的数据版本号,不残留任何外网 URL
  701. const sourceVersion = (/:\/\/[^/]+\/planet\/([^/]+)\//.exec(tileTemplate) || [])[1] || '';
  702. outStyle.metadata = Object.assign({}, outStyle.metadata || {}, {
  703. 'x-offline': {
  704. generatedAt: new Date().toISOString(),
  705. sourceVersion,
  706. tiers: TIERS.map((t) => ({ zoom: 'z' + t.min + '-z' + t.max, bbox: t.bbox })),
  707. minzoom: MINZOOM,
  708. maxzoom: MAXZOOM,
  709. },
  710. });
  711. await ensureDir(path.dirname(STYLE_OUT));
  712. // 样式内容没变化时不重写文件,避免每次 map:fetch 都产生一个只有时间戳差异的 diff
  713. const sameStyle = await isSameStyle(outStyle);
  714. if (sameStyle) {
  715. log(' 样式内容未变化,跳过写入: ' + path.relative(ROOT, STYLE_OUT));
  716. } else {
  717. await writeFile(STYLE_OUT, JSON.stringify(outStyle, null, 2));
  718. }
  719. /** 忽略 x-offline.generatedAt,判断新旧样式是否等价 */
  720. async function isSameStyle(next) {
  721. let prevText;
  722. try {
  723. prevText = await readFile(STYLE_OUT, 'utf8');
  724. } catch {
  725. return false;
  726. }
  727. try {
  728. const prev = JSON.parse(prevText);
  729. const norm = (s) => {
  730. const c = JSON.parse(JSON.stringify(s));
  731. if (c.metadata && c.metadata['x-offline']) c.metadata['x-offline'].generatedAt = '';
  732. return JSON.stringify(c);
  733. };
  734. return norm(prev) === norm(next);
  735. } catch {
  736. return false;
  737. }
  738. }
  739. // --------------------------------------------------------------------------
  740. // 汇总
  741. // --------------------------------------------------------------------------
  742. log('');
  743. log('======== 完成 ========');
  744. log('样式文件 :', path.relative(ROOT, STYLE_OUT));
  745. log('矢量瓦片 :', fmtMB(strippedBytes), '(' + tileJobs.length + ' 张' + (STRIP_TILES ? ',已精简' : '') + ')');
  746. log('字体字形 :', fmtMB(glyphBytes));
  747. log('图标雪碧 :', fmtMB(spriteBytes));
  748. log('合计 :', fmtMB(strippedBytes + glyphBytes + spriteBytes));
  749. log('资源前缀 :', BASE);
  750. log('提示 : 用 --skip-existing 断点续传;--maxzoom=12 可再细一级。');