mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-15 14:16:52 +00:00
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:
1 parent
2297971851
commit
2bd5e4fbf2
9 files changed
+599
-214
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user