Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

372 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* Directly backfill Google vector tile responses into mobilegym-data/map/vector.
*
* This supplements, rather than replaces, the browser-driven snapshot:
* - reads the existing vector manifest for the current Roadmap pb template
* - calculates z/x/y tile ranges for named centers + radius
* - fetches missing vector tiles
* - merges them into manifest.json and updates map-sw.js VECTOR_CACHE_NAME
* - regenerates the small bootstrap manifest used by service worker install
*
* Defaults are intentionally scoped to the bench hot spots requested during
* development: Palace Museum and Wudaokou Shopping Center, 2km radius, z10-z18.
*
* Env:
* MAP_VECTOR_BACKFILL_CENTERS='[{"name":"...","lat":39.9,"lng":116.3}]'
* MAP_VECTOR_BACKFILL_RADIUS_M=2000
* MAP_VECTOR_BACKFILL_ZOOMS=10-18 # also supports "10,12,17-18"
* MAP_VECTOR_BACKFILL_CONCURRENCY=8
* MAP_VECTOR_BACKFILL_LIMIT=0 # non-zero for a capped probe
* MAP_VECTOR_BACKFILL_DRY_RUN=1
*/
import { createHash } from 'crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import {
updateServiceWorkerCacheName,
writeVectorBootstrapManifest,
} from './bootstrap_manifest_utils.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const APP_DIR = resolve(__dirname, '..');
const ROOT = resolve(APP_DIR, '../..');
const VECTOR_DIR = resolve(ROOT, 'mobilegym-data/map/vector');
const FILES_DIR = resolve(VECTOR_DIR, 'files');
const MANIFEST_PATH = resolve(VECTOR_DIR, 'manifest.json');
const SW_PATH = resolve(APP_DIR, 'sw/map-sw.js');
const BOOTSTRAP_MANIFEST_PATH = resolve(APP_DIR, 'sw/vector/bootstrap-manifest.json');
const PLACES_PATH = resolve(APP_DIR, 'data/places.json');
const VECTOR_MANIFEST_SOURCE = 'mobilegym-data/map/vector/manifest.json';
const DEFAULT_CENTER_NAMES = ['故宫博物院', '五道口购物中心'];
const RADIUS_M = Number.parseFloat(process.env.MAP_VECTOR_BACKFILL_RADIUS_M || '2000');
const CONCURRENCY = Math.max(1, Number.parseInt(process.env.MAP_VECTOR_BACKFILL_CONCURRENCY || '8', 10));
const LIMIT = Math.max(0, Number.parseInt(process.env.MAP_VECTOR_BACKFILL_LIMIT || '0', 10));
const DRY_RUN = process.env.MAP_VECTOR_BACKFILL_DRY_RUN === '1';
const ZOOMS = parseZooms(process.env.MAP_VECTOR_BACKFILL_ZOOMS || '10-20');
function parseZooms(raw) {
const out = new Set();
for (const part of raw.split(',').map((p) => p.trim()).filter(Boolean)) {
const range = part.match(/^(\d+)-(\d+)$/);
if (range) {
const a = Number.parseInt(range[1], 10);
const b = Number.parseInt(range[2], 10);
for (let z = Math.min(a, b); z <= Math.max(a, b); z++) out.add(z);
continue;
}
const z = Number.parseInt(part, 10);
if (Number.isFinite(z)) out.add(z);
}
return [...out].filter((z) => z >= 0 && z <= 22).sort((a, b) => a - b);
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf-8'));
}
function loadManifest() {
if (!existsSync(MANIFEST_PATH)) {
throw new Error(`Missing vector manifest: ${MANIFEST_PATH}`);
}
return readJson(MANIFEST_PATH);
}
function loadCenters() {
const raw = process.env.MAP_VECTOR_BACKFILL_CENTERS;
if (raw?.trim()) {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error('MAP_VECTOR_BACKFILL_CENTERS must be a JSON array');
return parsed.map((item, index) => normalizeCenter(item, `center-${index + 1}`));
}
const places = readJson(PLACES_PATH).places || {};
const rows = Object.values(places);
return DEFAULT_CENTER_NAMES.map((name) => {
const row = rows.find((r) => localizedName(r) === name)
|| rows.find((r) => JSON.stringify(r).includes(name));
if (!row) throw new Error(`Cannot find default center in places.json: ${name}`);
return normalizeCenter({
name,
lat: row.lat,
lng: row.lng,
}, name);
});
}
function localizedName(row) {
if (!row?.name) return '';
return typeof row.name === 'string' ? row.name : (row.name.zh || row.name.en || '');
}
function normalizeCenter(item, fallbackName) {
const lat = Number(item?.lat);
const lng = Number(item?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
throw new Error(`Invalid center ${fallbackName}: lat/lng required`);
}
return {
name: String(item?.name || fallbackName),
lat,
lng,
};
}
function findVectorTileTemplate(entries) {
const entry = entries.find((e) => typeof e.url === 'string' && e.url.includes('/maps/vt/pb='));
if (!entry) throw new Error('No /maps/vt/pb= vector tile URL found in vector manifest');
const match = entry.url.match(/^(.*\/maps\/vt\/pb=!1m4!1m3!1i)\d+(!2i)\d+(!3i)\d+(.*)$/);
if (!match) throw new Error(`Cannot parse vector tile pb template: ${entry.url}`);
return {
prefix: match[1],
xToken: match[2],
yToken: match[3],
suffix: match[4],
contentType: entry.contentType || 'application/vnd.google.octet-stream-compressible; charset=x-user-defined',
cacheControl: entry.cacheControl,
};
}
function tileUrl(template, z, x, y) {
return `${template.prefix}${z}${template.xToken}${x}${template.yToken}${y}${template.suffix}`;
}
function lonToTileX(lng, z) {
const n = 2 ** z;
return Math.floor(((lng + 180) / 360) * n);
}
function latToTileY(lat, z) {
const n = 2 ** z;
const clamped = Math.max(-85.05112878, Math.min(85.05112878, lat));
const rad = (clamped * Math.PI) / 180;
return Math.floor((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2 * n);
}
function tileBoundsForRadius(center, radiusM, z) {
const dLat = radiusM / 111_320;
const dLng = radiusM / (111_320 * Math.cos((center.lat * Math.PI) / 180));
const n = 2 ** z;
const xMin = clampInt(lonToTileX(center.lng - dLng, z), 0, n - 1);
const xMax = clampInt(lonToTileX(center.lng + dLng, z), 0, n - 1);
const yMin = clampInt(latToTileY(center.lat + dLat, z), 0, n - 1);
const yMax = clampInt(latToTileY(center.lat - dLat, z), 0, n - 1);
return {
xMin: Math.min(xMin, xMax),
xMax: Math.max(xMin, xMax),
yMin: Math.min(yMin, yMax),
yMax: Math.max(yMin, yMax),
};
}
function clampInt(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function buildWorkItems(centers, template, existingEntries) {
const existing = new Map();
for (const entry of existingEntries) {
if (entry?.url && entry?.file && existsSync(resolve(FILES_DIR, entry.file))) {
existing.set(entry.url, entry);
}
}
const items = [];
const seen = new Set();
const perZoom = new Map();
for (const center of centers) {
for (const z of ZOOMS) {
const bounds = tileBoundsForRadius(center, RADIUS_M, z);
let total = 0;
let missing = 0;
for (let x = bounds.xMin; x <= bounds.xMax; x++) {
for (let y = bounds.yMin; y <= bounds.yMax; y++) {
total++;
const url = tileUrl(template, z, x, y);
if (existing.has(url) || seen.has(url)) continue;
seen.add(url);
missing++;
items.push({ center: center.name, z, x, y, url });
}
}
const prev = perZoom.get(z) || { total: 0, missing: 0 };
prev.total += total;
prev.missing += missing;
perZoom.set(z, prev);
}
}
return { items, perZoom };
}
function guessExt(contentType) {
const ct = (contentType || '').toLowerCase();
if (ct.includes('javascript')) return '.js';
if (ct.startsWith('application/json')) return '.json';
if (ct.startsWith('text/css')) return '.css';
if (ct.startsWith('font/woff2') || ct.includes('woff2')) return '.woff2';
if (ct.startsWith('font/woff') || ct.includes('woff')) return '.woff';
if (ct.startsWith('image/png')) return '.png';
if (ct.startsWith('image/jpeg')) return '.jpg';
if (ct.startsWith('image/webp')) return '.webp';
return '.bin';
}
async function fetchOne(item, template) {
const res = await fetch(item.url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const contentType = res.headers.get('content-type') || template.contentType;
const body = Buffer.from(await res.arrayBuffer());
if (body.length === 0) throw new Error('empty body');
const file = `${createHash('sha1').update(item.url).digest('hex').slice(0, 16)}${guessExt(contentType)}`;
writeFileSync(resolve(FILES_DIR, file), body);
return {
url: item.url,
file,
contentType,
cacheControl: res.headers.get('cache-control') || template.cacheControl || undefined,
status: res.status,
size: body.length,
bodyHash: createHash('sha1').update(body).digest('hex'),
};
}
async function runQueue(items, template) {
const results = [];
const failures = [];
let cursor = 0;
let done = 0;
async function worker() {
while (cursor < items.length) {
const item = items[cursor++];
try {
const entry = await fetchOne(item, template);
results.push(entry);
} catch (error) {
failures.push({ item, error: error instanceof Error ? error.message : String(error) });
}
done++;
if (done % 50 === 0 || done === items.length) {
process.stdout.write(`\r[vector-backfill] fetched ${done}/${items.length}, ok=${results.length}, fail=${failures.length} `);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
process.stdout.write('\n');
return { results, failures };
}
function makeSnapshotCacheName(entries) {
const seed = entries
.map((entry) => [
entry.url,
entry.file,
entry.status,
entry.size,
entry.bodyHash,
].join('\t'))
.join('\n');
const hash = createHash('sha1').update(seed).digest('hex').slice(0, 12);
return `map-vector-cache-${hash}`;
}
function updateMapSwCacheName(cacheName) {
updateServiceWorkerCacheName({
swPath: SW_PATH,
constName: 'VECTOR_CACHE_NAME',
cacheName,
prefix: 'map-vector-cache-',
logPrefix: '[vector-backfill]',
});
}
function writeBootstrapManifest(entries, sourceCacheName) {
// Backfill only adds tile payloads, but still rewrite the bootstrap manifest
// from the merged vector manifest so every vector-writing script has the same
// derivation path and future non-tile changes cannot leave it stale.
const result = writeVectorBootstrapManifest({
entries,
outputPath: BOOTSTRAP_MANIFEST_PATH,
source: VECTOR_MANIFEST_SOURCE,
sourceCacheName,
});
updateServiceWorkerCacheName({
swPath: SW_PATH,
constName: 'BOOTSTRAP_CACHE_NAME',
cacheName: result.cacheName,
prefix: 'map-bootstrap-cache-',
logPrefix: '[vector-backfill]',
});
return result;
}
function mergeAndWriteManifest(manifest, newEntries) {
const byUrl = new Map();
for (const entry of manifest.entries || []) {
if (entry?.url && entry?.file) byUrl.set(entry.url, entry);
}
for (const entry of newEntries) byUrl.set(entry.url, entry);
const entries = [...byUrl.values()].sort((a, b) => a.url.localeCompare(b.url));
const cacheName = makeSnapshotCacheName(entries);
const nextManifest = {
...manifest,
version: 1,
generatedAt: new Date().toISOString(),
cacheName,
entries,
};
writeFileSync(MANIFEST_PATH, JSON.stringify(nextManifest, null, 2));
updateMapSwCacheName(cacheName);
const bootstrapManifest = writeBootstrapManifest(entries, cacheName);
console.log(`[vector-backfill] bootstrap manifest: ${bootstrapManifest.entries.length} startup entries`);
return nextManifest;
}
async function main() {
if (!ZOOMS.length) throw new Error('No zoom levels configured');
if (!Number.isFinite(RADIUS_M) || RADIUS_M <= 0) throw new Error('MAP_VECTOR_BACKFILL_RADIUS_M must be > 0');
if (!existsSync(FILES_DIR)) mkdirSync(FILES_DIR, { recursive: true });
const manifest = loadManifest();
const centers = loadCenters();
const template = findVectorTileTemplate(manifest.entries || []);
const { items: allItems, perZoom } = buildWorkItems(centers, template, manifest.entries || []);
const items = LIMIT > 0 ? allItems.slice(0, LIMIT) : allItems;
console.log('[vector-backfill] centers:', centers.map((c) => `${c.name}(${c.lat},${c.lng})`).join(', '));
console.log(`[vector-backfill] radius=${RADIUS_M}m zooms=${ZOOMS.join(',')} concurrency=${CONCURRENCY}`);
for (const z of ZOOMS) {
const row = perZoom.get(z) || { total: 0, missing: 0 };
console.log(`[vector-backfill] z${z}: total tiles in requested areas=${row.total}, missing=${row.missing}`);
}
console.log(`[vector-backfill] will fetch ${items.length}${LIMIT > 0 ? ` (limited from ${allItems.length})` : ''} tiles`);
if (DRY_RUN || items.length === 0) {
if (DRY_RUN) console.log('[vector-backfill] dry run: no files written');
return;
}
const beforeCount = manifest.entries?.length || 0;
const { results, failures } = await runQueue(items, template);
const nextManifest = mergeAndWriteManifest(manifest, results);
const addedBytes = results.reduce((sum, entry) => sum + (entry.size || 0), 0);
console.log(`[vector-backfill] manifest: ${beforeCount} -> ${nextManifest.entries.length} entries`);
console.log(`[vector-backfill] added ${results.length} tiles, ${(addedBytes / 1024 / 1024).toFixed(1)} MB`);
if (failures.length) {
console.warn(`[vector-backfill] failures=${failures.length}`);
for (const failure of failures.slice(0, 12)) {
console.warn(` z${failure.item.z}/${failure.item.x}/${failure.item.y}: ${failure.error}`);
}
}
}
main().catch((error) => {
console.error('[vector-backfill] failed:', error);
process.exit(1);
});