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', () => {