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.
This commit is contained in:
tiennm99 committed 2026-09-21 16:21:14 +07:00
1 parent 2297971851
commit 2bd5e4fbf2
9 files changed
+599 -214

No files matched your search

+8 -10
View File
@@ -12,6 +12,11 @@
// 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';
@@ -20,6 +25,7 @@ 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;
@@ -46,16 +52,8 @@ const GRID_DEG = 0.0003;
const MAX_PER_CITY = Infinity;
function loadToken() {
const env = Object.fromEntries(
readFileSync('.env', 'utf8')
.split(/\r?\n/)
.filter((line) => line && !line.startsWith('#') && line.includes('='))
.map((line) => {
const at = line.indexOf('=');
return [line.slice(0, at).trim(), line.slice(at + 1).trim()];
})
);
const token = env.MAPILLARY_ACCESS_TOKEN;
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;
}
+12 -153
View File
@@ -30,6 +30,7 @@ import { dirname } from 'node:path';
import * as turf from '@turf/turf';
import { BOUNDARY_DIR as OUT_DIR, TREE_DIR } from './lib/paths.mjs';
import { boundaryEntries, writeBarrelFile } from './lib/barrel.mjs';
import { REGION_CONFIG } from './lib/region-config.mjs';
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
// Nominatim asks for a real identifier and at most one request per second.
@@ -49,148 +50,6 @@ const REQUEST_GAP_MS = 1100;
// covered along its edges, put 2.53% of its panoramas in that band at ~55m.
const LEAF_TOLERANCE = 0.0001; // ~11m
// The region tree. One entry per node.
//
// level 'country' | 'province' | 'district'
// query Nominatim free-text lookup. Leaves only; a province is the union
// of its children and the country has no polygon at all.
// center Optional override. The computed centre of mass of an irregular
// outline can land somewhere no one associates with the place, so
// the five original entry points keep their hand-picked centres.
// nameVi The accented Vietnamese name, shown to players. Provinces state
// it; a leaf's is derived from its query (see vietnameseName) and
// only needs stating where the derivation would mislead.
//
// To add a province: add its node and its leaves here, run this script, then
// scripts/build-pano-index.mjs and scripts/assign-pano-districts.mjs. No
// application code changes -- every screen reads the generated tree. The
// Coverage section of docs/project-overview.md explains what each kind of
// absent coverage means and which one is actually a defect.
const REGIONS = {
VN: { name: 'Vietnam', level: 'country' },
// -- Ha Noi ---------------------------------------------------------------
// Not merged in 2025, but split here into its 30 pre-2025 units.
HN: { name: 'Ha Noi', nameVi: 'Hà Nội', level: 'province', parent: 'VN', center: [21.0285, 105.8542], legacyBbox: [105.28896, 20.56452, 106.02004, 21.38542] },
'HN-BADINH': { name: 'Ba Dinh', level: 'district', parent: 'HN', query: 'Quận Ba Đình, Hà Nội, Việt Nam' },
'HN-HOANKIEM': { name: 'Hoan Kiem', level: 'district', parent: 'HN', query: 'Quận Hoàn Kiếm, Hà Nội, Việt Nam' },
'HN-TAYHO': { name: 'Tay Ho', level: 'district', parent: 'HN', query: 'Quận Tây Hồ, Hà Nội, Việt Nam' },
'HN-LONGBIEN': { name: 'Long Bien', level: 'district', parent: 'HN', query: 'Quận Long Biên, Hà Nội, Việt Nam' },
'HN-CAUGIAY': { name: 'Cau Giay', level: 'district', parent: 'HN', query: 'Quận Cầu Giấy, Hà Nội, Việt Nam' },
'HN-DONGDA': { name: 'Dong Da', level: 'district', parent: 'HN', query: 'Quận Đống Đa, Hà Nội, Việt Nam' },
'HN-HAIBATRUNG': { name: 'Hai Ba Trung', level: 'district', parent: 'HN', query: 'Quận Hai Bà Trưng, Hà Nội, Việt Nam' },
'HN-HOANGMAI': { name: 'Hoang Mai', level: 'district', parent: 'HN', query: 'Quận Hoàng Mai, Hà Nội, Việt Nam' },
'HN-THANHXUAN': { name: 'Thanh Xuan', level: 'district', parent: 'HN', query: 'Quận Thanh Xuân, Hà Nội, Việt Nam' },
'HN-BACTULIEM': { name: 'Bac Tu Liem', level: 'district', parent: 'HN', query: 'Quận Bắc Từ Liêm, Hà Nội, Việt Nam' },
'HN-NAMTULIEM': { name: 'Nam Tu Liem', level: 'district', parent: 'HN', query: 'Quận Nam Từ Liêm, Hà Nội, Việt Nam' },
'HN-HADONG': { name: 'Ha Dong', level: 'district', parent: 'HN', query: 'Quận Hà Đông, Hà Nội, Việt Nam' },
'HN-SONTAY': { name: 'Son Tay', level: 'district', parent: 'HN', query: 'Thị xã Sơn Tây, Hà Nội, Việt Nam' },
'HN-BAVI': { name: 'Ba Vi', level: 'district', parent: 'HN', query: 'Huyện Ba Vì, Hà Nội, Việt Nam' },
'HN-CHUONGMY': { name: 'Chuong My', level: 'district', parent: 'HN', query: 'Huyện Chương Mỹ, Hà Nội, Việt Nam' },
'HN-DANPHUONG': { name: 'Dan Phuong', level: 'district', parent: 'HN', query: 'Huyện Đan Phượng, Hà Nội, Việt Nam' },
'HN-DONGANH': { name: 'Dong Anh', level: 'district', parent: 'HN', query: 'Huyện Đông Anh, Hà Nội, Việt Nam' },
'HN-GIALAM': { name: 'Gia Lam', level: 'district', parent: 'HN', query: 'Huyện Gia Lâm, Hà Nội, Việt Nam' },
'HN-HOAIDUC': { name: 'Hoai Duc', level: 'district', parent: 'HN', query: 'Huyện Hoài Đức, Hà Nội, Việt Nam' },
'HN-MELINH': { name: 'Me Linh', level: 'district', parent: 'HN', query: 'Huyện Mê Linh, Hà Nội, Việt Nam' },
'HN-MYDUC': { name: 'My Duc', level: 'district', parent: 'HN', query: 'Huyện Mỹ Đức, Hà Nội, Việt Nam' },
'HN-PHUXUYEN': { name: 'Phu Xuyen', level: 'district', parent: 'HN', query: 'Huyện Phú Xuyên, Hà Nội, Việt Nam' },
'HN-PHUCTHO': { name: 'Phuc Tho', level: 'district', parent: 'HN', query: 'Huyện Phúc Thọ, Hà Nội, Việt Nam' },
'HN-QUOCOAI': { name: 'Quoc Oai', level: 'district', parent: 'HN', query: 'Huyện Quốc Oai, Hà Nội, Việt Nam' },
'HN-SOCSON': { name: 'Soc Son', level: 'district', parent: 'HN', query: 'Huyện Sóc Sơn, Hà Nội, Việt Nam' },
'HN-THACHTHAT': { name: 'Thach That', level: 'district', parent: 'HN', query: 'Huyện Thạch Thất, Hà Nội, Việt Nam' },
'HN-THANHOAI': { name: 'Thanh Oai', level: 'district', parent: 'HN', query: 'Huyện Thanh Oai, Hà Nội, Việt Nam' },
'HN-THANHTRI': { name: 'Thanh Tri', level: 'district', parent: 'HN', query: 'Huyện Thanh Trì, Hà Nội, Việt Nam' },
'HN-THUONGTIN': { name: 'Thuong Tin', level: 'district', parent: 'HN', query: 'Huyện Thường Tín, Hà Nội, Việt Nam' },
'HN-UNGHOA': { name: 'Ung Hoa', level: 'district', parent: 'HN', query: 'Huyện Ứng Hòa, Hà Nội, Việt Nam' },
// -- Ho Chi Minh ----------------------------------------------------------
// Districts 2 and 9 are absent on purpose: both merged into Thu Duc in 2021.
TPHCM: { name: 'Ho Chi Minh', nameVi: 'Hồ Chí Minh', level: 'province', parent: 'VN', center: [10.8231, 106.6297], legacyBbox: [106.46356, 10.35828, 107.02758, 10.92934] },
'TPHCM-Q1': { name: 'District 1', level: 'district', parent: 'TPHCM', query: 'Quận 1, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q3': { name: 'District 3', level: 'district', parent: 'TPHCM', query: 'Quận 3, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q4': { name: 'District 4', level: 'district', parent: 'TPHCM', query: 'Quận 4, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q5': { name: 'District 5', level: 'district', parent: 'TPHCM', query: 'Quận 5, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q6': { name: 'District 6', level: 'district', parent: 'TPHCM', query: 'Quận 6, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q7': { name: 'District 7', level: 'district', parent: 'TPHCM', query: 'Quận 7, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q8': { name: 'District 8', level: 'district', parent: 'TPHCM', query: 'Quận 8, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q10': { name: 'District 10', level: 'district', parent: 'TPHCM', query: 'Quận 10, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q11': { name: 'District 11', level: 'district', parent: 'TPHCM', query: 'Quận 11, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q12': { name: 'District 12', level: 'district', parent: 'TPHCM', query: 'Quận 12, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-BINHTAN': { name: 'Binh Tan', level: 'district', parent: 'TPHCM', query: 'Quận Bình Tân, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-BINHTHANH': { name: 'Binh Thanh', level: 'district', parent: 'TPHCM', query: 'Quận Bình Thạnh, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-GOVAP': { name: 'Go Vap', level: 'district', parent: 'TPHCM', query: 'Quận Gò Vấp, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-PHUNHUAN': { name: 'Phu Nhuan', level: 'district', parent: 'TPHCM', query: 'Quận Phú Nhuận, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-TANBINH': { name: 'Tan Binh', level: 'district', parent: 'TPHCM', query: 'Quận Tân Bình, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-TANPHU': { name: 'Tan Phu', level: 'district', parent: 'TPHCM', query: 'Quận Tân Phú, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-THUDUC': { name: 'Thu Duc', level: 'district', parent: 'TPHCM', query: 'Thành phố Thủ Đức, Việt Nam' },
'TPHCM-BINHCHANH': { name: 'Binh Chanh', level: 'district', parent: 'TPHCM', query: 'Huyện Bình Chánh, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-CANGIO': { name: 'Can Gio', level: 'district', parent: 'TPHCM', query: 'Huyện Cần Giờ, Thành phố Hồ Chí Minh, Việt Nam' },
// Cu Chi did not resolve for the pre-tree build either, which is why the old
// tphcm.json carried "missingParts": 1 and no panorama in the index sits in
// Cu Chi. Kept here so the gap stays visible rather than silently dropped.
'TPHCM-CUCHI': { name: 'Cu Chi', level: 'district', parent: 'TPHCM', query: 'Huyện Củ Chi, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-HOCMON': { name: 'Hoc Mon', level: 'district', parent: 'TPHCM', query: 'Huyện Hóc Môn, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-NHABE': { name: 'Nha Be', level: 'district', parent: 'TPHCM', query: 'Huyện Nhà Bè, Thành phố Hồ Chí Minh, Việt Nam' },
// -- Da Nang --------------------------------------------------------------
// Hoang Sa is administratively part of Da Nang but is a disputed offshore
// island group with no street imagery, so it is deliberately left out.
DN: { name: 'Da Nang', nameVi: 'Đà Nẵng', level: 'province', parent: 'VN', center: [16.0544, 108.2022], legacyBbox: [107.81854, 15.91799, 108.33864, 16.2255] },
'DN-HAICHAU': { name: 'Hai Chau', level: 'district', parent: 'DN', query: 'Quận Hải Châu, Đà Nẵng, Việt Nam' },
'DN-THANHKHE': { name: 'Thanh Khe', level: 'district', parent: 'DN', query: 'Quận Thanh Khê, Đà Nẵng, Việt Nam' },
'DN-SONTRA': { name: 'Son Tra', level: 'district', parent: 'DN', query: 'Quận Sơn Trà, Đà Nẵng, Việt Nam' },
'DN-NGUHANHSON': { name: 'Ngu Hanh Son', level: 'district', parent: 'DN', query: 'Quận Ngũ Hành Sơn, Đà Nẵng, Việt Nam' },
'DN-LIENCHIEU': { name: 'Lien Chieu', level: 'district', parent: 'DN', query: 'Quận Liên Chiểu, Đà Nẵng, Việt Nam' },
'DN-CAMLE': { name: 'Cam Le', level: 'district', parent: 'DN', query: 'Quận Cẩm Lệ, Đà Nẵng, Việt Nam' },
'DN-HOAVANG': { name: 'Hoa Vang', level: 'district', parent: 'DN', query: 'Huyện Hòa Vang, Đà Nẵng, Việt Nam' },
// -- Lam Dong / Long An ---------------------------------------------------
// DL and DH keep bare codes because their leaderboard keys already exist
// under those names. Every leaf added since is prefixed with its province.
LD: { name: 'Lam Dong', nameVi: 'Lâm Đồng', level: 'province', parent: 'VN', partialCoverage: 'one town covered', legacyBbox: [108.31521, 11.80798, 108.5944, 12.00855] },
DL: { name: 'Da Lat', level: 'district', parent: 'LD', query: 'Thành phố Đà Lạt, Việt Nam', center: [11.9404, 108.4583] },
// Long An covers three of its districts now, so legacyBbox is the whole
// pre-2025 province rather than the extent of Duc Hoa alone -- a leaf
// lookup is rejected when its centre falls outside the parent box, and
// Ben Luc and Can Giuoc both sit well south of Duc Hoa.
LA: { name: 'Long An', nameVi: 'Long An', level: 'province', parent: 'VN', partialCoverage: 'three districts covered', legacyBbox: [105.45, 10.35, 106.85, 11.1] },
DH: { name: 'Duc Hoa', level: 'district', parent: 'LA', query: 'Đức Hòa, Việt Nam', center: [10.8888, 106.3825] },
'LA-BENLUC': { name: 'Ben Luc', level: 'district', parent: 'LA', query: 'Bến Lức, Việt Nam' },
'LA-CANGIUOC': { name: 'Can Giuoc', level: 'district', parent: 'LA', query: 'Cần Giuộc, Việt Nam' },
// -- Dong Nai -------------------------------------------------------------
// Code DNA, not DN: Da Nang holds that one. Long Khanh is left out -- its
// panoramas fall in only two 1.1km cells, one short of playable.
DNA: { name: 'Dong Nai', nameVi: 'Đồng Nai', level: 'province', parent: 'VN', partialCoverage: 'six districts covered', legacyBbox: [106.6, 10.45, 107.85, 11.6] },
'DNA-BIENHOA': { name: 'Bien Hoa', level: 'district', parent: 'DNA', query: 'Thành phố Biên Hòa, Đồng Nai, Việt Nam' },
'DNA-NHONTRACH': { name: 'Nhon Trach', level: 'district', parent: 'DNA', query: 'Nhơn Trạch, Đồng Nai, Việt Nam' },
'DNA-LONGTHANH': { name: 'Long Thanh', level: 'district', parent: 'DNA', query: 'Long Thành, Đồng Nai, Việt Nam' },
'DNA-TRANGBOM': { name: 'Trang Bom', level: 'district', parent: 'DNA', query: 'Trảng Bom, Đồng Nai, Việt Nam' },
'DNA-VINHCUU': { name: 'Vinh Cuu', level: 'district', parent: 'DNA', query: 'Vĩnh Cửu, Đồng Nai, Việt Nam' },
'DNA-THONGNHAT': { name: 'Thong Nhat', level: 'district', parent: 'DNA', query: 'Thống Nhất, Đồng Nai, Việt Nam' },
// -- Binh Duong -----------------------------------------------------------
// Ben Cat is deliberately absent despite having coverage: its imagery was
// captured in 2016, and a decade-old streetscape makes a worse round than
// no round at all.
BD: { name: 'Binh Duong', nameVi: 'Bình Dương', level: 'province', parent: 'VN', partialCoverage: 'three cities covered', legacyBbox: [106.35, 10.85, 107.2, 11.85] },
'BD-DIAN': { name: 'Di An', level: 'district', parent: 'BD', query: 'Dĩ An, Việt Nam' },
'BD-THUANAN': { name: 'Thuan An', level: 'district', parent: 'BD', query: 'Thuận An, Việt Nam' },
'BD-THUDAUMOT': { name: 'Thu Dau Mot', level: 'district', parent: 'BD', query: 'Thủ Dầu Một, Việt Nam' },
// -- Thanh Hoa / Quang Nam ------------------------------------------------
// Two provinces carried by a couple of towns each, the same shape as Lam
// Dong. Both are here for variety: everything else added alongside them is
// the industrial ring around Ho Chi Minh City, which all looks alike.
TH: { name: 'Thanh Hoa', nameVi: 'Thanh Hóa', level: 'province', parent: 'VN', partialCoverage: 'two towns covered', legacyBbox: [104.35, 19.3, 106.1, 20.65] },
'TH-THANHHOA': { name: 'Thanh Hoa City', nameVi: 'TP. Thanh Hóa', level: 'district', parent: 'TH', query: 'Thành phố Thanh Hóa, Thanh Hóa, Việt Nam' },
'TH-SAMSON': { name: 'Sam Son', level: 'district', parent: 'TH', query: 'Sầm Sơn, Thanh Hóa, Việt Nam' },
QNA: { name: 'Quang Nam', nameVi: 'Quảng Nam', level: 'province', parent: 'VN', partialCoverage: 'one town covered', legacyBbox: [107.1, 14.9, 108.75, 16.2] },
'QNA-HOIAN': { name: 'Hoi An', level: 'district', parent: 'QNA', query: 'Hội An, Việt Nam' },
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
@@ -202,7 +61,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
* @returns {string} Directory name, lowercased province code.
*/
function folderFor(code) {
const config = REGIONS[code];
const config = REGION_CONFIG[code];
// The country has no polygon of its own, so it has no home directory either.
if (config.level === 'country') return null;
const province = config.level === 'province' ? code : config.parent;
@@ -221,7 +80,7 @@ function fileFor(code) {
/** Direct children of a node, in declaration order. */
function childrenOf(code) {
return Object.keys(REGIONS).filter((key) => REGIONS[key].parent === code);
return Object.keys(REGION_CONFIG).filter((key) => REGION_CONFIG[key].parent === code);
}
/**
@@ -285,7 +144,7 @@ async function lookupArea(query, box) {
* @returns {Object} The written feature.
*/
function writeBoundary(code, geometry, extra) {
const config = REGIONS[code];
const config = REGION_CONFIG[code];
const feature = turf.feature(geometry);
const bbox = turf.bbox(feature).map((n) => Number(n.toFixed(5)));
const centroid = turf.centerOfMass(feature).geometry.coordinates;
@@ -321,8 +180,8 @@ function writeBoundary(code, geometry, extra) {
* @returns {Promise<boolean>} True when it resolved.
*/
async function buildLeaf(code) {
const config = REGIONS[code];
const box = REGIONS[config.parent]?.legacyBbox;
const config = REGION_CONFIG[code];
const box = REGION_CONFIG[config.parent]?.legacyBbox;
// Qualified first, then bare. See bareForm() for why one form is not enough.
const forms = [config.query];
@@ -473,7 +332,7 @@ function writeBoundaryBarrel() {
* Nam" names the place as "Hoàn Kiếm" once the administrative type is dropped.
* The type stays when the rest is only a number ("Quận 7"), because that IS the
* name. The country has none: it is "Vietnam" in an English interface.
* @param {Object} config A REGIONS entry.
* @param {Object} config A REGION_CONFIG entry.
* @returns {string|undefined}
*/
function vietnameseName(config) {
@@ -485,7 +344,7 @@ function vietnameseName(config) {
}
function writeTree(resolvedState) {
const nodes = Object.entries(REGIONS).map(([code, config]) => {
const nodes = Object.entries(REGION_CONFIG).map(([code, config]) => {
const state = resolvedState[code] ?? {};
const node = {
code,
@@ -533,14 +392,14 @@ const args = process.argv.slice(2);
const regenerate = args.includes('--regenerate');
const only = args.filter((arg) => !arg.startsWith('--'));
const provinces = Object.keys(REGIONS).filter((code) => REGIONS[code].level === 'province');
const provinces = Object.keys(REGION_CONFIG).filter((code) => REGION_CONFIG[code].level === 'province');
const targets = only.length ? provinces.filter((code) => only.includes(code)) : provinces;
const resolvedState = {};
let unresolved = 0;
for (const province of targets) {
console.log(`\n${province} — ${REGIONS[province].name}`);
console.log(`\n${province} — ${REGION_CONFIG[province].name}`);
for (const leaf of childrenOf(province)) {
const ok = regenerate
? existsSync(`${OUT_DIR}/${fileFor(leaf)}`)
@@ -558,7 +417,7 @@ for (const province of targets) {
// Read back what landed on disk, so the tree records the real extents rather
// than what the build hoped for.
for (const code of Object.keys(REGIONS)) {
for (const code of Object.keys(REGION_CONFIG)) {
const relative = fileFor(code);
if (!relative) continue;
const path = `${OUT_DIR}/${relative}`;
@@ -595,6 +454,6 @@ const codes = writeBoundaryBarrel();
writeTree(resolvedState);
console.log(`\nbarrel: ${codes.length} boundaries`);
console.log(`tree: ${Object.keys(REGIONS).length} nodes -> ${TREE_DIR}/index.js`);
console.log(`tree: ${Object.keys(REGION_CONFIG).length} nodes -> ${TREE_DIR}/index.js`);
if (unresolved) console.log(`unresolved leaves: ${unresolved} (recorded, not fatal)`);
console.log('done');
+3 -18
View File
@@ -8,26 +8,11 @@
// board is dumped whole, member and score, under its logical key, so the file
// can be replayed with ZADD against any prefix.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { writeFileSync } from 'node:fs';
import { getUpstash, scanKeys, zRangeWithScores } from '../src/lib/upstash.js';
import { applyEnvFile } from './lib/env.mjs';
/** Read .env the way the other scripts do, without pulling in a dependency. */
function loadEnvFile() {
if (!existsSync('.env')) return {};
return Object.fromEntries(
readFileSync('.env', 'utf8')
.split(/\r?\n/)
.filter((line) => line && !line.startsWith('#') && line.includes('='))
.map((line) => {
const at = line.indexOf('=');
return [line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"(.*)"$/, '$1')];
})
);
}
for (const [key, value] of Object.entries(loadEnvFile())) {
process.env[key] ??= value;
}
applyEnvFile();
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const outPath = process.argv[2] ?? `leaderboard-backup-${stamp}.json`;
+59
View File
@@ -0,0 +1,59 @@
// Parse and apply a .env file, the way every offline pipeline script needs to:
// none of them may assume `next dev`'s automatic .env loading, since they run
// standalone under plain `node`.
//
// One copy, because four scripts used to carry their own, and one of those
// copies had silently drifted: it did not strip the quotes `vercel env pull`
// wraps values in, so a pulled DATABASE_URL would fail to dial with the quotes
// still attached.
import { readFileSync, existsSync } from 'node:fs';
/**
* Parse .env-format text into a plain key/value map.
*
* Blank lines and full-line comments are skipped. A value quoted with `"..."`
* (as `vercel env pull` writes) has its quotes stripped; unquoted values keep
* an embedded `=` intact because only the first `=` on the line splits key
* from value.
* @param {string} text Raw .env file contents.
* @returns {Object<string, string>} Parsed key/value map.
*/
export function parseEnv(text) {
return Object.fromEntries(
text
.split(/\r?\n/)
.filter((line) => line && !line.startsWith('#') && line.includes('='))
.map((line) => {
const at = line.indexOf('=');
const key = line.slice(0, at).trim();
const value = line.slice(at + 1).trim().replace(/^"(.*)"$/, '$1');
return [key, value];
})
);
}
/**
* Read and parse a .env file.
* @param {string} path File path, defaults to '.env' in the process cwd.
* @returns {Object<string, string>} Parsed key/value map, or {} when the file
* is absent -- every caller treats a missing .env as "use the environment
* instead", not as an error.
*/
export function loadEnvFile(path) {
const target = path ?? '.env';
if (!existsSync(target)) return {};
return parseEnv(readFileSync(target, 'utf8'));
}
/**
* Load a .env file into process.env, without overriding a variable the
* environment already set.
* @param {string} path File path, defaults to '.env' in the process cwd.
* @returns {void}
*/
export function applyEnvFile(path) {
for (const [key, value] of Object.entries(loadEnvFile(path))) {
process.env[key] ??= value;
}
}
+147
View File
@@ -0,0 +1,147 @@
// Input data for scripts/build-region-boundaries.mjs: the region tree as
// hand-edited config, one entry per node.
//
// Named REGION_CONFIG rather than REGIONS to stay distinct from the GENERATED
// REGIONS tree at src/data/regions/index.js, which every other script imports
// -- the two used to share a name across different modules, which reads as
// the same object until you check which file you are in.
//
// level 'country' | 'province' | 'district'
// query Nominatim free-text lookup. Leaves only; a province is the union
// of its children and the country has no polygon at all.
// center Optional override. The computed centre of mass of an irregular
// outline can land somewhere no one associates with the place, so
// the five original entry points keep their hand-picked centres.
// nameVi The accented Vietnamese name, shown to players. Provinces state
// it; a leaf's is derived from its query (see vietnameseName) and
// only needs stating where the derivation would mislead.
//
// To add a province: add its node and its leaves here, run this script, then
// scripts/build-pano-index.mjs and scripts/assign-pano-districts.mjs. No
// application code changes -- every screen reads the generated tree. The
// Coverage section of docs/project-overview.md explains what each kind of
// absent coverage means and which one is actually a defect.
export const REGION_CONFIG = {
VN: { name: 'Vietnam', level: 'country' },
// -- Ha Noi ---------------------------------------------------------------
// Not merged in 2025, but split here into its 30 pre-2025 units.
HN: { name: 'Ha Noi', nameVi: 'Hà Nội', level: 'province', parent: 'VN', center: [21.0285, 105.8542], legacyBbox: [105.28896, 20.56452, 106.02004, 21.38542] },
'HN-BADINH': { name: 'Ba Dinh', level: 'district', parent: 'HN', query: 'Quận Ba Đình, Hà Nội, Việt Nam' },
'HN-HOANKIEM': { name: 'Hoan Kiem', level: 'district', parent: 'HN', query: 'Quận Hoàn Kiếm, Hà Nội, Việt Nam' },
'HN-TAYHO': { name: 'Tay Ho', level: 'district', parent: 'HN', query: 'Quận Tây Hồ, Hà Nội, Việt Nam' },
'HN-LONGBIEN': { name: 'Long Bien', level: 'district', parent: 'HN', query: 'Quận Long Biên, Hà Nội, Việt Nam' },
'HN-CAUGIAY': { name: 'Cau Giay', level: 'district', parent: 'HN', query: 'Quận Cầu Giấy, Hà Nội, Việt Nam' },
'HN-DONGDA': { name: 'Dong Da', level: 'district', parent: 'HN', query: 'Quận Đống Đa, Hà Nội, Việt Nam' },
'HN-HAIBATRUNG': { name: 'Hai Ba Trung', level: 'district', parent: 'HN', query: 'Quận Hai Bà Trưng, Hà Nội, Việt Nam' },
'HN-HOANGMAI': { name: 'Hoang Mai', level: 'district', parent: 'HN', query: 'Quận Hoàng Mai, Hà Nội, Việt Nam' },
'HN-THANHXUAN': { name: 'Thanh Xuan', level: 'district', parent: 'HN', query: 'Quận Thanh Xuân, Hà Nội, Việt Nam' },
'HN-BACTULIEM': { name: 'Bac Tu Liem', level: 'district', parent: 'HN', query: 'Quận Bắc Từ Liêm, Hà Nội, Việt Nam' },
'HN-NAMTULIEM': { name: 'Nam Tu Liem', level: 'district', parent: 'HN', query: 'Quận Nam Từ Liêm, Hà Nội, Việt Nam' },
'HN-HADONG': { name: 'Ha Dong', level: 'district', parent: 'HN', query: 'Quận Hà Đông, Hà Nội, Việt Nam' },
'HN-SONTAY': { name: 'Son Tay', level: 'district', parent: 'HN', query: 'Thị xã Sơn Tây, Hà Nội, Việt Nam' },
'HN-BAVI': { name: 'Ba Vi', level: 'district', parent: 'HN', query: 'Huyện Ba Vì, Hà Nội, Việt Nam' },
'HN-CHUONGMY': { name: 'Chuong My', level: 'district', parent: 'HN', query: 'Huyện Chương Mỹ, Hà Nội, Việt Nam' },
'HN-DANPHUONG': { name: 'Dan Phuong', level: 'district', parent: 'HN', query: 'Huyện Đan Phượng, Hà Nội, Việt Nam' },
'HN-DONGANH': { name: 'Dong Anh', level: 'district', parent: 'HN', query: 'Huyện Đông Anh, Hà Nội, Việt Nam' },
'HN-GIALAM': { name: 'Gia Lam', level: 'district', parent: 'HN', query: 'Huyện Gia Lâm, Hà Nội, Việt Nam' },
'HN-HOAIDUC': { name: 'Hoai Duc', level: 'district', parent: 'HN', query: 'Huyện Hoài Đức, Hà Nội, Việt Nam' },
'HN-MELINH': { name: 'Me Linh', level: 'district', parent: 'HN', query: 'Huyện Mê Linh, Hà Nội, Việt Nam' },
'HN-MYDUC': { name: 'My Duc', level: 'district', parent: 'HN', query: 'Huyện Mỹ Đức, Hà Nội, Việt Nam' },
'HN-PHUXUYEN': { name: 'Phu Xuyen', level: 'district', parent: 'HN', query: 'Huyện Phú Xuyên, Hà Nội, Việt Nam' },
'HN-PHUCTHO': { name: 'Phuc Tho', level: 'district', parent: 'HN', query: 'Huyện Phúc Thọ, Hà Nội, Việt Nam' },
'HN-QUOCOAI': { name: 'Quoc Oai', level: 'district', parent: 'HN', query: 'Huyện Quốc Oai, Hà Nội, Việt Nam' },
'HN-SOCSON': { name: 'Soc Son', level: 'district', parent: 'HN', query: 'Huyện Sóc Sơn, Hà Nội, Việt Nam' },
'HN-THACHTHAT': { name: 'Thach That', level: 'district', parent: 'HN', query: 'Huyện Thạch Thất, Hà Nội, Việt Nam' },
'HN-THANHOAI': { name: 'Thanh Oai', level: 'district', parent: 'HN', query: 'Huyện Thanh Oai, Hà Nội, Việt Nam' },
'HN-THANHTRI': { name: 'Thanh Tri', level: 'district', parent: 'HN', query: 'Huyện Thanh Trì, Hà Nội, Việt Nam' },
'HN-THUONGTIN': { name: 'Thuong Tin', level: 'district', parent: 'HN', query: 'Huyện Thường Tín, Hà Nội, Việt Nam' },
'HN-UNGHOA': { name: 'Ung Hoa', level: 'district', parent: 'HN', query: 'Huyện Ứng Hòa, Hà Nội, Việt Nam' },
// -- Ho Chi Minh ----------------------------------------------------------
// Districts 2 and 9 are absent on purpose: both merged into Thu Duc in 2021.
TPHCM: { name: 'Ho Chi Minh', nameVi: 'Hồ Chí Minh', level: 'province', parent: 'VN', center: [10.8231, 106.6297], legacyBbox: [106.46356, 10.35828, 107.02758, 10.92934] },
'TPHCM-Q1': { name: 'District 1', level: 'district', parent: 'TPHCM', query: 'Quận 1, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q3': { name: 'District 3', level: 'district', parent: 'TPHCM', query: 'Quận 3, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q4': { name: 'District 4', level: 'district', parent: 'TPHCM', query: 'Quận 4, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q5': { name: 'District 5', level: 'district', parent: 'TPHCM', query: 'Quận 5, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q6': { name: 'District 6', level: 'district', parent: 'TPHCM', query: 'Quận 6, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q7': { name: 'District 7', level: 'district', parent: 'TPHCM', query: 'Quận 7, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q8': { name: 'District 8', level: 'district', parent: 'TPHCM', query: 'Quận 8, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q10': { name: 'District 10', level: 'district', parent: 'TPHCM', query: 'Quận 10, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q11': { name: 'District 11', level: 'district', parent: 'TPHCM', query: 'Quận 11, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-Q12': { name: 'District 12', level: 'district', parent: 'TPHCM', query: 'Quận 12, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-BINHTAN': { name: 'Binh Tan', level: 'district', parent: 'TPHCM', query: 'Quận Bình Tân, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-BINHTHANH': { name: 'Binh Thanh', level: 'district', parent: 'TPHCM', query: 'Quận Bình Thạnh, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-GOVAP': { name: 'Go Vap', level: 'district', parent: 'TPHCM', query: 'Quận Gò Vấp, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-PHUNHUAN': { name: 'Phu Nhuan', level: 'district', parent: 'TPHCM', query: 'Quận Phú Nhuận, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-TANBINH': { name: 'Tan Binh', level: 'district', parent: 'TPHCM', query: 'Quận Tân Bình, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-TANPHU': { name: 'Tan Phu', level: 'district', parent: 'TPHCM', query: 'Quận Tân Phú, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-THUDUC': { name: 'Thu Duc', level: 'district', parent: 'TPHCM', query: 'Thành phố Thủ Đức, Việt Nam' },
'TPHCM-BINHCHANH': { name: 'Binh Chanh', level: 'district', parent: 'TPHCM', query: 'Huyện Bình Chánh, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-CANGIO': { name: 'Can Gio', level: 'district', parent: 'TPHCM', query: 'Huyện Cần Giờ, Thành phố Hồ Chí Minh, Việt Nam' },
// Cu Chi did not resolve for the pre-tree build either, which is why the old
// tphcm.json carried "missingParts": 1 and no panorama in the index sits in
// Cu Chi. Kept here so the gap stays visible rather than silently dropped.
'TPHCM-CUCHI': { name: 'Cu Chi', level: 'district', parent: 'TPHCM', query: 'Huyện Củ Chi, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-HOCMON': { name: 'Hoc Mon', level: 'district', parent: 'TPHCM', query: 'Huyện Hóc Môn, Thành phố Hồ Chí Minh, Việt Nam' },
'TPHCM-NHABE': { name: 'Nha Be', level: 'district', parent: 'TPHCM', query: 'Huyện Nhà Bè, Thành phố Hồ Chí Minh, Việt Nam' },
// -- Da Nang --------------------------------------------------------------
// Hoang Sa is administratively part of Da Nang but is a disputed offshore
// island group with no street imagery, so it is deliberately left out.
DN: { name: 'Da Nang', nameVi: 'Đà Nẵng', level: 'province', parent: 'VN', center: [16.0544, 108.2022], legacyBbox: [107.81854, 15.91799, 108.33864, 16.2255] },
'DN-HAICHAU': { name: 'Hai Chau', level: 'district', parent: 'DN', query: 'Quận Hải Châu, Đà Nẵng, Việt Nam' },
'DN-THANHKHE': { name: 'Thanh Khe', level: 'district', parent: 'DN', query: 'Quận Thanh Khê, Đà Nẵng, Việt Nam' },
'DN-SONTRA': { name: 'Son Tra', level: 'district', parent: 'DN', query: 'Quận Sơn Trà, Đà Nẵng, Việt Nam' },
'DN-NGUHANHSON': { name: 'Ngu Hanh Son', level: 'district', parent: 'DN', query: 'Quận Ngũ Hành Sơn, Đà Nẵng, Việt Nam' },
'DN-LIENCHIEU': { name: 'Lien Chieu', level: 'district', parent: 'DN', query: 'Quận Liên Chiểu, Đà Nẵng, Việt Nam' },
'DN-CAMLE': { name: 'Cam Le', level: 'district', parent: 'DN', query: 'Quận Cẩm Lệ, Đà Nẵng, Việt Nam' },
'DN-HOAVANG': { name: 'Hoa Vang', level: 'district', parent: 'DN', query: 'Huyện Hòa Vang, Đà Nẵng, Việt Nam' },
// -- Lam Dong / Long An ---------------------------------------------------
// DL and DH keep bare codes because their leaderboard keys already exist
// under those names. Every leaf added since is prefixed with its province.
LD: { name: 'Lam Dong', nameVi: 'Lâm Đồng', level: 'province', parent: 'VN', partialCoverage: 'one town covered', legacyBbox: [108.31521, 11.80798, 108.5944, 12.00855] },
DL: { name: 'Da Lat', level: 'district', parent: 'LD', query: 'Thành phố Đà Lạt, Việt Nam', center: [11.9404, 108.4583] },
// Long An covers three of its districts now, so legacyBbox is the whole
// pre-2025 province rather than the extent of Duc Hoa alone -- a leaf
// lookup is rejected when its centre falls outside the parent box, and
// Ben Luc and Can Giuoc both sit well south of Duc Hoa.
LA: { name: 'Long An', nameVi: 'Long An', level: 'province', parent: 'VN', partialCoverage: 'three districts covered', legacyBbox: [105.45, 10.35, 106.85, 11.1] },
DH: { name: 'Duc Hoa', level: 'district', parent: 'LA', query: 'Đức Hòa, Việt Nam', center: [10.8888, 106.3825] },
'LA-BENLUC': { name: 'Ben Luc', level: 'district', parent: 'LA', query: 'Bến Lức, Việt Nam' },
'LA-CANGIUOC': { name: 'Can Giuoc', level: 'district', parent: 'LA', query: 'Cần Giuộc, Việt Nam' },
// -- Dong Nai -------------------------------------------------------------
// Code DNA, not DN: Da Nang holds that one. Long Khanh is left out -- its
// panoramas fall in only two 1.1km cells, one short of playable.
DNA: { name: 'Dong Nai', nameVi: 'Đồng Nai', level: 'province', parent: 'VN', partialCoverage: 'six districts covered', legacyBbox: [106.6, 10.45, 107.85, 11.6] },
'DNA-BIENHOA': { name: 'Bien Hoa', level: 'district', parent: 'DNA', query: 'Thành phố Biên Hòa, Đồng Nai, Việt Nam' },
'DNA-NHONTRACH': { name: 'Nhon Trach', level: 'district', parent: 'DNA', query: 'Nhơn Trạch, Đồng Nai, Việt Nam' },
'DNA-LONGTHANH': { name: 'Long Thanh', level: 'district', parent: 'DNA', query: 'Long Thành, Đồng Nai, Việt Nam' },
'DNA-TRANGBOM': { name: 'Trang Bom', level: 'district', parent: 'DNA', query: 'Trảng Bom, Đồng Nai, Việt Nam' },
'DNA-VINHCUU': { name: 'Vinh Cuu', level: 'district', parent: 'DNA', query: 'Vĩnh Cửu, Đồng Nai, Việt Nam' },
'DNA-THONGNHAT': { name: 'Thong Nhat', level: 'district', parent: 'DNA', query: 'Thống Nhất, Đồng Nai, Việt Nam' },
// -- Binh Duong -----------------------------------------------------------
// Ben Cat is deliberately absent despite having coverage: its imagery was
// captured in 2016, and a decade-old streetscape makes a worse round than
// no round at all.
BD: { name: 'Binh Duong', nameVi: 'Bình Dương', level: 'province', parent: 'VN', partialCoverage: 'three cities covered', legacyBbox: [106.35, 10.85, 107.2, 11.85] },
'BD-DIAN': { name: 'Di An', level: 'district', parent: 'BD', query: 'Dĩ An, Việt Nam' },
'BD-THUANAN': { name: 'Thuan An', level: 'district', parent: 'BD', query: 'Thuận An, Việt Nam' },
'BD-THUDAUMOT': { name: 'Thu Dau Mot', level: 'district', parent: 'BD', query: 'Thủ Dầu Một, Việt Nam' },
// -- Thanh Hoa / Quang Nam ------------------------------------------------
// Two provinces carried by a couple of towns each, the same shape as Lam
// Dong. Both are here for variety: everything else added alongside them is
// the industrial ring around Ho Chi Minh City, which all looks alike.
TH: { name: 'Thanh Hoa', nameVi: 'Thanh Hóa', level: 'province', parent: 'VN', partialCoverage: 'two towns covered', legacyBbox: [104.35, 19.3, 106.1, 20.65] },
'TH-THANHHOA': { name: 'Thanh Hoa City', nameVi: 'TP. Thanh Hóa', level: 'district', parent: 'TH', query: 'Thành phố Thanh Hóa, Thanh Hóa, Việt Nam' },
'TH-SAMSON': { name: 'Sam Son', level: 'district', parent: 'TH', query: 'Sầm Sơn, Thanh Hóa, Việt Nam' },
QNA: { name: 'Quang Nam', nameVi: 'Quảng Nam', level: 'province', parent: 'VN', partialCoverage: 'one town covered', legacyBbox: [107.1, 14.9, 108.75, 16.2] },
'QNA-HOIAN': { name: 'Hoi An', level: 'district', parent: 'QNA', query: 'Hội An, Việt Nam' },
};
+1 -15
View File
@@ -27,24 +27,10 @@ import { REGION_COUNTS } from '../src/data/regions/counts.js';
import { PANO_DIR } from './lib/paths.mjs';
import { panoramasDdl, PANO_PROVINCES_DDL } from './lib/pano-schema.mjs';
import { validatePanoArtifact } from './lib/pano-artifacts.mjs';
import { loadEnvFile } from './lib/env.mjs';
const BATCH_SIZE = 5000;
/** Read .env the way the other scripts do, without pulling in a dependency. */
function loadEnvFile() {
if (!existsSync('.env')) return {};
return Object.fromEntries(
readFileSync('.env', 'utf8')
.split(/\r?\n/)
.filter((line) => line && !line.startsWith('#') && line.includes('='))
.map((line) => {
const at = line.indexOf('=');
// `vercel env pull` quotes its values; the raw URL is what dials.
return [line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"(.*)"$/, '$1')];
})
);
}
function databaseUrl() {
const env = loadEnvFile();
const url =
+2 -18
View File
@@ -14,26 +14,10 @@
// already played on another day in the window, the cheapest available
// measure of whether anyone comes back.
import { readFileSync, existsSync } from 'node:fs';
import { statsDays, readDay, distinctPlayersAcross } from '../src/lib/stats.js';
import { applyEnvFile } from './lib/env.mjs';
/** Read .env the way the other scripts do, without pulling in a dependency. */
function loadEnvFile() {
if (!existsSync('.env')) return {};
return Object.fromEntries(
readFileSync('.env', 'utf8')
.split(/\r?\n/)
.filter((line) => line && !line.startsWith('#') && line.includes('='))
.map((line) => {
const at = line.indexOf('=');
return [line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"(.*)"$/, '$1')];
})
);
}
for (const [key, value] of Object.entries(loadEnvFile())) {
process.env[key] ??= value;
}
applyEnvFile();
const LEVELS = ['country', 'province', 'district', 'daily'];
const windowDays = Math.max(1, Number(process.argv[2]) || 14);