| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805 |
- #!/usr/bin/env node
- /**
- * 离线地图资源下载与本地化样式生成脚本
- * ---------------------------------------------------------------------------
- * 把项目当前使用的外网地图资源(OpenFreeMap)一次性抓取到本地 public/map 下,
- * 并生成一份所有地址都指向本地的 style.json,使内网 / 离线环境也能正常显示地图,
- * 且样式与线上(dev / prod)完全一致。
- *
- * 抓取内容:
- * 1. 矢量瓦片 tiles/{z}/{x}/{y}.pbf (源自 tiles.openfreemap.org/planet)
- * 2. 字体字形 fonts/{fontstack}/{range}.pbf (glyphs,地图注记文字 / 点位数字依赖)
- * !!! 默认抓全部 256 个区间,不要裁剪 !!!
- * vjmap 解析字形时执行 `for (const g of parsed.glyphs)`,只要某个区间缺失、
- * 请求拿到空数据,glyphs 就是 undefined,直接抛 "glyphs is not iterable"。
- * 3. 图标雪碧 sprites/ofm.json|png|@2x.png (地图 POI 图标)
- * 4. 生成样式 shanxi.offline.json (源样式 shanxi.map.json 的本地化版本)
- *
- * 用法:
- * node scripts/fetch-offline-map.mjs # 默认:陕西范围 z0-z9(体积最小)
- * node scripts/fetch-offline-map.mjs --maxzoom=11 # 更细(+约 53MB),--maxzoom=12 再 +约 70MB
- * node scripts/fetch-offline-map.mjs --skip-existing # 断点续传,已存在的文件跳过
- * node scripts/fetch-offline-map.mjs --bbox=105,31,112,40 # 自定义范围 lonMin,latMin,lonMax,latMax
- * node scripts/fetch-offline-map.mjs --base=/map/ # 部署在子路径下时改写资源前缀
- * node scripts/fetch-offline-map.mjs --fonts="Noto Sans Regular" # 只保留一个字体栈(省约 78MB)
- * node scripts/fetch-offline-map.mjs --no-strip # 不精简瓦片(保留多语言 name 字段)
- * node scripts/fetch-offline-map.mjs --glyphs=all # 全量字形(最保险,体积大)
- *
- * 网络要求:只在能上网的机器上执行一次;执行完把 public/map 整个目录拷到内网即可。
- */
- import { mkdir, readFile, readdir, rm, writeFile, stat } from 'node:fs/promises';
- import path from 'node:path';
- // --------------------------------------------------------------------------
- // 参数
- // --------------------------------------------------------------------------
- const ROOT = process.cwd();
- const args = {};
- for (const rawArg of process.argv.slice(2)) {
- const m = /^--([^=]+)(?:=(.*))?$/.exec(rawArg);
- if (m) args[m[1]] = m[2] === undefined ? true : m[2];
- }
- // 陕西省大致范围,默认外扩 0.7 度,避免地图边缘缺瓦片
- const PADDING = Number(args.padding ?? 0.7);
- const [bLon, bLat, tLon, tLat] = String(args.bbox ?? '105.4,31.6,111.3,39.7')
- .split(',')
- .map(Number);
- // lonMin, latMin, lonMax, latMax
- const BBOX = [bLon - PADDING, bLat - PADDING, tLon + PADDING, tLat + PADDING];
- const MINZOOM = Number(args.minzoom ?? 0);
- const MAXZOOM = Number(args.maxzoom ?? 9);
- const OUT_DIR = path.resolve(ROOT, args.out ?? 'public/map');
- const BASE = String(args.base ?? '/map/');
- // 源样式:保留 OpenFreeMap 外网地址的原始样式(生成器输入)
- const STYLE_SRC = path.resolve(ROOT, args.style ?? 'scripts/map/shanxi.map.source.json');
- // 产物:直接覆盖前端引用的样式文件,这样前端代码一行都不用改
- const STYLE_OUT = path.resolve(ROOT, args['style-out'] ?? 'public/js/shanxi.map.json');
- const CONCURRENCY = Number(args.concurrency ?? 12);
- const SKIP_EXISTING = Boolean(args['skip-existing'] || args.resume);
- // used=按瓦片文字推导(默认,体积最小) | all-cjk=全量 CJK | all=全部 256 个区间(最保险)
- const GLYPH_MODE = String(args.glyphs ?? 'used');
- // 精简瓦片:删除样式用不到的 name:* 字段、未被引用的键值、样式不引用的图层(约省 28%)
- const STRIP_TILES = !args['no-strip'];
- const KEEP_NAME_KEYS = new Set(
- String(args['keep-names'] ?? 'name,name:latin,name:nonlatin')
- .split(',')
- .map((s) => s.trim())
- .filter(Boolean),
- );
- // vjmap(mapbox-gl 1.x) 的 normalizeSpriteURL 会无条件 parseUrl(),
- // 只接受 "协议://..." 形式的绝对地址,相对路径会抛 "Unable to parse URL object"。
- // 因此默认不写 sprite 字段(地图照常渲染,只是 POI 图标不显示);
- // 需要图标时用 --sprite-base=http://your-host/map/sprites/ofm 传入绝对地址。
- const SPRITE_BASE = args['sprite-base'] ? String(args['sprite-base']) : '';
- const UA =
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
- const VECTOR_SOURCE_ID = 'openmaptiles';
- // 样式里声明了但没有图层使用的源,本地化时直接移除
- const UNUSED_SOURCES = ['ne2_shaded'];
- // --------------------------------------------------------------------------
- // 工具函数
- // --------------------------------------------------------------------------
- const log = (...a) => console.log(...a);
- const fmtMB = (bytes) => (bytes / 1048576).toFixed(1) + 'MB';
- function lonLatToTile(lon, lat, z) {
- const n = 2 ** z;
- const latClamped = Math.min(Math.max(lat, -85.05112878), 85.05112878);
- const x = Math.floor(((lon + 180) / 360) * n);
- const rad = (latClamped * Math.PI) / 180;
- const y = Math.floor(((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n);
- return [Math.min(Math.max(x, 0), n - 1), Math.min(Math.max(y, 0), n - 1)];
- }
- /**
- * 分层范围:低层级看到的范围远大于省界(全屏地图在 z6 时横向能看到 40 多度经度),
- * 若只下载省内瓦片,屏幕上会出现大片空白。因此按层级给不同范围:
- * z0-z6 中国范围(张数很少,成本低)
- * z7-z8 陕西周边
- * z9+ 陕西省内
- * 可用 --tiers="0-6:73,3,136,54;7-8:98,24,122,45;9-11:104.7,30.9,112,40.4" 覆盖。
- */
- function buildTiers() {
- const custom = args.tiers ? String(args.tiers) : null;
- const parse = (s) => s.split(',').map(Number);
- if (custom) {
- return custom
- .split(';')
- .filter(Boolean)
- .map((seg) => {
- const [zooms, bbox] = seg.split(':');
- const [z0, z1] = zooms.split('-').map(Number);
- return { min: z0, max: z1, bbox: parse(bbox) };
- });
- }
- return [
- // z0-z6:中国范围(首页默认 z6)
- { min: 0, max: Math.min(6, MAXZOOM), bbox: parse(String(args['china-bbox'] ?? '73,3,136,54')) },
- // z7-z8:陕西周边
- { min: 7, max: Math.min(8, MAXZOOM), bbox: parse(String(args['region-bbox'] ?? '98,24,122,45')) },
- // z9+:陕西省内
- { min: 9, max: MAXZOOM, bbox: BBOX },
- ].filter((t) => t.min <= t.max);
- }
- const TIERS = buildTiers();
- function bboxForZoom(z) {
- const tier = TIERS.find((t) => z >= t.min && z <= t.max);
- return tier ? tier.bbox : BBOX;
- }
- function tileRangeForZoom(z) {
- const [lonMin, latMin, lonMax, latMax] = bboxForZoom(z);
- const [x0, y0] = lonLatToTile(lonMin, latMax, z);
- const [x1, y1] = lonLatToTile(lonMax, latMin, z);
- return { x0, x1, y0, y1, count: (x1 - x0 + 1) * (y1 - y0 + 1), bbox: bboxForZoom(z) };
- }
- async function ensureDir(p) {
- await mkdir(p, { recursive: true });
- }
- async function fileSize(p) {
- try {
- return (await stat(p)).size;
- } catch {
- return -1;
- }
- }
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
- async function fetchBuffer(url, retries = 4) {
- let lastErr;
- for (let i = 0; i <= retries; i++) {
- try {
- const res = await fetch(url, { headers: { 'User-Agent': UA } });
- if (res.status === 404) return { notFound: true, buffer: Buffer.alloc(0) };
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const ab = await res.arrayBuffer();
- return { notFound: false, buffer: Buffer.from(ab) };
- } catch (e) {
- lastErr = e;
- if (i < retries) await sleep(300 * (i + 1) * (i + 1));
- }
- }
- throw new Error('下载失败 ' + url + ' : ' + (lastErr && lastErr.message));
- }
- /** 简单并发池 */
- async function pool(items, limit, worker, onProgress) {
- let cursor = 0;
- let done = 0;
- const results = new Array(items.length);
- const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
- while (cursor < items.length) {
- const idx = cursor++;
- results[idx] = await worker(items[idx], idx);
- done += 1;
- if (onProgress && (done % 100 === 0 || done === items.length)) onProgress(done, items.length);
- }
- });
- await Promise.all(runners);
- return results;
- }
- // --------------------------------------------------------------------------
- // 矢量瓦片精简(protobuf / MVT 原地重写,无第三方依赖)
- // - 删除样式用不到的 name:xx 字段(本样式只用 name:latin / name:nonlatin / ref)
- // - 删除因此不再被任何要素引用的 value,以及这些 key
- // - 删除样式没有引用的 source-layer(如 mountain_peak)
- // 对同一文件重复执行是幂等的。
- // --------------------------------------------------------------------------
- function pbReadVarint(buf, i) {
- let v = 0;
- let sh = 0;
- let c;
- do {
- if (i >= buf.length) throw new Error('truncated varint');
- c = buf[i++];
- v += (c & 0x7f) * Math.pow(2, sh);
- sh += 7;
- if (sh > 63) throw new Error('varint too long');
- } while (c & 0x80);
- return [v, i];
- }
- function pbWriteVarint(out, v) {
- while (v > 0x7f) {
- out.push((v & 0x7f) | 0x80);
- v = Math.floor(v / 128);
- }
- out.push(v & 0x7f);
- }
- function pbPush(out, bytes) {
- for (let i = 0; i < bytes.length; i++) out.push(bytes[i]);
- }
- function pbWriteBytes(out, field, bytes) {
- pbWriteVarint(out, field * 8 + 2);
- pbWriteVarint(out, bytes.length);
- pbPush(out, bytes);
- }
- function* pbFields(buf) {
- let i = 0;
- while (i < buf.length) {
- let tag;
- [tag, i] = pbReadVarint(buf, i);
- const field = Math.floor(tag / 8);
- const wire = tag % 8;
- if (wire === 0) {
- let v;
- [v, i] = pbReadVarint(buf, i);
- yield { field, wire, value: v };
- } else if (wire === 2) {
- let len;
- [len, i] = pbReadVarint(buf, i);
- if (i + len > buf.length) throw new Error('length overflow');
- yield { field, wire, value: buf.subarray(i, i + len) };
- i += len;
- } else if (wire === 5) {
- yield { field, wire, value: buf.subarray(i, i + 4) };
- i += 4;
- } else if (wire === 1) {
- yield { field, wire, value: buf.subarray(i, i + 8) };
- i += 8;
- } else {
- throw new Error('unsupported wire type ' + wire);
- }
- }
- }
- function encodeFeature(feat, keyRemap, valRemap) {
- const out = [];
- if (feat.id != null) {
- pbWriteVarint(out, 1 * 8 + 0);
- pbWriteVarint(out, feat.id);
- }
- const tags = [];
- for (let i = 0; i + 1 < feat.tags.length; i += 2) {
- const k = keyRemap.get(feat.tags[i]);
- if (k === undefined) continue;
- tags.push(k, valRemap.get(feat.tags[i + 1]));
- }
- if (tags.length) {
- const tb = [];
- tags.forEach((v) => pbWriteVarint(tb, v));
- pbWriteBytes(out, 2, tb);
- }
- if (feat.type != null) {
- pbWriteVarint(out, 3 * 8 + 0);
- pbWriteVarint(out, feat.type);
- }
- if (feat.geom) pbWriteBytes(out, 4, feat.geom);
- return Buffer.from(out);
- }
- function stripLayer(buf, keepLayers, stats) {
- let name = null;
- let extent = 4096;
- let version = 2;
- const keys = [];
- const values = [];
- const features = [];
- for (const f of pbFields(buf)) {
- if (f.field === 1 && f.wire === 2) name = f.value;
- else if (f.field === 2 && f.wire === 2) features.push(f.value);
- else if (f.field === 3 && f.wire === 2) keys.push(f.value);
- else if (f.field === 4 && f.wire === 2) values.push(f.value);
- else if (f.field === 5 && f.wire === 0) extent = f.value;
- else if (f.field === 15 && f.wire === 0) version = f.value;
- }
- const layerName = name ? name.toString('utf8') : '';
- if (keepLayers && keepLayers.size && !keepLayers.has(layerName)) {
- stats.droppedLayers += 1;
- return null;
- }
- const keyRemap = new Map();
- const keptKeys = [];
- keys.forEach((kb, idx) => {
- const k = kb.toString('utf8');
- // OpenMapTiles 里多语言字段有两套命名:name:xx(冒号)和 name_xx(下划线,如 name_en/name_de/name_int)
- const isName = k.startsWith('name:') || /^name_[a-z]{2,3}$/.test(k);
- if (!isName || KEEP_NAME_KEYS.has(k)) {
- keyRemap.set(idx, keptKeys.length);
- keptKeys.push(idx);
- } else {
- stats.droppedKeys += 1;
- }
- });
- const parsed = [];
- const usedValues = new Set();
- for (const fb of features) {
- const feat = { id: null, type: null, geom: null, tags: [] };
- for (const f of pbFields(fb)) {
- if (f.field === 1 && f.wire === 0) feat.id = f.value;
- else if (f.field === 3 && f.wire === 0) feat.type = f.value;
- else if (f.field === 4 && f.wire === 2) feat.geom = f.value;
- else if (f.field === 2 && f.wire === 2) {
- let i = 0;
- while (i < f.value.length) {
- let v;
- [v, i] = pbReadVarint(f.value, i);
- feat.tags.push(v);
- }
- }
- }
- for (let i = 0; i + 1 < feat.tags.length; i += 2) {
- if (keyRemap.has(feat.tags[i])) usedValues.add(feat.tags[i + 1]);
- }
- parsed.push(feat);
- }
- const valRemap = new Map();
- const keptValues = [];
- values.forEach((vb, idx) => {
- if (usedValues.has(idx)) {
- valRemap.set(idx, keptValues.length);
- keptValues.push(vb);
- } else {
- stats.droppedValues += 1;
- }
- });
- const out = [];
- pbWriteVarint(out, 15 * 8 + 0);
- pbWriteVarint(out, version);
- if (name) pbWriteBytes(out, 1, name);
- for (const feat of parsed) pbWriteBytes(out, 2, encodeFeature(feat, keyRemap, valRemap));
- for (const i of keptKeys) pbWriteBytes(out, 3, keys[i]);
- for (const vb of keptValues) pbWriteBytes(out, 4, vb);
- pbWriteVarint(out, 5 * 8 + 0);
- pbWriteVarint(out, extent);
- return Buffer.from(out);
- }
- function stripTile(buf, keepLayers) {
- const stats = { droppedKeys: 0, droppedValues: 0, droppedLayers: 0 };
- const out = [];
- for (const f of pbFields(buf)) {
- if (f.field === 3 && f.wire === 2) {
- const layer = stripLayer(f.value, keepLayers, stats);
- if (layer) pbWriteBytes(out, 3, layer);
- }
- }
- return { buffer: Buffer.from(out), stats };
- }
- /**
- * 清理不再需要的字体文件:
- * - 删掉不在 FONTSTACKS 里的字体栈目录(例如 --fonts 只留 Regular 时,删掉 Italic/Bold)
- * - 删掉不在 NEEDED_RANGES 里的区间文件
- * 保证反复执行 pnpm map:fetch 时体积会收敛,而不是越积越多。
- */
- async function pruneFonts() {
- const fontsDir = path.join(OUT_DIR, 'fonts');
- let dirs = [];
- try {
- dirs = await readdir(fontsDir, { withFileTypes: true });
- } catch {
- return;
- }
- const keepStacks = new Set(FONTSTACKS);
- const keepRanges = new Set([...NEEDED_RANGES].map((r) => r * 256 + '-' + (r * 256 + 255)));
- let removed = 0;
- let freed = 0;
- for (const d of dirs) {
- if (!d.isDirectory()) continue;
- const full = path.join(fontsDir, d.name);
- if (!keepStacks.has(d.name)) {
- for (const f of await readdir(full)) {
- const s = await fileSize(path.join(full, f));
- if (s > 0) freed += s;
- }
- await rm(full, { recursive: true, force: true });
- removed += 1;
- log(' 移除未使用的字体栈: ' + d.name);
- continue;
- }
- for (const f of await readdir(full)) {
- if (!f.endsWith('.pbf')) continue;
- if (!keepRanges.has(f.slice(0, -4))) {
- const s = await fileSize(path.join(full, f));
- if (s > 0) freed += s;
- await rm(path.join(full, f), { force: true });
- removed += 1;
- }
- }
- }
- if (removed) log(' 清理多余字体文件 ' + removed + ' 个,释放 ' + fmtMB(freed));
- }
- // --------------------------------------------------------------------------
- // 1. 解析源样式
- // --------------------------------------------------------------------------
- log('> 读取源样式:', path.relative(ROOT, STYLE_SRC));
- const style = JSON.parse(await readFile(STYLE_SRC, 'utf8'));
- const srcVec = style.sources && style.sources[VECTOR_SOURCE_ID];
- if (!srcVec) throw new Error('源样式里找不到矢量源 ' + VECTOR_SOURCE_ID);
- let tileTemplate;
- if (srcVec.url) {
- log(' 矢量源 TileJSON:', srcVec.url);
- const { buffer } = await fetchBuffer(srcVec.url);
- const tilejson = JSON.parse(buffer.toString('utf8'));
- tileTemplate = tilejson.tiles[0];
- } else {
- tileTemplate = srcVec.tiles[0];
- }
- log(' 瓦片模板:', tileTemplate);
- // 收集样式中实际使用的字体栈
- const fontstacks = new Set();
- for (const layer of style.layers || []) {
- const tf = layer.layout && layer.layout['text-font'];
- if (Array.isArray(tf)) tf.forEach((f) => fontstacks.add(f));
- }
- // 代码里 vjmap.Symbol 手动指定过的字体栈
- for (const extra of String(args['extra-fonts'] ?? 'Noto Sans Regular').split(',')) {
- if (extra.trim()) fontstacks.add(extra.trim());
- }
- let FONTSTACKS = [...fontstacks];
- // 字体是体积大头:字形是 SDF 位图,单个区间约 150KB,一个字体栈 150 个区间就约 25MB,
- // 样式里 Regular/Italic/Bold 三个栈全下会到 75MB —— 比瓦片还大。
- // 所以默认只保留一个字体栈(Noto Sans Regular),并把样式里所有 text-font 改写过去。
- // --fonts="Noto Sans Regular,Noto Sans Italic" 保留多个字体栈
- // --fonts= 留空则沿用样式里的全部字体栈
- const FONTS_ARG = args.fonts === undefined ? 'Noto Sans Regular' : String(args.fonts);
- if (FONTS_ARG) {
- const list = FONTS_ARG.split(',')
- .map((s) => s.trim())
- .filter(Boolean);
- if (list.length) FONTSTACKS = list;
- }
- log(' 字体栈:', FONTSTACKS.join(' | '));
- // 样式引用到的 source-layer,精简瓦片时只保留这些图层
- const KEEP_SOURCE_LAYERS = new Set((style.layers || []).map((l) => l['source-layer']).filter(Boolean));
- // --------------------------------------------------------------------------
- // 2. 下载矢量瓦片
- // --------------------------------------------------------------------------
- await ensureDir(path.join(OUT_DIR, 'tiles'));
- const tileJobs = [];
- for (let z = MINZOOM; z <= MAXZOOM; z++) {
- const r = tileRangeForZoom(z);
- for (let x = r.x0; x <= r.x1; x++) {
- for (let y = r.y0; y <= r.y1; y++) tileJobs.push({ z, x, y });
- }
- }
- log('> 矢量瓦片: z' + MINZOOM + '-z' + MAXZOOM + ',共 ' + tileJobs.length + ' 张');
- for (const t of TIERS) {
- log(
- ' z' + t.min + '-z' + t.max +
- ' lon[' + t.bbox[0] + ',' + t.bbox[2] + '] lat[' + t.bbox[1] + ',' + t.bbox[3] + ']',
- );
- }
- let tilesBytes = 0;
- let tilesSkipped = 0;
- const t0 = Date.now();
- await pool(
- tileJobs,
- CONCURRENCY,
- async (job) => {
- const dir = path.join(OUT_DIR, 'tiles', String(job.z), String(job.x));
- const file = path.join(dir, job.y + '.pbf');
- const existing = SKIP_EXISTING ? await fileSize(file) : -1;
- if (existing >= 0) {
- tilesBytes += existing;
- tilesSkipped += 1;
- return;
- }
- const url = tileTemplate
- .replace('{z}', job.z)
- .replace('{x}', job.x)
- .replace('{y}', job.y);
- const { buffer } = await fetchBuffer(url);
- await ensureDir(dir);
- // 空瓦片也写空文件,避免浏览器 404 反复报错
- await writeFile(file, buffer);
- tilesBytes += buffer.length;
- },
- (d, total) => {
- const elapsed = (Date.now() - t0) / 1000;
- log(
- ' ' +
- d +
- '/' +
- total +
- ' ' +
- fmtMB(tilesBytes) +
- ' 用时 ' +
- elapsed.toFixed(0) +
- 's (' +
- (d / Math.max(elapsed, 1)).toFixed(1) +
- ' 张/s)',
- );
- },
- );
- log(' OK 瓦片完成: ' + fmtMB(tilesBytes) + (tilesSkipped ? '(跳过已存在 ' + tilesSkipped + ' 张)' : ''));
- // --------------------------------------------------------------------------
- // 2.5 精简矢量瓦片(删除样式用不到的 name:xx / 值 / 图层)
- // --------------------------------------------------------------------------
- let strippedBytes = tilesBytes;
- if (STRIP_TILES) {
- log('> 精简矢量瓦片:删除样式未使用的 name:xx 字段与图层 …');
- const t1 = Date.now();
- let before = 0;
- let after = 0;
- let droppedLayers = 0;
- let droppedKeys = 0;
- let touched = 0;
- await pool(
- tileJobs,
- 32,
- async (job) => {
- const file = path.join(OUT_DIR, 'tiles', String(job.z), String(job.x), job.y + '.pbf');
- const size = await fileSize(file);
- if (size <= 0) return;
- const raw = await readFile(file);
- const { buffer, stats } = stripTile(raw, KEEP_SOURCE_LAYERS);
- droppedLayers += stats.droppedLayers;
- droppedKeys += stats.droppedKeys;
- if (buffer.length !== raw.length || stats.droppedLayers || stats.droppedKeys) {
- await writeFile(file, buffer);
- touched += 1;
- }
- before += raw.length;
- after += buffer.length;
- },
- (d, total) => {
- if (d % 1000 === 0 || d === total) log(' ' + d + '/' + total + ' 用时 ' + ((Date.now() - t1) / 1000).toFixed(0) + 's');
- },
- );
- strippedBytes = after;
- log(
- ' OK 精简完成: ' + fmtMB(before) + ' -> ' + fmtMB(after) +
- '(省 ' + fmtMB(before - after) + ',改写 ' + touched + ' 张;删除 name 键 ' + droppedKeys + ' 个、图层 ' + droppedLayers + ' 个)',
- );
- } else {
- log(' 跳过瓦片精简(--no-strip)');
- }
- // --------------------------------------------------------------------------
- // 3. 统计瓦片内文字字符 -> 推导需要的字形区间(避免下载 50MB+ 全量 CJK)
- // --------------------------------------------------------------------------
- const NEEDED_RANGES = new Set([0, 1]);
- if (GLYPH_MODE === 'all') {
- // 全量:0-65535 的 256 个区间。虽然体积最大,但可以彻底避免
- // “某个字符所在区间没下载 → 空响应 → vjmap 报 i5.glyphs is not iterable”。
- for (let r = 0; r <= 255; r++) NEEDED_RANGES.add(r);
- log(' 全量字形模式:每个字体栈 256 个区间');
- } else if (GLYPH_MODE === 'all-cjk') {
- for (let r = 78; r <= 159; r++) NEEDED_RANGES.add(r);
- [2, 3, 4, 48, 49, 255].forEach((r) => NEEDED_RANGES.add(r));
- } else {
- log('> 扫描瓦片文字,推导所需字形区间 …');
- // 按 MVT 规范只取真正的字符串:key(layer 的 field 3)和 value 里的 string_value(value 的 field 1)。
- // 不能用「把整个 value 消息当字符串解码」的做法——数字型 value 会被误解码成乱码字符,
- // 从而漏掉真正要用的区间(曾导致缺区间 → glyphs is not iterable)。
- const chars = new Set();
- const decoder = new TextDecoder('utf-8', { fatal: true });
- function addString(bytes) {
- let s = null;
- try {
- s = decoder.decode(bytes);
- } catch {
- return; // 不是合法 UTF-8,忽略
- }
- for (const ch of s) {
- const cp = ch.codePointAt(0);
- if (cp >= 0x20 && cp < 0x10000) chars.add(cp); // 字形只支持 BMP
- }
- }
- function collectTileStrings(buf) {
- for (const layerField of pbFields(buf)) {
- if (layerField.field !== 3 || layerField.wire !== 2) continue;
- for (const lf of pbFields(layerField.value)) {
- if (lf.field === 3 && lf.wire === 2) {
- addString(lf.value); // key
- } else if (lf.field === 4 && lf.wire === 2) {
- for (const vf of pbFields(lf.value)) {
- if (vf.field === 1 && vf.wire === 2) addString(vf.value); // Value.string_value
- }
- }
- }
- }
- }
- // 统计所有已下载瓦片里的文字:地图上渲染的每个字都来自这些瓦片,
- // 因此按瓦片字符集下载字形区间是完备的(精简瓦片后字符集会进一步变小)
- const scanMinzoom = Number(args['glyph-scan-minzoom'] ?? 0);
- let scanned = 0;
- for (const job of tileJobs) {
- if (job.z < scanMinzoom) continue;
- const fpath = path.join(OUT_DIR, 'tiles', String(job.z), String(job.x), job.y + '.pbf');
- const size = await fileSize(fpath);
- if (size <= 0) continue;
- collectTileStrings(await readFile(fpath));
- scanned++;
- }
- for (const cp of chars) NEEDED_RANGES.add(cp >> 8);
- // 兜底区间:ASCII、拉丁扩展、通用标点、CJK 标点、全角,以及数字(点位数量)
- [0, 1, 2, 3, 4, 32, 33, 48, 49, 255].forEach((r) => NEEDED_RANGES.add(r));
- log(' 扫描 ' + scanned + ' 张瓦片,命中 ' + chars.size + ' 个字符,需要 ' + NEEDED_RANGES.size + ' 个字形区间');
- }
- // --------------------------------------------------------------------------
- // 4. 下载 glyphs 字体
- // --------------------------------------------------------------------------
- log('> 下载 glyphs 字体 …');
- const glyphTemplate = style.glyphs.replace('{fontstack}', '__FONT__').replace('{range}', '__RANGE__');
- let glyphBytes = 0;
- const glyphJobs = [];
- for (const font of FONTSTACKS) {
- for (const r of [...NEEDED_RANGES].sort((a, b) => a - b)) {
- const lo = r * 256;
- glyphJobs.push({ font, range: lo + '-' + (lo + 255) });
- }
- }
- await pool(
- glyphJobs,
- CONCURRENCY,
- async ({ font, range }) => {
- const dir = path.join(OUT_DIR, 'fonts', font);
- const file = path.join(dir, range + '.pbf');
- if (SKIP_EXISTING && (await fileSize(file)) > 0) return;
- const url = glyphTemplate
- .replace('__FONT__', encodeURIComponent(font))
- .replace('__RANGE__', range);
- const { buffer, notFound } = await fetchBuffer(url);
- if (notFound || buffer.length === 0) return;
- await ensureDir(dir);
- await writeFile(file, buffer);
- glyphBytes += buffer.length;
- },
- (d, total) => {
- if (d % 30 === 0 || d === total) log(' ' + d + '/' + total + ' ' + fmtMB(glyphBytes));
- },
- );
- log(
- ' OK 字体完成: ' +
- fmtMB(glyphBytes) +
- '(' +
- FONTSTACKS.length +
- ' 个字体栈 x ' +
- NEEDED_RANGES.size +
- ' 个区间)',
- );
- await pruneFonts();
- // --------------------------------------------------------------------------
- // 5. 下载 sprite 图标
- // --------------------------------------------------------------------------
- log('> 下载 sprite 图标 …');
- await ensureDir(path.join(OUT_DIR, 'sprites'));
- const spriteBase = style.sprite;
- let spriteBytes = 0;
- for (const suffix of ['.json', '.png', '@2x.png']) {
- const { buffer } = await fetchBuffer(spriteBase + suffix);
- if (!buffer.length) continue;
- await writeFile(path.join(OUT_DIR, 'sprites', 'ofm' + suffix), buffer);
- spriteBytes += buffer.length;
- }
- log(' OK sprite 完成: ' + fmtMB(spriteBytes));
- // --------------------------------------------------------------------------
- // 6. 生成本地化样式
- // --------------------------------------------------------------------------
- log('> 生成本地化样式 …');
- const outStyle = JSON.parse(JSON.stringify(style));
- const usedSources = new Set((style.layers || []).map((l) => l.source).filter(Boolean));
- const sources = {};
- for (const [id, src] of Object.entries(style.sources)) {
- if (!usedSources.has(id) && UNUSED_SOURCES.includes(id)) {
- log(' 移除未被引用的瓦片源: ' + id);
- continue;
- }
- if (id === VECTOR_SOURCE_ID) {
- sources[id] = {
- type: 'vector',
- scheme: 'xyz',
- tiles: [BASE + 'tiles/{z}/{x}/{y}.pbf'],
- minzoom: MINZOOM,
- maxzoom: MAXZOOM,
- };
- if (src.attribution) sources[id].attribution = src.attribution;
- continue;
- }
- sources[id] = src;
- }
- outStyle.sources = sources;
- outStyle.glyphs = BASE + 'fonts/{fontstack}/{range}.pbf';
- if (FONTS_ARG && FONTSTACKS.length) {
- const only = FONTSTACKS[0];
- for (const l of outStyle.layers || []) {
- if (l.layout && Array.isArray(l.layout['text-font'])) l.layout['text-font'] = [only];
- }
- log(' 已将样式内全部 text-font 统一为: ' + only);
- }
- if (SPRITE_BASE) {
- outStyle.sprite = SPRITE_BASE;
- log(' sprite 使用绝对地址: ' + SPRITE_BASE);
- } else {
- delete outStyle.sprite;
- log(' 不写入 sprite 字段(vjmap 只接受绝对地址;地图正常,仅 POI 图标不显示)');
- log(' 需要图标请加 --sprite-base=http://<内网域名或IP>/map/sprites/ofm');
- }
- outStyle.name = (style.name || 'Custom') + ' (offline)';
- // 只记录 OpenFreeMap 的数据版本号,不残留任何外网 URL
- const sourceVersion = (/:\/\/[^/]+\/planet\/([^/]+)\//.exec(tileTemplate) || [])[1] || '';
- outStyle.metadata = Object.assign({}, outStyle.metadata || {}, {
- 'x-offline': {
- generatedAt: new Date().toISOString(),
- sourceVersion,
- tiers: TIERS.map((t) => ({ zoom: 'z' + t.min + '-z' + t.max, bbox: t.bbox })),
- minzoom: MINZOOM,
- maxzoom: MAXZOOM,
- },
- });
- await ensureDir(path.dirname(STYLE_OUT));
- // 样式内容没变化时不重写文件,避免每次 map:fetch 都产生一个只有时间戳差异的 diff
- const sameStyle = await isSameStyle(outStyle);
- if (sameStyle) {
- log(' 样式内容未变化,跳过写入: ' + path.relative(ROOT, STYLE_OUT));
- } else {
- await writeFile(STYLE_OUT, JSON.stringify(outStyle, null, 2));
- }
- /** 忽略 x-offline.generatedAt,判断新旧样式是否等价 */
- async function isSameStyle(next) {
- let prevText;
- try {
- prevText = await readFile(STYLE_OUT, 'utf8');
- } catch {
- return false;
- }
- try {
- const prev = JSON.parse(prevText);
- const norm = (s) => {
- const c = JSON.parse(JSON.stringify(s));
- if (c.metadata && c.metadata['x-offline']) c.metadata['x-offline'].generatedAt = '';
- return JSON.stringify(c);
- };
- return norm(prev) === norm(next);
- } catch {
- return false;
- }
- }
- // --------------------------------------------------------------------------
- // 汇总
- // --------------------------------------------------------------------------
- log('');
- log('======== 完成 ========');
- log('样式文件 :', path.relative(ROOT, STYLE_OUT));
- log('矢量瓦片 :', fmtMB(strippedBytes), '(' + tileJobs.length + ' 张' + (STRIP_TILES ? ',已精简' : '') + ')');
- log('字体字形 :', fmtMB(glyphBytes));
- log('图标雪碧 :', fmtMB(spriteBytes));
- log('合计 :', fmtMB(strippedBytes + glyphBytes + spriteBytes));
- log('资源前缀 :', BASE);
- log('提示 : 用 --skip-existing 断点续传;--maxzoom=12 可再细一级。');
|