From 2bd5e4fbf2849de1fe8b328d26de1ba67f4aa619 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:21:14 +0700 Subject: [PATCH] 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. --- package.json | 1 + scripts/build-pano-index.mjs | 18 +- scripts/build-region-boundaries.mjs | 165 +------------ scripts/export-leaderboards.mjs | 21 +- scripts/lib/env.mjs | 59 +++++ scripts/lib/region-config.mjs | 147 +++++++++++ scripts/seed-pano-db.mjs | 16 +- scripts/stats.mjs | 20 +- tests/assign-districts.test.js | 366 ++++++++++++++++++++++++++++ 9 files changed, 599 insertions(+), 214 deletions(-) create mode 100644 scripts/lib/env.mjs create mode 100644 scripts/lib/region-config.mjs create mode 100644 tests/assign-districts.test.js diff --git a/package.json b/package.json index 062da18..8a4d73a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/build-pano-index.mjs b/scripts/build-pano-index.mjs index 53f671a..07a7345 100644 --- a/scripts/build-pano-index.mjs +++ b/scripts/build-pano-index.mjs @@ -12,6 +12,11 @@ // scripts/build-region-boundaries.mjs. Output is data-build/panos/.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; } diff --git a/scripts/build-region-boundaries.mjs b/scripts/build-region-boundaries.mjs index f2c5ce6..a944726 100644 --- a/scripts/build-region-boundaries.mjs +++ b/scripts/build-region-boundaries.mjs @@ -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} 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'); diff --git a/scripts/export-leaderboards.mjs b/scripts/export-leaderboards.mjs index 7dc91d9..5f6c27a 100644 --- a/scripts/export-leaderboards.mjs +++ b/scripts/export-leaderboards.mjs @@ -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`; diff --git a/scripts/lib/env.mjs b/scripts/lib/env.mjs new file mode 100644 index 0000000..4077631 --- /dev/null +++ b/scripts/lib/env.mjs @@ -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} 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} 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; + } +} diff --git a/scripts/lib/region-config.mjs b/scripts/lib/region-config.mjs new file mode 100644 index 0000000..4b7f25f --- /dev/null +++ b/scripts/lib/region-config.mjs @@ -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' }, +}; diff --git a/scripts/seed-pano-db.mjs b/scripts/seed-pano-db.mjs index abf4488..e073801 100644 --- a/scripts/seed-pano-db.mjs +++ b/scripts/seed-pano-db.mjs @@ -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 = diff --git a/scripts/stats.mjs b/scripts/stats.mjs index ce5ee33..9732879 100644 --- a/scripts/stats.mjs +++ b/scripts/stats.mjs @@ -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); diff --git a/tests/assign-districts.test.js b/tests/assign-districts.test.js new file mode 100644 index 0000000..f9441af --- /dev/null +++ b/tests/assign-districts.test.js @@ -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 + ); + }); +});