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

+1
View File
@@ -19,6 +19,7 @@
"data:districts": "node scripts/assign-pano-districts.mjs",
"data:repartition": "npm run data:boundaries -- --regenerate && npm run data:districts",
"data:seed": "node scripts/seed-pano-db.mjs",
"data:refresh": "npm run data:boundaries && npm run data:panos && npm run data:districts && npm run data:seed",
"stats": "node scripts/stats.mjs",
"leaderboard:export": "node scripts/export-leaderboards.mjs",
"test:e2e": "playwright test"
+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);
+366
View File
@@ -0,0 +1,366 @@
// Pins the geometry rules scripts/lib/assign-districts.mjs decides a
// panorama's district credit by, against small synthetic polygons rather than
// the real boundaries -- fast, and pinning the rules rather than the data.
//
// districtFor/nearestDistrict/cellKey/coverageVerdict/outlineSegments take
// plain data and are exercised directly. loadBoundary/prepareDistricts/
// assignPanos read boundary files off disk through REGIONS, so node:fs and
// the generated region tree are mocked with a small synthetic province.
import { describe, it, expect } from 'vitest';
import { vi } from 'vitest';
import * as turf from '@turf/turf';
const { REGION_FIXTURE, boundaryFiles } = vi.hoisted(() => ({
// A synthetic province: two square districts (A, B) with a gap between
// them, and a third (C) whose boundary file omits properties.bbox, to pin
// the turf.bbox(...) fallback in prepareDistricts.
REGION_FIXTURE: {
TESTP: { level: 'province', parent: 'VN' },
'TESTP-A': { level: 'district', parent: 'TESTP' },
'TESTP-B': { level: 'district', parent: 'TESTP' },
'TESTP-C': { level: 'district', parent: 'TESTP' },
},
boundaryFiles: new Map(),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
readFileSync: (path, ...rest) =>
boundaryFiles.has(path) ? boundaryFiles.get(path) : actual.readFileSync(path, ...rest),
};
});
vi.mock('../src/data/regions/index.js', () => ({ REGIONS: REGION_FIXTURE }));
const { BOUNDARY_DIR } = await import('../scripts/lib/paths.mjs');
const {
loadBoundary,
outlineSegments,
prepareDistricts,
districtFor,
nearestDistrict,
assignPanos,
cellKey,
coverageVerdict,
CELL_DEG,
MIN_PANOS,
MIN_CELLS,
THIN_CELLS,
} = await import('../scripts/lib/assign-districts.mjs');
/** A rectangular GeoJSON feature, in the shape a real boundary file has. */
function rectFeature(code, minLng, minLat, maxLng, maxLat, includeBbox) {
const geometry = {
type: 'Polygon',
coordinates: [
[
[minLng, minLat],
[maxLng, minLat],
[maxLng, maxLat],
[minLng, maxLat],
[minLng, minLat],
],
],
};
return {
type: 'Feature',
properties: {
code,
...(includeBbox === false ? {} : { bbox: [minLng, minLat, maxLng, maxLat] }),
},
geometry,
};
}
// TESTP: [0,0]-[3,1]. A: [0,0]-[1,1]. Gap: [1,0]-[2,1]. B: [2,0]-[3,1].
// C duplicates A's shape but its file has no properties.bbox.
boundaryFiles.set(
`${BOUNDARY_DIR}/testp/testp.json`,
JSON.stringify(rectFeature('TESTP', 0, 0, 3, 1))
);
boundaryFiles.set(
`${BOUNDARY_DIR}/testp/testp-a.json`,
JSON.stringify(rectFeature('TESTP-A', 0, 0, 1, 1))
);
boundaryFiles.set(
`${BOUNDARY_DIR}/testp/testp-b.json`,
JSON.stringify(rectFeature('TESTP-B', 2, 0, 3, 1))
);
boundaryFiles.set(
`${BOUNDARY_DIR}/testp/testp-c.json`,
JSON.stringify(rectFeature('TESTP-C', 0, 0, 1, 1, false))
);
describe('loadBoundary', () => {
it('reads a province boundary from its own file', () => {
const boundary = loadBoundary('TESTP');
expect(boundary.properties.code).toBe('TESTP');
});
it("reads a leaf boundary from its parent province's directory", () => {
const boundary = loadBoundary('TESTP-A');
expect(boundary.properties.code).toBe('TESTP-A');
});
it('throws for a code absent from the region tree', () => {
expect(() => loadBoundary('NOPE')).toThrow(/Unknown region: NOPE/);
});
});
describe('outlineSegments', () => {
it('returns one LineString for a plain polygon', () => {
const feature = rectFeature('X', 0, 0, 1, 1);
const segments = outlineSegments(feature);
expect(segments).toHaveLength(1);
expect(segments[0].geometry.type).toBe('LineString');
});
it('flattens a polygon-with-hole into one LineString per ring', () => {
const withHole = turf.polygon([
[
[0, 0],
[4, 0],
[4, 4],
[0, 4],
[0, 0],
],
[
[1, 1],
[1, 2],
[2, 2],
[2, 1],
[1, 1],
],
]);
const segments = outlineSegments(withHole);
expect(segments).toHaveLength(2);
expect(segments.every((s) => s.geometry.type === 'LineString')).toBe(true);
});
it('flattens a MultiPolygon into one LineString per part', () => {
const multi = turf.multiPolygon([
[
[
[0, 0],
[1, 0],
[1, 1],
[0, 1],
[0, 0],
],
],
[
[
[2, 0],
[3, 0],
[3, 1],
[2, 1],
[2, 0],
],
],
]);
const segments = outlineSegments(multi);
expect(segments).toHaveLength(2);
expect(segments.every((s) => s.geometry.type === 'LineString')).toBe(true);
});
});
describe('prepareDistricts', () => {
it('carries the boundary bbox through when the file has one', () => {
const [a] = prepareDistricts(['TESTP-A']);
expect(a.bbox).toEqual([0, 0, 1, 1]);
});
it('computes the bbox with turf when the file omits properties.bbox', () => {
const [c] = prepareDistricts(['TESTP-C']);
expect(c.bbox).toEqual([0, 0, 1, 1]);
});
it('keeps district order as given', () => {
const districts = prepareDistricts(['TESTP-B', 'TESTP-A']);
expect(districts.map((d) => d.code)).toEqual(['TESTP-B', 'TESTP-A']);
});
});
describe('districtFor', () => {
// A bbox-only rectangle is a poor proxy for an irregular shape: a point can
// sit inside a district's bbox while sitting outside its actual outline.
const triangle = {
code: 'TRI',
boundary: turf.polygon([
[
[0, 0],
[2, 0],
[0, 2],
[0, 0],
],
]),
bbox: [0, 0, 2, 2],
};
it('assigns a point inside exactly one polygon to it', () => {
const [a, b] = prepareDistricts(['TESTP-A', 'TESTP-B']);
expect(districtFor({ lat: 0.5, lng: 0.5 }, [a, b])).toBe('TESTP-A');
expect(districtFor({ lat: 0.5, lng: 2.5 }, [a, b])).toBe('TESTP-B');
});
it('rejects a point inside a bbox but outside the real polygon shape', () => {
expect(districtFor({ lat: 1.5, lng: 1.5 }, [triangle])).toBeNull();
});
it('accepts a point that clears both the bbox and the polygon shape', () => {
expect(districtFor({ lat: 0.5, lng: 0.5 }, [triangle])).toBe('TRI');
});
it('returns null for a point inside the province but outside every district', () => {
const [a, b] = prepareDistricts(['TESTP-A', 'TESTP-B']);
// (1.5, 0.5) is inside TESTP [0,0]-[3,1] but in the gap between A and B.
expect(districtFor({ lat: 0.5, lng: 1.5 }, [a, b])).toBeNull();
});
it('assigns a point on a shared border to exactly the first-listed district', () => {
const adjacentA = { code: 'ADJ-A', boundary: turf.polygon([[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]), bbox: [0, 0, 1, 1] };
const adjacentB = { code: 'ADJ-B', boundary: turf.polygon([[[1, 0], [2, 0], [2, 1], [1, 1], [1, 0]]]), bbox: [1, 0, 2, 1] };
const point = { lat: 0.5, lng: 1 };
// Both polygons contain the shared edge, so the tie has to resolve one
// way, deterministically, rather than double-crediting the point.
expect(districtFor(point, [adjacentA, adjacentB])).toBe('ADJ-A');
expect(districtFor(point, [adjacentB, adjacentA])).toBe('ADJ-B');
});
it('returns null when there are no candidate districts', () => {
expect(districtFor({ lat: 0.5, lng: 0.5 }, [])).toBeNull();
});
});
describe('nearestDistrict', () => {
it('picks the district whose outline is actually closest', () => {
// Ranked by distance to the real outline rather than to a bbox centre --
// see the doc comment on nearestDistrict for why that distinction
// mattered in production (up to 6km of misattribution in Ho Chi Minh
// City). Here the two candidates are simply placed so only the outline
// distance, not a naive centroid guess, can tell them apart correctly.
const near = {
code: 'NEAR',
lines: outlineSegments(turf.polygon([[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]])),
};
const far = {
code: 'FAR',
lines: outlineSegments(turf.polygon([[[5, 5], [6, 5], [6, 6], [5, 6], [5, 5]]])),
};
const nearest = nearestDistrict({ lat: 0.5, lng: 2 }, [near, far]);
expect(nearest.code).toBe('NEAR');
expect(nearest.km).toBeGreaterThan(0);
});
it('returns null when there are no districts', () => {
expect(nearestDistrict({ lat: 0, lng: 0 }, [])).toBeNull();
});
});
describe('cellKey', () => {
it('gives the same key to two points in the same cell', () => {
const a = cellKey({ lat: 10.001, lng: 106.001 });
const b = cellKey({ lat: 10.005, lng: 106.005 });
expect(a).toBe(b);
});
it('gives different keys to points a full cell width apart', () => {
const a = cellKey({ lat: 10, lng: 106 });
const b = cellKey({ lat: 10 + CELL_DEG, lng: 106 });
expect(a).not.toBe(b);
});
});
describe('coverageVerdict', () => {
it('is unplayable below the panorama floor', () => {
expect(coverageVerdict(MIN_PANOS - 1, MIN_CELLS)).toEqual({ playable: false, thin: false });
});
it('is unplayable below the cell floor even with plenty of panoramas', () => {
expect(coverageVerdict(1000, MIN_CELLS - 1)).toEqual({ playable: false, thin: false });
});
it('is playable and thin just at the floor', () => {
expect(coverageVerdict(MIN_PANOS, MIN_CELLS)).toEqual({ playable: true, thin: true });
});
it('is playable and not thin once cells reach the thin threshold', () => {
expect(coverageVerdict(1000, THIN_CELLS)).toEqual({ playable: true, thin: false });
});
});
describe('assignPanos', () => {
it('assigns a point inside exactly one district and counts it there', () => {
const panos = [{ id: 'p1', lat: 0.5, lng: 0.5 }];
const result = assignPanos(panos, ['TESTP-A', 'TESTP-B']);
expect(result.assignments).toEqual(['TESTP-A']);
expect(result.counts).toEqual({ 'TESTP-A': 1, 'TESTP-B': 0 });
expect(result.stranded).toBe(0);
expect(result.unassigned).toBe(0);
});
it('assigns a stranded point (in the gap) to exactly one district by nearest outline, never both', () => {
// (1.5, 0.5) sits equidistant along the gap's midline, so this also pins
// that a tie does not get double-counted.
const panos = [{ id: 'gap', lat: 0.5, lng: 1.5 }];
const result = assignPanos(panos, ['TESTP-A', 'TESTP-B']);
expect(result.stranded).toBe(1);
expect(result.unassigned).toBe(0);
expect([null]).not.toContain(result.assignments[0]);
const assignedCode = result.assignments[0];
expect(['TESTP-A', 'TESTP-B']).toContain(assignedCode);
// Exactly one district's count reflects the assignment.
expect(result.counts['TESTP-A'] + result.counts['TESTP-B']).toBe(1);
expect(result.counts[assignedCode]).toBe(1);
});
it('marks every panorama unassigned when the province has no district polygons', () => {
const panos = [{ id: 'p1', lat: 0.5, lng: 0.5 }, { id: 'p2', lat: 0.6, lng: 0.6 }];
const result = assignPanos(panos, []);
expect(result.assignments).toEqual([null, null]);
expect(result.unassigned).toBe(2);
expect(result.stranded).toBe(0);
expect(result.counts).toEqual({});
});
it('produces counts and cells that sum to the input, and reports the worst stranded distance', () => {
const panos = [
{ id: 'a1', lat: 0.1, lng: 0.1 },
{ id: 'a2', lat: 0.9, lng: 0.9 },
{ id: 'b1', lat: 0.1, lng: 2.9 },
{ id: 'gap1', lat: 0.5, lng: 1.5 },
];
const result = assignPanos(panos, ['TESTP-A', 'TESTP-B']);
const totalCounted = result.counts['TESTP-A'] + result.counts['TESTP-B'];
expect(totalCounted).toBe(panos.length);
expect(result.assignments).toHaveLength(panos.length);
expect(result.stranded).toBe(1);
expect(result.worstStrandedKm).toBeGreaterThan(0);
// cells reports distinct ~1.1km cells actually occupied per district, so
// it can never exceed that district's own panorama count.
expect(result.cells['TESTP-A']).toBeLessThanOrEqual(result.counts['TESTP-A']);
expect(result.cells['TESTP-B']).toBeLessThanOrEqual(result.counts['TESTP-B']);
});
it('does not drop a point outside the province -- that clip is the caller\'s job', () => {
// assignPanos only chooses among the districts it is given; it never
// checks the point against a province outline. A point far outside TESTP
// still gets a nearest-outline assignment rather than coming back null --
// which is exactly why build-pano-index.mjs and assign-pano-districts.mjs
// both clip against the province boundary before calling this.
const farAway = { id: 'far', lat: 50, lng: 50 };
const result = assignPanos([farAway], ['TESTP-A', 'TESTP-B']);
expect(result.assignments[0]).not.toBeNull();
expect(result.stranded).toBe(1);
// The clip callers actually perform: reject it against the province
// boundary before it would ever reach assignPanos.
const province = loadBoundary('TESTP');
expect(turf.booleanPointInPolygon(turf.point([farAway.lng, farAway.lat]), province)).toBe(
false
);
});
});