Files
vngeoguessr/scripts/build-pano-index.mjs
T
tiennm99 2bd5e4fbf2 refactor(scripts): one env loader, the region config as data, tests for district assignment
Four scripts each carried a copy of the .env parser, and one copy lacked
the unquoting the others had, so vercel env pull output broke it. They
share scripts/lib/env.mjs now. The hand-edited region configuration
moves out of the boundary builder into scripts/lib/region-config.mjs,
where it no longer shares a name with the generated tree the sibling
scripts import. scripts/lib/assign-districts.mjs, which decides which
district a panorama is credited to, gets its first tests, against
synthetic polygons so they pin geometry rules rather than data. A
data:refresh command runs the whole pipeline, with its Mapillary cost
stated where it is invoked.
2026-09-21 16:21:14 +07:00

306 lines
12 KiB
JavaScript

// Build the panorama index each city plays from.
//
// The Graph API's /images?bbox= endpoint is unusable for this: it returns HTTP
// 500 in dense areas whatever the window size or limit, and takes seconds where
// it does work. The vector tile API answers everywhere, so coverage is
// discovered here, once, and the runtime is left with a single by-ID lookup.
//
// node scripts/build-pano-index.mjs # every city
// node scripts/build-pano-index.mjs TPHCM DN # only these
//
// Needs MAPILLARY_ACCESS_TOKEN in .env, and the boundaries built first by
// scripts/build-region-boundaries.mjs. Output is data-build/panos/<code>.json
// (gitignored), which is plain JSON, safe to prune by hand, and uploaded to
// Postgres by scripts/seed-pano-db.mjs.
//
// A full rebuild of every city costs roughly 2,800 tile requests against
// tiles.mapillary.com's 50,000/day cap -- see TILE_RETRIES below. `npm run
// data:refresh` runs this as one step of the full pipeline; budget for that
// cost before invoking it.
import { mkdirSync, readFileSync, statSync, writeFileSync, existsSync } from 'node:fs';
import { VectorTile } from '@mapbox/vector-tile';
import { PbfReader } from 'pbf';
import * as turf from '@turf/turf';
import { REGIONS } from '../src/data/regions/index.js';
import { assignPanos } from './lib/assign-districts.mjs';
import { BOUNDARY_DIR, PANO_DIR as OUT_DIR } from './lib/paths.mjs';
import { loadEnvFile } from './lib/env.mjs';
// The image layer, the one carrying per-image points, only exists at z14.
const ZOOM = 14;
const TILE_URL = 'https://tiles.mapillary.com/maps/vtp/mly1_public/2';
// Tiles run to ~9MB in dense districts, so a few at a time is plenty.
const CONCURRENCY = 4;
// tiles.mapillary.com allows 50,000 requests per day, and a full rebuild of
// every city costs about 2,800. That is comfortable for occasional use but not
// unlimited: roughly seventeen full rebuilds exhaust a day's budget. Exceeding
// it returns 4xx, so failures are retried and a build that still cannot get
// every tile refuses to write, rather than quietly shipping a partial snapshot.
const TILE_RETRIES = 3;
const RETRY_BACKOFF_MS = 2000;
// One panorama per cell, so rounds spread out instead of stacking up on
// whichever street was driven most. Coverage is heavily clustered along a few
// roads, so the cell has to be small or a whole city collapses to a handful of
// locations: Da Lat's 4,309 panoramas fall in only 86 cells at 165m.
// ~0.0003 degrees is roughly 33m, far enough apart to look like a new place.
const GRID_DEG = 0.0003;
// No cap: the index keeps every distinct location Mapillary has inside the
// city. Capping was only ever a file-size measure, and it did not affect
// coverage -- the cells that hold imagery are saturated, so a sample and the
// full set reach the same places. It did cost density.
const MAX_PER_CITY = Infinity;
function loadToken() {
const env = loadEnvFile();
const token = process.env.MAPILLARY_ACCESS_TOKEN ?? env.MAPILLARY_ACCESS_TOKEN;
if (!token) throw new Error('MAPILLARY_ACCESS_TOKEN missing from .env');
return token;
}
/** Web-mercator tile containing a coordinate. */
function tileFor(lng, lat, zoom) {
const scale = 2 ** zoom;
const rad = (lat * Math.PI) / 180;
return {
x: Math.floor(((lng + 180) / 360) * scale),
y: Math.floor(((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * scale),
};
}
/** Every tile covering a bbox at one zoom. */
function tilesForBbox([west, south, east, north], zoom) {
const topLeft = tileFor(west, north, zoom);
const bottomRight = tileFor(east, south, zoom);
const tiles = [];
for (let x = topLeft.x; x <= bottomRight.x; x++) {
for (let y = topLeft.y; y <= bottomRight.y; y++) tiles.push({ x, y, z: zoom });
}
return tiles;
}
/**
* Fetch one tile and return its panorama points.
* @param {Object} tile Tile coordinates.
* @param {string} token Mapillary access token.
* @returns {Promise<{points: Object[], bytes: number}>}
*/
async function fetchPanoPoints(tile, token) {
const url = `${TILE_URL}/${tile.z}/${tile.x}/${tile.y}?access_token=${token}`;
let response = null;
let lastError = null;
for (let attempt = 1; attempt <= TILE_RETRIES; attempt++) {
try {
response = await fetch(url, { signal: AbortSignal.timeout(120000) });
if (response.ok) break;
// 429 and the other 4xx the tile API uses for an exhausted quota are
// worth waiting on; a 404 is not, but retrying three times costs little.
lastError = `HTTP ${response.status}`;
response = null;
} catch (error) {
lastError = error.name === 'TimeoutError' ? 'timeout' : String(error.message).slice(0, 80);
}
if (attempt < TILE_RETRIES) await sleep(RETRY_BACKOFF_MS * attempt);
}
if (!response) throw new Error(`tile ${tile.x}/${tile.y}: ${lastError}`);
const buffer = Buffer.from(await response.arrayBuffer());
const layer = new VectorTile(new PbfReader(buffer)).layers.image;
if (!layer) return { points: [], bytes: buffer.length };
const points = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
if (!feature.properties.is_pano) continue;
const [lng, lat] = feature.toGeoJSON(tile.x, tile.y, tile.z).geometry.coordinates;
points.push({
id: String(feature.properties.id),
lat: Number(lat.toFixed(6)),
lng: Number(lng.toFixed(6)),
capturedAt: feature.properties.captured_at ?? null,
});
}
return { points, bytes: buffer.length };
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Run tasks with a fixed number in flight. */
async function inBatches(items, size, worker) {
const results = [];
for (let i = 0; i < items.length; i += size) {
results.push(...(await Promise.all(items.slice(i, i + size).map(worker))));
}
return results;
}
/**
* Keep one panorama per grid cell so locations are spread, not clustered.
* @param {Object[]} points Candidate panoramas.
* @returns {Object[]} Thinned set.
*/
function thinToGrid(points) {
const kept = new Map();
for (const point of points) {
const cell = `${Math.floor(point.lat / GRID_DEG)}:${Math.floor(point.lng / GRID_DEG)}`;
// Prefer the most recent capture in a cell; newer imagery tends to be better.
const existing = kept.get(cell);
if (!existing || (point.capturedAt ?? 0) > (existing.capturedAt ?? 0)) {
kept.set(cell, point);
}
}
return [...kept.values()];
}
async function buildCity(code, token) {
const slug = code.toLowerCase();
// Boundaries are grouped by province: src/data/boundaries/<province>/<code>.json
const boundaryPath = `${BOUNDARY_DIR}/${slug}/${slug}.json`;
if (!existsSync(boundaryPath)) {
throw new Error(`${code}: missing ${boundaryPath} — run build-region-boundaries.mjs first`);
}
const boundary = JSON.parse(readFileSync(boundaryPath, 'utf8'));
const bbox = boundary.properties.bbox ?? turf.bbox(boundary);
const tiles = tilesForBbox(bbox, ZOOM);
tilesRequested += tiles.length;
console.log(
`\n${code} — ${boundary.properties.name}: ${tiles.length} tiles at z${ZOOM} ` +
`(${tilesRequested} this run, of 50,000/day)`
);
let done = 0;
let totalBytes = 0;
let failed = 0;
const raw = [];
await inBatches(tiles, CONCURRENCY, async (tile) => {
try {
const { points, bytes } = await fetchPanoPoints(tile, token);
raw.push(...points);
totalBytes += bytes;
} catch (error) {
failed++;
console.warn(` ${error.message}`);
}
done++;
if (done % 5 === 0 || done === tiles.length) {
process.stdout.write(
`\r ${done}/${tiles.length} tiles, ${raw.length} panoramas, ` +
`${(totalBytes / 1e6).toFixed(0)} MB `
);
}
});
process.stdout.write('\n');
if (failed > 0) {
// Writing here would produce a snapshot with holes that nothing downstream
// could distinguish from genuinely absent coverage.
throw new Error(
`${code}: ${failed} of ${tiles.length} tiles could not be fetched, so the ` +
'index would be incomplete. Nothing written. If this is the daily tile ' +
'quota (50,000/day), wait and re-run.'
);
}
// Tiles cover the bbox, not the city, so drop anything outside the outline.
const inside = raw.filter((p) =>
turf.booleanPointInPolygon(turf.point([p.lng, p.lat]), boundary)
);
console.log(` ${inside.length} inside the boundary (of ${raw.length})`);
let panos = thinToGrid(inside);
console.log(` ${panos.length} after grid thinning at ${GRID_DEG} deg`);
if (panos.length > MAX_PER_CITY) {
// Shuffle before truncating, so a cap does not bias towards one corner.
for (let i = panos.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[panos[i], panos[j]] = [panos[j], panos[i]];
}
panos = panos.slice(0, MAX_PER_CITY);
console.log(` capped to ${MAX_PER_CITY}`);
}
if (panos.length === 0) throw new Error(`${code}: no panoramas found`);
panos.sort((a, b) => a.lat - b.lat || a.lng - b.lng);
panos = panos.map(({ id, lat, lng }) => ({ id, lat, lng }));
// Assign districts here, through the same module scripts/assign-pano-districts.mjs
// uses. An index written without them would still load, but every leaf draw
// would find an empty bucket and every province draw would credit the
// province instead of a district -- silently, and forever.
const leaves = (REGIONS[code].children ?? []).filter((leaf) => REGIONS[leaf].bbox);
const { assignments, counts, cells, stranded, unassigned, worstStrandedKm } = assignPanos(
panos,
leaves
);
const districtIndex = Object.fromEntries(leaves.map((leaf, i) => [leaf, i]));
console.log(
` ${leaves.length} districts, ${stranded} stranded (worst ${worstStrandedKm}km)`
);
const header = {
code,
name: boundary.properties.name,
center: boundary.properties.center,
bbox: boundary.properties.bbox,
count: panos.length,
districts: leaves,
districtCounts: counts,
districtCells: cells,
stranded,
worstStrandedKm,
unassigned,
source: `mapillary vector tiles z${ZOOM}`,
grid: GRID_DEG,
generatedAt: new Date().toISOString(),
};
// Written by hand rather than through JSON.stringify's indent option: one
// panorama per line keeps a six-figure list diffable and editable, where a
// fully indented file would run to millions of lines.
const body =
Object.entries(header)
.map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)},`)
.join('\n') +
'\n "panos": [\n' +
panos
.map((p, i) => {
const d = assignments[i];
const suffix = d === null || d === undefined ? '' : `,"d":${districtIndex[d]}`;
return ` {"id":${JSON.stringify(p.id)},"lat":${p.lat},"lng":${p.lng}${suffix}}`;
})
.join(',\n') +
'\n ]\n';
mkdirSync(OUT_DIR, { recursive: true });
const path = `${OUT_DIR}/${code.toLowerCase()}.json`;
writeFileSync(path, `{\n${body}}\n`);
const mb = (statSync(path).size / 1e6).toFixed(1);
console.log(` -> ${path} ${panos.length} panoramas, ${mb} MB`);
}
let tilesRequested = 0;
const token = loadToken();
const requested = process.argv.slice(2);
const PROVINCES = Object.keys(REGIONS).filter((code) => REGIONS[code].level === 'province');
const codes = requested.length ? requested : PROVINCES;
// Validated before the loop. A stray code -- 'DL' was a province two commits
// ago -- would otherwise spend ~2,800 tile requests against a 50,000/day cap
// and write an artifact the seed script then refuses as not-a-province.
const unknown = codes.filter((code) => !PROVINCES.includes(code));
if (unknown.length > 0) {
throw new Error(
`Not provinces: ${unknown.join(', ')}. ` +
`Indexes are built per province: ${PROVINCES.join(', ')}.`
);
}
for (const code of codes) {
await buildCity(code, token);
}
console.log('\ndone. Next: node scripts/assign-pano-districts.mjs, then node scripts/seed-pano-db.mjs');