From 6dbe2e01c8cf492a30c3f9734862f1d19e79d2d0 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 20 Sep 2026 23:28:50 +0700 Subject: [PATCH] feat(regions): show province and district names in Vietnamese Every province and district in the tree now carries its accented name, derived by the boundary script from the OSM query it already held, and regionName() is what the picker, the game header, the reveal, the search results, the API's region.name and the share text show. The ASCII name stays as the stable form for codes, logs and search aliases, and the map search matches either spelling. The font loads the Vietnamese subset so the glyphs render in Geist. --- docs/features.md | 7 ++ docs/project-overview.md | 3 + scripts/build-region-boundaries.mjs | 43 +++++-- src/app/components/GameClient.js | 4 +- src/app/components/LeaderboardModal.js | 4 +- src/app/components/RegionPicker.js | 11 +- src/app/components/RegionSelect.js | 6 +- src/app/game/[region]/page.js | 4 +- src/app/layout.js | 6 +- src/data/regions/index.js | 168 ++++++++++++------------- src/lib/geo-search.js | 12 +- src/lib/region-request.js | 6 +- src/lib/regions.js | 16 ++- tests/e2e/audio.spec.js | 8 +- tests/e2e/game.spec.js | 6 +- tests/e2e/helpers.js | 18 +-- tests/e2e/home.spec.js | 14 +-- tests/e2e/routing.spec.js | 4 +- tests/geo-search.test.js | 10 +- tests/guess-route.test.js | 2 +- tests/new-game-route.test.js | 2 +- tests/region-request.test.js | 4 +- tests/regions.test.js | 2 +- 23 files changed, 206 insertions(+), 154 deletions(-) diff --git a/docs/features.md b/docs/features.md index 4a6a4e6..5291f77 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,6 +3,13 @@ ## Location Coverage - **Three-level region tree**: Vietnam → nine provinces → 75 districts and towns, generated into `src/data/regions/` and traversed through `src/lib/regions.js` +- **Names shown in Vietnamese**: every province and district carries an + accented `nameVi` ("Hoàn Kiếm", "Quận 7"), derived by the boundary script + from its OSM query, and `regionName()` in `src/lib/regions.js` is what the + interface, the API's `region.name` and the share text show. The ASCII `name` + stays as the stable form for codes, logs and search aliases; the map search + matches either spelling. The country stays "Vietnam" in an English + interface. The font loads the `vietnamese` subset for the glyphs - **Play at any level**: the whole country, one province, or one district - **Pre-2025-merger boundaries**: Da Lat sits under Lam Dong, Duc Hoa under Long An - **Partial by design**: see the Coverage note in [project-overview.md](project-overview.md) diff --git a/docs/project-overview.md b/docs/project-overview.md index bc0d20e..6623df3 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -40,6 +40,9 @@ Tau, which is not what people mean by the city. Two consequences to remember: ## Coverage +Region names in these docs are the ASCII `name`; players see the accented +`nameVi` (see Location Coverage in [features.md](features.md)). + Nine provinces — Ha Noi, Ho Chi Minh, Da Nang, Lam Dong, Long An, Dong Nai, Binh Duong, Thanh Hoa, Quang Nam — split into 75 districts and towns. Coverage is deliberately partial and will grow in future releases. diff --git a/scripts/build-region-boundaries.mjs b/scripts/build-region-boundaries.mjs index 2d408d4..f2c5ce6 100644 --- a/scripts/build-region-boundaries.mjs +++ b/scripts/build-region-boundaries.mjs @@ -57,6 +57,9 @@ const LEAF_TOLERANCE = 0.0001; // ~11m // 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 @@ -68,7 +71,7 @@ const REGIONS = { // -- Ha Noi --------------------------------------------------------------- // Not merged in 2025, but split here into its 30 pre-2025 units. - HN: { name: 'Ha Noi', level: 'province', parent: 'VN', center: [21.0285, 105.8542], legacyBbox: [105.28896, 20.56452, 106.02004, 21.38542] }, + 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' }, @@ -102,7 +105,7 @@ const REGIONS = { // -- Ho Chi Minh ---------------------------------------------------------- // Districts 2 and 9 are absent on purpose: both merged into Thu Duc in 2021. - TPHCM: { name: 'Ho Chi Minh', level: 'province', parent: 'VN', center: [10.8231, 106.6297], legacyBbox: [106.46356, 10.35828, 107.02758, 10.92934] }, + 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' }, @@ -132,7 +135,7 @@ const REGIONS = { // -- 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', level: 'province', parent: 'VN', center: [16.0544, 108.2022], legacyBbox: [107.81854, 15.91799, 108.33864, 16.2255] }, + 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' }, @@ -144,14 +147,14 @@ const REGIONS = { // -- 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', level: 'province', parent: 'VN', partialCoverage: 'one town covered', legacyBbox: [108.31521, 11.80798, 108.5944, 12.00855] }, + 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', level: 'province', parent: 'VN', partialCoverage: 'three districts covered', legacyBbox: [105.45, 10.35, 106.85, 11.1] }, + 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' }, @@ -159,7 +162,7 @@ const REGIONS = { // -- 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', level: 'province', parent: 'VN', partialCoverage: 'six districts covered', legacyBbox: [106.6, 10.45, 107.85, 11.6] }, + 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' }, @@ -171,7 +174,7 @@ const REGIONS = { // 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', level: 'province', parent: 'VN', partialCoverage: 'three cities covered', legacyBbox: [106.35, 10.85, 107.2, 11.85] }, + 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' }, @@ -180,11 +183,11 @@ const REGIONS = { // 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', level: 'province', parent: 'VN', partialCoverage: 'two towns covered', legacyBbox: [104.35, 19.3, 106.1, 20.65] }, - 'TH-THANHHOA': { name: 'Thanh Hoa City', level: 'district', parent: 'TH', query: 'Thành phố Thanh Hóa, Thanh Hóa, Việt Nam' }, + 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', level: 'province', parent: 'VN', partialCoverage: 'one town covered', legacyBbox: [107.1, 14.9, 108.75, 16.2] }, + 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' }, }; @@ -463,6 +466,24 @@ function writeBoundaryBarrel() { * writer. src/lib/regions.js joins the two. * @param {Object} resolvedState Per-code resolution info. */ +/** + * The accented Vietnamese name of a node, for display. + * + * Leaves carry it inside their Nominatim query: "Quận Hoàn Kiếm, Hà Nội, Việt + * 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. + * @returns {string|undefined} + */ +function vietnameseName(config) { + if (config.nameVi) return config.nameVi; + if (!config.query) return undefined; + const head = config.query.split(',')[0].trim(); + const stripped = head.replace(/^(?:Quận|Huyện|Thị xã|Thành phố)\s+/u, ''); + return /^\d+$/.test(stripped) ? head : stripped; +} + function writeTree(resolvedState) { const nodes = Object.entries(REGIONS).map(([code, config]) => { const state = resolvedState[code] ?? {}; @@ -473,6 +494,8 @@ function writeTree(resolvedState) { level: config.level, children: childrenOf(code), }; + const nameVi = vietnameseName(config); + if (nameVi) node.nameVi = nameVi; if (state.center) node.center = state.center; if (state.bbox) node.bbox = state.bbox; if (config.partialCoverage) node.partialCoverage = config.partialCoverage; diff --git a/src/app/components/GameClient.js b/src/app/components/GameClient.js index 19b62a4..1050ddc 100644 --- a/src/app/components/GameClient.js +++ b/src/app/components/GameClient.js @@ -18,7 +18,7 @@ import { playSound } from '../../lib/audio'; // The revealed path comes from /api/guess (the RESOLVED district), not from // regionPath(pickedRegion) -- computing it client-side from what the player // chose would make the reveal meaningless for a country round. -import { getRegion, isRegion } from '../../lib/regions'; +import { getRegion, isRegion, regionName as regionNameOf } from '../../lib/regions'; import { dailyDay } from '../../lib/daily-calendar'; import { getDailyProgress, saveDailyResult, currentStreak } from '../../lib/daily-progress'; @@ -135,7 +135,7 @@ export default function GameClient({ region, daily = false }) { // than the load-bearing check it used to be when the code came from a query // string this component read itself. const pickedRegion = isRegion(region) ? getRegion(region) : null; - const regionName = pickedRegion ? pickedRegion.name : 'Vietnam'; + const regionName = pickedRegion ? regionNameOf(pickedRegion.code) : 'Vietnam'; const initializingRef = useRef(false); // The next round, fetched while the result dialog is open so Next Round can diff --git a/src/app/components/LeaderboardModal.js b/src/app/components/LeaderboardModal.js index dbd6f85..c6c0612 100644 --- a/src/app/components/LeaderboardModal.js +++ b/src/app/components/LeaderboardModal.js @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import LeaderboardList from './LeaderboardList'; import RegionSelect from './RegionSelect'; -import { COUNTRY_CODE, getRegion } from '../../lib/regions'; +import { COUNTRY_CODE, regionName } from '../../lib/regions'; // The leaderboard feature: the header trigger button plus the dialog it opens. // Owning the button here keeps the open gesture and the cache clear in one @@ -89,7 +89,7 @@ export default function LeaderboardModal({ currentUsername }) { - {region ? getRegion(region).name : 'Leaderboards'} + {region ? regionName(region) : 'Leaderboards'} {/* sr-only: the region select and type toggle right below say the same thing visually; Radix still needs a description. */} diff --git a/src/app/components/RegionPicker.js b/src/app/components/RegionPicker.js index d86972b..edefe26 100644 --- a/src/app/components/RegionPicker.js +++ b/src/app/components/RegionPicker.js @@ -21,6 +21,7 @@ import { isUnresolved, provinces, regionSlug, + regionName, } from '../../lib/regions'; /** Thousands separators, so 225,966 reads as a quantity rather than a code. */ @@ -106,7 +107,7 @@ function UnavailableRow({ code }) {
- {getRegion(code).name} + {regionName(code)} {unavailableLabel(code)}
); @@ -137,7 +138,7 @@ export default function RegionPicker({ onPlayClick }) { {lastRegion && ( - + )} @@ -154,7 +155,7 @@ export default function RegionPicker({ onPlayClick }) { > - {region.name} + {regionName(province)} {region.partialCoverage && ( - + {districts.map((district) => isPlayable(district) ? ( - + ) : ( ) diff --git a/src/app/components/RegionSelect.js b/src/app/components/RegionSelect.js index 1daf939..5263385 100644 --- a/src/app/components/RegionSelect.js +++ b/src/app/components/RegionSelect.js @@ -13,10 +13,10 @@ import { import { COUNTRY_CODE, childrenOf, - getRegion, isPlayable, isThin, provinces, + regionName, } from '../../lib/regions'; const LEVELS = [ @@ -70,7 +70,7 @@ export default function RegionSelect({ } return provinces() .map((province) => ({ - label: getRegion(province).name, + label: regionName(province), codes: childrenOf(province).filter((code) => !playableOnly || isPlayable(code)), })) .filter((group) => group.codes.length > 0); @@ -120,7 +120,7 @@ export default function RegionSelect({ {group.label && {group.label}} {group.codes.map((code) => ( - {getRegion(code).name} + {regionName(code)} {isThin(code) ? ' · few streets' : ''} ))} diff --git a/src/app/game/[region]/page.js b/src/app/game/[region]/page.js index d5ffd2b..fa94064 100644 --- a/src/app/game/[region]/page.js +++ b/src/app/game/[region]/page.js @@ -2,8 +2,8 @@ import { notFound } from 'next/navigation'; import GameClient from '../../components/GameClient'; import { allRegions, - getRegion, regionFromSlug, + regionName, regionPath, regionSlug, } from '../../../lib/regions'; @@ -43,7 +43,7 @@ export async function generateMetadata({ params }) { if (!code) return {}; - const { name } = getRegion(code); + const name = regionName(code); // Narrowest first, country dropped: 'Ba Dinh, Ha Noi' rather than // 'Vietnam, Ha Noi, Ba Dinh' -- regionPath returns outermost first, so this // reverses it, and the country is the same word on all 85. diff --git a/src/app/layout.js b/src/app/layout.js index 3e56b11..3348199 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -8,14 +8,16 @@ import { SpeedInsights } from '@vercel/speed-insights/next'; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +// Region names are shown in accented Vietnamese, so the font has to ship the +// Vietnamese glyphs or every "Hà Nội" falls back to the system face. const geistSans = Geist({ variable: "--font-geist-sans", - subsets: ["latin"], + subsets: ["latin", "vietnamese"], }); const geistMono = Geist_Mono({ variable: "--font-geist-mono", - subsets: ["latin"], + subsets: ["latin", "vietnamese"], }); // Deployment URL for absolute Open Graph URLs. Vercel sets the production diff --git a/src/data/regions/index.js b/src/data/regions/index.js index 26fed5e..b7f4a88 100644 --- a/src/data/regions/index.js +++ b/src/data/regions/index.js @@ -8,88 +8,88 @@ export const REGIONS = { "VN": {"code":"VN","name":"Vietnam","parent":null,"level":"country","children":["HN","TPHCM","DN","LD","LA","DNA","BD","TH","QNA"],"center":[15.8718,106.99349],"bbox":[105.28896,10.35818,108.69802,21.38542]}, - "HN": {"code":"HN","name":"Ha Noi","parent":"VN","level":"province","children":["HN-BADINH","HN-HOANKIEM","HN-TAYHO","HN-LONGBIEN","HN-CAUGIAY","HN-DONGDA","HN-HAIBATRUNG","HN-HOANGMAI","HN-THANHXUAN","HN-BACTULIEM","HN-NAMTULIEM","HN-HADONG","HN-SONTAY","HN-BAVI","HN-CHUONGMY","HN-DANPHUONG","HN-DONGANH","HN-GIALAM","HN-HOAIDUC","HN-MELINH","HN-MYDUC","HN-PHUXUYEN","HN-PHUCTHO","HN-QUOCOAI","HN-SOCSON","HN-THACHTHAT","HN-THANHOAI","HN-THANHTRI","HN-THUONGTIN","HN-UNGHOA"],"center":[21.0285,105.8542],"bbox":[105.28896,20.56452,106.02004,21.38542]}, - "HN-BADINH": {"code":"HN-BADINH","name":"Ba Dinh","parent":"HN","level":"district","children":[],"center":[21.03578,105.826],"bbox":[105.80135,21.01596,105.85597,21.05337]}, - "HN-HOANKIEM": {"code":"HN-HOANKIEM","name":"Hoan Kiem","parent":"HN","level":"district","children":[],"center":[21.02901,105.85378],"bbox":[105.84093,21.01813,105.86728,21.04321]}, - "HN-TAYHO": {"code":"HN-TAYHO","name":"Tay Ho","parent":"HN","level":"district","children":[],"center":[21.06931,105.81988],"bbox":[105.79178,21.03951,105.84828,21.09737]}, - "HN-LONGBIEN": {"code":"HN-LONGBIEN","name":"Long Bien","parent":"HN","level":"district","children":[],"center":[21.04246,105.89397],"bbox":[105.84106,20.99411,105.93877,21.08081]}, - "HN-CAUGIAY": {"code":"HN-CAUGIAY","name":"Cau Giay","parent":"HN","level":"district","children":[],"center":[21.0306,105.79168],"bbox":[105.76716,21.00066,105.81352,21.05879]}, - "HN-DONGDA": {"code":"HN-DONGDA","name":"Dong Da","parent":"HN","level":"district","children":[],"center":[21.01547,105.82503],"bbox":[105.79763,20.99742,105.84263,21.03215]}, - "HN-HAIBATRUNG": {"code":"HN-HAIBATRUNG","name":"Hai Ba Trung","parent":"HN","level":"district","children":[],"center":[21.00564,105.8585],"bbox":[105.8409,20.98996,105.88241,21.02127]}, - "HN-HOANGMAI": {"code":"HN-HOANGMAI","name":"Hoang Mai","parent":"HN","level":"district","children":[],"center":[20.97454,105.86322],"bbox":[105.80739,20.94544,105.90879,21.00376]}, - "HN-THANHXUAN": {"code":"HN-THANHXUAN","name":"Thanh Xuan","parent":"HN","level":"district","children":[],"center":[20.99482,105.81531],"bbox":[105.79218,20.98056,105.84287,21.00885]}, - "HN-BACTULIEM": {"code":"HN-BACTULIEM","name":"Bac Tu Liem","parent":"HN","level":"district","children":[],"center":[21.07198,105.7613],"bbox":[105.71721,21.03798,105.80499,21.10812]}, - "HN-NAMTULIEM": {"code":"HN-NAMTULIEM","name":"Nam Tu Liem","parent":"HN","level":"district","children":[],"center":[21.01256,105.76151],"bbox":[105.72656,20.97966,105.7992,21.05656]}, - "HN-HADONG": {"code":"HN-HADONG","name":"Ha Dong","parent":"HN","level":"district","children":[],"center":[20.95356,105.75706],"bbox":[105.70525,20.91082,105.8043,20.99698]}, - "HN-SONTAY": {"code":"HN-SONTAY","name":"Son Tay","parent":"HN","level":"district","children":[],"center":[21.09694,105.47954],"bbox":[105.41344,21.02175,105.53798,21.17188]}, - "HN-BAVI": {"code":"HN-BAVI","name":"Ba Vi","parent":"HN","level":"district","children":[],"center":[21.14125,105.38297],"bbox":[105.28896,21.0074,105.47524,21.30451]}, - "HN-CHUONGMY": {"code":"HN-CHUONGMY","name":"Chuong My","parent":"HN","level":"district","children":[],"center":[20.87763,105.66036],"bbox":[105.55102,20.7919,105.75984,20.96484]}, - "HN-DANPHUONG": {"code":"HN-DANPHUONG","name":"Dan Phuong","parent":"HN","level":"district","children":[],"center":[21.11385,105.67739],"bbox":[105.62321,21.06614,105.74057,21.17336]}, - "HN-DONGANH": {"code":"HN-DONGANH","name":"Dong Anh","parent":"HN","level":"district","children":[],"center":[21.13619,105.84265],"bbox":[105.73214,21.06689,105.92674,21.20628]}, - "HN-GIALAM": {"code":"HN-GIALAM","name":"Gia Lam","parent":"HN","level":"district","children":[],"center":[21.02888,105.95055],"bbox":[105.88147,20.92633,106.02004,21.12119]}, - "HN-HOAIDUC": {"code":"HN-HOAIDUC","name":"Hoai Duc","parent":"HN","level":"district","children":[],"center":[21.02405,105.69946],"bbox":[105.64615,20.95542,105.74275,21.09028]}, - "HN-MELINH": {"code":"HN-MELINH","name":"Me Linh","parent":"HN","level":"district","children":[],"center":[21.18352,105.7058],"bbox":[105.61499,21.12176,105.7909,21.23963]}, - "HN-MYDUC": {"code":"HN-MYDUC","name":"My Duc","parent":"HN","level":"district","children":[],"center":[20.68678,105.72026],"bbox":[105.64768,20.56452,105.81286,20.82997]}, - "HN-PHUXUYEN": {"code":"HN-PHUXUYEN","name":"Phu Xuyen","parent":"HN","level":"district","children":[],"center":[20.73559,105.90112],"bbox":[105.7918,20.65079,106.00392,20.80684]}, - "HN-PHUCTHO": {"code":"HN-PHUCTHO","name":"Phuc Tho","parent":"HN","level":"district","children":[],"center":[21.11707,105.58374],"bbox":[105.50053,21.04026,105.66491,21.17912]}, - "HN-QUOCOAI": {"code":"HN-QUOCOAI","name":"Quoc Oai","parent":"HN","level":"district","children":[],"center":[20.96769,105.59765],"bbox":[105.4782,20.90939,105.72589,21.04616]}, - "HN-SOCSON": {"code":"HN-SOCSON","name":"Soc Son","parent":"HN","level":"district","children":[],"center":[21.27256,105.83045],"bbox":[105.72329,21.17692,105.93902,21.38542]}, - "HN-THACHTHAT": {"code":"HN-THACHTHAT","name":"Thach That","parent":"HN","level":"district","children":[],"center":[21.01894,105.53016],"bbox":[105.39192,20.94573,105.63988,21.10199]}, - "HN-THANHOAI": {"code":"HN-THANHOAI","name":"Thanh Oai","parent":"HN","level":"district","children":[],"center":[20.85577,105.78086],"bbox":[105.72512,20.78033,105.84123,20.93973]}, - "HN-THANHTRI": {"code":"HN-THANHTRI","name":"Thanh Tri","parent":"HN","level":"district","children":[],"center":[20.93266,105.84089],"bbox":[105.78821,20.89699,105.91311,20.98653]}, - "HN-THUONGTIN": {"code":"HN-THUONGTIN","name":"Thuong Tin","parent":"HN","level":"district","children":[],"center":[20.84497,105.87482],"bbox":[105.81267,20.75192,105.93617,20.91223]}, - "HN-UNGHOA": {"code":"HN-UNGHOA","name":"Ung Hoa","parent":"HN","level":"district","children":[],"center":[20.7119,105.79932],"bbox":[105.69662,20.61188,105.90562,20.80969]}, - "TPHCM": {"code":"TPHCM","name":"Ho Chi Minh","parent":"VN","level":"province","children":["TPHCM-Q1","TPHCM-Q3","TPHCM-Q4","TPHCM-Q5","TPHCM-Q6","TPHCM-Q7","TPHCM-Q8","TPHCM-Q10","TPHCM-Q11","TPHCM-Q12","TPHCM-BINHTAN","TPHCM-BINHTHANH","TPHCM-GOVAP","TPHCM-PHUNHUAN","TPHCM-TANBINH","TPHCM-TANPHU","TPHCM-THUDUC","TPHCM-BINHCHANH","TPHCM-CANGIO","TPHCM-CUCHI","TPHCM-HOCMON","TPHCM-NHABE"],"center":[10.8231,106.6297],"bbox":[106.46356,10.35818,107.02767,10.92934],"missingParts":1}, - "TPHCM-Q1": {"code":"TPHCM-Q1","name":"District 1","parent":"TPHCM","level":"district","children":[],"center":[10.77622,106.69715],"bbox":[106.68169,10.75352,106.7152,10.79693]}, - "TPHCM-Q3": {"code":"TPHCM-Q3","name":"District 3","parent":"TPHCM","level":"district","children":[],"center":[10.78146,106.68284],"bbox":[106.66429,10.76543,106.69813,10.79251]}, - "TPHCM-Q4": {"code":"TPHCM-Q4","name":"District 4","parent":"TPHCM","level":"district","children":[],"center":[10.75846,106.70554],"bbox":[106.68642,10.75171,106.72228,10.76976]}, - "TPHCM-Q5": {"code":"TPHCM-Q5","name":"District 5","parent":"TPHCM","level":"district","children":[],"center":[10.75494,106.66946],"bbox":[106.65051,10.74614,106.68727,10.76543]}, - "TPHCM-Q6": {"code":"TPHCM-Q6","name":"District 6","parent":"TPHCM","level":"district","children":[],"center":[10.74587,106.63524],"bbox":[106.62108,10.72973,106.65505,10.76202]}, - "TPHCM-Q7": {"code":"TPHCM-Q7","name":"District 7","parent":"TPHCM","level":"district","children":[],"center":[10.73646,106.72962],"bbox":[106.69009,10.69795,106.76866,10.77763]}, - "TPHCM-Q8": {"code":"TPHCM-Q8","name":"District 8","parent":"TPHCM","level":"district","children":[],"center":[10.7245,106.64055],"bbox":[106.59456,10.68985,106.69285,10.75211]}, - "TPHCM-Q10": {"code":"TPHCM-Q10","name":"District 10","parent":"TPHCM","level":"district","children":[],"center":[10.77237,106.66826],"bbox":[106.65613,10.75843,106.6828,10.78693]}, - "TPHCM-Q11": {"code":"TPHCM-Q11","name":"District 11","parent":"TPHCM","level":"district","children":[],"center":[10.76412,106.64773],"bbox":[106.6342,10.7534,106.66156,10.77845]}, - "TPHCM-Q12": {"code":"TPHCM-Q12","name":"District 12","parent":"TPHCM","level":"district","children":[],"center":[10.86453,106.65697],"bbox":[106.60328,10.81861,106.7182,10.9042]}, - "TPHCM-BINHTAN": {"code":"TPHCM-BINHTAN","name":"Binh Tan","parent":"TPHCM","level":"district","children":[],"center":[10.76473,106.59754],"bbox":[106.55831,10.71096,106.62526,10.82978]}, - "TPHCM-BINHTHANH": {"code":"TPHCM-BINHTHANH","name":"Binh Thanh","parent":"TPHCM","level":"district","children":[],"center":[10.81206,106.7162],"bbox":[106.68385,10.78534,106.75139,10.83917]}, - "TPHCM-GOVAP": {"code":"TPHCM-GOVAP","name":"Go Vap","parent":"TPHCM","level":"district","children":[],"center":[10.8383,106.66741],"bbox":[106.63194,10.80997,106.70106,10.86079]}, - "TPHCM-PHUNHUAN": {"code":"TPHCM-PHUNHUAN","name":"Phu Nhuan","parent":"TPHCM","level":"district","children":[],"center":[10.80033,106.67817],"bbox":[106.66596,10.78791,106.69248,10.81414]}, - "TPHCM-TANBINH": {"code":"TPHCM-TANBINH","name":"Tan Binh","parent":"TPHCM","level":"district","children":[],"center":[10.80882,106.65239],"bbox":[106.62789,10.76891,106.67861,10.83725]}, - "TPHCM-TANPHU": {"code":"TPHCM-TANPHU","name":"Tan Phu","parent":"TPHCM","level":"district","children":[],"center":[10.79255,106.62689],"bbox":[106.6074,10.75817,106.64768,10.82469]}, - "TPHCM-THUDUC": {"code":"TPHCM-THUDUC","name":"Thu Duc","parent":"TPHCM","level":"district","children":[],"center":[10.82035,106.78951],"bbox":[106.69783,10.74212,106.88176,10.89885]}, - "TPHCM-BINHCHANH": {"code":"TPHCM-BINHCHANH","name":"Binh Chanh","parent":"TPHCM","level":"district","children":[],"center":[10.73382,106.57587],"bbox":[106.46356,10.62531,106.69495,10.87364]}, - "TPHCM-CANGIO": {"code":"TPHCM-CANGIO","name":"Can Gio","parent":"TPHCM","level":"district","children":[],"center":[10.51704,106.881],"bbox":[106.73846,10.35818,107.02767,10.67427]}, - "TPHCM-CUCHI": {"code":"TPHCM-CUCHI","name":"Cu Chi","parent":"TPHCM","level":"district","children":[],"coverage":"unresolved"}, - "TPHCM-HOCMON": {"code":"TPHCM-HOCMON","name":"Hoc Mon","parent":"TPHCM","level":"district","children":[],"center":[10.88585,106.58983],"bbox":[106.49562,10.8261,106.69279,10.92934]}, - "TPHCM-NHABE": {"code":"TPHCM-NHABE","name":"Nha Be","parent":"TPHCM","level":"district","children":[],"center":[10.6543,106.73051],"bbox":[106.67328,10.57636,106.78373,10.72563]}, - "DN": {"code":"DN","name":"Da Nang","parent":"VN","level":"province","children":["DN-HAICHAU","DN-THANHKHE","DN-SONTRA","DN-NGUHANHSON","DN-LIENCHIEU","DN-CAMLE","DN-HOAVANG"],"center":[16.0544,108.2022],"bbox":[107.81854,15.91799,108.33864,16.22576]}, - "DN-HAICHAU": {"code":"DN-HAICHAU","name":"Hai Chau","parent":"DN","level":"district","children":[],"center":[16.05669,108.21143],"bbox":[108.18883,16.02131,108.236,16.10365]}, - "DN-THANHKHE": {"code":"DN-THANHKHE","name":"Thanh Khe","parent":"DN","level":"district","children":[],"center":[16.06315,108.19039],"bbox":[108.16378,16.02637,108.21456,16.08084]}, - "DN-SONTRA": {"code":"DN-SONTRA","name":"Son Tra","parent":"DN","level":"district","children":[],"center":[16.11094,108.26514],"bbox":[108.20903,16.05099,108.33864,16.15565]}, - "DN-NGUHANHSON": {"code":"DN-NGUHANHSON","name":"Ngu Hanh Son","parent":"DN","level":"district","children":[],"center":[15.99755,108.25003],"bbox":[108.21683,15.95597,108.28957,16.05675]}, - "DN-LIENCHIEU": {"code":"DN-LIENCHIEU","name":"Lien Chieu","parent":"DN","level":"district","children":[],"center":[16.13388,108.14835],"bbox":[108.08699,16.0331,108.21061,16.22576]}, - "DN-CAMLE": {"code":"DN-CAMLE","name":"Cam Le","parent":"DN","level":"district","children":[],"center":[16.01409,108.19606],"bbox":[108.15769,15.97164,108.23951,16.06188]}, - "DN-HOAVANG": {"code":"DN-HOAVANG","name":"Hoa Vang","parent":"DN","level":"district","children":[],"center":[16.06583,108.01436],"bbox":[107.81854,15.91799,108.22494,16.21544]}, - "LD": {"code":"LD","name":"Lam Dong","parent":"VN","level":"province","children":["DL"],"center":[11.92055,108.4644],"bbox":[108.31521,11.80798,108.59461,12.00855],"partialCoverage":"one town covered"}, - "DL": {"code":"DL","name":"Da Lat","parent":"LD","level":"district","children":[],"center":[11.9404,108.4583],"bbox":[108.31521,11.80798,108.59461,12.00855]}, - "LA": {"code":"LA","name":"Long An","parent":"VN","level":"province","children":["DH","LA-BENLUC","LA-CANGIUOC"],"center":[10.77014,106.48428],"bbox":[106.27082,10.57389,106.73324,11.02578],"partialCoverage":"three districts covered"}, - "DH": {"code":"DH","name":"Duc Hoa","parent":"LA","level":"district","children":[],"center":[10.8888,106.3825],"bbox":[106.27082,10.7409,106.53287,11.02578]}, - "LA-BENLUC": {"code":"LA-BENLUC","name":"Ben Luc","parent":"LA","level":"district","children":[],"center":[10.69188,106.45105],"bbox":[106.33144,10.58941,106.56008,10.79766]}, - "LA-CANGIUOC": {"code":"LA-CANGIUOC","name":"Can Giuoc","parent":"LA","level":"district","children":[],"center":[10.61496,106.68721],"bbox":[106.63494,10.57389,106.73324,10.65666]}, - "DNA": {"code":"DNA","name":"Dong Nai","parent":"VN","level":"province","children":["DNA-BIENHOA","DNA-NHONTRACH","DNA-LONGTHANH","DNA-TRANGBOM","DNA-VINHCUU","DNA-THONGNHAT"],"center":[11.03911,107.02893],"bbox":[106.76197,10.64365,107.25919,11.51163],"partialCoverage":"six districts covered"}, - "DNA-BIENHOA": {"code":"DNA-BIENHOA","name":"Bien Hoa","parent":"DNA","level":"district","children":[],"center":[10.91804,106.88104],"bbox":[106.76197,10.8225,106.98711,11.00586]}, - "DNA-NHONTRACH": {"code":"DNA-NHONTRACH","name":"Nhon Trach","parent":"DNA","level":"district","children":[],"center":[10.73706,106.89264],"bbox":[106.82559,10.68785,106.96365,10.78522]}, - "DNA-LONGTHANH": {"code":"DNA-LONGTHANH","name":"Long Thanh","parent":"DNA","level":"district","children":[],"center":[10.76537,107.02319],"bbox":[106.86427,10.64365,107.1463,10.87903]}, - "DNA-TRANGBOM": {"code":"DNA-TRANGBOM","name":"Trang Bom","parent":"DNA","level":"district","children":[],"center":[10.96923,107.03504],"bbox":[106.90356,10.85913,107.12883,11.10274]}, - "DNA-VINHCUU": {"code":"DNA-VINHCUU","name":"Vinh Cuu","parent":"DNA","level":"district","children":[],"center":[11.23874,107.04996],"bbox":[106.77447,10.97383,107.23242,11.51163]}, - "DNA-THONGNHAT": {"code":"DNA-THONGNHAT","name":"Thong Nhat","parent":"DNA","level":"district","children":[],"center":[10.99013,107.15468],"bbox":[107.05891,10.8488,107.25919,11.10027]}, - "BD": {"code":"BD","name":"Binh Duong","parent":"VN","level":"province","children":["BD-DIAN","BD-THUANAN","BD-THUDAUMOT"],"center":[10.92921,106.7089],"bbox":[106.6298,10.86348,106.79462,10.99723],"partialCoverage":"three cities covered"}, - "BD-DIAN": {"code":"BD-DIAN","name":"Di An","parent":"BD","level":"district","children":[],"center":[10.90191,106.75607],"bbox":[106.73418,10.86348,106.79462,10.93726]}, - "BD-THUANAN": {"code":"BD-THUANAN","name":"Thuan An","parent":"BD","level":"district","children":[],"center":[10.9418,106.67865],"bbox":[106.6502,10.9208,106.7073,10.9636]}, - "BD-THUDAUMOT": {"code":"BD-THUDAUMOT","name":"Thu Dau Mot","parent":"BD","level":"district","children":[],"center":[10.9701,106.6585],"bbox":[106.6298,10.93972,106.68926,10.99723]}, - "TH": {"code":"TH","name":"Thanh Hoa","parent":"VN","level":"province","children":["TH-THANHHOA","TH-SAMSON"],"center":[19.79405,105.7771],"bbox":[105.64736,19.6996,105.93343,19.88351],"partialCoverage":"two towns covered"}, - "TH-THANHHOA": {"code":"TH-THANHHOA","name":"Thanh Hoa City","parent":"TH","level":"district","children":[],"center":[19.80359,105.7578],"bbox":[105.64736,19.72713,105.86242,19.88351]}, - "TH-SAMSON": {"code":"TH-SAMSON","name":"Sam Son","parent":"TH","level":"district","children":[],"center":[19.74492,105.87647],"bbox":[105.81954,19.6996,105.93343,19.78598]}, - "QNA": {"code":"QNA","name":"Quang Nam","parent":"VN","level":"province","children":["QNA-HOIAN"],"center":[15.89237,108.47816],"bbox":[108.28964,15.81121,108.69802,15.98104],"partialCoverage":"one town covered"}, - "QNA-HOIAN": {"code":"QNA-HOIAN","name":"Hoi An","parent":"QNA","level":"district","children":[],"center":[15.89237,108.47816],"bbox":[108.28964,15.81121,108.69802,15.98104]}, + "HN": {"code":"HN","name":"Ha Noi","parent":"VN","level":"province","children":["HN-BADINH","HN-HOANKIEM","HN-TAYHO","HN-LONGBIEN","HN-CAUGIAY","HN-DONGDA","HN-HAIBATRUNG","HN-HOANGMAI","HN-THANHXUAN","HN-BACTULIEM","HN-NAMTULIEM","HN-HADONG","HN-SONTAY","HN-BAVI","HN-CHUONGMY","HN-DANPHUONG","HN-DONGANH","HN-GIALAM","HN-HOAIDUC","HN-MELINH","HN-MYDUC","HN-PHUXUYEN","HN-PHUCTHO","HN-QUOCOAI","HN-SOCSON","HN-THACHTHAT","HN-THANHOAI","HN-THANHTRI","HN-THUONGTIN","HN-UNGHOA"],"nameVi":"Hà Nội","center":[21.0285,105.8542],"bbox":[105.28896,20.56452,106.02004,21.38542]}, + "HN-BADINH": {"code":"HN-BADINH","name":"Ba Dinh","parent":"HN","level":"district","children":[],"nameVi":"Ba Đình","center":[21.03578,105.826],"bbox":[105.80135,21.01596,105.85597,21.05337]}, + "HN-HOANKIEM": {"code":"HN-HOANKIEM","name":"Hoan Kiem","parent":"HN","level":"district","children":[],"nameVi":"Hoàn Kiếm","center":[21.02901,105.85378],"bbox":[105.84093,21.01813,105.86728,21.04321]}, + "HN-TAYHO": {"code":"HN-TAYHO","name":"Tay Ho","parent":"HN","level":"district","children":[],"nameVi":"Tây Hồ","center":[21.06931,105.81988],"bbox":[105.79178,21.03951,105.84828,21.09737]}, + "HN-LONGBIEN": {"code":"HN-LONGBIEN","name":"Long Bien","parent":"HN","level":"district","children":[],"nameVi":"Long Biên","center":[21.04246,105.89397],"bbox":[105.84106,20.99411,105.93877,21.08081]}, + "HN-CAUGIAY": {"code":"HN-CAUGIAY","name":"Cau Giay","parent":"HN","level":"district","children":[],"nameVi":"Cầu Giấy","center":[21.0306,105.79168],"bbox":[105.76716,21.00066,105.81352,21.05879]}, + "HN-DONGDA": {"code":"HN-DONGDA","name":"Dong Da","parent":"HN","level":"district","children":[],"nameVi":"Đống Đa","center":[21.01547,105.82503],"bbox":[105.79763,20.99742,105.84263,21.03215]}, + "HN-HAIBATRUNG": {"code":"HN-HAIBATRUNG","name":"Hai Ba Trung","parent":"HN","level":"district","children":[],"nameVi":"Hai Bà Trưng","center":[21.00564,105.8585],"bbox":[105.8409,20.98996,105.88241,21.02127]}, + "HN-HOANGMAI": {"code":"HN-HOANGMAI","name":"Hoang Mai","parent":"HN","level":"district","children":[],"nameVi":"Hoàng Mai","center":[20.97454,105.86322],"bbox":[105.80739,20.94544,105.90879,21.00376]}, + "HN-THANHXUAN": {"code":"HN-THANHXUAN","name":"Thanh Xuan","parent":"HN","level":"district","children":[],"nameVi":"Thanh Xuân","center":[20.99482,105.81531],"bbox":[105.79218,20.98056,105.84287,21.00885]}, + "HN-BACTULIEM": {"code":"HN-BACTULIEM","name":"Bac Tu Liem","parent":"HN","level":"district","children":[],"nameVi":"Bắc Từ Liêm","center":[21.07198,105.7613],"bbox":[105.71721,21.03798,105.80499,21.10812]}, + "HN-NAMTULIEM": {"code":"HN-NAMTULIEM","name":"Nam Tu Liem","parent":"HN","level":"district","children":[],"nameVi":"Nam Từ Liêm","center":[21.01256,105.76151],"bbox":[105.72656,20.97966,105.7992,21.05656]}, + "HN-HADONG": {"code":"HN-HADONG","name":"Ha Dong","parent":"HN","level":"district","children":[],"nameVi":"Hà Đông","center":[20.95356,105.75706],"bbox":[105.70525,20.91082,105.8043,20.99698]}, + "HN-SONTAY": {"code":"HN-SONTAY","name":"Son Tay","parent":"HN","level":"district","children":[],"nameVi":"Sơn Tây","center":[21.09694,105.47954],"bbox":[105.41344,21.02175,105.53798,21.17188]}, + "HN-BAVI": {"code":"HN-BAVI","name":"Ba Vi","parent":"HN","level":"district","children":[],"nameVi":"Ba Vì","center":[21.14125,105.38297],"bbox":[105.28896,21.0074,105.47524,21.30451]}, + "HN-CHUONGMY": {"code":"HN-CHUONGMY","name":"Chuong My","parent":"HN","level":"district","children":[],"nameVi":"Chương Mỹ","center":[20.87763,105.66036],"bbox":[105.55102,20.7919,105.75984,20.96484]}, + "HN-DANPHUONG": {"code":"HN-DANPHUONG","name":"Dan Phuong","parent":"HN","level":"district","children":[],"nameVi":"Đan Phượng","center":[21.11385,105.67739],"bbox":[105.62321,21.06614,105.74057,21.17336]}, + "HN-DONGANH": {"code":"HN-DONGANH","name":"Dong Anh","parent":"HN","level":"district","children":[],"nameVi":"Đông Anh","center":[21.13619,105.84265],"bbox":[105.73214,21.06689,105.92674,21.20628]}, + "HN-GIALAM": {"code":"HN-GIALAM","name":"Gia Lam","parent":"HN","level":"district","children":[],"nameVi":"Gia Lâm","center":[21.02888,105.95055],"bbox":[105.88147,20.92633,106.02004,21.12119]}, + "HN-HOAIDUC": {"code":"HN-HOAIDUC","name":"Hoai Duc","parent":"HN","level":"district","children":[],"nameVi":"Hoài Đức","center":[21.02405,105.69946],"bbox":[105.64615,20.95542,105.74275,21.09028]}, + "HN-MELINH": {"code":"HN-MELINH","name":"Me Linh","parent":"HN","level":"district","children":[],"nameVi":"Mê Linh","center":[21.18352,105.7058],"bbox":[105.61499,21.12176,105.7909,21.23963]}, + "HN-MYDUC": {"code":"HN-MYDUC","name":"My Duc","parent":"HN","level":"district","children":[],"nameVi":"Mỹ Đức","center":[20.68678,105.72026],"bbox":[105.64768,20.56452,105.81286,20.82997]}, + "HN-PHUXUYEN": {"code":"HN-PHUXUYEN","name":"Phu Xuyen","parent":"HN","level":"district","children":[],"nameVi":"Phú Xuyên","center":[20.73559,105.90112],"bbox":[105.7918,20.65079,106.00392,20.80684]}, + "HN-PHUCTHO": {"code":"HN-PHUCTHO","name":"Phuc Tho","parent":"HN","level":"district","children":[],"nameVi":"Phúc Thọ","center":[21.11707,105.58374],"bbox":[105.50053,21.04026,105.66491,21.17912]}, + "HN-QUOCOAI": {"code":"HN-QUOCOAI","name":"Quoc Oai","parent":"HN","level":"district","children":[],"nameVi":"Quốc Oai","center":[20.96769,105.59765],"bbox":[105.4782,20.90939,105.72589,21.04616]}, + "HN-SOCSON": {"code":"HN-SOCSON","name":"Soc Son","parent":"HN","level":"district","children":[],"nameVi":"Sóc Sơn","center":[21.27256,105.83045],"bbox":[105.72329,21.17692,105.93902,21.38542]}, + "HN-THACHTHAT": {"code":"HN-THACHTHAT","name":"Thach That","parent":"HN","level":"district","children":[],"nameVi":"Thạch Thất","center":[21.01894,105.53016],"bbox":[105.39192,20.94573,105.63988,21.10199]}, + "HN-THANHOAI": {"code":"HN-THANHOAI","name":"Thanh Oai","parent":"HN","level":"district","children":[],"nameVi":"Thanh Oai","center":[20.85577,105.78086],"bbox":[105.72512,20.78033,105.84123,20.93973]}, + "HN-THANHTRI": {"code":"HN-THANHTRI","name":"Thanh Tri","parent":"HN","level":"district","children":[],"nameVi":"Thanh Trì","center":[20.93266,105.84089],"bbox":[105.78821,20.89699,105.91311,20.98653]}, + "HN-THUONGTIN": {"code":"HN-THUONGTIN","name":"Thuong Tin","parent":"HN","level":"district","children":[],"nameVi":"Thường Tín","center":[20.84497,105.87482],"bbox":[105.81267,20.75192,105.93617,20.91223]}, + "HN-UNGHOA": {"code":"HN-UNGHOA","name":"Ung Hoa","parent":"HN","level":"district","children":[],"nameVi":"Ứng Hòa","center":[20.7119,105.79932],"bbox":[105.69662,20.61188,105.90562,20.80969]}, + "TPHCM": {"code":"TPHCM","name":"Ho Chi Minh","parent":"VN","level":"province","children":["TPHCM-Q1","TPHCM-Q3","TPHCM-Q4","TPHCM-Q5","TPHCM-Q6","TPHCM-Q7","TPHCM-Q8","TPHCM-Q10","TPHCM-Q11","TPHCM-Q12","TPHCM-BINHTAN","TPHCM-BINHTHANH","TPHCM-GOVAP","TPHCM-PHUNHUAN","TPHCM-TANBINH","TPHCM-TANPHU","TPHCM-THUDUC","TPHCM-BINHCHANH","TPHCM-CANGIO","TPHCM-CUCHI","TPHCM-HOCMON","TPHCM-NHABE"],"nameVi":"Hồ Chí Minh","center":[10.8231,106.6297],"bbox":[106.46356,10.35818,107.02767,10.92934],"missingParts":1}, + "TPHCM-Q1": {"code":"TPHCM-Q1","name":"District 1","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 1","center":[10.77622,106.69715],"bbox":[106.68169,10.75352,106.7152,10.79693]}, + "TPHCM-Q3": {"code":"TPHCM-Q3","name":"District 3","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 3","center":[10.78146,106.68284],"bbox":[106.66429,10.76543,106.69813,10.79251]}, + "TPHCM-Q4": {"code":"TPHCM-Q4","name":"District 4","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 4","center":[10.75846,106.70554],"bbox":[106.68642,10.75171,106.72228,10.76976]}, + "TPHCM-Q5": {"code":"TPHCM-Q5","name":"District 5","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 5","center":[10.75494,106.66946],"bbox":[106.65051,10.74614,106.68727,10.76543]}, + "TPHCM-Q6": {"code":"TPHCM-Q6","name":"District 6","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 6","center":[10.74587,106.63524],"bbox":[106.62108,10.72973,106.65505,10.76202]}, + "TPHCM-Q7": {"code":"TPHCM-Q7","name":"District 7","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 7","center":[10.73646,106.72962],"bbox":[106.69009,10.69795,106.76866,10.77763]}, + "TPHCM-Q8": {"code":"TPHCM-Q8","name":"District 8","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 8","center":[10.7245,106.64055],"bbox":[106.59456,10.68985,106.69285,10.75211]}, + "TPHCM-Q10": {"code":"TPHCM-Q10","name":"District 10","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 10","center":[10.77237,106.66826],"bbox":[106.65613,10.75843,106.6828,10.78693]}, + "TPHCM-Q11": {"code":"TPHCM-Q11","name":"District 11","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 11","center":[10.76412,106.64773],"bbox":[106.6342,10.7534,106.66156,10.77845]}, + "TPHCM-Q12": {"code":"TPHCM-Q12","name":"District 12","parent":"TPHCM","level":"district","children":[],"nameVi":"Quận 12","center":[10.86453,106.65697],"bbox":[106.60328,10.81861,106.7182,10.9042]}, + "TPHCM-BINHTAN": {"code":"TPHCM-BINHTAN","name":"Binh Tan","parent":"TPHCM","level":"district","children":[],"nameVi":"Bình Tân","center":[10.76473,106.59754],"bbox":[106.55831,10.71096,106.62526,10.82978]}, + "TPHCM-BINHTHANH": {"code":"TPHCM-BINHTHANH","name":"Binh Thanh","parent":"TPHCM","level":"district","children":[],"nameVi":"Bình Thạnh","center":[10.81206,106.7162],"bbox":[106.68385,10.78534,106.75139,10.83917]}, + "TPHCM-GOVAP": {"code":"TPHCM-GOVAP","name":"Go Vap","parent":"TPHCM","level":"district","children":[],"nameVi":"Gò Vấp","center":[10.8383,106.66741],"bbox":[106.63194,10.80997,106.70106,10.86079]}, + "TPHCM-PHUNHUAN": {"code":"TPHCM-PHUNHUAN","name":"Phu Nhuan","parent":"TPHCM","level":"district","children":[],"nameVi":"Phú Nhuận","center":[10.80033,106.67817],"bbox":[106.66596,10.78791,106.69248,10.81414]}, + "TPHCM-TANBINH": {"code":"TPHCM-TANBINH","name":"Tan Binh","parent":"TPHCM","level":"district","children":[],"nameVi":"Tân Bình","center":[10.80882,106.65239],"bbox":[106.62789,10.76891,106.67861,10.83725]}, + "TPHCM-TANPHU": {"code":"TPHCM-TANPHU","name":"Tan Phu","parent":"TPHCM","level":"district","children":[],"nameVi":"Tân Phú","center":[10.79255,106.62689],"bbox":[106.6074,10.75817,106.64768,10.82469]}, + "TPHCM-THUDUC": {"code":"TPHCM-THUDUC","name":"Thu Duc","parent":"TPHCM","level":"district","children":[],"nameVi":"Thủ Đức","center":[10.82035,106.78951],"bbox":[106.69783,10.74212,106.88176,10.89885]}, + "TPHCM-BINHCHANH": {"code":"TPHCM-BINHCHANH","name":"Binh Chanh","parent":"TPHCM","level":"district","children":[],"nameVi":"Bình Chánh","center":[10.73382,106.57587],"bbox":[106.46356,10.62531,106.69495,10.87364]}, + "TPHCM-CANGIO": {"code":"TPHCM-CANGIO","name":"Can Gio","parent":"TPHCM","level":"district","children":[],"nameVi":"Cần Giờ","center":[10.51704,106.881],"bbox":[106.73846,10.35818,107.02767,10.67427]}, + "TPHCM-CUCHI": {"code":"TPHCM-CUCHI","name":"Cu Chi","parent":"TPHCM","level":"district","children":[],"nameVi":"Củ Chi","coverage":"unresolved"}, + "TPHCM-HOCMON": {"code":"TPHCM-HOCMON","name":"Hoc Mon","parent":"TPHCM","level":"district","children":[],"nameVi":"Hóc Môn","center":[10.88585,106.58983],"bbox":[106.49562,10.8261,106.69279,10.92934]}, + "TPHCM-NHABE": {"code":"TPHCM-NHABE","name":"Nha Be","parent":"TPHCM","level":"district","children":[],"nameVi":"Nhà Bè","center":[10.6543,106.73051],"bbox":[106.67328,10.57636,106.78373,10.72563]}, + "DN": {"code":"DN","name":"Da Nang","parent":"VN","level":"province","children":["DN-HAICHAU","DN-THANHKHE","DN-SONTRA","DN-NGUHANHSON","DN-LIENCHIEU","DN-CAMLE","DN-HOAVANG"],"nameVi":"Đà Nẵng","center":[16.0544,108.2022],"bbox":[107.81854,15.91799,108.33864,16.22576]}, + "DN-HAICHAU": {"code":"DN-HAICHAU","name":"Hai Chau","parent":"DN","level":"district","children":[],"nameVi":"Hải Châu","center":[16.05669,108.21143],"bbox":[108.18883,16.02131,108.236,16.10365]}, + "DN-THANHKHE": {"code":"DN-THANHKHE","name":"Thanh Khe","parent":"DN","level":"district","children":[],"nameVi":"Thanh Khê","center":[16.06315,108.19039],"bbox":[108.16378,16.02637,108.21456,16.08084]}, + "DN-SONTRA": {"code":"DN-SONTRA","name":"Son Tra","parent":"DN","level":"district","children":[],"nameVi":"Sơn Trà","center":[16.11094,108.26514],"bbox":[108.20903,16.05099,108.33864,16.15565]}, + "DN-NGUHANHSON": {"code":"DN-NGUHANHSON","name":"Ngu Hanh Son","parent":"DN","level":"district","children":[],"nameVi":"Ngũ Hành Sơn","center":[15.99755,108.25003],"bbox":[108.21683,15.95597,108.28957,16.05675]}, + "DN-LIENCHIEU": {"code":"DN-LIENCHIEU","name":"Lien Chieu","parent":"DN","level":"district","children":[],"nameVi":"Liên Chiểu","center":[16.13388,108.14835],"bbox":[108.08699,16.0331,108.21061,16.22576]}, + "DN-CAMLE": {"code":"DN-CAMLE","name":"Cam Le","parent":"DN","level":"district","children":[],"nameVi":"Cẩm Lệ","center":[16.01409,108.19606],"bbox":[108.15769,15.97164,108.23951,16.06188]}, + "DN-HOAVANG": {"code":"DN-HOAVANG","name":"Hoa Vang","parent":"DN","level":"district","children":[],"nameVi":"Hòa Vang","center":[16.06583,108.01436],"bbox":[107.81854,15.91799,108.22494,16.21544]}, + "LD": {"code":"LD","name":"Lam Dong","parent":"VN","level":"province","children":["DL"],"nameVi":"Lâm Đồng","center":[11.92055,108.4644],"bbox":[108.31521,11.80798,108.59461,12.00855],"partialCoverage":"one town covered"}, + "DL": {"code":"DL","name":"Da Lat","parent":"LD","level":"district","children":[],"nameVi":"Đà Lạt","center":[11.9404,108.4583],"bbox":[108.31521,11.80798,108.59461,12.00855]}, + "LA": {"code":"LA","name":"Long An","parent":"VN","level":"province","children":["DH","LA-BENLUC","LA-CANGIUOC"],"nameVi":"Long An","center":[10.77014,106.48428],"bbox":[106.27082,10.57389,106.73324,11.02578],"partialCoverage":"three districts covered"}, + "DH": {"code":"DH","name":"Duc Hoa","parent":"LA","level":"district","children":[],"nameVi":"Đức Hòa","center":[10.8888,106.3825],"bbox":[106.27082,10.7409,106.53287,11.02578]}, + "LA-BENLUC": {"code":"LA-BENLUC","name":"Ben Luc","parent":"LA","level":"district","children":[],"nameVi":"Bến Lức","center":[10.69188,106.45105],"bbox":[106.33144,10.58941,106.56008,10.79766]}, + "LA-CANGIUOC": {"code":"LA-CANGIUOC","name":"Can Giuoc","parent":"LA","level":"district","children":[],"nameVi":"Cần Giuộc","center":[10.61496,106.68721],"bbox":[106.63494,10.57389,106.73324,10.65666]}, + "DNA": {"code":"DNA","name":"Dong Nai","parent":"VN","level":"province","children":["DNA-BIENHOA","DNA-NHONTRACH","DNA-LONGTHANH","DNA-TRANGBOM","DNA-VINHCUU","DNA-THONGNHAT"],"nameVi":"Đồng Nai","center":[11.03911,107.02893],"bbox":[106.76197,10.64365,107.25919,11.51163],"partialCoverage":"six districts covered"}, + "DNA-BIENHOA": {"code":"DNA-BIENHOA","name":"Bien Hoa","parent":"DNA","level":"district","children":[],"nameVi":"Biên Hòa","center":[10.91804,106.88104],"bbox":[106.76197,10.8225,106.98711,11.00586]}, + "DNA-NHONTRACH": {"code":"DNA-NHONTRACH","name":"Nhon Trach","parent":"DNA","level":"district","children":[],"nameVi":"Nhơn Trạch","center":[10.73706,106.89264],"bbox":[106.82559,10.68785,106.96365,10.78522]}, + "DNA-LONGTHANH": {"code":"DNA-LONGTHANH","name":"Long Thanh","parent":"DNA","level":"district","children":[],"nameVi":"Long Thành","center":[10.76537,107.02319],"bbox":[106.86427,10.64365,107.1463,10.87903]}, + "DNA-TRANGBOM": {"code":"DNA-TRANGBOM","name":"Trang Bom","parent":"DNA","level":"district","children":[],"nameVi":"Trảng Bom","center":[10.96923,107.03504],"bbox":[106.90356,10.85913,107.12883,11.10274]}, + "DNA-VINHCUU": {"code":"DNA-VINHCUU","name":"Vinh Cuu","parent":"DNA","level":"district","children":[],"nameVi":"Vĩnh Cửu","center":[11.23874,107.04996],"bbox":[106.77447,10.97383,107.23242,11.51163]}, + "DNA-THONGNHAT": {"code":"DNA-THONGNHAT","name":"Thong Nhat","parent":"DNA","level":"district","children":[],"nameVi":"Thống Nhất","center":[10.99013,107.15468],"bbox":[107.05891,10.8488,107.25919,11.10027]}, + "BD": {"code":"BD","name":"Binh Duong","parent":"VN","level":"province","children":["BD-DIAN","BD-THUANAN","BD-THUDAUMOT"],"nameVi":"Bình Dương","center":[10.92921,106.7089],"bbox":[106.6298,10.86348,106.79462,10.99723],"partialCoverage":"three cities covered"}, + "BD-DIAN": {"code":"BD-DIAN","name":"Di An","parent":"BD","level":"district","children":[],"nameVi":"Dĩ An","center":[10.90191,106.75607],"bbox":[106.73418,10.86348,106.79462,10.93726]}, + "BD-THUANAN": {"code":"BD-THUANAN","name":"Thuan An","parent":"BD","level":"district","children":[],"nameVi":"Thuận An","center":[10.9418,106.67865],"bbox":[106.6502,10.9208,106.7073,10.9636]}, + "BD-THUDAUMOT": {"code":"BD-THUDAUMOT","name":"Thu Dau Mot","parent":"BD","level":"district","children":[],"nameVi":"Thủ Dầu Một","center":[10.9701,106.6585],"bbox":[106.6298,10.93972,106.68926,10.99723]}, + "TH": {"code":"TH","name":"Thanh Hoa","parent":"VN","level":"province","children":["TH-THANHHOA","TH-SAMSON"],"nameVi":"Thanh Hóa","center":[19.79405,105.7771],"bbox":[105.64736,19.6996,105.93343,19.88351],"partialCoverage":"two towns covered"}, + "TH-THANHHOA": {"code":"TH-THANHHOA","name":"Thanh Hoa City","parent":"TH","level":"district","children":[],"nameVi":"TP. Thanh Hóa","center":[19.80359,105.7578],"bbox":[105.64736,19.72713,105.86242,19.88351]}, + "TH-SAMSON": {"code":"TH-SAMSON","name":"Sam Son","parent":"TH","level":"district","children":[],"nameVi":"Sầm Sơn","center":[19.74492,105.87647],"bbox":[105.81954,19.6996,105.93343,19.78598]}, + "QNA": {"code":"QNA","name":"Quang Nam","parent":"VN","level":"province","children":["QNA-HOIAN"],"nameVi":"Quảng Nam","center":[15.89237,108.47816],"bbox":[108.28964,15.81121,108.69802,15.98104],"partialCoverage":"one town covered"}, + "QNA-HOIAN": {"code":"QNA-HOIAN","name":"Hoi An","parent":"QNA","level":"district","children":[],"nameVi":"Hội An","center":[15.89237,108.47816],"bbox":[108.28964,15.81121,108.69802,15.98104]}, }; diff --git a/src/lib/geo-search.js b/src/lib/geo-search.js index 4b95acd..e996609 100644 --- a/src/lib/geo-search.js +++ b/src/lib/geo-search.js @@ -7,7 +7,7 @@ // test in tests/geo-search.test.js enforces that this module never grows a // path to pano-index.js, pano-db.js, or the boundary barrel. -import { allRegions, getRegion, ancestorsOf, COUNTRY_CODE } from './regions.js'; +import { allRegions, getRegion, ancestorsOf, COUNTRY_CODE, regionName } from './regions.js'; export const PHOTON_ENDPOINT = 'https://photon.komoot.io/api'; @@ -42,7 +42,9 @@ const ADMIN_PREFIX = /^(?:quan|q\.?|huyen|h\.?|phuong|p\.?|district|thi xa|tx\.? * @returns {string[]} Folded keys. */ export function regionSearchKeys(region) { - const aliases = [foldDiacritics(region.name)]; + // Both spellings: the ASCII name a foreign player types and the accented + // Vietnamese one a local does, each folded so diacritics never matter. + const aliases = [...new Set([region.name, region.nameVi].filter(Boolean).map(foldDiacritics))]; const districtNumber = aliases[0].match(/^district (\d+)$/); if (districtNumber) { aliases.push(`quan ${districtNumber[1]}`, `q ${districtNumber[1]}`); @@ -92,13 +94,13 @@ export function searchRegions(query, rootCode) { } return scored - .sort((a, b) => a.rank - b.rank || a.region.name.localeCompare(b.region.name)) + .sort((a, b) => a.rank - b.rank || regionName(a.region.code).localeCompare(regionName(b.region.code))) .slice(0, 5) .map(({ region }) => ({ kind: 'region', - label: region.name, + label: regionName(region.code), sublabel: region.parent && region.parent !== COUNTRY_CODE - ? getRegion(region.parent).name + ? regionName(region.parent) : 'Province', center: region.center ?? null, bbox: region.bbox ?? null, diff --git a/src/lib/region-request.js b/src/lib/region-request.js index bd5bac3..3ccbddd 100644 --- a/src/lib/region-request.js +++ b/src/lib/region-request.js @@ -4,7 +4,7 @@ // or defaulting slightly differently is how a typo becomes a leaderboard key // nobody reads. -import { getRegion, isRegion, isPlayable, regionPath, COUNTRY_CODE } from './regions.js'; +import { getRegion, isRegion, isPlayable, regionPath, COUNTRY_CODE, regionName } from './regions.js'; /** * A 400-shaped failure a route can return directly. @@ -56,7 +56,7 @@ export function resolvePlayableRegion(searchParams) { if (!resolved.ok) return resolved; if (!isPlayable(resolved.code)) { - return reject(`${getRegion(resolved.code).name} has no street view coverage yet`); + return reject(`${regionName(resolved.code)} has no street view coverage yet`); } return resolved; } @@ -74,7 +74,7 @@ export function publicRegion(code) { const region = getRegion(code); return { code, - name: region.name, + name: regionName(code), path: regionPath(code), level: region.level, }; diff --git a/src/lib/regions.js b/src/lib/regions.js index 776ea00..05f6b1b 100644 --- a/src/lib/regions.js +++ b/src/lib/regions.js @@ -35,6 +35,20 @@ export function isRegion(code) { return Boolean(code && REGIONS[code]); } +/** + * The name to show a player: the accented Vietnamese form where the tree has + * one, else the ASCII `name`. Places are named the way they are written where + * they are -- "Hoàn Kiếm", not "Hoan Kiem" -- and the country stays "Vietnam" + * because the rest of the interface is English. `name` remains the stable + * ASCII form for search aliases and logs. + * @param {string} code Region code. + * @returns {string} + */ +export function regionName(code) { + const region = getRegion(code); + return region.nameVi ?? region.name; +} + /** * The URL path segment for a region: `/game/tphcm-q7`. * @@ -148,7 +162,7 @@ export function provinces() { export function regionPath(code) { return ancestorsOf(code) .reverse() - .map((c) => getRegion(c).name); + .map((c) => regionName(c)); } /** Every node in the tree, in declaration order. */ diff --git a/tests/e2e/audio.spec.js b/tests/e2e/audio.spec.js index 8df8610..d82c452 100644 --- a/tests/e2e/audio.spec.js +++ b/tests/e2e/audio.spec.js @@ -73,7 +73,7 @@ for (const width of [320, 360]) { test(`game header fits a ${width}px viewport`, async ({ page }) => { await page.setViewportSize({ width, height: 720 }); await page.goto('/game/tphcm'); - await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + await expect(page.getByText('Hồ Chí Minh', { exact: true })).toBeVisible(); // One combined switch, not the pair: the pair is display:none here, which // also keeps it out of the accessibility tree. @@ -90,7 +90,7 @@ for (const width of [320, 360]) { test('mutes everything from the compact switch on a phone', async ({ page }) => { await page.setViewportSize({ width: 360, height: 720 }); await page.goto('/game/tphcm'); - await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + await expect(page.getByText('Hồ Chí Minh', { exact: true })).toBeVisible(); const mute = page.getByRole('button', { name: 'Sound', exact: true }); await expect(mute).toHaveAttribute('aria-pressed', 'true'); @@ -116,7 +116,7 @@ test('plays on with audio files missing', async ({ page }) => { page.on('pageerror', (error) => errors.push(error.message)); await page.goto('/game/tphcm'); - await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + await expect(page.getByText('Hồ Chí Minh', { exact: true })).toBeVisible(); // Effects stay ON here: muting them is what made an earlier version of this // test exercise only the music path. @@ -139,7 +139,7 @@ test('plays on with audio files missing', async ({ page }) => { test('keeps both header variants in step across the breakpoint', async ({ page }) => { await page.setViewportSize({ width: 360, height: 720 }); await page.goto('/game/tphcm'); - await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + await expect(page.getByText('Hồ Chí Minh', { exact: true })).toBeVisible(); await page.getByRole('button', { name: 'Sound', exact: true }).click(); diff --git a/tests/e2e/game.spec.js b/tests/e2e/game.spec.js index d154fac..4db9cd6 100644 --- a/tests/e2e/game.spec.js +++ b/tests/e2e/game.spec.js @@ -16,7 +16,7 @@ test.beforeEach(async ({ page }) => { test('plays a round to the reveal and into the next one', async ({ page }) => { // Round loaded: header badge names the picked region, viewer container is up. - await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + await expect(page.getByText('Hồ Chí Minh', { exact: true })).toBeVisible(); // No guess yet: the submit button says so and is disabled. const submit = page.getByRole('button', { name: /Place a guess first/ }); @@ -42,7 +42,7 @@ test('plays a round to the reveal and into the next one', async ({ page }) => { await expect(dialog).toBeVisible(); // exact: the sr-only dialog description ("... 123m away.") also contains it. await expect(dialog.getByText('123m away', { exact: true })).toBeVisible(); - await expect(dialog.getByText('Vietnam › Ho Chi Minh › District 7')).toBeVisible(); + await expect(dialog.getByText('Vietnam › Hồ Chí Minh › Quận 7')).toBeVisible(); // The bookkeeping lives behind a collapsed "Leaderboard results" section so // the payoff fits one viewport; open it before asserting its contents. @@ -50,7 +50,7 @@ test('plays a round to the reveal and into the next one', async ({ page }) => { // The scoring ladder, with the achieved band present. await expect(dialog.getByText('≤200m = 3')).toBeVisible(); await expect(dialog.getByText('Score added at 3 levels (+3, +3, +3)')).toBeVisible(); - await expect(dialog.getByText('District 7', { exact: true })).toBeVisible(); + await expect(dialog.getByText('Quận 7', { exact: true })).toBeVisible(); // Next round resets to a fresh, unguessed state and swaps in a new image. await dialog.getByRole('button', { name: 'Next Round' }).click(); diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js index 37d86fc..3b3d156 100644 --- a/tests/e2e/helpers.js +++ b/tests/e2e/helpers.js @@ -27,8 +27,8 @@ export function newGameResponse(sessionId, round) { sessionId, region: { code: 'TPHCM', - name: 'Ho Chi Minh', - path: ['Vietnam', 'Ho Chi Minh'], + name: 'Hồ Chí Minh', + path: ['Vietnam', 'Hồ Chí Minh'], level: 'province', }, // A distinct URL per round, as in production (the session id is NOT the @@ -38,7 +38,7 @@ export function newGameResponse(sessionId, round) { }; } -/** /api/guess response: a 123m round on a TPHCM pick, revealed as District 7. */ +/** /api/guess response: a 123m round on a TPHCM pick, revealed as Quận 7. */ export function guessResponse(username) { // `points` is what this round added at that level; `score` is the // accumulated total. Values mirror the real route: 123m is 3 points on the @@ -52,19 +52,19 @@ export function guessResponse(username) { distance: 123, score: 3, levels: [ - scoreLevel('TPHCM-Q7', 'District 7', 1, 3), - scoreLevel('TPHCM', 'Ho Chi Minh', 2, 3), + scoreLevel('TPHCM-Q7', 'Quận 7', 1, 3), + scoreLevel('TPHCM', 'Hồ Chí Minh', 2, 3), scoreLevel('VN', 'Vietnam', 5, 3), ], distanceLevels: [ - distanceLevel('TPHCM-Q7', 'District 7', 1), - distanceLevel('TPHCM', 'Ho Chi Minh', 3), + distanceLevel('TPHCM-Q7', 'Quận 7', 1), + distanceLevel('TPHCM', 'Hồ Chí Minh', 3), distanceLevel('VN', 'Vietnam', 9), ], region: { code: 'TPHCM-Q7', - name: 'District 7', - path: ['Vietnam', 'Ho Chi Minh', 'District 7'], + name: 'Quận 7', + path: ['Vietnam', 'Hồ Chí Minh', 'Quận 7'], level: 'district', }, globalRank: 5, diff --git a/tests/e2e/home.spec.js b/tests/e2e/home.spec.js index ade97ea..8f4b28e 100644 --- a/tests/e2e/home.spec.js +++ b/tests/e2e/home.spec.js @@ -13,15 +13,15 @@ test.beforeEach(async ({ page }) => { test('offers the country and every province', async ({ page }) => { await expect(page.getByRole('link', { name: /Play anywhere in Vietnam/ })).toBeVisible(); - for (const province of ['Ha Noi', 'Ho Chi Minh', 'Da Nang', 'Lam Dong', 'Long An']) { + for (const province of ['Ha Noi', 'Hồ Chí Minh', 'Da Nang', 'Lam Dong', 'Long An']) { await expect(page.getByRole('button', { name: new RegExp(province) })).toBeVisible(); } }); test('expands a province to playable district links', async ({ page }) => { - await page.getByRole('button', { name: /Ho Chi Minh/ }).click(); - await expect(page.getByRole('link', { name: /Play anywhere in Ho Chi Minh/ })).toBeVisible(); - const district7 = page.getByRole('link', { name: /District 7/ }); + await page.getByRole('button', { name: /Hồ Chí Minh/ }).click(); + await expect(page.getByRole('link', { name: /Play anywhere in Hồ Chí Minh/ })).toBeVisible(); + const district7 = page.getByRole('link', { name: /Quận 7/ }); await expect(district7).toBeVisible(); await expect(district7).toHaveAttribute('href', '/game/tphcm-q7'); }); @@ -29,10 +29,10 @@ test('expands a province to playable district links', async ({ page }) => { test('lists an uncovered district as disabled, with the reason', async ({ page }) => { // Cu Chi has no OSM boundary left; it must be shown, not hidden, and carry // no game link. - await page.getByRole('button', { name: /Ho Chi Minh/ }).click(); - const row = page.locator('div', { hasText: /^Cu Chino map data$/ }).last(); + await page.getByRole('button', { name: /Hồ Chí Minh/ }).click(); + const row = page.locator('div', { hasText: /^Củ Chino map data$/ }).last(); await expect(row).toBeVisible(); - await expect(page.getByRole('link', { name: /^Cu Chi$/ })).toHaveCount(0); + await expect(page.getByRole('link', { name: /^Củ Chi$/ })).toHaveCount(0); }); test('shows the build commit in the debug footer and copies it on click', async ({ page, context }) => { diff --git a/tests/e2e/routing.spec.js b/tests/e2e/routing.spec.js index 3602abe..858097b 100644 --- a/tests/e2e/routing.spec.js +++ b/tests/e2e/routing.spec.js @@ -34,7 +34,7 @@ test('serves the canonical region URL without redirecting', async ({ page }) => expect(response.status()).toBe(200); expect(landedAt(page)).toBe('/game/tphcm'); // Landed on a real round, not an error panel. - await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + await expect(page.getByText('Hồ Chí Minh', { exact: true })).toBeVisible(); }); test('redirects the legacy ?region= form', async ({ page }) => { @@ -74,7 +74,7 @@ test('serves an uppercase URL directly, without redirecting', async ({ page }) = const response = await page.goto('/game/TPHCM'); expect(response.status()).toBe(200); expect(landedAt(page)).toBe('/game/TPHCM'); - await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + await expect(page.getByText('Hồ Chí Minh', { exact: true })).toBeVisible(); }); test('lowercases an uppercase legacy region in one hop', async ({ page }) => { diff --git a/tests/geo-search.test.js b/tests/geo-search.test.js index 6246ba5..ada19a9 100644 --- a/tests/geo-search.test.js +++ b/tests/geo-search.test.js @@ -36,15 +36,15 @@ describe('regionSearchKeys', () => { describe('searchRegions', () => { it('matches Vietnamese admin phrasing against the tree names', () => { - expect(searchRegions('quận 7', 'TPHCM')[0].label).toBe('District 7'); - expect(searchRegions('q7', 'TPHCM')[0].label).toBe('District 7'); - expect(searchRegions('hoàn kiếm', 'VN')[0].label).toBe('Hoan Kiem'); - expect(searchRegions('huyện Hóc Môn', 'VN')[0].label).toBe('Hoc Mon'); + expect(searchRegions('quận 7', 'TPHCM')[0].label).toBe('Quận 7'); + expect(searchRegions('q7', 'TPHCM')[0].label).toBe('Quận 7'); + expect(searchRegions('hoàn kiếm', 'VN')[0].label).toBe('Hoàn Kiếm'); + expect(searchRegions('huyện Hóc Môn', 'VN')[0].label).toBe('Hóc Môn'); }); it('scopes results to the played region', () => { expect(searchRegions('district 1', 'HN')).toEqual([]); - expect(searchRegions('tay ho', 'HN')[0].label).toBe('Tay Ho'); + expect(searchRegions('tay ho', 'HN')[0].label).toBe('Tây Hồ'); }); it('returns nothing for junk or too-short queries', () => { diff --git a/tests/guess-route.test.js b/tests/guess-route.test.js index 373ea1a..7fb9bea 100644 --- a/tests/guess-route.test.js +++ b/tests/guess-route.test.js @@ -82,7 +82,7 @@ describe('POST /api/guess', () => { const body = await ( await guess({ username: 'mai', sessionId: 's3', guessLat: HCMC.lat, guessLng: HCMC.lng }) ).json(); - expect(body.gameResult.region.path).toEqual(['Vietnam', 'Ho Chi Minh', 'District 7']); + expect(body.gameResult.region.path).toEqual(['Vietnam', 'Hồ Chí Minh', 'Quận 7']); }); it('cannot be replayed for double credit', async () => { diff --git a/tests/new-game-route.test.js b/tests/new-game-route.test.js index 3147c93..13ee52c 100644 --- a/tests/new-game-route.test.js +++ b/tests/new-game-route.test.js @@ -115,7 +115,7 @@ describe('GET /api/new-game', () => { it('rejects a region with no coverage, naming it', async () => { const response = await GET(request('region=TPHCM-CUCHI')); expect(response.status).toBe(400); - expect((await response.json()).error).toMatch(/Cu Chi/); + expect((await response.json()).error).toMatch(/Củ Chi/); }); it('rejects a missing region', async () => { diff --git a/tests/region-request.test.js b/tests/region-request.test.js index 6597ae2..b9c69e2 100644 --- a/tests/region-request.test.js +++ b/tests/region-request.test.js @@ -70,8 +70,8 @@ describe('publicRegion', () => { it('describes what the player picked', () => { expect(publicRegion('TPHCM')).toEqual({ code: 'TPHCM', - name: 'Ho Chi Minh', - path: ['Vietnam', 'Ho Chi Minh'], + name: 'Hồ Chí Minh', + path: ['Vietnam', 'Hồ Chí Minh'], level: 'province', }); }); diff --git a/tests/regions.test.js b/tests/regions.test.js index fc0b1fc..15d17be 100644 --- a/tests/regions.test.js +++ b/tests/regions.test.js @@ -134,7 +134,7 @@ describe('ancestry', () => { }); it('renders an outermost-first path', () => { - expect(regionPath('DL')).toEqual(['Vietnam', 'Lam Dong', 'Da Lat']); + expect(regionPath('DL')).toEqual(['Vietnam', 'Lâm Đồng', 'Đà Lạt']); }); it('rejects an unknown code with the known ones listed', () => {