diff --git a/AGENTS.md b/AGENTS.md index 08a8b46..90f9797 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ history/ ├── eslint.config.js # flat config (ESLint 9) ├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/ ├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases -├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 168 tests) +├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 189 tests) ├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1) ├── index.html ├── presets/ # 5 scenario JSON files + index.json (manifest) @@ -64,12 +64,13 @@ history/ │ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator) │ ├── regions.geo.json # 1,800 sub-regions, id=-, parent=ISO_A3 (the choropleth) │ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1) -│ └── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2) +│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2) +│ └── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44) ├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only; │ │ # never imported by .ts): generate → coarsen → cleanup → smooth → │ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys -│ │ # → build-region-adjacency -│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests +│ │ # → build-region-adjacency → build-region-centroids +│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests ├── shared/ # NO runtime deps — the single source of truth │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …) │ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and @@ -115,13 +116,13 @@ history/ | `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently | | `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` | | `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 | -| `npm test` | vitest unit tests (`server/` + `scripts/`; 168 tests) | +| `npm test` | vitest unit tests (`server/` + `scripts/`; 189 tests) | | `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs | | `npm run lint` | ESLint flat config | | `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) | | `npm run split:regions` | (re)apply the #31 Arkhangel'sk split (`RU-ARK` → mainland + `RU-ARK-NE` + `RU-ARK-SZ`) — idempotent no-op once applied | | `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied | -| `npm run build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) | +| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) | | `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` | **Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth. @@ -249,6 +250,42 @@ history/ three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). The stub never proposes a nation op (`nationOps: []`), so this path is covered by unit tests only (`server/engine.test.ts`). +- **Map markers (v0.27, #44) — LLM-placed named points, region-anchored, canvas-icon rendered.** + The LLM can place named **point markers** on the map each turn: eight `MarkerType`s + (`city, factory, army, port, capital, fleet, rocket, gathering`; a **capital** is at most one + per nation, a **gathering** a political/social mass event that can enable a non-military + takeover). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required, + default `[]`; `MarkerSpec = { type, name, regionKey }`). A marker's stable `id` is the FNV-1a + 32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase + base-26 characters (mirroring the region-key scheme) — so a **re-add is idempotent** and a + **remove is unambiguous** (by id). `GameState.markers: MapMarker[]` is required (`[]` at game + creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes). + `applyTurnResult` handles markers **after** territory claims: (1) remove by id (absent id → + no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim + ownership** — a marker always follows its region (a captured region follows its new owner; a + region released to no man's land, or a key with 0 or 2+ owners, becomes `null`); (3) add — each + new spec is placed at its region's centroid (from `public/region-centroids.json`, injected via + the optional `centroidOf` param on `applyTurnResult`) plus a deterministic per-axis jitter + (`spread ≈ 0.1 × the largest part's smallest bbox dimension`, so multiple markers of one region + scatter instead of stacking), owned by the region's post-turn owner, stamped with the acting + turn. Adds are **dropped, never thrown**, when the region key is unknown or has no centroid, + when the id already exists, or beyond the hard cap `MAX_MARKERS = 400`. The system prompt + documents the field + types + placement rules; `buildUserPrompt` carries the current markers + (as `MarkerInfo[]`) so the model can reference their ids in `remove`; a marker add/remove makes + a turn "meaningful". **Rendering is canvas-generated per-type ICON images, NOT text labels** + (`src/components/WorldMap.tsx`): every external glyph/font endpoint is a dead 404 (OpenFreeMap's + `fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`) and the OFM dark basemap only + renders because it is **raster** (labels baked into the tiles) — there is no working vector + font, so markers are drawn as self-contained, offline, deterministic per-type icons + (`drawMarkerIcon`: distinct shape per type, filled with the type color, outlined for legibility), + registered as named `ImageData` images that survive `styledata`. The marker **name shows in the + hover tooltip** (not a persistent on-map label), and **clicking a marker focuses its owning + nation's dossier**. There are **no separate marker event handlers** — MapLibre's per-layer event + delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead + `queryRenderedFeatures` the marker layer under the cursor and act on the marker when present. + The **stub is marker-free** (`markers: { add: [], remove: [] }`) — e2e (CI, `LLM_STUB=1`) is + unaffected and the marker path is unit-tested only (`server/engine.test.ts`, + `scripts/region-centroids.test.ts`, `server/regions.test.ts`, `server/db.test.ts`). - **Map click → dossier target (owner resolved from the RegionMap, by region key).** Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was @@ -369,7 +406,8 @@ history/ (works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle. - The `dist/` client is committed to the build output only; the `public/` data files (`regions.geo.json` — the choropleth — `region-keys.json` + `region-adjacency.json` — the - ownership/adjacency manifests — and `countries.geo.json`) are copied into `dist/` by Vite at + ownership/adjacency manifests — `region-centroids.json` — the marker anchors (#44) — and + `countries.geo.json`) are copied into `dist/` by Vite at build time, so the prod image serves them from `dist/` (no separate copy). The server's region loader (`server/regions.ts`) probes `public/` then `dist/` for the manifest pair, because the Docker image keeps only `dist/` (the v0.20 fix — before it, region-level ownership silently diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ab668..65feea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to **History** are listed here, newest first. +## v0.27.0 — 2026-10-14 22:34 +- **#44: the LLM can place named point markers on the map.** The model may now place up to a handful of named *point markers* per turn (a capital, city, factory complex, army deployment, port, fleet concentration, rocket launch site, or a political **gathering**), each anchored to a region key, persisted per game, and rendered on the choropleth as a MapLibre symbol layer. + - **Types (`shared/types.ts` + `shared/protocol.ts`).** Eight `MarkerType`s — `city, factory, army, port, capital, fleet, rocket, gathering` — with a shared `MARKER_TYPE_INFO` map (display label + color + glyph). A `MapMarker = { id, type, name, regionKey, lat, lon, ownerNationId, placedTurn }`; `GameState.markers: MapMarker[]` is **required** (`[]` at game creation, and a legacy SQLite row without the field loads as `[]` — additive, never wipes existing markers). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required, default `[]`); `MarkerSpec = { type, name, regionKey }`. + - **Stable ids (`shared/protocol.ts`).** A marker's `id` is the FNV-1a 32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase base-26 characters (mirroring the region-key scheme). This makes a **re-add idempotent** (the same type+name+region never creates a second marker) and a **remove unambiguous** (by id). + - **Centroid data (`public/region-centroids.json`, new).** ~1,800 entries keyed identically to `region-keys.json`, each `{ lat, lon, spread }`: the anchor is the **centroid of the region's largest-area part** (a single representative point, not an average across disjoint parts), and `spread` ≈ **0.1 × that part's smallest bbox dimension** (in degrees). A new idempotent pipeline stage `scripts/build-region-centroids.mjs` (`npm run build:region-centroids`) regenerates it deterministically (byte-identical re-run, sha256-pinned by the test). The engine uses the centroid as the marker's base position and the `spread` to jitter multiple markers of one region around it (a deterministic per-axis FNV-1a-derived offset, so markers scatter instead of stacking). + - **Engine (`server/engine.ts`).** `applyTurnResult` processes markers **after** territory claims, in three steps: (1) remove by id (a listed-but-absent id is a no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim ownership** — a captured region follows its new owner, a region released to no man's land (or a key with 0 or 2+ owners) becomes `null` (a marker always follows its region); (3) add — each new spec is placed at its region's centroid (injected via a new optional `centroidOf` param) plus the per-axis jitter, owned by the region's post-turn owner, stamped with the acting turn. Adds are dropped (never thrown) when their region key is unknown / has no centroid, when the id already exists (idempotent re-add), or beyond the hard cap `MAX_MARKERS = 400` (later adds dropped). The system prompt documents the field, the eight types, and the placement rules; `buildUserPrompt` carries the current markers (as `MarkerInfo[]`) so the model can reference their stable ids in `remove`; `isMeaningful` treats a marker add/remove as a real world change. + - **Client (`src/components/WorldMap.tsx`).** A second GeoJSON source + a symbol layer sits above the choropleth. **Deliberate deviation from the original "name as a text label" design:** every external glyph endpoint is a dead 404 (OpenFreeMap's `fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`), and the OFM dark basemap only renders at all because it is **raster** (labels baked into the PNG tiles) — there is no working vector font. So markers are drawn as **self-contained, offline, canvas-generated per-type icons** (`drawMarkerIcon`: a distinct shape per type — circle / rounded-square / triangle / diamond / star / center-dotted circles / up-arrow — filled with the type color, outlined for legibility on the dark basemap), registered as named `ImageData` images that survive `styledata`. The marker **name is shown in the hover tooltip** (region name + owner chip + marker name) instead of a persistent on-map label, and **clicking a marker focuses its owning nation's dossier** (a marker's owner is its region's owner). There are no separate marker event handlers: MapLibre's per-layer event delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead `queryRenderedFeatures` the marker layer under the cursor and act on the marker when present. + - **Stub (`server/stub.ts`) stays marker-free** — it returns `markers: { add: [], remove: [] }` — so the e2e suite (CI, `LLM_STUB=1`) is unaffected and fully deterministic; the full marker path is covered by unit tests only. +- **Tests (168 → 189).** New `scripts/region-centroids.test.ts` (1,800 entries, key-set match, finite/world-bounds, each centroid inside its feature's bbox via turf-free coordinate iteration, spot-checked anchors, byte-identical re-run determinism); `server/regions.test.ts` (`hasCentroids`/`centroidOf` in dev + prod-`dist/` layouts, graceful degradation when absent); `server/db.test.ts` (legacy row → `markers: []`); and an `engine.test.ts` marker suite (valid add → id/position/owner/placedTurn, idempotent re-add, region-validity/centroid drops, the 400 cap, remove by id / no-op, owner-follows-region incl. release→null and re-claim, `buildUserPrompt`/system-prompt contract, `validateMarkerOps` raw cleaner, and the `runTurn` centroid wiring). +- **Verification.** Typecheck, lint, **189 unit tests**, and the production build all pass (the build copies `region-centroids.json` into `dist/` like the other manifests, so it works in the prod container layout). +- **Deploy:** manual `docker compose up -d --build` on the host — **no `down -v`** (markers are additive; legacy games load with `markers: []`). + ## v0.26.0 — 2026-10-14 19:24 - **#41: the `RU-ARK` mainland's Arctic-coast band is re-coarsened into two new regions** — a pure re-partition of the existing polygon parts: no new geometry, no coordinate change (the feature union stays byte-identical). After #31 the mainland `RU-ARK` was a 33-part MultiPolygon; its 28-part band (parts whose centroid lat ≥ 68 — the Arctic-coast strip) is now split into: - `RU-PEO` — Pechora (13 parts, ~37,678 km²) → new region key `pechora-qwhs`. The southwestern band (around the Pechora delta / Nenets border). diff --git a/package.json b/package.json index 8647e47..53bbdf9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.26.0", + "version": "0.27.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", @@ -18,6 +18,7 @@ "split:regions:band": "node scripts/split-arkhangel-band.mjs", "build:region-keys": "node scripts/build-region-keys.mjs", "build:region-adjacency": "node scripts/build-region-adjacency.mjs", + "build:region-centroids": "node scripts/build-region-centroids.mjs", "typecheck": "tsc --noEmit", "lint": "eslint .", "e2e": "playwright test" diff --git a/public/region-centroids.json b/public/region-centroids.json new file mode 100644 index 0000000..7c25e85 --- /dev/null +++ b/public/region-centroids.json @@ -0,0 +1,9002 @@ +{ + "balkh-ckka": { + "lat": 36.637523, + "lon": 67.060677, + "spread": 0.17132 + }, + "bamyan-mjkz": { + "lat": 34.596552, + "lon": 67.23108, + "spread": 0.15233 + }, + "badghis-werz": { + "lat": 35.266461, + "lon": 63.713487, + "spread": 0.15083 + }, + "badakhshan-qjrt": { + "lat": 36.901133, + "lon": 71.896822, + "spread": 0.30307 + }, + "baghlan-wqdr": { + "lat": 35.801847, + "lon": 68.773635, + "spread": 0.15508 + }, + "farah-dajq": { + "lat": 32.689373, + "lon": 62.72396, + "spread": 0.21257 + }, + "faryab-ztoh": { + "lat": 36.136457, + "lon": 64.900361, + "spread": 0.19274 + }, + "ghazni-nnab": { + "lat": 33.245894, + "lon": 67.728842, + "spread": 0.19283 + }, + "ghor-bxar": { + "lat": 34.257774, + "lon": 64.920976, + "spread": 0.2147 + }, + "hilmand-gmdm": { + "lat": 31.609507, + "lon": 64.085029, + "spread": 0.27591 + }, + "hirat-yddg": { + "lat": 34.199174, + "lon": 62.340872, + "spread": 0.28353 + }, + "jawzjan-ibdk": { + "lat": 36.565469, + "lon": 65.733944, + "spread": 0.14654 + }, + "kabul-agcw": { + "lat": 34.837576, + "lon": 69.875241, + "spread": 0.19527 + }, + "kandahar-cacs": { + "lat": 31.525992, + "lon": 66.226522, + "spread": 0.30325 + }, + "nimroz-eoll": { + "lat": 31.208655, + "lon": 62.522355, + "spread": 0.27252 + }, + "nuristan-ofkf": { + "lat": 35.250329, + "lon": 70.861004, + "spread": 0.14998 + }, + "paktika-wbrf": { + "lat": 32.445671, + "lon": 68.735364, + "spread": 0.16684 + }, + "paktya-opvd": { + "lat": 33.737156, + "lon": 69.402381, + "spread": 0.13208 + }, + "samangan-iybb": { + "lat": 36.047544, + "lon": 67.603269, + "spread": 0.12727 + }, + "sari-pul-eubi": { + "lat": 35.706012, + "lon": 66.224784, + "spread": 0.1601 + }, + "takhar-tbah": { + "lat": 36.868218, + "lon": 69.352618, + "spread": 0.18268 + }, + "uruzgan-ebhn": { + "lat": 32.8383, + "lon": 66.158513, + "spread": 0.10421 + }, + "uruzgan-unxj": { + "lat": 33.657135, + "lon": 66.287542, + "spread": 0.13349 + }, + "wardak-wpjg": { + "lat": 34.371114, + "lon": 68.146419, + "spread": 0.11223 + }, + "zabul-ftpc": { + "lat": 32.296896, + "lon": 67.0393, + "spread": 0.15156 + }, + "bengo-awxb": { + "lat": -9.080071, + "lon": 13.860088, + "spread": 0.17122 + }, + "benguela-ifxx": { + "lat": -12.87647, + "lon": 14.008544, + "spread": 0.21254 + }, + "bie-usov": { + "lat": -12.161702, + "lon": 17.394072, + "spread": 0.34867 + }, + "cuando-cubango-sifc": { + "lat": -15.70713, + "lon": 19.207488, + "spread": 0.44479 + }, + "cunene-etup": { + "lat": -16.241014, + "lon": 15.056691, + "spread": 0.22729 + }, + "cuanza-norte-avuq": { + "lat": -8.777516, + "lon": 14.988168, + "spread": 0.17419 + }, + "cuanza-sul-uays": { + "lat": -10.828576, + "lon": 14.898052, + "spread": 0.25325 + }, + "huambo-czeb": { + "lat": -12.676369, + "lon": 15.777727, + "spread": 0.17691 + }, + "huila-ylej": { + "lat": -14.764277, + "lon": 14.808274, + "spread": 0.30255 + }, + "lunda-norte-kkid": { + "lat": -8.512865, + "lon": 19.422685, + "spread": 0.34838 + }, + "lunda-sul-iyot": { + "lat": -10.2631, + "lon": 20.528522, + "spread": 0.31386 + }, + "malanje-quaz": { + "lat": -9.719778, + "lon": 16.85545, + "spread": 0.37233 + }, + "moxico-ievb": { + "lat": -12.480608, + "lon": 20.976278, + "spread": 0.5577 + }, + "namibe-mitv": { + "lat": -15.383605, + "lon": 12.85839, + "spread": 0.19456 + }, + "uige-alyd": { + "lat": -7.403685, + "lon": 15.396667, + "spread": 0.25536 + }, + "zaire-ikgg": { + "lat": -6.698453, + "lon": 13.676042, + "spread": 0.19512 + }, + "elbasan-bjpz": { + "lat": 40.601391, + "lon": 20.101723, + "spread": 0.17471 + }, + "shkoder-gmnr": { + "lat": 41.81745, + "lon": 19.929889, + "spread": 0.12929 + }, + "abu-dhabi-mywn": { + "lat": 24.101771, + "lon": 54.005645, + "spread": 0.2349 + }, + "dubay-kskv": { + "lat": 25.210858, + "lon": 55.871186, + "spread": 0.13672 + }, + "salta-uunu": { + "lat": -24.228936, + "lon": -65.564443, + "spread": 0.43855 + }, + "buenos-aires-mwnv": { + "lat": -37.276686, + "lon": -60.19192, + "spread": 0.67191 + }, + "san-luis-amnp": { + "lat": -33.353761, + "lon": -66.026606, + "spread": 0.25067 + }, + "entre-rios-wonq": { + "lat": -32.014661, + "lon": -58.872906, + "spread": 0.29651 + }, + "la-rioja-opnr": { + "lat": -29.248997, + "lon": -67.774788, + "spread": 0.4198 + }, + "santiago-del-estero-grns": { + "lat": -27.869965, + "lon": -64.041215, + "spread": 0.34708 + }, + "chaco-yfnb": { + "lat": -26.036213, + "lon": -60.255904, + "spread": 0.38798 + }, + "san-juan-iind": { + "lat": -30.910124, + "lon": -69.223789, + "spread": 0.38593 + }, + "catamarca-ekne": { + "lat": -27.552659, + "lon": -66.750316, + "spread": 0.42516 + }, + "la-pampa-aznx": { + "lat": -37.717459, + "lon": -66.237306, + "spread": 0.4324 + }, + "mendoza-sany": { + "lat": -34.090949, + "lon": -69.019456, + "spread": 0.40578 + }, + "misiones-ocnz": { + "lat": -26.787941, + "lon": -54.711278, + "spread": 0.23478 + }, + "formosa-ctnd": { + "lat": -25.14755, + "lon": -59.700336, + "spread": 0.44018 + }, + "neuquen-yvne": { + "lat": -38.472178, + "lon": -70.501216, + "spread": 0.39452 + }, + "rio-negro-qxnf": { + "lat": -40.019885, + "lon": -67.371598, + "spread": 0.44537 + }, + "santa-fe-iyng": { + "lat": -31.262826, + "lon": -60.709289, + "spread": 0.39891 + }, + "tucuman-ennz": { + "lat": -26.972235, + "lon": -65.421585, + "spread": 0.17083 + }, + "chubut-apna": { + "lat": -43.770741, + "lon": -68.168257, + "spread": 0.40004 + }, + "tierra-del-fuego-sqnb": { + "lat": -54.182204, + "lon": -67.28312, + "spread": 0.24124 + }, + "corrientes-ksnc": { + "lat": -28.793119, + "lon": -57.574486, + "spread": 0.34903 + }, + "cordoba-cgnl": { + "lat": -31.68817, + "lon": -63.644642, + "spread": 0.4025 + }, + "jujuy-yinm": { + "lat": -23.4039, + "lon": -65.666219, + "spread": 0.28236 + }, + "santa-cruz-mjnn": { + "lat": -49.193838, + "lon": -69.90542, + "spread": 0.63867 + }, + "gegharkunik-qezd": { + "lat": 40.268736, + "lon": 44.979268, + "spread": 0.1031 + }, + "lori-aqmn": { + "lat": 40.716289, + "lon": 44.402413, + "spread": 0.12807 + }, + "antarctica-mvxe": { + "lat": -72.917436, + "lon": 4.093257, + "spread": 2.67821 + }, + "antarctica-ccbf": { + "lat": -74.773972, + "lon": -130.239754, + "spread": 0.19304 + }, + "archipel-des-kerguelen-fnmd": { + "lat": -49.266361, + "lon": 69.491758, + "spread": 0.10515 + }, + "iles-saint-paul-et-nouvelle-amsterdam-smio": { + "lat": -37.8498, + "lon": 77.525533, + "spread": 0.00507 + }, + "archipel-des-crozet-oypb": { + "lat": -46.3954, + "lon": 51.780825, + "spread": 0.0076 + }, + "iles-eparses-de-l-ocean-indien-zmrd": { + "lat": -22.337, + "lon": 40.3644, + "spread": 0.00242 + }, + "new-south-wales-guah": { + "lat": -33.356087, + "lon": 149.16131, + "spread": 0.9361 + }, + "lord-howe-island-krnq": { + "lat": -26.45764, + "lon": 153.131338, + "spread": 0.008785 + }, + "northern-territory-ylns": { + "lat": -13.091433, + "lon": 133.374327, + "spread": 0.90001 + }, + "queensland-opqa": { + "lat": -19.271253, + "lon": 146.772257, + "spread": 1.55063 + }, + "south-australia-cutu": { + "lat": -34.219621, + "lon": 136.655474, + "spread": 1.2 + }, + "tasmania-kptu": { + "lat": -42.367594, + "lon": 146.985755, + "spread": 0.29687 + }, + "victoria-svnw": { + "lat": -37.142027, + "lon": 145.276746, + "spread": 0.51585 + }, + "western-australia-sxjw": { + "lat": -21.918645, + "lon": 121.20589, + "spread": 1.58431 + }, + "macquarie-island-yijx": { + "lat": -54.5742, + "lon": 158.89385, + "spread": 0.01242 + }, + "karnten-cypo": { + "lat": 47.285079, + "lon": 13.353381, + "spread": 0.1653 + }, + "niederosterreich-uapp": { + "lat": 47.927248, + "lon": 16.04092, + "spread": 0.21729 + }, + "oberosterreich-mbpq": { + "lat": 48.07267, + "lon": 13.918047, + "spread": 0.128 + }, + "steiermark-afps": { + "lat": 47.168704, + "lon": 14.824639, + "spread": 0.12095 + }, + "tirol-sgpt": { + "lat": 47.256322, + "lon": 10.755262, + "spread": 0.09629 + }, + "agcabadi-yazh": { + "lat": 39.837041, + "lon": 46.627562, + "spread": 0.20762 + }, + "kalbacar-zzbg": { + "lat": 41.161395, + "lon": 46.406205, + "spread": 0.163 + }, + "quba-nxpp": { + "lat": 39.743314, + "lon": 48.752005, + "spread": 0.238983 + }, + "bururi-uaba": { + "lat": -3.42085, + "lon": 29.618347, + "spread": 0.1189 + }, + "ruyigi-kdjc": { + "lat": -3.028697, + "lon": 30.178338, + "spread": 0.12205 + }, + "east-flanders-cicp": { + "lat": 50.856408, + "lon": 4.220392, + "spread": 0.15626 + }, + "luxembourg-krjs": { + "lat": 50.190332, + "lon": 5.391597, + "spread": 0.13087 + }, + "atakora-oobn": { + "lat": 10.887495, + "lon": 1.587432, + "spread": 0.1519 + }, + "alibori-smbm": { + "lat": 11.42424, + "lon": 2.87816, + "spread": 0.18218 + }, + "borgou-yqft": { + "lat": 9.758443, + "lon": 2.840091, + "spread": 0.18594 + }, + "collines-qzhd": { + "lat": 8.117192, + "lon": 2.248631, + "spread": 0.11157 + }, + "donga-geqd": { + "lat": 9.209378, + "lon": 1.859261, + "spread": 0.08678 + }, + "zou-qmpu": { + "lat": 6.861344, + "lon": 2.167167, + "spread": 0.12604 + }, + "bazega-ufdx": { + "lat": 12.331955, + "lon": -1.479755, + "spread": 0.12377 + }, + "boulgou-oyva": { + "lat": 11.653, + "lon": -0.6815, + "spread": 0.16867 + }, + "komoe-siqa": { + "lat": 10.348157, + "lon": -4.464896, + "spread": 0.13055 + }, + "gnagna-qjxm": { + "lat": 13.063618, + "lon": 0.332759, + "spread": 0.16203 + }, + "gourma-okzn": { + "lat": 12.247067, + "lon": 0.502681, + "spread": 0.14246 + }, + "houet-yrsy": { + "lat": 11.408738, + "lon": -4.371337, + "spread": 0.12024 + }, + "kenedougou-yxcn": { + "lat": 11.152069, + "lon": -5.008515, + "spread": 0.0973 + }, + "kompienga-wuin": { + "lat": 11.310932, + "lon": 0.667421, + "spread": 0.09849 + }, + "mou-houn-azqo": { + "lat": 12.475628, + "lon": -3.819892, + "spread": 0.17813 + }, + "oudalan-ooyt": { + "lat": 14.314341, + "lon": -0.226191, + "spread": 0.1543 + }, + "poni-upoo": { + "lat": 10.082257, + "lon": -3.153843, + "spread": 0.13012 + }, + "sissili-amog": { + "lat": 11.437435, + "lon": -2.120175, + "spread": 0.09957 + }, + "sanmatenga-alfl": { + "lat": 13.233442, + "lon": -1.008387, + "spread": 0.1502 + }, + "sanguie-skxs": { + "lat": 12.630466, + "lon": -2.579109, + "spread": 0.19714 + }, + "soum-whaq": { + "lat": 14.135338, + "lon": -1.449977, + "spread": 0.10888 + }, + "tapoa-gpat": { + "lat": 12.186406, + "lon": 1.794844, + "spread": 0.11904 + }, + "tuy-updk": { + "lat": 11.424941, + "lon": -3.1783, + "spread": 0.14654 + }, + "yatenga-ajue": { + "lat": 13.636811, + "lon": -2.284178, + "spread": 0.11994 + }, + "chittagong-bxry": { + "lat": 22.732756, + "lon": 91.636165, + "spread": 0.20407 + }, + "dhaka-gvrx": { + "lat": 23.887367, + "lon": 90.235386, + "spread": 0.19403 + }, + "khulna-purw": { + "lat": 22.668473, + "lon": 89.797926, + "spread": 0.21366 + }, + "rajshahi-usrv": { + "lat": 24.570974, + "lon": 88.999222, + "spread": 0.14877 + }, + "rangpur-drru": { + "lat": 25.977823, + "lon": 88.96052, + "spread": 0.14521 + }, + "sylhet-iprt": { + "lat": 24.533674, + "lon": 91.608585, + "spread": 0.11892 + }, + "burgas-nowy": { + "lat": 42.403097, + "lon": 26.926732, + "spread": 0.11624 + }, + "veliko-tarnovo-zews": { + "lat": 43.151515, + "lon": 24.93433, + "spread": 0.1093 + }, + "vratsa-lhwu": { + "lat": 43.586181, + "lon": 23.257485, + "spread": 0.12192 + }, + "plovdiv-euus": { + "lat": 42.095863, + "lon": 24.656232, + "spread": 0.14845 + }, + "sofia-corh": { + "lat": 42.379322, + "lon": 23.408603, + "spread": 0.18746 + }, + "stara-zagora-hmrg": { + "lat": 41.960094, + "lon": 25.522597, + "spread": 0.15434 + }, + "shumen-ahrd": { + "lat": 43.469092, + "lon": 27.014206, + "spread": 0.13504 + }, + "acklins-qehg": { + "lat": 22.533383, + "lon": -73.978933, + "spread": 0.04253 + }, + "bimini-sdjg": { + "lat": 25.748167, + "lon": -79.2706, + "spread": 0.00505 + }, + "berry-islands-wejq": { + "lat": 26.649233, + "lon": -78.517228, + "spread": 0.045 + }, + "cat-island-kqms": { + "lat": 24.289517, + "lon": -75.435967, + "spread": 0.04418 + }, + "exuma-raxf": { + "lat": 23.126775, + "lon": -75.02765, + "spread": 0.04914 + }, + "inagua-ttnr": { + "lat": 21.09837, + "lon": -73.42784, + "spread": 0.0402 + }, + "mayaguana-kkei": { + "lat": 22.3775, + "lon": -73.029575, + "spread": 0.01282 + }, + "north-eleuthera-nwgq": { + "lat": 25.281213, + "lon": -76.39775, + "spread": 0.05751 + }, + "north-andros-dagc": { + "lat": 24.705647, + "lon": -78.10206, + "spread": 0.07151 + }, + "ragged-island-fprn": { + "lat": 22.2104, + "lon": -75.72675, + "spread": 0.00093 + }, + "san-salvador-kzur": { + "lat": 24.030225, + "lon": -74.492075, + "spread": 0.00933 + }, + "west-bosnia-ayyu": { + "lat": 43.402057, + "lon": 17.771043, + "spread": 0.18988 + }, + "zenica-doboj-kqyp": { + "lat": 44.32115, + "lon": 18.562546, + "spread": 0.18606 + }, + "banja-luka-qguu": { + "lat": 44.760593, + "lon": 16.816456, + "spread": 0.12171 + }, + "brest-uhpa": { + "lat": 52.458328, + "lon": 25.664628, + "spread": 0.18798 + }, + "gomel-evel": { + "lat": 52.243329, + "lon": 29.445579, + "spread": 0.21182 + }, + "grodno-sbey": { + "lat": 53.841839, + "lon": 25.437893, + "spread": 0.22501 + }, + "mogilev-srqt": { + "lat": 53.595632, + "lon": 30.277555, + "spread": 0.17656 + }, + "minsk-sirf": { + "lat": 53.67159, + "lon": 27.651223, + "spread": 0.26414 + }, + "vitebsk-anmn": { + "lat": 55.144615, + "lon": 28.826433, + "spread": 0.19314 + }, + "cayo-ejvk": { + "lat": 17.265903, + "lon": -88.624063, + "spread": 0.11367 + }, + "el-beni-aptp": { + "lat": -12.791781, + "lon": -65.213913, + "spread": 0.59751 + }, + "cochabamba-oqtq": { + "lat": -17.323489, + "lon": -65.659197, + "spread": 0.28002 + }, + "chuquisaca-mztf": { + "lat": -19.840852, + "lon": -64.562055, + "spread": 0.31805 + }, + "la-paz-kftj": { + "lat": -15.518393, + "lon": -68.225819, + "spread": 0.2908 + }, + "pando-uitl": { + "lat": -10.970557, + "lon": -66.927562, + "spread": 0.28136 + }, + "oruro-qktm": { + "lat": -18.468064, + "lon": -67.609232, + "spread": 0.24238 + }, + "potosi-iltd": { + "lat": -20.210239, + "lon": -66.729853, + "spread": 0.40457 + }, + "santa-cruz-kptg": { + "lat": -17.596441, + "lon": -61.215886, + "spread": 0.70105 + }, + "tarija-grth": { + "lat": -21.645894, + "lon": -64.159083, + "spread": 0.19723 + }, + "acre-ekzw": { + "lat": -9.489134, + "lon": -71.11128, + "spread": 0.40227 + }, + "alagoas-ivzd": { + "lat": -9.511262, + "lon": -36.574212, + "spread": 0.16695 + }, + "amazonas-qtzc": { + "lat": -2.963795, + "lon": -64.798411, + "spread": 1.20739 + }, + "amapa-gozp": { + "lat": 1.626517, + "lon": -52.322462, + "spread": 0.49149 + }, + "bahia-gncm": { + "lat": -12.441452, + "lon": -41.568424, + "spread": 0.92014 + }, + "ceara-wceu": { + "lat": -5.340151, + "lon": -39.596916, + "spread": 0.4213 + }, + "espirito-santo-yopi": { + "lat": -19.4738, + "lon": -40.794965, + "spread": 0.22086 + }, + "goias-suuo": { + "lat": -15.566571, + "lon": -49.089603, + "spread": 0.70672 + }, + "maranhao-ojve": { + "lat": -3.887699, + "lon": -45.078045, + "spread": 0.69307 + }, + "minas-gerais-ymvg": { + "lat": -18.54324, + "lon": -45.173091, + "spread": 0.86472 + }, + "mato-grosso-do-sul-wfvs": { + "lat": -20.085628, + "lon": -55.232317, + "spread": 0.68631 + }, + "mato-grosso-aqvz": { + "lat": -13.879451, + "lon": -56.222512, + "spread": 1.06775 + }, + "para-mmiu": { + "lat": -1.700607, + "lon": -50.949524, + "spread": 1.2453 + }, + "paraiba-aniv": { + "lat": -7.160214, + "lon": -36.8513, + "spread": 0.22563 + }, + "pernambuco-kfiq": { + "lat": -8.312576, + "lon": -37.793328, + "spread": 0.22181 + }, + "piaui-icjc": { + "lat": -7.28619, + "lon": -42.885024, + "spread": 0.55539 + }, + "parana-arjl": { + "lat": -25.092437, + "lon": -50.900901, + "spread": 0.41831 + }, + "rio-de-janeiro-mhoj": { + "lat": -22.399922, + "lon": -43.114465, + "spread": 0.25487 + }, + "rio-grande-do-norte-knon": { + "lat": -6.071933, + "lon": -36.84356, + "spread": 0.21479 + }, + "rondonia-gpoo": { + "lat": -10.665638, + "lon": -63.333835, + "spread": 0.56623 + }, + "roraima-itoh": { + "lat": 2.49991, + "lon": -61.647394, + "spread": 0.59424 + }, + "rio-grande-do-sul-auoi": { + "lat": -29.910414, + "lon": -52.747721, + "spread": 0.66687 + }, + "santa-catarina-ourg": { + "lat": -27.271238, + "lon": -50.844877, + "spread": 0.33745 + }, + "sergipe-glra": { + "lat": -10.773, + "lon": -37.413656, + "spread": 0.18355 + }, + "sao-paulo-eurp": { + "lat": -22.606474, + "lon": -47.960685, + "spread": 0.54994 + }, + "tocantins-kczu": { + "lat": -9.751236, + "lon": -48.065626, + "spread": 0.4985 + }, + "belait-asmw": { + "lat": 4.353675, + "lon": 114.402963, + "spread": 0.06646 + }, + "thimphu-agoq": { + "lat": 27.425668, + "lon": 89.435756, + "spread": 0.12877 + }, + "wangdi-phodrang-ipqp": { + "lat": 27.52217, + "lon": 90.302152, + "spread": 0.16365 + }, + "central-ypdw": { + "lat": -21.267858, + "lon": 26.855942, + "spread": 0.4948 + }, + "ghanzi-oamb": { + "lat": -22.225771, + "lon": 22.353443, + "spread": 0.23185 + }, + "kgalagadi-gaxy": { + "lat": -25.50785, + "lon": 22.343557, + "spread": 0.35832 + }, + "kweneng-kxwi": { + "lat": -24.139412, + "lon": 25.364535, + "spread": 0.25114 + }, + "north-west-qhdz": { + "lat": -19.076665, + "lon": 24.258975, + "spread": 0.32177 + }, + "southern-ctpk": { + "lat": -25.217186, + "lon": 24.689114, + "spread": 0.18301 + }, + "ouham-mnot": { + "lat": 6.758979, + "lon": 17.957143, + "spread": 0.2393 + }, + "bamingui-bangoran-ilrg": { + "lat": 8.463362, + "lon": 20.756777, + "spread": 0.27379 + }, + "basse-kotto-earz": { + "lat": 4.98134, + "lon": 21.31278, + "spread": 0.14988 + }, + "haute-kotto-ymrh": { + "lat": 7.419304, + "lon": 22.707596, + "spread": 0.34635 + }, + "haut-mbomou-qdrb": { + "lat": 6.276296, + "lon": 25.471629, + "spread": 0.31804 + }, + "mambere-kadei-carz": { + "lat": 4.550607, + "lon": 15.96905, + "spread": 0.17211 + }, + "nana-grebizi-mcza": { + "lat": 7.256095, + "lon": 19.345076, + "spread": 0.1501 + }, + "kemo-uuzv": { + "lat": 5.77367, + "lon": 19.312745, + "spread": 0.13882 + }, + "lobaye-uriq": { + "lat": 4.085118, + "lon": 17.679564, + "spread": 0.15513 + }, + "mbomou-ydky": { + "lat": 5.384789, + "lon": 23.563329, + "spread": 0.26183 + }, + "ombella-m-poko-iklq": { + "lat": 5.139448, + "lon": 17.860684, + "spread": 0.2029 + }, + "nana-mambere-glnv": { + "lat": 5.712681, + "lon": 15.373427, + "spread": 0.18121 + }, + "ouham-pende-obpq": { + "lat": 6.726079, + "lon": 16.192989, + "spread": 0.21783 + }, + "sangha-mbaere-mqfz": { + "lat": 3.453563, + "lon": 16.299174, + "spread": 0.20222 + }, + "ouaka-mmrt": { + "lat": 6.105366, + "lon": 20.704182, + "spread": 0.24665 + }, + "vakaga-ahth": { + "lat": 9.660239, + "lon": 22.432761, + "spread": 0.2416 + }, + "alberta-zopq": { + "lat": 52.091021, + "lon": -116.936906, + "spread": 1.00017 + }, + "british-columbia-vmsh": { + "lat": 52.665687, + "lon": -126.302173, + "spread": 1.10076 + }, + "manitoba-thmy": { + "lat": 57.182568, + "lon": -94.184316, + "spread": 1.10074 + }, + "new-brunswick-wcni": { + "lat": 46.395611, + "lon": -65.952506, + "spread": 0.30144 + }, + "newfoundland-and-labrador-kmno": { + "lat": 55.78411, + "lon": -62.096675, + "spread": 0.88849 + }, + "nova-scotia-ndnz": { + "lat": 44.799587, + "lon": -63.93876, + "spread": 0.25211 + }, + "northwest-territories-gynw": { + "lat": 67.766639, + "lon": -129.682192, + "spread": 1.05926 + }, + "nunavut-banx": { + "lat": 67.454727, + "lon": -94.850913, + "spread": 1.2002 + }, + "ontario-bzqu": { + "lat": 49.432672, + "lon": -83.604514, + "spread": 1.51922 + }, + "quebec-yegk": { + "lat": 55.574087, + "lon": -69.60969, + "spread": 1.73732 + }, + "saskatchewan-cmli": { + "lat": 54.7604, + "lon": -105.07326, + "spread": 0.86357 + }, + "yukon-hsje": { + "lat": 63.94701, + "lon": -131.854446, + "spread": 0.96508 + }, + "bern-pmiz": { + "lat": 46.644402, + "lon": 7.18585, + "spread": 0.14377 + }, + "zurich-kdpk": { + "lat": 46.836103, + "lon": 8.991632, + "spread": 0.19748 + }, + "aisen-del-general-carlos-ibanez-del-campo-mrpn": { + "lat": -46.51233, + "lon": -73.66161, + "spread": 0.4582 + }, + "antofagasta-czps": { + "lat": -23.497567, + "lon": -69.265338, + "spread": 0.3726 + }, + "arica-y-parinacota-mbpk": { + "lat": -18.32932, + "lon": -69.703827, + "spread": 0.14349 + }, + "la-araucania-aepm": { + "lat": -38.68714, + "lon": -72.273773, + "spread": 0.20837 + }, + "atacama-khpo": { + "lat": -27.550487, + "lon": -70.063417, + "spread": 0.32058 + }, + "bio-bio-kfrw": { + "lat": -37.387066, + "lon": -72.590777, + "spread": 0.204 + }, + "coquimbo-qtuj": { + "lat": -30.636742, + "lon": -70.783942, + "spread": 0.18545 + }, + "libertador-general-bernardo-o-higgins-ghiu": { + "lat": -34.432633, + "lon": -71.040627, + "spread": 0.11272 + }, + "los-lagos-mmix": { + "lat": -42.306814, + "lon": -72.548104, + "spread": 0.22621 + }, + "los-rios-gxix": { + "lat": -39.907607, + "lon": -72.529633, + "spread": 0.12731 + }, + "magallanes-y-antartica-chilena-eqmb": { + "lat": -51.634723, + "lon": -73.087792, + "spread": 0.51668 + }, + "maule-kvme": { + "lat": -35.711995, + "lon": -71.395227, + "spread": 0.17246 + }, + "nuble-wmop": { + "lat": -36.6662, + "lon": -71.935996, + "spread": 0.11856 + }, + "region-metropolitana-de-santiago-qwdi": { + "lat": -33.6273, + "lon": -70.596072, + "spread": 0.13099 + }, + "tarapaca-mrpq": { + "lat": -20.257212, + "lon": -69.1996, + "spread": 0.18227 + }, + "valparaiso-mfuk": { + "lat": -32.924757, + "lon": -71.033152, + "spread": 0.18711 + }, + "anhui-sanz": { + "lat": 31.98208, + "lon": 117.296279, + "spread": 0.47432 + }, + "beijing-qsqi": { + "lat": 40.171178, + "lon": 116.456367, + "spread": 0.16029 + }, + "chongqing-mcsc": { + "lat": 29.810996, + "lon": 107.822898, + "spread": 0.4004 + }, + "fujian-gmhk": { + "lat": 25.740272, + "lon": 118.634814, + "spread": 0.45789 + }, + "guangdong-eajt": { + "lat": 22.642081, + "lon": 113.037962, + "spread": 0.52663 + }, + "gansu-exjm": { + "lat": 36.759368, + "lon": 102.612862, + "spread": 1.01928 + }, + "guangxi-cgjb": { + "lat": 23.641843, + "lon": 108.631202, + "spread": 0.49583 + }, + "guizhou-qjjd": { + "lat": 27.009602, + "lon": 107.039629, + "spread": 0.46147 + }, + "henan-qdfb": { + "lat": 34.015347, + "lon": 114.26349, + "spread": 0.49625 + }, + "hubei-eefc": { + "lat": 30.726011, + "lon": 112.04721, + "spread": 0.42002 + }, + "hebei-owfx": { + "lat": 39.814665, + "lon": 116.502049, + "spread": 0.64099 + }, + "hainan-mpfj": { + "lat": 19.219868, + "lon": 110.008464, + "spread": 0.19943 + }, + "heilongjiang-shfe": { + "lat": 48.398326, + "lon": 127.328443, + "spread": 1.01367 + }, + "hunan-gkfg": { + "lat": 27.355508, + "lon": 111.597009, + "spread": 0.54456 + }, + "jilin-akky": { + "lat": 43.620724, + "lon": 126.29502, + "spread": 0.54332 + }, + "jiangsu-kpkl": { + "lat": 32.820332, + "lon": 119.331578, + "spread": 0.43246 + }, + "jiangxi-geke": { + "lat": 27.584496, + "lon": 115.713525, + "spread": 0.4913 + }, + "liaoning-wswa": { + "lat": 40.980243, + "lon": 122.233854, + "spread": 0.47514 + }, + "inner-mongol-ioap": { + "lat": 44.153346, + "lon": 115.048896, + "spread": 1.59297 + }, + "ningxia-kwbm": { + "lat": 37.063992, + "lon": 106.206897, + "spread": 0.33032 + }, + "qinghai-cnbf": { + "lat": 34.823088, + "lon": 96.767646, + "spread": 0.77896 + }, + "sichuan-eufy": { + "lat": 30.537785, + "lon": 103.015732, + "spread": 0.82649 + }, + "shandong-ajfr": { + "lat": 36.361667, + "lon": 119.204582, + "spread": 0.38717 + }, + "shaanxi-kyfb": { + "lat": 35.304926, + "lon": 108.726582, + "spread": 0.57281 + }, + "shanxi-ubft": { + "lat": 38.043712, + "lon": 112.471834, + "spread": 0.43035 + }, + "tianjin-krnw": { + "lat": 39.386682, + "lon": 117.3861, + "spread": 0.13219 + }, + "paracel-islands-yftc": { + "lat": 15.7773, + "lon": 111.20055, + "spread": 0.00038 + }, + "xinjiang-oasq": { + "lat": 40.85931, + "lon": 83.587813, + "spread": 1.48258 + }, + "xizang-kzsk": { + "lat": 31.120329, + "lon": 89.025443, + "spread": 0.91362 + }, + "yunnan-mbuv": { + "lat": 25.481452, + "lon": 101.790781, + "spread": 0.80805 + }, + "zhejiang-amww": { + "lat": 29.272841, + "lon": 120.801392, + "spread": 0.41195 + }, + "lagunes-igqk": { + "lat": 5.560697, + "lon": -4.382374, + "spread": 0.12382 + }, + "haut-sassandra-aiql": { + "lat": 7.12773, + "lon": -6.251709, + "spread": 0.15389 + }, + "savanes-sjqm": { + "lat": 9.596331, + "lon": -5.495169, + "spread": 0.21915 + }, + "vallee-du-bandama-oyqf": { + "lat": 8.324281, + "lon": -4.990877, + "spread": 0.18544 + }, + "dix-huit-montagnes-gcqh": { + "lat": 7.861263, + "lon": -7.840466, + "spread": 0.15025 + }, + "zanzan-qsqb": { + "lat": 8.624479, + "lon": -3.463184, + "spread": 0.18162 + }, + "bas-sassandra-muqc": { + "lat": 5.525848, + "lon": -6.826886, + "spread": 0.18528 + }, + "denguele-ycsk": { + "lat": 9.735678, + "lon": -7.302544, + "spread": 0.15634 + }, + "n-zi-comoe-uesl": { + "lat": 7.059476, + "lon": -4.505319, + "spread": 0.18137 + }, + "worodougou-wiso": { + "lat": 8.487208, + "lon": -6.211225, + "spread": 0.15841 + }, + "sud-bandama-sksp": { + "lat": 5.91675, + "lon": -5.633254, + "spread": 0.14622 + }, + "agneby-glsq": { + "lat": 6.419152, + "lon": -3.736208, + "spread": 0.15625 + }, + "cavally-qqst": { + "lat": 6.43715, + "lon": -7.880045, + "spread": 0.13406 + }, + "adamaoua-kqsc": { + "lat": 7.074723, + "lon": 12.795329, + "spread": 0.21956 + }, + "centre-aexz": { + "lat": 4.550377, + "lon": 11.604411, + "spread": 0.29717 + }, + "extreme-nord-oubi": { + "lat": 11.4035, + "lon": 14.459921, + "spread": 0.22885 + }, + "est-umbd": { + "lat": 3.586249, + "lon": 14.440045, + "spread": 0.36927 + }, + "littoral-oyts": { + "lat": 4.266697, + "lon": 10.050105, + "spread": 0.16817 + }, + "nord-gkzb": { + "lat": 8.517489, + "lon": 13.573508, + "spread": 0.31952 + }, + "nord-ouest-yvzz": { + "lat": 6.336779, + "lon": 10.381183, + "spread": 0.14074 + }, + "ouest-kdbx": { + "lat": 5.5693, + "lon": 10.617286, + "spread": 0.13455 + }, + "sud-mukj": { + "lat": 2.877712, + "lon": 11.687381, + "spread": 0.15079 + }, + "sud-ouest-yxkl": { + "lat": 5.080857, + "lon": 9.23193, + "spread": 0.16005 + }, + "bas-congo-yhcf": { + "lat": -5.045655, + "lon": 14.147992, + "spread": 0.17748 + }, + "bandundu-gzcq": { + "lat": -4.37198, + "lon": 18.433356, + "spread": 0.50989 + }, + "equateur-mkpe": { + "lat": 0.627996, + "lon": 21.138829, + "spread": 0.76567 + }, + "katanga-igym": { + "lat": -9.730539, + "lon": 26.146846, + "spread": 0.84557 + }, + "kasai-oriental-gzyi": { + "lat": -4.619293, + "lon": 23.822411, + "spread": 0.43703 + }, + "kinshasa-city-aoyb": { + "lat": -4.56345, + "lon": 15.803811, + "spread": 0.10834 + }, + "kasai-occidental-udyu": { + "lat": -5.327, + "lon": 21.898647, + "spread": 0.40396 + }, + "maniema-eoks": { + "lat": -2.315348, + "lon": 26.437766, + "spread": 0.43738 + }, + "nord-kivu-gxmp": { + "lat": -0.532923, + "lon": 28.647603, + "spread": 0.27763 + }, + "orientale-scpz": { + "lat": 2.174287, + "lon": 26.284056, + "spread": 0.74845 + }, + "sud-kivu-katu": { + "lat": -3.049445, + "lon": 28.350303, + "spread": 0.25814 + }, + "lekoumou-inqi": { + "lat": -3.043308, + "lon": 13.448081, + "spread": 0.17384 + }, + "kouilou-qfqd": { + "lat": -4.415014, + "lon": 12.02111, + "spread": 0.15031 + }, + "likouala-aiqf": { + "lat": 1.938145, + "lon": 17.557545, + "spread": 0.21989 + }, + "cuvette-wtpy": { + "lat": -0.696505, + "lon": 15.678748, + "spread": 0.24691 + }, + "niari-svpz": { + "lat": -3.204944, + "lon": 12.53192, + "spread": 0.2038 + }, + "bouenza-elwk": { + "lat": -3.978459, + "lon": 13.637411, + "spread": 0.13272 + }, + "pool-umwl": { + "lat": -3.764933, + "lon": 14.804547, + "spread": 0.21822 + }, + "sangha-oowm": { + "lat": 1.420309, + "lon": 15.150849, + "spread": 0.26844 + }, + "plateaux-kqwn": { + "lat": -2.219466, + "lon": 15.102922, + "spread": 0.21217 + }, + "cuvette-ouest-crwo": { + "lat": 0.117665, + "lon": 14.632419, + "spread": 0.14541 + }, + "amazonas-isdt": { + "lat": -1.428135, + "lon": -71.63694, + "spread": 0.43697 + }, + "antioquia-kqan": { + "lat": 7.114056, + "lon": -75.673143, + "spread": 0.32386 + }, + "arauca-yxsw": { + "lat": 6.557607, + "lon": -70.996471, + "spread": 0.10736 + }, + "bolivar-shkq": { + "lat": 9.13682, + "lon": -74.849287, + "spread": 0.19489 + }, + "boyaca-uekx": { + "lat": 5.946181, + "lon": -73.167267, + "spread": 0.23497 + }, + "caldas-wkha": { + "lat": 5.370362, + "lon": -75.405952, + "spread": 0.10867 + }, + "caqueta-qfhx": { + "lat": 0.865181, + "lon": -73.940838, + "spread": 0.36267 + }, + "casanare-aihz": { + "lat": 5.507497, + "lon": -71.984273, + "spread": 0.20531 + }, + "cauca-szht": { + "lat": 2.293387, + "lon": -76.83867, + "spread": 0.22929 + }, + "cesar-qcua": { + "lat": 9.309084, + "lon": -73.557528, + "spread": 0.12219 + }, + "choco-qaew": { + "lat": 6.379211, + "lon": -77.113496, + "spread": 0.18533 + }, + "cordoba-srwy": { + "lat": 8.457923, + "lon": -75.685995, + "spread": 0.17041 + }, + "cundinamarca-ieom": { + "lat": 4.593133, + "lon": -73.950158, + "spread": 0.18382 + }, + "guainia-utmi": { + "lat": 2.708167, + "lon": -68.557447, + "spread": 0.28672 + }, + "guaviare-spmp": { + "lat": 2.044505, + "lon": -71.846359, + "spread": 0.22414 + }, + "huila-appk": { + "lat": 2.713364, + "lon": -75.574643, + "spread": 0.20785 + }, + "la-guajira-utww": { + "lat": 11.641837, + "lon": -72.36452, + "spread": 0.20599 + }, + "magdalena-wrsg": { + "lat": 10.139814, + "lon": -74.418432, + "spread": 0.16917 + }, + "meta-ymbx": { + "lat": 3.239154, + "lon": -73.156551, + "spread": 0.32949 + }, + "narino-osue": { + "lat": 1.560889, + "lon": -77.829397, + "spread": 0.21767 + }, + "norte-de-santander-svgs": { + "lat": 7.9551, + "lon": -72.910259, + "spread": 0.16465 + }, + "putumayo-ksux": { + "lat": 0.366934, + "lon": -75.7581, + "spread": 0.19957 + }, + "santander-mmat": { + "lat": 6.754691, + "lon": -73.530053, + "spread": 0.20382 + }, + "san-andres-y-providencia-sbat": { + "lat": 13.339033, + "lon": -81.371067, + "spread": 0.00173 + }, + "sucre-glea": { + "lat": 9.238476, + "lon": -75.210472, + "spread": 0.11629 + }, + "tolima-gone": { + "lat": 4.081196, + "lon": -75.316412, + "spread": 0.16328 + }, + "valle-del-cauca-urfx": { + "lat": 3.883691, + "lon": -76.789348, + "spread": 0.17509 + }, + "vaupes-ihfh": { + "lat": 0.200172, + "lon": -70.284315, + "spread": 0.28834 + }, + "vichada-oozm": { + "lat": 4.587882, + "lon": -69.048505, + "spread": 0.35309 + }, + "colombia-weor": { + "lat": 3.9596, + "lon": -81.609, + "spread": 0.00012 + }, + "alajuela-vwmk": { + "lat": 10.500708, + "lon": -84.386776, + "spread": 0.12459 + }, + "guanacaste-ffmq": { + "lat": 10.510632, + "lon": -85.438704, + "spread": 0.11824 + }, + "limon-ynmv": { + "lat": 9.931503, + "lon": -83.553312, + "spread": 0.18683 + }, + "puntarenas-evmt": { + "lat": 9.139193, + "lon": -83.756188, + "spread": 0.22961 + }, + "pinar-del-rio-ejbc": { + "lat": 22.26781, + "lon": -83.890285, + "spread": 0.12524 + }, + "matanzas-kobf": { + "lat": 22.737513, + "lon": -81.605742, + "spread": 0.11582 + }, + "villa-clara-cpbg": { + "lat": 22.42171, + "lon": -80.231297, + "spread": 0.12432 + }, + "sancti-spiritus-msbi": { + "lat": 21.970948, + "lon": -79.163368, + "spread": 0.09386 + }, + "camaguey-ewbu": { + "lat": 21.566362, + "lon": -77.684731, + "spread": 0.13848 + }, + "granma-yeei": { + "lat": 20.827359, + "lon": -76.894151, + "spread": 0.16021 + }, + "santiago-de-cuba-qfej": { + "lat": 20.506279, + "lon": -75.660354, + "spread": 0.13694 + }, + "northern-cyprus-mvnb": { + "lat": 35.195447, + "lon": 33.569813, + "spread": 0.06893 + }, + "nicosia-gzli": { + "lat": 34.933743, + "lon": 33.006348, + "spread": 0.05744 + }, + "moravskoslezsky-sbcc": { + "lat": 49.730824, + "lon": 17.619066, + "spread": 0.15893 + }, + "plzensky-ysxk": { + "lat": 49.481271, + "lon": 13.721144, + "spread": 0.18468 + }, + "stredocesky-ihft": { + "lat": 50.371938, + "lon": 14.734792, + "spread": 0.15505 + }, + "vysocina-kamr": { + "lat": 49.412617, + "lon": 16.185674, + "spread": 0.15855 + }, + "brandenburg-yrjj": { + "lat": 52.604575, + "lon": 13.514855, + "spread": 0.21853 + }, + "baden-wurttemberg-oyje": { + "lat": 48.755657, + "lon": 9.176847, + "spread": 0.22505 + }, + "bayern-aiju": { + "lat": 48.926484, + "lon": 11.105764, + "spread": 0.32918 + }, + "hessen-bqlm": { + "lat": 50.619265, + "lon": 9.113295, + "spread": 0.2252 + }, + "mecklenburg-vorpommern-pqcp": { + "lat": 53.790093, + "lon": 12.640589, + "spread": 0.13708 + }, + "niedersachsen-vxfc": { + "lat": 52.607122, + "lon": 8.913925, + "spread": 0.2602 + }, + "nordrhein-westfalen-hafe": { + "lat": 51.587975, + "lon": 7.748608, + "spread": 0.2201 + }, + "rheinland-pfalz-cfwr": { + "lat": 49.884184, + "lon": 7.319843, + "spread": 0.1957 + }, + "schleswig-holstein-gmaj": { + "lat": 54.192151, + "lon": 9.760547, + "spread": 0.1542 + }, + "sachsen-oiah": { + "lat": 50.99903, + "lon": 13.383082, + "spread": 0.15093 + }, + "sachsen-anhalt-arad": { + "lat": 51.948059, + "lon": 11.576727, + "spread": 0.21224 + }, + "thuringen-mxiz": { + "lat": 50.904445, + "lon": 10.996538, + "spread": 0.14253 + }, + "dikhil-zrvh": { + "lat": 11.377127, + "lon": 42.140691, + "spread": 0.09511 + }, + "tadjourah-jsgf": { + "lat": 12.13975, + "lon": 42.759031, + "spread": 0.11497 + }, + "midtjylland-gjbs": { + "lat": 56.557803, + "lon": 9.399595, + "spread": 0.20489 + }, + "syddanmark-ykbt": { + "lat": 55.432743, + "lon": 11.759197, + "spread": 0.15601 + }, + "la-altagracia-iexf": { + "lat": 18.644247, + "lon": -69.154126, + "spread": 0.08916 + }, + "san-juan-iczh": { + "lat": 18.967377, + "lon": -70.350487, + "spread": 0.164583 + }, + "adrar-ctwy": { + "lat": 24.717908, + "lon": 0.713079, + "spread": 0.94518 + }, + "laghouat-mwwa": { + "lat": 33.727974, + "lon": 2.612778, + "spread": 0.19969 + }, + "batna-enwu": { + "lat": 35.375214, + "lon": 5.917883, + "spread": 0.11461 + }, + "biskra-oqww": { + "lat": 34.685242, + "lon": 5.520191, + "spread": 0.17157 + }, + "bechar-kfwp": { + "lat": 30.785118, + "lon": -2.874661, + "spread": 0.41201 + }, + "tamanghasset-mozx": { + "lat": 25.412433, + "lon": 5.272344, + "spread": 1.00627 + }, + "tebessa-iqzy": { + "lat": 35.053691, + "lon": 7.822322, + "spread": 0.12955 + }, + "tiaret-stza": { + "lat": 35.013204, + "lon": 1.29322, + "spread": 0.16209 + }, + "djelfa-uxzd": { + "lat": 34.651726, + "lon": 3.33492, + "spread": 0.28361 + }, + "setif-mbzf": { + "lat": 36.280423, + "lon": 5.043863, + "spread": 0.12596 + }, + "skikda-odba": { + "lat": 36.566356, + "lon": 6.407848, + "spread": 0.11935 + }, + "sidi-bel-abbes-kfbb": { + "lat": 34.8656, + "lon": -0.419926, + "spread": 0.19763 + }, + "medea-elbf": { + "lat": 36.232754, + "lon": 3.074906, + "spread": 0.14738 + }, + "m-sila-sobh": { + "lat": 35.309363, + "lon": 4.250984, + "spread": 0.17969 + }, + "ouargla-wxdg": { + "lat": 30.68459, + "lon": 5.837743, + "spread": 0.498 + }, + "el-bayadh-gadi": { + "lat": 33.120375, + "lon": 0.974925, + "spread": 0.27456 + }, + "illizi-ccdj": { + "lat": 26.340163, + "lon": 8.328393, + "spread": 0.58944 + }, + "tindouf-ewdf": { + "lat": 28.529658, + "lon": -6.359717, + "spread": 0.40702 + }, + "el-oued-oldp": { + "lat": 33.499657, + "lon": 6.940887, + "spread": 0.24996 + }, + "khenchela-yomx": { + "lat": 35.483141, + "lon": 6.965509, + "spread": 0.17145 + }, + "souk-ahras-uqmy": { + "lat": 36.551876, + "lon": 7.633814, + "spread": 0.10335 + }, + "naama-swmc": { + "lat": 33.233232, + "lon": -0.860076, + "spread": 0.18328 + }, + "ghardaia-czme": { + "lat": 31.46467, + "lon": 3.170285, + "spread": 0.30349 + }, + "relizane-ybmf": { + "lat": 35.845977, + "lon": 0.944891, + "spread": 0.11111 + }, + "azuay-kruk": { + "lat": -2.775345, + "lon": -79.091075, + "spread": 0.13366 + }, + "orellana-cmuh": { + "lat": -0.721419, + "lon": -76.548856, + "spread": 0.14993 + }, + "esmeraldas-ikug": { + "lat": 0.700977, + "lon": -79.16428, + "spread": 0.14844 + }, + "guayas-whue": { + "lat": -2.078918, + "lon": -80.022647, + "spread": 0.18808 + }, + "loja-czuz": { + "lat": -3.869019, + "lon": -79.815984, + "spread": 0.141 + }, + "manabi-myuy": { + "lat": -0.791573, + "lon": -80.185123, + "spread": 0.14993 + }, + "pichincha-wrub": { + "lat": 0.175705, + "lon": -78.613273, + "spread": 0.18762 + }, + "los-rios-mouz": { + "lat": -1.247023, + "lon": -78.940397, + "spread": 0.17424 + }, + "morona-santiago-unuy": { + "lat": -2.591208, + "lon": -78.181064, + "spread": 0.20908 + }, + "tungurahua-ylux": { + "lat": -0.605548, + "lon": -77.682512, + "spread": 0.1269 + }, + "sucumbios-ejuw": { + "lat": -0.036989, + "lon": -76.624548, + "spread": 0.12944 + }, + "galapagos-sguu": { + "lat": -0.435214, + "lon": -91.208386, + "spread": 0.0792 + }, + "pastaza-gdus": { + "lat": -1.643471, + "lon": -77.064959, + "spread": 0.15667 + }, + "zamora-chinchipe-qcur": { + "lat": -4.189111, + "lon": -78.977283, + "spread": 0.10849 + }, + "aswan-sxuz": { + "lat": 23.336108, + "lon": 32.495928, + "spread": 0.21046 + }, + "al-bahr-al-ahmar-oaom": { + "lat": 25.588294, + "lon": 33.034733, + "spread": 0.6232 + }, + "al-buhayrah-ilnf": { + "lat": 31.007489, + "lon": 30.293353, + "spread": 0.12814 + }, + "al-fayyum-wdgf": { + "lat": 29.22539, + "lon": 30.73744, + "spread": 0.10214 + }, + "al-jizah-abuz": { + "lat": 29.1204, + "lon": 30.130264, + "spread": 0.26659 + }, + "janub-sina-mrqx": { + "lat": 28.962125, + "lon": 33.832125, + "spread": 0.21966 + }, + "al-minya-mjfl": { + "lat": 28.12953, + "lon": 30.17537, + "spread": 0.11609 + }, + "matruh-cafp": { + "lat": 30.312584, + "lon": 26.986084, + "spread": 0.399 + }, + "ash-sharqiyah-wcnm": { + "lat": 30.743623, + "lon": 31.618868, + "spread": 0.24926 + }, + "shamal-sina-uhqn": { + "lat": 30.77869, + "lon": 33.388695, + "spread": 0.18242 + }, + "al-wadi-at-jadid-spvg": { + "lat": 24.516958, + "lon": 31.707961, + "spread": 0.56732 + }, + "debubawi-keyih-bahri-jrxo": { + "lat": 13.4303, + "lon": 42.008447, + "spread": 0.25912 + }, + "debub-rtxk": { + "lat": 15.731385, + "lon": 39.386158, + "spread": 0.25767 + }, + "gash-barka-coeg": { + "lat": 15.195292, + "lon": 37.519146, + "spread": 0.23299 + }, + "maekel-litf": { + "lat": 14.850659, + "lon": 38.886959, + "spread": 0.11594 + }, + "semenawi-keyih-bahri-comw": { + "lat": 16.449385, + "lon": 37.89429, + "spread": 0.19892 + }, + "albacete-attf": { + "lat": 38.768374, + "lon": -1.968977, + "spread": 0.13931 + }, + "barcelona-iybx": { + "lat": 42.03494, + "lon": 2.157272, + "spread": 0.13015 + }, + "badajoz-ciwt": { + "lat": 38.848437, + "lon": -6.068661, + "spread": 0.14977 + }, + "burgos-asxn": { + "lat": 42.663126, + "lon": -3.646128, + "spread": 0.19946 + }, + "cadiz-aazc": { + "lat": 36.721471, + "lon": -5.206332, + "spread": 0.12711 + }, + "caceres-kdze": { + "lat": 39.615288, + "lon": -6.205859, + "spread": 0.14488 + }, + "cordoba-mxza": { + "lat": 37.786996, + "lon": -4.736262, + "spread": 0.15418 + }, + "ciudad-real-adzn": { + "lat": 39.089627, + "lon": -3.845462, + "spread": 0.12267 + }, + "cuenca-chzq": { + "lat": 39.985566, + "lon": -2.232721, + "spread": 0.14331 + }, + "las-palmas-ujic": { + "lat": 28.3899, + "lon": -14.113557, + "spread": 0.06827 + }, + "granada-yhir": { + "lat": 37.282613, + "lon": -2.767335, + "spread": 0.138 + }, + "guadalajara-izim": { + "lat": 40.71229, + "lon": -2.616234, + "spread": 0.11645 + }, + "huelva-qhbd": { + "lat": 37.530558, + "lon": -6.7414, + "spread": 0.13899 + }, + "huesca-ihkb": { + "lat": 42.178319, + "lon": -0.208438, + "spread": 0.15458 + }, + "jaen-ekbf": { + "lat": 38.021189, + "lon": -3.428768, + "spread": 0.11468 + }, + "lerida-onbh": { + "lat": 41.689986, + "lon": 0.953392, + "spread": 0.1675 + }, + "leon-uyuj": { + "lat": 42.657942, + "lon": -5.874958, + "spread": 0.11971 + }, + "la-rioja-anut": { + "lat": 42.805291, + "lon": -2.438228, + "spread": 0.15436 + }, + "lugo-qbvz": { + "lat": 43.111588, + "lon": -8.0987, + "spread": 0.1465 + }, + "madrid-kpbi": { + "lat": 40.633402, + "lon": -4.258604, + "spread": 0.16897 + }, + "melilla-uuxv": { + "lat": 35.291533, + "lon": -2.934567, + "spread": 0.00349 + }, + "murcia-qjxo": { + "lat": 38.26531, + "lon": -1.10036, + "spread": 0.15018 + }, + "asturias-usbk": { + "lat": 43.2477, + "lon": -6.012159, + "spread": 0.07702 + }, + "orense-aqch": { + "lat": 42.248839, + "lon": -8.006589, + "spread": 0.10397 + }, + "palencia-qubl": { + "lat": 41.980878, + "lon": -4.546647, + "spread": 0.1495 + }, + "baleares-ayfx": { + "lat": 39.650581, + "lon": 3.04, + "spread": 0.06997 + }, + "salamanca-qrmw": { + "lat": 40.745612, + "lon": -6.031519, + "spread": 0.10087 + }, + "sevilla-oxma": { + "lat": 37.399966, + "lon": -5.637262, + "spread": 0.13409 + }, + "soria-comu": { + "lat": 41.706505, + "lon": -2.479273, + "spread": 0.10724 + }, + "teruel-ofof": { + "lat": 40.579918, + "lon": -0.733587, + "spread": 0.16376 + }, + "santa-cruz-de-tenerife-ggog": { + "lat": 28.293967, + "lon": -16.478556, + "spread": 0.05723 + }, + "toledo-yvoz": { + "lat": 39.786268, + "lon": -4.275785, + "spread": 0.10474 + }, + "valencia-ydbr": { + "lat": 39.345424, + "lon": -0.796105, + "spread": 0.12785 + }, + "zaragoza-ajbv": { + "lat": 41.703258, + "lon": -0.959805, + "spread": 0.17958 + }, + "zamora-qhdj": { + "lat": 41.668982, + "lon": -5.927868, + "spread": 0.10897 + }, + "harju-qnwd": { + "lat": 58.799702, + "lon": 24.708826, + "spread": 0.18016 + }, + "ida-viru-ictd": { + "lat": 59.014, + "lon": 26.529822, + "spread": 0.11496 + }, + "tartu-aymq": { + "lat": 57.972304, + "lon": 26.574422, + "spread": 0.11936 + }, + "afar-cnlj": { + "lat": 11.502857, + "lon": 40.478347, + "spread": 0.2704 + }, + "amhara-kylq": { + "lat": 11.139621, + "lon": 38.16607, + "spread": 0.49402 + }, + "benshangul-gumaz-iznr": { + "lat": 10.641835, + "lon": 35.696537, + "spread": 0.28946 + }, + "gambela-peoples-sggc": { + "lat": 7.839874, + "lon": 34.345296, + "spread": 0.15925 + }, + "oromiya-otzl": { + "lat": 7.930893, + "lon": 38.325081, + "spread": 0.68686 + }, + "southern-nations-nationalities-and-peoples-ohez": { + "lat": 6.819027, + "lon": 37.088711, + "spread": 0.40156 + }, + "somali-eiea": { + "lat": 7.652626, + "lon": 42.03695, + "spread": 0.76847 + }, + "tigray-ucnt": { + "lat": 13.767396, + "lon": 38.584688, + "spread": 0.26284 + }, + "southern-ostrobothnia-ihkk": { + "lat": 63.007509, + "lon": 22.599548, + "spread": 0.22289 + }, + "southern-savonia-ewkd": { + "lat": 61.876143, + "lon": 27.791109, + "spread": 0.19891 + }, + "kainuu-ayke": { + "lat": 64.678479, + "lon": 28.883824, + "spread": 0.18433 + }, + "central-finland-cpkp": { + "lat": 62.389217, + "lon": 25.500733, + "spread": 0.21243 + }, + "lapland-gcno": { + "lat": 67.715924, + "lon": 25.606267, + "spread": 0.44834 + }, + "pirkanmaa-cenp": { + "lat": 61.522949, + "lon": 23.029113, + "spread": 0.16023 + }, + "north-karelia-mhnr": { + "lat": 62.877407, + "lon": 29.46031, + "spread": 0.21593 + }, + "northern-ostrobothnia-eins": { + "lat": 65.122046, + "lon": 26.827189, + "spread": 0.30345 + }, + "northern-savonia-aknt": { + "lat": 63.053192, + "lon": 27.688978, + "spread": 0.17733 + }, + "paijat-hame-olnu": { + "lat": 60.976112, + "lon": 26.389136, + "spread": 0.13945 + }, + "uusimaa-gpng": { + "lat": 60.467141, + "lon": 23.865263, + "spread": 0.15091 + }, + "rotuma-kdui": { + "lat": -16.560363, + "lon": 179.779535, + "spread": 0.05213 + }, + "western-alun": { + "lat": -17.750222, + "lon": 178.061335, + "spread": 0.09394 + }, + "falkland-islands-ubzb": { + "lat": -51.748985, + "lon": -58.738084, + "spread": 0.10932 + }, + "ain-lqtt": { + "lat": 46.163586, + "lon": 4.805049, + "spread": 0.19133 + }, + "aisne-gstu": { + "lat": 49.537515, + "lon": 4.016806, + "spread": 0.13112 + }, + "haute-corse-gmyy": { + "lat": 42.105904, + "lon": 9.006537, + "spread": 0.10101 + }, + "allier-butv": { + "lat": 46.846803, + "lon": 3.390994, + "spread": 0.15934 + }, + "alpes-de-haute-provence-svtw": { + "lat": 44.038355, + "lon": 6.316866, + "spread": 0.20889 + }, + "ardeche-zatz": { + "lat": 44.830693, + "lon": 3.896117, + "spread": 0.13082 + }, + "aude-pmva": { + "lat": 42.921412, + "lon": 2.42578, + "spread": 0.11166 + }, + "aveyron-kovb": { + "lat": 43.940226, + "lon": 2.640823, + "spread": 0.17153 + }, + "cantal-rgvw": { + "lat": 45.42055, + "lon": 2.604167, + "spread": 0.16404 + }, + "charente-maritime-djvy": { + "lat": 45.764372, + "lon": -0.318768, + "spread": 0.12265 + }, + "cher-yyvr": { + "lat": 47.004239, + "lon": 2.002454, + "spread": 0.1256 + }, + "cote-d-or-zhyz": { + "lat": 47.850509, + "lon": 4.996222, + "spread": 0.17883 + }, + "cotes-d-armor-qiya": { + "lat": 48.085481, + "lon": -3.205673, + "spread": 0.14241 + }, + "creuse-lkyb": { + "lat": 45.88225, + "lon": 1.66165, + "spread": 0.10055 + }, + "doubs-xnyd": { + "lat": 46.95005, + "lon": 6.079025, + "spread": 0.12967 + }, + "eure-jqyf": { + "lat": 49.304471, + "lon": 1.10449, + "spread": 0.14006 + }, + "gard-guab": { + "lat": 43.762679, + "lon": 4.765581, + "spread": 0.12775 + }, + "haute-garonne-bwac": { + "lat": 43.093271, + "lon": 1.283523, + "spread": 0.13491 + }, + "gers-wyad": { + "lat": 43.703637, + "lon": 0.464821, + "spread": 0.07603 + }, + "gironde-nzae": { + "lat": 44.781447, + "lon": -0.488473, + "spread": 0.13753 + }, + "isere-ghaj": { + "lat": 45.041394, + "lon": 5.41578, + "spread": 0.17076 + }, + "landes-kudi": { + "lat": 43.660617, + "lon": -0.7213, + "spread": 0.17572 + }, + "loir-et-cher-fwdj": { + "lat": 47.920263, + "lon": 1.823521, + "spread": 0.17532 + }, + "loire-atlantique-mode": { + "lat": 47.708469, + "lon": -1.72686, + "spread": 0.16326 + }, + "lot-et-garonne-psdh": { + "lat": 44.389256, + "lon": 0.961756, + "spread": 0.12707 + }, + "marne-hpgs": { + "lat": 48.961653, + "lon": 4.696997, + "spread": 0.11969 + }, + "moselle-rygy": { + "lat": 49.022605, + "lon": 6.881014, + "spread": 0.09589 + }, + "orne-jeiv": { + "lat": 48.950703, + "lon": -0.776715, + "spread": 0.15433 + }, + "pas-de-calais-egiw": { + "lat": 50.37414, + "lon": 2.925784, + "spread": 0.11185 + }, + "sarthe-ebkd": { + "lat": 47.74012, + "lon": -0.446967, + "spread": 0.15732 + }, + "savoie-zdke": { + "lat": 45.788523, + "lon": 6.450958, + "spread": 0.14023 + }, + "seien-et-marne-bxka": { + "lat": 48.675443, + "lon": 2.493113, + "spread": 0.11064 + }, + "somme-sgzc": { + "lat": 49.801526, + "lon": 2.31547, + "spread": 0.12912 + }, + "vendee-lozh": { + "lat": 46.6017, + "lon": -0.76265, + "spread": 0.11286 + }, + "vienne-cpzi": { + "lat": 46.821607, + "lon": 0.682356, + "spread": 0.14559 + }, + "vosges-oszk": { + "lat": 47.876008, + "lon": 6.514285, + "spread": 0.1263 + }, + "yonne-juzl": { + "lat": 48.070936, + "lon": 3.805539, + "spread": 0.13977 + }, + "guyane-francaise-gmcw": { + "lat": 3.723117, + "lon": -53.185597, + "spread": 0.29431 + }, + "guadeloupe-wqci": { + "lat": 16.206233, + "lon": -61.650983, + "spread": 0.02457 + }, + "martinique-tuau": { + "lat": 14.574564, + "lon": -60.975209, + "spread": 0.04091 + }, + "la-reunion-voeo": { + "lat": -21.105578, + "lon": 55.556822, + "spread": 0.05065 + }, + "mayotte-gthm": { + "lat": -12.8175, + "lon": 45.12505, + "spread": 0.01582 + }, + "estuaire-qawm": { + "lat": 0.347039, + "lon": 9.836531, + "spread": 0.16377 + }, + "haut-ogooue-kcwn": { + "lat": -1.378997, + "lon": 13.783616, + "spread": 0.1758 + }, + "moyen-ogooue-eewo": { + "lat": -0.519056, + "lon": 10.437372, + "spread": 0.14666 + }, + "ngounie-atwh": { + "lat": -1.712177, + "lon": 11.342135, + "spread": 0.22296 + }, + "nyanga-suwi": { + "lat": -3.14276, + "lon": 11.1962, + "spread": 0.16831 + }, + "ogooue-ivindo-mwwj": { + "lat": 0.532986, + "lon": 13.176127, + "spread": 0.21879 + }, + "ogooue-lolo-cxwk": { + "lat": -0.952604, + "lon": 12.564996, + "spread": 0.20612 + }, + "ogooue-maritime-ulwt": { + "lat": -1.717162, + "lon": 9.565155, + "spread": 0.19115 + }, + "wouleu-ntem-qnwu": { + "lat": 1.315972, + "lon": 11.970578, + "spread": 0.20924 + }, + "aberdeenshire-kzrn": { + "lat": 56.763031, + "lon": -3.294163, + "spread": 0.16961 + }, + "cambridgeshire-fwds": { + "lat": 52.274228, + "lon": -0.553809, + "spread": 0.13703 + }, + "cumbria-oohy": { + "lat": 54.729995, + "lon": -2.318279, + "spread": 0.17417 + }, + "devon-slaz": { + "lat": 50.54213, + "lon": -4.268467, + "spread": 0.12886 + }, + "dumfries-and-galloway-sres": { + "lat": 55.86206, + "lon": -4.556213, + "spread": 0.20136 + }, + "highland-saxg": { + "lat": 57.661815, + "lon": -4.818711, + "spread": 0.21802 + }, + "isles-of-scilly-oznv": { + "lat": 50.036169, + "lon": -5.374977, + "spread": 0.01704 + }, + "lincolnshire-odkr": { + "lat": 53.031776, + "lon": -0.103161, + "spread": 0.12542 + }, + "north-yorkshire-qalk": { + "lat": 53.822802, + "lon": -1.048275, + "spread": 0.13271 + }, + "omagh-gocy": { + "lat": 54.580803, + "lon": -6.477883, + "spread": 0.11888 + }, + "oxfordshire-qxcr": { + "lat": 52.021824, + "lon": -1.573154, + "spread": 0.18825 + }, + "powys-zcfq": { + "lat": 51.991522, + "lon": -3.770463, + "spread": 0.14777 + }, + "suffolk-peqe": { + "lat": 51.601489, + "lon": 0.525317, + "spread": 0.17854 + }, + "shropshire-mhvl": { + "lat": 53.17148, + "lon": -2.998322, + "spread": 0.19298 + }, + "somerset-drhz": { + "lat": 51.116214, + "lon": -1.681999, + "spread": 0.11703 + }, + "shetland-islands-ypae": { + "lat": 60.320858, + "lon": -1.305739, + "spread": 0.06558 + }, + "imereti-vlst": { + "lat": 41.821558, + "lon": 42.890166, + "spread": 0.14175 + }, + "kakheti-lswd": { + "lat": 41.689374, + "lon": 45.797774, + "spread": 0.15157 + }, + "mtskheta-mtianeti-dbcb": { + "lat": 41.98197, + "lon": 44.513712, + "spread": 0.15949 + }, + "samegrelo-zemo-svaneti-wjlp": { + "lat": 42.82046, + "lon": 42.109971, + "spread": 0.15165 + }, + "ashanti-abux": { + "lat": 6.863594, + "lon": -1.640997, + "spread": 0.17692 + }, + "brong-ahafo-qqwr": { + "lat": 7.557728, + "lon": -1.841274, + "spread": 0.24016 + }, + "eastern-irer": { + "lat": 6.048918, + "lon": -0.745997, + "spread": 0.21735 + }, + "northern-ibne": { + "lat": 9.268939, + "lon": -0.885462, + "spread": 0.27348 + }, + "volta-ihni": { + "lat": 7.294788, + "lon": 0.374642, + "spread": 0.15829 + }, + "upper-west-avqy": { + "lat": 10.536844, + "lon": -1.402359, + "spread": 0.15037 + }, + "western-qwvr": { + "lat": 5.910116, + "lon": -2.469491, + "spread": 0.184 + }, + "beyla-vyfv": { + "lat": 8.731055, + "lon": -8.169818, + "spread": 0.13067 + }, + "boke-jifl": { + "lat": 11.045333, + "lon": -14.484942, + "spread": 0.11938 + }, + "dinguiraye-vqqn": { + "lat": 11.608486, + "lon": -10.735262, + "spread": 0.12745 + }, + "faranah-lbvx": { + "lat": 9.951953, + "lon": -10.651368, + "spread": 0.13111 + }, + "gaoual-jpxg": { + "lat": 11.970157, + "lon": -13.323311, + "spread": 0.14699 + }, + "kankan-kmei": { + "lat": 9.726288, + "lon": -8.922892, + "spread": 0.1839 + }, + "kindia-gbod": { + "lat": 9.769297, + "lon": -13.125462, + "spread": 0.15624 + }, + "kouroussa-jsoo": { + "lat": 10.53365, + "lon": -10.186187, + "spread": 0.15532 + }, + "kissidougou-hloa": { + "lat": 8.93593, + "lon": -10.110974, + "spread": 0.14304 + }, + "mandiana-alzh": { + "lat": 10.743957, + "lon": -8.562496, + "spread": 0.14051 + }, + "mali-ayzp": { + "lat": 11.884239, + "lon": -11.891617, + "spread": 0.14255 + }, + "mamou-rzzq": { + "lat": 10.597771, + "lon": -11.548958, + "spread": 0.14445 + }, + "pita-rhov": { + "lat": 10.963692, + "lon": -12.511267, + "spread": 0.12041 + }, + "siguiri-juvq": { + "lat": 11.811119, + "lon": -9.443052, + "spread": 0.14812 + }, + "telimele-jadt": { + "lat": 10.594862, + "lon": -13.709386, + "spread": 0.15059 + }, + "yomou-dhxs": { + "lat": 8.105763, + "lon": -9.098192, + "spread": 0.18527 + }, + "central-river-qqfp": { + "lat": 13.545742, + "lon": -15.158042, + "spread": 0.05843 + }, + "bafata-krfg": { + "lat": 11.775937, + "lon": -14.92873, + "spread": 0.17505 + }, + "oio-eyrz": { + "lat": 12.258772, + "lon": -15.580332, + "spread": 0.0794 + }, + "annobon-eedl": { + "lat": -1.45725, + "lon": 5.62375, + "spread": 0.00021 + }, + "bioko-sur-yvgu": { + "lat": 3.5237, + "lon": 8.695933, + "spread": 0.0521 + }, + "centro-sur-gwiq": { + "lat": 1.535737, + "lon": 10.118605, + "spread": 0.14168 + }, + "anatoliki-makedonia-kai-thraki-abyd": { + "lat": 41.196067, + "lon": 25.1491, + "spread": 0.10183 + }, + "kentriki-makedonia-wdye": { + "lat": 40.458722, + "lon": 23.136573, + "spread": 0.14466 + }, + "dytiki-makedonia-keyf": { + "lat": 39.875773, + "lon": 20.967815, + "spread": 0.19724 + }, + "thessalia-yhyh": { + "lat": 39.473753, + "lon": 22.270135, + "spread": 0.1204 + }, + "dytiki-ellada-mlyj": { + "lat": 37.797, + "lon": 21.665356, + "spread": 0.09682 + }, + "sterea-ellada-inyu": { + "lat": 38.5289, + "lon": 23.048468, + "spread": 0.15922 + }, + "peloponnisos-wqyw": { + "lat": 37.297991, + "lon": 22.587544, + "spread": 0.17721 + }, + "kommune-kujalleq-nbjz": { + "lat": 60.954379, + "lon": -44.435295, + "spread": 0.27705 + }, + "qaasuitsup-kommunia-kawz": { + "lat": 73.338837, + "lon": -57.866631, + "spread": 1.37257 + }, + "qeqqata-kommunia-mhwd": { + "lat": 66.170759, + "lon": -52.163844, + "spread": 0.31909 + }, + "kommuneqarfik-sermersooq-eqcb": { + "lat": 65.590323, + "lon": -39.157981, + "spread": 1.01298 + }, + "nationalparken-menh": { + "lat": 78.635219, + "lon": -29.411975, + "spread": 1.26345 + }, + "alta-verapaz-myed": { + "lat": 15.5795, + "lon": -90.2156, + "spread": 0.09228 + }, + "baja-verapaz-ongg": { + "lat": 15.054303, + "lon": -89.47198, + "spread": 0.15541 + }, + "huehuetenango-mjgd": { + "lat": 15.420859, + "lon": -91.107641, + "spread": 0.12625 + }, + "jutiapa-wdml": { + "lat": 14.338044, + "lon": -90.390888, + "spread": 0.11856 + }, + "peten-kent": { + "lat": 16.6807, + "lon": -90.275637, + "spread": 0.19739 + }, + "san-marcos-cvvw": { + "lat": 14.708304, + "lon": -91.685375, + "spread": 0.11491 + }, + "barima-waini-wifs": { + "lat": 7.530437, + "lon": -59.576212, + "spread": 0.1844 + }, + "mahaica-berbice-igzl": { + "lat": 3.849265, + "lon": -57.610114, + "spread": 0.202 + }, + "pomeroon-supenaam-iooq": { + "lat": 6.217768, + "lon": -59.852191, + "spread": 0.2037 + }, + "potaro-siparuni-qaor": { + "lat": 4.818543, + "lon": -59.359596, + "spread": 0.17096 + }, + "upper-demerara-berbice-ynhw": { + "lat": 2.913989, + "lon": -59.223875, + "spread": 0.20648 + }, + "upper-takutu-upper-essequibo-cohg": { + "lat": 6.010912, + "lon": -58.196412, + "spread": 0.13658 + }, + "comayagua-gvxb": { + "lat": 14.267294, + "lon": -87.731415, + "spread": 0.13965 + }, + "el-paraiso-gxia": { + "lat": 13.692806, + "lon": -86.720069, + "spread": 0.1444 + }, + "gracias-a-dios-kumu": { + "lat": 15.181447, + "lon": -84.086935, + "spread": 0.13684 + }, + "olancho-qasq": { + "lat": 15.112321, + "lon": -86.007611, + "spread": 0.19876 + }, + "santa-barbara-qgiw": { + "lat": 14.825838, + "lon": -88.484447, + "spread": 0.16178 + }, + "yoro-chgi": { + "lat": 15.46597, + "lon": -87.243887, + "spread": 0.10378 + }, + "zagrebacka-cijs": { + "lat": 45.798109, + "lon": 16.498789, + "spread": 0.15147 + }, + "licko-senjska-cvjk": { + "lat": 45.139512, + "lon": 14.769412, + "spread": 0.13648 + }, + "zadarska-mglb": { + "lat": 44.014913, + "lon": 16.141876, + "spread": 0.19834 + }, + "osjecko-baranjska-ivlu": { + "lat": 45.421844, + "lon": 18.18694, + "spread": 0.11166 + }, + "l-artibonite-egma": { + "lat": 19.375061, + "lon": -72.397219, + "spread": 0.12372 + }, + "ouest-wjof": { + "lat": 18.417895, + "lon": -73.019726, + "spread": 0.09466 + }, + "bekes-itph": { + "lat": 47.005383, + "lon": 20.672817, + "spread": 0.14089 + }, + "bacs-kiskun-udpx": { + "lat": 46.477011, + "lon": 19.704566, + "spread": 0.12316 + }, + "hajdu-bihar-edea": { + "lat": 47.946817, + "lon": 21.342519, + "spread": 0.16284 + }, + "pest-akxn": { + "lat": 47.535609, + "lon": 19.419443, + "spread": 0.14943 + }, + "somogy-ymes": { + "lat": 46.312782, + "lon": 18.152832, + "spread": 0.12099 + }, + "veszprem-czmj": { + "lat": 47.188725, + "lon": 16.925184, + "spread": 0.17185 + }, + "aceh-ocsz": { + "lat": 4.148778, + "lon": 97.169991, + "spread": 0.30835 + }, + "bangka-belitung-ixwo": { + "lat": -2.222086, + "lon": 105.986293, + "spread": 0.16114 + }, + "bengkulu-qwwn": { + "lat": -3.555854, + "lon": 102.569912, + "spread": 0.26803 + }, + "banten-stwc": { + "lat": -6.50298, + "lon": 106.051975, + "spread": 0.11058 + }, + "gorontalo-gihp": { + "lat": 0.687175, + "lon": 122.360525, + "spread": 0.07159 + }, + "jambi-sxbt": { + "lat": -1.743508, + "lon": 102.855911, + "spread": 0.20362 + }, + "jawa-barat-msbq": { + "lat": -6.791324, + "lon": 107.625118, + "spread": 0.19042 + }, + "jawa-timur-skbb": { + "lat": -7.824342, + "lon": 112.759806, + "spread": 0.20078 + }, + "jawa-tengah-eqby": { + "lat": -7.27276, + "lon": 110.253011, + "spread": 0.18227 + }, + "kalimantan-barat-qpei": { + "lat": -0.029596, + "lon": 110.936058, + "spread": 0.51639 + }, + "kalimantan-timur-kxex": { + "lat": 1.47518, + "lon": 116.604588, + "spread": 0.52353 + }, + "kalimantan-selatan-smex": { + "lat": -2.641832, + "lon": 115.604814, + "spread": 0.22622 + }, + "kalimantan-tengah-area": { + "lat": -1.809551, + "lon": 113.459406, + "spread": 0.43091 + }, + "lampung-yjht": { + "lat": -4.987591, + "lon": 104.888213, + "spread": 0.21766 + }, + "maluku-qzjd": { + "lat": -3.239713, + "lon": 129.299173, + "spread": 0.10888 + }, + "maluku-utara-osjp": { + "lat": 0.754439, + "lon": 128.032795, + "spread": 0.1501 + }, + "nusa-tenggara-barat-ywlq": { + "lat": -8.59705, + "lon": 118.062535, + "spread": 0.10214 + }, + "nusa-tenggara-timur-iflm": { + "lat": -8.543311, + "lon": 121.741403, + "spread": 0.08662 + }, + "papua-rydx": { + "lat": -5.162596, + "lon": 138.087063, + "spread": 0.67434 + }, + "papua-barat-mady": { + "lat": -2.582137, + "lon": 133.283631, + "spread": 0.39075 + }, + "riau-zdhp": { + "lat": 0.490959, + "lon": 102.026361, + "spread": 0.36395 + }, + "sulawesi-utara-ojkh": { + "lat": 1.007655, + "lon": 124.256205, + "spread": 0.14651 + }, + "sumatera-barat-vokk": { + "lat": -0.589309, + "lon": 100.530367, + "spread": 0.26897 + }, + "sulawesi-tenggara-ctkn": { + "lat": -3.860928, + "lon": 122.079989, + "spread": 0.19702 + }, + "sulawesi-selatan-thkw": { + "lat": -3.694638, + "lon": 120.251862, + "spread": 0.24346 + }, + "sulawesi-barat-zpku": { + "lat": -2.447221, + "lon": 119.278296, + "spread": 0.11162 + }, + "sumatera-selatan-enkt": { + "lat": -2.936416, + "lon": 104.168337, + "spread": 0.32436 + }, + "sulawesi-tengah-lskw": { + "lat": -0.706353, + "lon": 121.111462, + "spread": 0.39573 + }, + "sumatera-utara-qqkv": { + "lat": 2.459747, + "lon": 99.123475, + "spread": 0.26664 + }, + "andaman-and-nicobar-stzm": { + "lat": 12.518059, + "lon": 92.8385, + "spread": 0.05535 + }, + "andhra-pradesh-amam": { + "lat": 15.793521, + "lon": 80.090103, + "spread": 0.65129 + }, + "arunachal-pradesh-mpao": { + "lat": 28.061636, + "lon": 94.777469, + "spread": 0.27196 + }, + "assam-dqap": { + "lat": 26.170725, + "lon": 92.640241, + "spread": 0.38596 + }, + "bihar-cbcp": { + "lat": 25.720509, + "lon": 85.691592, + "spread": 0.3214 + }, + "chhattisgarh-yiee": { + "lat": 21.055684, + "lon": 81.920744, + "spread": 0.40908 + }, + "gujarat-sooi": { + "lat": 22.549225, + "lon": 71.685672, + "spread": 0.46818 + }, + "himachal-pradesh-ggdz": { + "lat": 31.852945, + "lon": 77.30409, + "spread": 0.28507 + }, + "haryana-wkdb": { + "lat": 29.248874, + "lon": 76.209411, + "spread": 0.31286 + }, + "jharkhand-qjit": { + "lat": 23.674605, + "lon": 85.72878, + "spread": 0.33377 + }, + "jammu-and-kashmir-vhis": { + "lat": 33.531649, + "lon": 75.012349, + "spread": 0.24683 + }, + "karnataka-rslx": { + "lat": 14.841794, + "lon": 76.112695, + "spread": 0.48646 + }, + "kerala-clls": { + "lat": 10.569853, + "lon": 76.364651, + "spread": 0.25495 + }, + "ladakh-ppmi": { + "lat": 33.738376, + "lon": 77.78679, + "spread": 0.3149 + }, + "maharashtra-qypg": { + "lat": 19.228544, + "lon": 76.086817, + "spread": 0.64095 + }, + "meghalaya-oepk": { + "lat": 25.651482, + "lon": 91.417915, + "spread": 0.10833 + }, + "manipur-ahpm": { + "lat": 24.848845, + "lon": 93.90789, + "spread": 0.17292 + }, + "madhya-pradesh-qlpo": { + "lat": 24.107608, + "lon": 78.252756, + "spread": 0.5785 + }, + "mizoram-yapy": { + "lat": 23.222573, + "lon": 92.839735, + "spread": 0.11796 + }, + "nagaland-yvrv": { + "lat": 25.974242, + "lon": 94.347273, + "spread": 0.18274 + }, + "odisha-uxvk": { + "lat": 20.182893, + "lon": 84.301187, + "spread": 0.47588 + }, + "punjab-kjkb": { + "lat": 30.841938, + "lon": 75.675038, + "spread": 0.29451 + }, + "rajasthan-sfpr": { + "lat": 26.053934, + "lon": 74.79108, + "spread": 0.7168 + }, + "telangana-mfvw": { + "lat": 17.877658, + "lon": 79.278985, + "spread": 0.40616 + }, + "tamil-nadu-sqvd": { + "lat": 11.027302, + "lon": 78.32296, + "spread": 0.4107 + }, + "tripura-ylvj": { + "lat": 23.813969, + "lon": 91.842627, + "spread": 0.11931 + }, + "uttar-pradesh-aaxe": { + "lat": 26.411701, + "lon": 80.368485, + "spread": 0.64747 + }, + "uttarakhand-ygxi": { + "lat": 30.097118, + "lon": 79.189189, + "spread": 0.27278 + }, + "west-bengal-isbk": { + "lat": 24.155972, + "lon": 88.114275, + "spread": 0.40661 + }, + "cork-qnps": { + "lat": 52.069442, + "lon": -8.737947, + "spread": 0.13094 + }, + "galway-kjar": { + "lat": 53.564755, + "lon": -9.366139, + "spread": 0.17721 + }, + "roscommon-ytzq": { + "lat": 53.788465, + "lon": -7.383275, + "spread": 0.1294 + }, + "wexford-iasb": { + "lat": 52.847644, + "lon": -7.225877, + "spread": 0.13181 + }, + "east-azarbaijan-qxil": { + "lat": 38.082017, + "lon": 46.811581, + "spread": 0.24522 + }, + "west-azarbaijan-eyim": { + "lat": 37.716432, + "lon": 45.294123, + "spread": 0.33174 + }, + "ardebil-aain": { + "lat": 38.479375, + "lon": 47.837803, + "spread": 0.23168 + }, + "esfahan-wpig": { + "lat": 32.695112, + "lon": 51.68233, + "spread": 0.37598 + }, + "ilam-oqih": { + "lat": 33.276943, + "lon": 46.70912, + "spread": 0.19232 + }, + "bushehr-ksii": { + "lat": 28.710297, + "lon": 51.432986, + "spread": 0.29203 + }, + "tehran-ytij": { + "lat": 35.586991, + "lon": 51.788686, + "spread": 0.12408 + }, + "chahar-mahall-and-bakhtiari-uiis": { + "lat": 31.935935, + "lon": 50.773841, + "spread": 0.15613 + }, + "khuzestan-yrkr": { + "lat": 31.001835, + "lon": 48.973742, + "spread": 0.28496 + }, + "zanjan-qsks": { + "lat": 36.431296, + "lon": 48.526858, + "spread": 0.16757 + }, + "semnan-mukt": { + "lat": 35.970019, + "lon": 54.704488, + "spread": 0.30294 + }, + "sistan-and-baluchestan-avku": { + "lat": 27.595737, + "lon": 60.898462, + "spread": 0.45447 + }, + "fars-wxkv": { + "lat": 29.631878, + "lon": 52.991447, + "spread": 0.45731 + }, + "kerman-oykw": { + "lat": 28.430102, + "lon": 57.289849, + "spread": 0.51148 + }, + "kordestan-gakx": { + "lat": 35.550091, + "lon": 47.194736, + "spread": 0.17652 + }, + "kermanshah-ccky": { + "lat": 34.534434, + "lon": 46.527857, + "spread": 0.16444 + }, + "kohgiluyeh-and-buyer-ahmad-yekj": { + "lat": 30.782084, + "lon": 50.822453, + "spread": 0.13431 + }, + "gilan-ugkk": { + "lat": 37.496357, + "lon": 49.273143, + "spread": 0.18721 + }, + "lorestan-ydnw": { + "lat": 33.540445, + "lon": 48.685152, + "spread": 0.17559 + }, + "mazandaran-ufnx": { + "lat": 36.377212, + "lon": 52.323876, + "spread": 0.12183 + }, + "markazi-qhny": { + "lat": 34.615842, + "lon": 49.844832, + "spread": 0.2138 + }, + "hormozgan-einz": { + "lat": 27.001729, + "lon": 56.405796, + "spread": 0.35475 + }, + "hamadan-akna": { + "lat": 34.96807, + "lon": 48.589214, + "spread": 0.17666 + }, + "yazd-slnb": { + "lat": 32.478441, + "lon": 55.655059, + "spread": 0.53537 + }, + "qom-knnc": { + "lat": 34.620031, + "lon": 50.949254, + "spread": 0.11126 + }, + "golestan-cond": { + "lat": 37.305374, + "lon": 54.978363, + "spread": 0.16413 + }, + "qazvin-crno": { + "lat": 36.028462, + "lon": 49.930087, + "spread": 0.13715 + }, + "south-khorasan-usnp": { + "lat": 32.411581, + "lon": 59.772037, + "spread": 0.33086 + }, + "razavi-khorasan-cdqd": { + "lat": 35.632384, + "lon": 58.683674, + "spread": 0.50795 + }, + "north-khorasan-yfqe": { + "lat": 37.434603, + "lon": 56.938817, + "spread": 0.16558 + }, + "al-anbar-yjqa": { + "lat": 33.118726, + "lon": 42.047434, + "spread": 0.46079 + }, + "arbil-sbqc": { + "lat": 36.678238, + "lon": 43.828455, + "spread": 0.19444 + }, + "al-basrah-lrsw": { + "lat": 30.259565, + "lon": 47.621515, + "spread": 0.19978 + }, + "babil-emst": { + "lat": 32.7628, + "lon": 44.329304, + "spread": 0.1311 + }, + "diyala-pzxe": { + "lat": 34.061456, + "lon": 45.180049, + "spread": 0.17197 + }, + "dhi-qar-pmxm": { + "lat": 31.241433, + "lon": 46.230267, + "spread": 0.14063 + }, + "maysan-rzfn": { + "lat": 31.875944, + "lon": 46.920078, + "spread": 0.15236 + }, + "al-muthannia-pffh": { + "lat": 30.814432, + "lon": 45.513109, + "spread": 0.26645 + }, + "an-najaf-buiy": { + "lat": 31.471547, + "lon": 44.087771, + "spread": 0.19435 + }, + "ninawa-bhig": { + "lat": 36.254856, + "lon": 42.782974, + "spread": 0.21136 + }, + "sala-ad-din-simf": { + "lat": 34.57501, + "lon": 43.783661, + "spread": 0.22902 + }, + "as-sulaymaniyah-fimw": { + "lat": 35.515482, + "lon": 45.327048, + "spread": 0.17873 + }, + "at-ta-mim-xbob": { + "lat": 35.452031, + "lon": 44.117931, + "spread": 0.11917 + }, + "wasit-vawa": { + "lat": 32.455508, + "lon": 45.414306, + "spread": 0.21744 + }, + "vesturland-ajfn": { + "lat": 65.344411, + "lon": -22.46867, + "spread": 0.26579 + }, + "nordurland-vestra-safh": { + "lat": 65.55971, + "lon": -19.792536, + "spread": 0.14079 + }, + "nordurland-eystra-kbfi": { + "lat": 65.906097, + "lon": -16.895838, + "spread": 0.20646 + }, + "austurland-cdfj": { + "lat": 64.922317, + "lon": -14.847463, + "spread": 0.22133 + }, + "sudurland-ysfc": { + "lat": 63.890629, + "lon": -19.58892, + "spread": 0.14839 + }, + "hadarom-uves": { + "lat": 31.540315, + "lon": 34.981381, + "spread": 0.12101 + }, + "alessandria-shfc": { + "lat": 45.107203, + "lon": 9.098622, + "spread": 0.12413 + }, + "aoste-ymff": { + "lat": 45.742623, + "lon": 8.172758, + "spread": 0.12791 + }, + "bari-gtie": { + "lat": 40.642106, + "lon": 16.810903, + "spread": 0.16573 + }, + "belluno-iojp": { + "lat": 45.84195, + "lon": 12.308386, + "spread": 0.16176 + }, + "bologna-otjs": { + "lat": 44.559566, + "lon": 11.788666, + "spread": 0.14058 + }, + "brescia-mzjw": { + "lat": 45.72848, + "lon": 10.122204, + "spread": 0.17133 + }, + "bozen-ukjd": { + "lat": 46.410683, + "lon": 11.265587, + "spread": 0.14033 + }, + "campobasso-wmlo": { + "lat": 41.345108, + "lon": 14.685454, + "spread": 0.18755 + }, + "cosenza-oolz": { + "lat": 39.209923, + "lon": 16.414832, + "spread": 0.14606 + }, + "catania-kqla": { + "lat": 37.525184, + "lon": 14.551091, + "spread": 0.16204 + }, + "firenze-sksq": { + "lat": 43.681359, + "lon": 10.935603, + "spread": 0.13301 + }, + "grosseto-gtuw": { + "lat": 42.768003, + "lon": 11.725554, + "spread": 0.13992 + }, + "palermo-cprw": { + "lat": 37.738732, + "lon": 13.4481, + "spread": 0.11198 + }, + "perugia-ozrc": { + "lat": 43.260164, + "lon": 12.596039, + "spread": 0.15688 + }, + "parma-uqrn": { + "lat": 44.371068, + "lon": 9.937879, + "spread": 0.10624 + }, + "potenza-yerf": { + "lat": 40.516852, + "lon": 15.564491, + "spread": 0.1236 + }, + "roma-sawi": { + "lat": 41.985865, + "lon": 13.280612, + "spread": 0.1695 + }, + "sassari-aeyz": { + "lat": 40.119893, + "lon": 8.964261, + "spread": 0.16791 + }, + "turin-yfaa": { + "lat": 44.600381, + "lon": 7.678322, + "spread": 0.18106 + }, + "clarendon-muxv": { + "lat": 18.102403, + "lon": -77.414587, + "spread": 0.08035 + }, + "amman-avrc": { + "lat": 32.021986, + "lon": 36.022205, + "spread": 0.15 + }, + "mafraq-invm": { + "lat": 32.351611, + "lon": 37.436133, + "spread": 0.16756 + }, + "ma-an-lrvp": { + "lat": 30.227213, + "lon": 36.22744, + "spread": 0.20586 + }, + "hokkaido-ksfx": { + "lat": 43.166224, + "lon": 142.559575, + "spread": 0.41148 + }, + "iwate-uvfz": { + "lat": 39.374232, + "lon": 141.573176, + "spread": 0.14122 + }, + "akita-mmft": { + "lat": 40.401233, + "lon": 140.651877, + "spread": 0.19833 + }, + "yamagata-gofu": { + "lat": 38.49736, + "lon": 140.8437, + "spread": 0.13745 + }, + "fukushima-wpfv": { + "lat": 37.402986, + "lon": 140.258391, + "spread": 0.11696 + }, + "ibaraki-odfe": { + "lat": 35.895493, + "lon": 140.242728, + "spread": 0.12058 + }, + "tochigi-kfff": { + "lat": 36.540505, + "lon": 139.213842, + "spread": 0.11697 + }, + "niigata-idhx": { + "lat": 37.439674, + "lon": 138.909748, + "spread": 0.18143 + }, + "yamanashi-gwhj": { + "lat": 35.588395, + "lon": 139.133816, + "spread": 0.11361 + }, + "nagano-kfji": { + "lat": 35.979941, + "lon": 136.898306, + "spread": 0.280114 + }, + "gifu-agjj": { + "lat": 37.151867, + "lon": 136.9984, + "spread": 0.00161 + }, + "shizuoka-ycjg": { + "lat": 35.021995, + "lon": 137.903657, + "spread": 0.10491 + }, + "mie-iljm": { + "lat": 34.389359, + "lon": 135.903357, + "spread": 0.17961 + }, + "hyogo-ksja": { + "lat": 35.25522, + "lon": 135.380074, + "spread": 0.11473 + }, + "okayama-mymn": { + "lat": 35.006169, + "lon": 133.750886, + "spread": 0.1176 + }, + "hiroshima-tqmi": { + "lat": 34.634224, + "lon": 132.608648, + "spread": 0.14117 + }, + "kochi-aimd": { + "lat": 33.581084, + "lon": 133.343293, + "spread": 0.16712 + }, + "fukuoka-ylvc": { + "lat": 33.267163, + "lon": 130.181688, + "spread": 0.13977 + }, + "oita-asvg": { + "lat": 33.200996, + "lon": 131.511235, + "spread": 0.09448 + }, + "miyazaki-stvh": { + "lat": 32.114642, + "lon": 130.912113, + "spread": 0.17533 + }, + "okinawa-cwvj": { + "lat": 26.513462, + "lon": 127.979192, + "spread": 0.0686 + }, + "aqmola-etpx": { + "lat": 51.767725, + "lon": 69.635779, + "spread": 0.37006 + }, + "aqtobe-sgpw": { + "lat": 49.448149, + "lon": 58.541653, + "spread": 0.6134 + }, + "almaty-waxb": { + "lat": 44.883966, + "lon": 78.280986, + "spread": 0.51148 + }, + "atyrau-ixli": { + "lat": 47.264547, + "lon": 51.497052, + "spread": 0.3417 + }, + "qaraghandy-khxs": { + "lat": 49.244615, + "lon": 72.440171, + "spread": 0.53171 + }, + "qostanay-qwtp": { + "lat": 52.125275, + "lon": 63.76747, + "spread": 0.65514 + }, + "qyzylorda-emfx": { + "lat": 45.237405, + "lon": 64.2595, + "spread": 0.54236 + }, + "mangghystau-ooqu": { + "lat": 44.149077, + "lon": 52.835321, + "spread": 0.52215 + }, + "pavlodar-abzq": { + "lat": 52.149646, + "lon": 75.439758, + "spread": 0.44426 + }, + "north-kazakhstan-kdzy": { + "lat": 53.706329, + "lon": 70.346469, + "spread": 0.32262 + }, + "east-kazakhstan-ipgz": { + "lat": 49.071871, + "lon": 81.448525, + "spread": 0.6138 + }, + "south-kazakhstan-qtgc": { + "lat": 42.798958, + "lon": 68.511879, + "spread": 0.49391 + }, + "west-kazakhstan-aqxa": { + "lat": 50.210695, + "lon": 50.979442, + "spread": 0.38364 + }, + "zhambyl-qhpv": { + "lat": 43.679417, + "lon": 73.140746, + "spread": 0.36169 + }, + "central-sqzo": { + "lat": -0.572708, + "lon": 36.825819, + "spread": 0.12844 + }, + "coast-kjrv": { + "lat": -2.730474, + "lon": 39.672874, + "spread": 0.39368 + }, + "eastern-qapp": { + "lat": 0.644397, + "lon": 37.808527, + "spread": 0.34062 + }, + "north-eastern-ewzp": { + "lat": 1.41556, + "lon": 39.876923, + "spread": 0.32202 + }, + "nyanza-wpnv": { + "lat": -0.049076, + "lon": 34.797036, + "spread": 0.1432 + }, + "rift-valley-wlpc": { + "lat": 1.182968, + "lon": 35.997712, + "spread": 0.40785 + }, + "batken-cbll": { + "lat": 39.923934, + "lon": 70.907728, + "spread": 0.09423 + }, + "chuy-xdlm": { + "lat": 42.556259, + "lon": 74.672222, + "spread": 0.13973 + }, + "jalal-abad-colt": { + "lat": 41.454125, + "lon": 72.62215, + "spread": 0.1388 + }, + "naryn-eilp": { + "lat": 41.468908, + "lon": 75.542467, + "spread": 0.21681 + }, + "osh-vjlq": { + "lat": 40.1347, + "lon": 73.054313, + "spread": 0.195 + }, + "talas-orlv": { + "lat": 42.42334, + "lon": 72.411515, + "spread": 0.07743 + }, + "ysyk-kol-hqmi": { + "lat": 42.056749, + "lon": 77.697603, + "spread": 0.1737 + }, + "batdambang-jett": { + "lat": 12.947043, + "lon": 102.952364, + "spread": 0.10687 + }, + "kampong-spoe-gatq": { + "lat": 11.158503, + "lon": 104.397897, + "spread": 0.12829 + }, + "kampong-thum-lytp": { + "lat": 12.507819, + "lon": 104.786619, + "spread": 0.15604 + }, + "kaoh-kong-astc": { + "lat": 11.51343, + "lon": 103.274363, + "spread": 0.13797 + }, + "kracheh-suom": { + "lat": 12.680333, + "lon": 106.29008, + "spread": 0.12129 + }, + "mondol-kiri-jvon": { + "lat": 12.816471, + "lon": 106.996957, + "spread": 0.12243 + }, + "preah-vihear-xsol": { + "lat": 13.89565, + "lon": 105.00545, + "spread": 0.13244 + }, + "prey-veng-qaoq": { + "lat": 11.553276, + "lon": 105.561995, + "spread": 0.17407 + }, + "pouthisat-lcor": { + "lat": 12.324363, + "lon": 103.638556, + "spread": 0.12368 + }, + "rotanokiri-exoo": { + "lat": 13.881758, + "lon": 107.081626, + "spread": 0.10427 + }, + "siemreab-vyop": { + "lat": 13.831179, + "lon": 103.544371, + "spread": 0.16949 + }, + "stoeng-treng-jiov": { + "lat": 13.950733, + "lon": 106.246583, + "spread": 0.1135 + }, + "gyeonggi-asoh": { + "lat": 37.468151, + "lon": 126.921346, + "spread": 0.12962 + }, + "gangwon-uuoi": { + "lat": 37.523761, + "lon": 128.297332, + "spread": 0.20077 + }, + "north-chungcheong-kvoj": { + "lat": 36.64193, + "lon": 127.11003, + "spread": 0.12431 + }, + "south-jeolla-wooe": { + "lat": 34.987558, + "lon": 126.912154, + "spread": 0.16643 + }, + "north-gyeongsang-mpof": { + "lat": 36.315881, + "lon": 128.687875, + "spread": 0.15474 + }, + "south-gyeongsang-edoo": { + "lat": 35.306332, + "lon": 128.553692, + "spread": 0.10495 + }, + "jeju-afop": { + "lat": 33.394456, + "lon": 126.595111, + "spread": 0.03662 + }, + "podujevo-ozes": { + "lat": 42.555403, + "lon": 20.797875, + "spread": 0.14084 + }, + "al-jahrah-cvgx": { + "lat": 29.403016, + "lon": 47.896226, + "spread": 0.15647 + }, + "attapu-cxyj": { + "lat": 15.214147, + "lon": 106.914638, + "spread": 0.14816 + }, + "bolikhamxai-csag": { + "lat": 18.453316, + "lon": 104.077964, + "spread": 0.12426 + }, + "champasak-agcn": { + "lat": 14.748124, + "lon": 105.976462, + "spread": 0.15434 + }, + "houaphan-lmpf": { + "lat": 20.306909, + "lon": 103.993539, + "spread": 0.13538 + }, + "khammouan-qmwz": { + "lat": 17.488873, + "lon": 105.291818, + "spread": 0.13335 + }, + "louang-namtha-fbyj": { + "lat": 20.5881, + "lon": 100.861239, + "spread": 0.16829 + }, + "louangphrabang-quyo": { + "lat": 20.164287, + "lon": 102.567043, + "spread": 0.16826 + }, + "oudomxai-trgc": { + "lat": 20.478053, + "lon": 101.617411, + "spread": 0.13839 + }, + "phongsali-okhy": { + "lat": 21.703015, + "lon": 102.381175, + "spread": 0.14584 + }, + "savannakhet-ulpr": { + "lat": 16.289185, + "lon": 105.846261, + "spread": 0.18288 + }, + "vientiane-qhaq": { + "lat": 18.464069, + "lon": 102.593375, + "spread": 0.15916 + }, + "xaignabouri-hdan": { + "lat": 18.951515, + "lon": 101.248979, + "spread": 0.15163 + }, + "xiangkhoang-hqaf": { + "lat": 19.504925, + "lon": 103.73632, + "spread": 0.12112 + }, + "beqaa-ryfc": { + "lat": 33.865913, + "lon": 35.878547, + "spread": 0.15045 + }, + "grand-cape-mount-acht": { + "lat": 6.807962, + "lon": -10.664672, + "spread": 0.13567 + }, + "grand-bassa-vzwu": { + "lat": 6.132947, + "lon": -9.485324, + "spread": 0.13027 + }, + "grand-gedeh-cewx": { + "lat": 5.77639, + "lon": -8.478414, + "spread": 0.1671 + }, + "nimba-xaov": { + "lat": 6.812661, + "lon": -8.872044, + "spread": 0.08854 + }, + "gbapolu-iigq": { + "lat": 7.531782, + "lon": -10.007688, + "spread": 0.18269 + }, + "river-gee-uqio": { + "lat": 4.915013, + "lon": -7.88215, + "spread": 0.11662 + }, + "ajdabiya-ikqv": { + "lat": 29.809585, + "lon": 20.772735, + "spread": 0.42478 + }, + "al-butnan-gytn": { + "lat": 30.869512, + "lon": 24.273035, + "spread": 0.21959 + }, + "ghadamis-aagv": { + "lat": 30.260231, + "lon": 10.8809, + "spread": 0.26765 + }, + "ghat-ebgf": { + "lat": 25.730747, + "lon": 10.560484, + "spread": 0.20894 + }, + "al-jufrah-mrvm": { + "lat": 28.244706, + "lon": 16.278409, + "spread": 0.38582 + }, + "al-kufrah-ehbh": { + "lat": 22.569983, + "lon": 22.713317, + "spread": 0.58114 + }, + "misratah-cbhm": { + "lat": 30.981573, + "lon": 15.071858, + "spread": 0.30729 + }, + "al-marj-ydhn": { + "lat": 31.869584, + "lon": 21.097547, + "spread": 0.19298 + }, + "murzuq-cohu": { + "lat": 25.334455, + "lon": 14.340369, + "spread": 0.5211 + }, + "mizdah-uchd": { + "lat": 30.17105, + "lon": 13.143288, + "spread": 0.33898 + }, + "an-nuqat-al-khams-ehkx": { + "lat": 32.454971, + "lon": 12.839262, + "spread": 0.12134 + }, + "al-qubbah-wlez": { + "lat": 31.945453, + "lon": 22.428207, + "spread": 0.13336 + }, + "sabha-sukt": { + "lat": 27.019225, + "lon": 14.857775, + "spread": 0.14961 + }, + "surt-otkn": { + "lat": 29.706063, + "lon": 17.579494, + "spread": 0.26649 + }, + "wadi-al-hayaa-qrtp": { + "lat": 26.227718, + "lon": 12.780053, + "spread": 0.17444 + }, + "ash-shati-qbtm": { + "lat": 28.283397, + "lon": 13.017326, + "spread": 0.33501 + }, + "ampara-oqcc": { + "lat": 7.642932, + "lon": 81.304498, + "spread": 0.13959 + }, + "anuradhapura-jzhh": { + "lat": 8.459853, + "lon": 80.450144, + "spread": 0.17199 + }, + "monaragala-wspd": { + "lat": 6.858071, + "lon": 81.150582, + "spread": 0.10515 + }, + "ratnapura-llsf": { + "lat": 6.798926, + "lon": 80.530938, + "spread": 0.11843 + }, + "maseru-quuq": { + "lat": -29.803843, + "lon": 27.874723, + "spread": 0.15525 + }, + "thaba-tseka-wjua": { + "lat": -29.331597, + "lon": 28.516161, + "spread": 0.16024 + }, + "klaipedos-kooa": { + "lat": 55.808637, + "lon": 22.547126, + "spread": 0.13888 + }, + "kauno-keod": { + "lat": 54.807722, + "lon": 24.497676, + "spread": 0.16918 + }, + "panevezio-cpal": { + "lat": 55.613369, + "lon": 25.032463, + "spread": 0.14009 + }, + "diekirch-kyaq": { + "lat": 49.7315, + "lon": 6.051619, + "spread": 0.07243 + }, + "gulbene-gcky": { + "lat": 57.423205, + "lon": 25.372018, + "spread": 0.13782 + }, + "tukums-qtmk": { + "lat": 56.764861, + "lon": 24.030971, + "spread": 0.10612 + }, + "rezeknes-mmcq": { + "lat": 56.703646, + "lon": 26.683704, + "spread": 0.19602 + }, + "ventspils-cuhk": { + "lat": 56.892833, + "lon": 21.941498, + "spread": 0.1682 + }, + "tanger-tetouan-eqeu": { + "lat": 34.780256, + "lon": -5.4165, + "spread": 0.18193 + }, + "taza-al-hoceima-taounate-otew": { + "lat": 34.519071, + "lon": -4.023875, + "spread": 0.17508 + }, + "oriental-kiep": { + "lat": 33.798114, + "lon": -2.630843, + "spread": 0.29672 + }, + "fes-boulemane-cjeq": { + "lat": 33.560418, + "lon": -4.366209, + "spread": 0.16934 + }, + "meknes-tafilalet-yler": { + "lat": 32.368664, + "lon": -5.005966, + "spread": 0.32433 + }, + "chaouia-ouardigha-edem": { + "lat": 33.265138, + "lon": -6.878554, + "spread": 0.18358 + }, + "doukkala-abda-sghb": { + "lat": 32.47776, + "lon": -8.616847, + "spread": 0.1475 + }, + "marrakech-tensift-al-haouz-weha": { + "lat": 31.66535, + "lon": -8.358086, + "spread": 0.20046 + }, + "tadla-azilal-idhz": { + "lat": 32.112886, + "lon": -6.414214, + "spread": 0.14008 + }, + "souss-massa-draa-mbhy": { + "lat": 30.388617, + "lon": -7.321676, + "spread": 0.28908 + }, + "guelmim-es-semara-qzhx": { + "lat": 28.528941, + "lon": -9.317472, + "spread": 0.44944 + }, + "laayoune-boujdour-sakia-el-hamra-cyhw": { + "lat": 26.082322, + "lon": -13.201183, + "spread": 0.34685 + }, + "oued-el-dahab-gwhv": { + "lat": 23.110773, + "lon": -15.096162, + "spread": 0.31715 + }, + "cimislia-acda": { + "lat": 46.54827, + "lon": 28.851081, + "spread": 0.20222 + }, + "falesti-qcpf": { + "lat": 47.859575, + "lon": 27.958353, + "spread": 0.14597 + }, + "analanjirofo-hgdp": { + "lat": -16.185662, + "lon": 49.485484, + "spread": 0.14015 + }, + "atsinanana-uoeq": { + "lat": -19.077244, + "lon": 48.54678, + "spread": 0.1796 + }, + "alaotra-mangoro-lper": { + "lat": -18.136803, + "lon": 48.404933, + "spread": 0.10669 + }, + "diana-oydk": { + "lat": -13.050387, + "lon": 48.939313, + "spread": 0.17442 + }, + "sava-atuj": { + "lat": -14.163038, + "lon": 49.7387, + "spread": 0.14014 + }, + "vatovavy-fitovinany-abdm": { + "lat": -21.243278, + "lon": 47.765944, + "spread": 0.15529 + }, + "atsimo-atsinanana-cjvi": { + "lat": -23.3284, + "lon": 47.117973, + "spread": 0.14706 + }, + "amoron-i-mania-ulvj": { + "lat": -20.435537, + "lon": 46.714921, + "spread": 0.11647 + }, + "ihorombe-mmvk": { + "lat": -22.669721, + "lon": 46.144629, + "spread": 0.20185 + }, + "haute-matsiatra-iovl": { + "lat": -21.392726, + "lon": 46.524342, + "spread": 0.15284 + }, + "boeny-fmdt": { + "lat": -16.121831, + "lon": 46.256962, + "spread": 0.20439 + }, + "sofia-kmae": { + "lat": -15.028396, + "lon": 48.305251, + "spread": 0.253 + }, + "melaky-ynaf": { + "lat": -17.754018, + "lon": 44.982495, + "spread": 0.20374 + }, + "betsiboka-upag": { + "lat": -17.242056, + "lon": 46.838556, + "spread": 0.16479 + }, + "analamanga-szdu": { + "lat": -18.3401, + "lon": 47.312533, + "spread": 0.14428 + }, + "bongolava-irjg": { + "lat": -18.603793, + "lon": 46.045247, + "spread": 0.16302 + }, + "vakinankaratra-dtjh": { + "lat": -19.58499, + "lon": 46.833528, + "spread": 0.15627 + }, + "anosy-jadv": { + "lat": -24.061961, + "lon": 46.368875, + "spread": 0.215 + }, + "androy-wzfq": { + "lat": -24.723253, + "lon": 45.505853, + "spread": 0.15098 + }, + "atsimo-andrefana-qbfr": { + "lat": -22.880117, + "lon": 44.58357, + "spread": 0.25676 + }, + "menabe-icfs": { + "lat": -20.251683, + "lon": 44.821583, + "spread": 0.23131 + }, + "baja-california-rkil": { + "lat": 30.297551, + "lon": -114.944167, + "spread": 0.43598 + }, + "baja-california-sur-uoie": { + "lat": 26.050109, + "lon": -112.300545, + "spread": 0.51262 + }, + "campeche-qfna": { + "lat": 18.816022, + "lon": -91.012946, + "spread": 0.3022 + }, + "chihuahua-imen": { + "lat": 28.138584, + "lon": -106.814459, + "spread": 0.58436 + }, + "chiapas-izef": { + "lat": 16.629672, + "lon": -92.517439, + "spread": 0.34444 + }, + "coahuila-givi": { + "lat": 26.786617, + "lon": -101.584434, + "spread": 0.41565 + }, + "durango-gnsc": { + "lat": 24.746727, + "lon": -104.889069, + "spread": 0.45195 + }, + "guerrero-iroj": { + "lat": 17.851645, + "lon": -100.08564, + "spread": 0.25523 + }, + "guanajuato-uvcd": { + "lat": 20.858055, + "lon": -100.972183, + "spread": 0.1964 + }, + "hidalgo-drfz": { + "lat": 20.45927, + "lon": -98.68436, + "spread": 0.18133 + }, + "jalisco-kpbu": { + "lat": 20.960363, + "lon": -103.429264, + "spread": 0.40812 + }, + "mexico-mpba": { + "lat": 19.20246, + "lon": -99.345705, + "spread": 0.1959 + }, + "michoacan-uekn": { + "lat": 19.324433, + "lon": -101.735635, + "spread": 0.24819 + }, + "nayarit-chkm": { + "lat": 21.838283, + "lon": -104.96818, + "spread": 0.1788 + }, + "nuevo-leon-rfly": { + "lat": 25.540102, + "lon": -99.956016, + "spread": 0.277 + }, + "oaxaca-vcjz": { + "lat": 17.036698, + "lon": -96.277162, + "spread": 0.30099 + }, + "puebla-apfj": { + "lat": 19.215182, + "lon": -97.903257, + "spread": 0.23175 + }, + "queretaro-mpre": { + "lat": 20.870814, + "lon": -99.787373, + "spread": 0.15161 + }, + "quintana-roo-ieho": { + "lat": 19.702108, + "lon": -87.901127, + "spread": 0.26738 + }, + "sinaloa-ahmy": { + "lat": 24.936888, + "lon": -107.757858, + "spread": 0.40482 + }, + "san-luis-potosi-wfta": { + "lat": 22.430794, + "lon": -100.129929, + "spread": 0.33907 + }, + "sonora-otbs": { + "lat": 28.917966, + "lon": -110.953941, + "spread": 0.61676 + }, + "tabasco-hhvb": { + "lat": 18.001427, + "lon": -92.476032, + "spread": 0.13966 + }, + "tamaulipas-apvq": { + "lat": 24.619187, + "lon": -98.97813, + "spread": 0.29863 + }, + "veracruz-kuoh": { + "lat": 19.9234, + "lon": -96.990228, + "spread": 0.50196 + }, + "mexico-ewrz": { + "lat": 22.474083, + "lon": -89.6861, + "spread": 0.01606 + }, + "yucatan-mpkz": { + "lat": 20.78495, + "lon": -89.052958, + "spread": 0.1965 + }, + "zacatecas-pbpt": { + "lat": 22.984094, + "lon": -102.722687, + "spread": 0.35013 + }, + "kavadartsi-yiil": { + "lat": 41.657327, + "lon": 21.550656, + "spread": 0.15209 + }, + "kayes-twwi": { + "lat": 13.806148, + "lon": -10.158437, + "spread": 0.38075 + }, + "koulikoro-oywj": { + "lat": 13.146567, + "lon": -7.703111, + "spread": 0.31904 + }, + "sikasso-fzwk": { + "lat": 11.490013, + "lon": -6.465816, + "spread": 0.2676 + }, + "segou-aowd": { + "lat": 13.747905, + "lon": -5.491838, + "spread": 0.29981 + }, + "mopti-vqwe": { + "lat": 14.647791, + "lon": -3.577753, + "spread": 0.27291 + }, + "timbuktu-mrwf": { + "lat": 17.243968, + "lon": -2.822306, + "spread": 0.65968 + }, + "gao-htwg": { + "lat": 16.352893, + "lon": 1.086943, + "spread": 0.40443 + }, + "kidal-yhwp": { + "lat": 19.574452, + "lon": 1.498565, + "spread": 0.42202 + }, + "sagaing-axrl": { + "lat": 24.430308, + "lon": 95.620989, + "spread": 0.30585 + }, + "bago-uzrm": { + "lat": 18.192111, + "lon": 96.291856, + "spread": 0.21665 + }, + "magway-karn": { + "lat": 20.321979, + "lon": 94.816037, + "spread": 0.19513 + }, + "mandalay-gcro": { + "lat": 21.617525, + "lon": 96.098537, + "spread": 0.20739 + }, + "tanintharyi-ydrp": { + "lat": 12.576438, + "lon": 98.66991, + "spread": 0.18351 + }, + "ayeyarwady-mhrr": { + "lat": 16.660058, + "lon": 95.295206, + "spread": 0.26126 + }, + "kachin-etts": { + "lat": 26.001047, + "lon": 97.264805, + "spread": 0.29152 + }, + "kayah-uutt": { + "lat": 19.331179, + "lon": 97.385732, + "spread": 0.10329 + }, + "kayin-owtu": { + "lat": 17.41195, + "lon": 97.649915, + "spread": 0.25036 + }, + "chin-kltn": { + "lat": 22.175559, + "lon": 93.475749, + "spread": 0.15666 + }, + "mon-cmto": { + "lat": 17.168912, + "lon": 97.269181, + "spread": 0.08127 + }, + "rakhine-wotp": { + "lat": 19.683277, + "lon": 93.605754, + "spread": 0.2728 + }, + "shan-qqtq": { + "lat": 21.636727, + "lon": 98.156466, + "spread": 0.48573 + }, + "niksic-mcna": { + "lat": 42.766383, + "lon": 19.287244, + "spread": 0.16857 + }, + "hentiy-kufs": { + "lat": 47.68372, + "lon": 110.34609, + "spread": 0.33649 + }, + "hovsgol-munb": { + "lat": 49.941935, + "lon": 99.692497, + "spread": 0.38688 + }, + "hovd-wxnd": { + "lat": 47.095443, + "lon": 92.10145, + "spread": 0.36041 + }, + "uvs-ccng": { + "lat": 49.672417, + "lon": 92.611286, + "spread": 0.24708 + }, + "tov-udnh": { + "lat": 47.742709, + "lon": 106.335534, + "spread": 0.27434 + }, + "selenge-mhnj": { + "lat": 49.309073, + "lon": 106.136216, + "spread": 0.19692 + }, + "suhbaatar-quqi": { + "lat": 46.297556, + "lon": 113.431976, + "spread": 0.30705 + }, + "omnogovi-axqk": { + "lat": 43.52544, + "lon": 103.802554, + "spread": 0.36023 + }, + "ovorhangay-onqe": { + "lat": 46.031495, + "lon": 102.892818, + "spread": 0.33755 + }, + "dzavhan-yqqg": { + "lat": 48.445361, + "lon": 96.531478, + "spread": 0.34324 + }, + "dundgovi-mgqq": { + "lat": 45.764103, + "lon": 106.270943, + "spread": 0.25639 + }, + "dornod-qpsp": { + "lat": 47.853541, + "lon": 116.186087, + "spread": 0.39789 + }, + "dornogovi-assr": { + "lat": 45.511242, + "lon": 109.677951, + "spread": 0.43167 + }, + "govi-altay-ovst": { + "lat": 45.482352, + "lon": 95.61018, + "spread": 0.5056 + }, + "bulgan-czsv": { + "lat": 48.998029, + "lon": 103.470295, + "spread": 0.31253 + }, + "bayanhongor-udsh": { + "lat": 45.759912, + "lon": 99.68263, + "spread": 0.41185 + }, + "bayan-olgiy-ucvu": { + "lat": 48.505006, + "lon": 89.79125, + "spread": 0.34148 + }, + "arhangay-efvw": { + "lat": 47.957852, + "lon": 100.802861, + "spread": 0.23963 + }, + "niassa-eefk": { + "lat": -12.9709, + "lon": 36.687763, + "spread": 0.41383 + }, + "manica-wffl": { + "lat": -19.187091, + "lon": 33.405206, + "spread": 0.22564 + }, + "gaza-mnfq": { + "lat": -23.722706, + "lon": 33.182888, + "spread": 0.32011 + }, + "inhambane-aqfs": { + "lat": -22.80153, + "lon": 34.676272, + "spread": 0.24589 + }, + "maputo-gvfv": { + "lat": -25.558473, + "lon": 32.500464, + "spread": 0.12339 + }, + "nampula-qyfx": { + "lat": -14.87849, + "lon": 39.61181, + "spread": 0.34285 + }, + "cabo-delgado-mdft": { + "lat": -12.084745, + "lon": 39.913817, + "spread": 0.26721 + }, + "zambezia-eefu": { + "lat": -16.934105, + "lon": 36.930984, + "spread": 0.38964 + }, + "sofala-ohfw": { + "lat": -19.45883, + "lon": 34.576689, + "spread": 0.27834 + }, + "tete-kjfx": { + "lat": -16.152967, + "lon": 33.385022, + "spread": 0.37098 + }, + "hodh-ech-chargui-twfu": { + "lat": 16.936825, + "lon": -7.808283, + "spread": 0.37964 + }, + "hodh-el-gharbi-oyfv": { + "lat": 16.393948, + "lon": -9.874068, + "spread": 0.23581 + }, + "assaba-fzfa": { + "lat": 16.680067, + "lon": -11.589787, + "spread": 0.22518 + }, + "gorgol-abfb": { + "lat": 16.007727, + "lon": -12.889627, + "spread": 0.17179 + }, + "brakna-rcfc": { + "lat": 16.961518, + "lon": -13.541565, + "spread": 0.24385 + }, + "trarza-mefd": { + "lat": 17.33292, + "lon": -15.315347, + "spread": 0.31917 + }, + "adrar-dffe": { + "lat": 20.499453, + "lon": -11.565324, + "spread": 0.5719 + }, + "dakhlet-nouadhibou-cifl": { + "lat": 20.369296, + "lon": -16.2727, + "spread": 0.19246 + }, + "tagant-tjfm": { + "lat": 18.144958, + "lon": -11.265492, + "spread": 0.26392 + }, + "guidimaka-cqhe": { + "lat": 15.335892, + "lon": -12.166075, + "spread": 0.12463 + }, + "tiris-zemmour-xshf": { + "lat": 23.874385, + "lon": -10.340646, + "spread": 0.59338 + }, + "inchiri-othg": { + "lat": 19.986414, + "lon": -15.554643, + "spread": 0.21549 + }, + "chikwawa-ulnn": { + "lat": -15.998364, + "lon": 34.91087, + "spread": 0.11075 + }, + "chitipa-qnno": { + "lat": -9.833865, + "lon": 33.61897, + "spread": 0.12014 + }, + "kasungu-scgl": { + "lat": -12.866107, + "lon": 33.666261, + "spread": 0.14891 + }, + "lilongwe-kdpi": { + "lat": -13.686715, + "lon": 33.547924, + "spread": 0.1539 + }, + "mangochi-qesd": { + "lat": -14.492924, + "lon": 34.579959, + "spread": 0.19227 + }, + "machinga-mtss": { + "lat": -15.3031, + "lon": 35.473935, + "spread": 0.08932 + }, + "mzimba-svso": { + "lat": -11.708741, + "lon": 33.622774, + "spread": 0.08924 + }, + "nkhata-bay-sgvt": { + "lat": -11.520333, + "lon": 34.306747, + "spread": 0.12392 + }, + "johor-omus": { + "lat": 1.924559, + "lon": 103.637577, + "spread": 0.15685 + }, + "kedah-gout": { + "lat": 5.932792, + "lon": 100.565431, + "spread": 0.08923 + }, + "kelantan-ypuu": { + "lat": 5.210933, + "lon": 102.037248, + "spread": 0.13219 + }, + "pahang-ihup": { + "lat": 3.971724, + "lon": 102.516643, + "spread": 0.22983 + }, + "perak-sxuz": { + "lat": 4.723297, + "lon": 101.079527, + "spread": 0.13843 + }, + "selangor-wkxy": { + "lat": 3.191035, + "lon": 101.693385, + "spread": 0.17681 + }, + "terengganu-smxz": { + "lat": 4.768616, + "lon": 102.911689, + "spread": 0.11101 + }, + "sabah-knxa": { + "lat": 5.371727, + "lon": 117.315882, + "spread": 0.28791 + }, + "sarawak-cpxb": { + "lat": 2.48744, + "lon": 112.776807, + "spread": 0.41064 + }, + "caprivi-yapw": { + "lat": -17.947654, + "lon": 23.164031, + "spread": 0.09995 + }, + "erongo-mmar": { + "lat": -22.270133, + "lon": 15.479539, + "spread": 0.29049 + }, + "hardap-qqnp": { + "lat": -24.463577, + "lon": 17.219702, + "spread": 0.2679 + }, + "karas-etva": { + "lat": -26.970444, + "lon": 17.22547, + "spread": 0.39574 + }, + "khomas-ievh": { + "lat": -23.064505, + "lon": 16.997622, + "spread": 0.23197 + }, + "kunene-cmvm": { + "lat": -19.018903, + "lon": 14.245889, + "spread": 0.42365 + }, + "otjozondjupa-yuld": { + "lat": -20.598632, + "lon": 17.5229, + "spread": 0.36139 + }, + "omaheke-ablr": { + "lat": -22.230674, + "lon": 18.780174, + "spread": 0.3016 + }, + "kavango-gglu": { + "lat": -18.1269, + "lon": 19.334046, + "spread": 0.17998 + }, + "omusati-gtlm": { + "lat": -18.528105, + "lon": 15.24986, + "spread": 0.1887 + }, + "oshikoto-cvln": { + "lat": -18.886532, + "lon": 17.0922, + "spread": 0.18266 + }, + "ohangwena-ialq": { + "lat": -17.665914, + "lon": 16.623371, + "spread": 0.0526 + }, + "nord-wofd": { + "lat": -21.443409, + "lon": 165.580547, + "spread": 0.23089 + }, + "agadez-ijfb": { + "lat": 18.716062, + "lon": 9.6325, + "spread": 0.81829 + }, + "diffa-akfc": { + "lat": 14.536956, + "lon": 12.655996, + "spread": 0.49358 + }, + "dosso-smfd": { + "lat": 13.137625, + "lon": 3.483319, + "spread": 0.21511 + }, + "maradi-knfe": { + "lat": 13.97356, + "lon": 7.34352, + "spread": 0.22554 + }, + "tahoua-gpff": { + "lat": 15.677563, + "lon": 5.250033, + "spread": 0.27999 + }, + "tillaberi-yrfg": { + "lat": 13.671304, + "lon": 2.340894, + "spread": 0.38244 + }, + "zinder-qsfh": { + "lat": 14.46246, + "lon": 9.301013, + "spread": 0.46756 + }, + "adamawa-ehbv": { + "lat": 9.293317, + "lon": 12.446543, + "spread": 0.23455 + }, + "bauchi-spdb": { + "lat": 10.815349, + "lon": 10.058168, + "spread": 0.23719 + }, + "benue-uwdf": { + "lat": 7.189453, + "lon": 8.637513, + "spread": 0.17152 + }, + "borno-emdz": { + "lat": 11.819961, + "lon": 13.019219, + "spread": 0.30288 + }, + "cross-river-erfd": { + "lat": 5.93177, + "lon": 8.280797, + "spread": 0.19624 + }, + "delta-qnoh": { + "lat": 5.246269, + "lon": 5.860729, + "spread": 0.17547 + }, + "edo-utql": { + "lat": 6.66188, + "lon": 5.822052, + "spread": 0.17179 + }, + "enugu-ijqf": { + "lat": 6.426941, + "lon": 7.221205, + "spread": 0.12565 + }, + "gombe-ogwc": { + "lat": 10.312135, + "lon": 11.273, + "spread": 0.1407 + }, + "jigawa-yzkn": { + "lat": 12.136531, + "lon": 9.420011, + "spread": 0.20335 + }, + "kaduna-aymn": { + "lat": 10.332049, + "lon": 7.551394, + "spread": 0.24963 + }, + "kebbi-wamo": { + "lat": 11.512328, + "lon": 4.717798, + "spread": 0.25604 + }, + "kano-oomx": { + "lat": 11.609377, + "lon": 8.544663, + "spread": 0.16433 + }, + "kogi-gqmy": { + "lat": 7.710445, + "lon": 6.627536, + "spread": 0.22016 + }, + "katsina-ezmx": { + "lat": 12.169445, + "lon": 7.681742, + "spread": 0.20952 + }, + "kwara-kema": { + "lat": 8.988267, + "lon": 4.475382, + "spread": 0.21671 + }, + "nassarawa-mrzh": { + "lat": 8.681692, + "lon": 8.260127, + "spread": 0.15708 + }, + "niger-qfzz": { + "lat": 10.085693, + "lon": 5.511809, + "spread": 0.31376 + }, + "ogun-soba": { + "lat": 6.998011, + "lon": 3.597028, + "spread": 0.16189 + }, + "ondo-aabr": { + "lat": 6.875444, + "lon": 5.129711, + "spread": 0.16489 + }, + "osun-qhbm": { + "lat": 7.53726, + "lon": 4.840203, + "spread": 0.10905 + }, + "oyo-ksbc": { + "lat": 7.919919, + "lon": 3.583559, + "spread": 0.18893 + }, + "plateau-oako": { + "lat": 9.405771, + "lon": 9.376533, + "spread": 0.19827 + }, + "rivers-esqx": { + "lat": 5.032541, + "lon": 7.240326, + "spread": 0.16675 + }, + "sokoto-mjsi": { + "lat": 12.960207, + "lon": 5.309386, + "spread": 0.23275 + }, + "taraba-cqbj": { + "lat": 7.728717, + "lon": 10.691347, + "spread": 0.28574 + }, + "yobe-sitq": { + "lat": 12.158948, + "lon": 11.338852, + "spread": 0.27855 + }, + "zamfara-iavl": { + "lat": 12.173973, + "lon": 6.198012, + "spread": 0.22097 + }, + "atlantico-norte-ieag": { + "lat": 14.197995, + "lon": -84.339105, + "spread": 0.19789 + }, + "atlantico-sur-ylal": { + "lat": 12.321005, + "lon": -84.086176, + "spread": 0.17461 + }, + "chontales-uefv": { + "lat": 11.524471, + "lon": -85.046571, + "spread": 0.1936 + }, + "jinotega-yvws": { + "lat": 13.710109, + "lon": -85.898866, + "spread": 0.17479 + }, + "leon-gvhg": { + "lat": 12.672461, + "lon": -86.786418, + "spread": 0.14974 + }, + "matagalpa-iykc": { + "lat": 12.7972, + "lon": -85.44245, + "spread": 0.13391 + }, + "bonaire-makp": { + "lat": 12.208783, + "lon": -68.297133, + "spread": 0.02059 + }, + "saba-gckq": { + "lat": 17.64365, + "lon": -63.24365, + "spread": 0.00065 + }, + "st-eustatius-wdkr": { + "lat": 17.501033, + "lon": -62.978967, + "spread": 0.00402 + }, + "gelderland-ygoe": { + "lat": 52.442767, + "lon": 6.168222, + "spread": 0.17158 + }, + "noord-holland-otrk": { + "lat": 51.804932, + "lon": 5.211986, + "spread": 0.22591 + }, + "akershus-ityh": { + "lat": 59.72138, + "lon": 11.048609, + "spread": 0.15426 + }, + "hedmark-akyb": { + "lat": 61.345966, + "lon": 11.233559, + "spread": 0.28225 + }, + "oppland-slyc": { + "lat": 61.325842, + "lon": 9.437405, + "spread": 0.22255 + }, + "buskerud-onyd": { + "lat": 60.115415, + "lon": 9.458279, + "spread": 0.16779 + }, + "telemark-ceyx": { + "lat": 59.345756, + "lon": 8.785444, + "spread": 0.14127 + }, + "aust-agder-ufyy": { + "lat": 58.972215, + "lon": 6.669075, + "spread": 0.18654 + }, + "hordaland-yrai": { + "lat": 60.291142, + "lon": 5.973002, + "spread": 0.14752 + }, + "sogn-og-fjordane-iuak": { + "lat": 61.374894, + "lon": 6.216598, + "spread": 0.15122 + }, + "more-og-romsdal-aval": { + "lat": 62.571946, + "lon": 7.385652, + "spread": 0.12385 + }, + "sor-trondelag-sxam": { + "lat": 64.063042, + "lon": 10.004605, + "spread": 0.446477 + }, + "nord-trondelag-kyan": { + "lat": 64.287691, + "lon": 11.631679, + "spread": 0.19222 + }, + "nordland-gaao": { + "lat": 67.023469, + "lon": 14.708632, + "spread": 0.36698 + }, + "troms-ccap": { + "lat": 69.431717, + "lon": 20.049651, + "spread": 0.1873 + }, + "finnmark-mdcj": { + "lat": 70.218726, + "lon": 26.52525, + "spread": 0.25701 + }, + "svalbard-eeck": { + "lat": 78.635724, + "lon": 15.134345, + "spread": 0.35006 + }, + "bouvet-island-vssk": { + "lat": -54.408633, + "lon": 3.410067, + "spread": 0.00756 + }, + "bagmati-ungl": { + "lat": 27.517493, + "lon": 85.005476, + "spread": 0.15763 + }, + "bheri-czgc": { + "lat": 28.597068, + "lon": 81.704112, + "spread": 0.12748 + }, + "gandaki-ohsw": { + "lat": 28.121383, + "lon": 83.761386, + "spread": 0.19609 + }, + "karnali-mbpa": { + "lat": 29.586193, + "lon": 82.195056, + "spread": 0.16968 + }, + "bhojpur-uxpy": { + "lat": 27.068727, + "lon": 87.469909, + "spread": 0.12486 + }, + "rapti-ewpq": { + "lat": 28.226195, + "lon": 82.553563, + "spread": 0.11923 + }, + "sagarmatha-otrd": { + "lat": 27.283928, + "lon": 86.254366, + "spread": 0.17419 + }, + "seti-qarh": { + "lat": 29.332897, + "lon": 81.001188, + "spread": 0.17546 + }, + "bay-of-plenty-wlsa": { + "lat": -38.118186, + "lon": 176.916449, + "spread": 0.1627 + }, + "canterbury-kxye": { + "lat": -43.53358, + "lon": 172.059607, + "spread": 0.31445 + }, + "chatham-islands-territory-svse": { + "lat": -43.874613, + "lon": -176.48546, + "spread": 0.04197 + }, + "hawke-s-bay-iunb": { + "lat": -39.327463, + "lon": 176.974926, + "spread": 0.18843 + }, + "marlborough-district-oyxs": { + "lat": -41.201231, + "lon": 173.392007, + "spread": 0.19614 + }, + "manawatu-wanganui-zrxu": { + "lat": -39.939461, + "lon": 175.193905, + "spread": 0.28644 + }, + "northland-aqhd": { + "lat": -35.835138, + "lon": 174.087765, + "spread": 0.26519 + }, + "otago-ctsh": { + "lat": -45.315016, + "lon": 169.711308, + "spread": 0.26981 + }, + "southland-ukrs": { + "lat": -45.581816, + "lon": 167.62398, + "spread": 0.24163 + }, + "waikato-gxqe": { + "lat": -37.790942, + "lon": 175.393818, + "spread": 0.18807 + }, + "west-coast-ucji": { + "lat": -42.641915, + "lon": 170.912663, + "spread": 0.36898 + }, + "kermadec-islands-ocbs": { + "lat": -29.26485, + "lon": -177.906, + "spread": 0.00566 + }, + "three-kings-islands-qafu": { + "lat": -34.706441, + "lon": 173.12794, + "spread": 0.050598 + }, + "antipodes-islands-ynit": { + "lat": -49.66725, + "lon": 178.80225, + "spread": 0.00999 + }, + "campbell-islands-ywks": { + "lat": -52.53136, + "lon": 169.18444, + "spread": 0.01311 + }, + "auckland-islands-ujmt": { + "lat": -50.7106, + "lon": 166.125593, + "spread": 0.03154 + }, + "the-snares-mvou": { + "lat": -48.023975, + "lon": 166.6154, + "spread": 0.00688 + }, + "tokelau-cqiy": { + "lat": -9.3573, + "lon": -171.19, + "spread": 0.00014 + }, + "al-batnah-north-ilnl": { + "lat": 24.131121, + "lon": 56.307117, + "spread": 0.14875 + }, + "ad-dakhliyah-qtih": { + "lat": 22.81665, + "lon": 57.732206, + "spread": 0.22621 + }, + "musandam-ubnu": { + "lat": 26.089514, + "lon": 56.333971, + "spread": 0.04095 + }, + "ash-sharqiyah-south-gkzv": { + "lat": 21.540943, + "lon": 58.98115, + "spread": 0.17571 + }, + "ash-sharqiyah-north-qdza": { + "lat": 23.149911, + "lon": 58.252858, + "spread": 0.2119 + }, + "al-wusta-mdok": { + "lat": 20.408423, + "lon": 57.120023, + "spread": 0.30522 + }, + "al-dhahira-eqif": { + "lat": 23.192367, + "lon": 55.94738, + "spread": 0.16879 + }, + "dhofar-gtht": { + "lat": 18.02205, + "lon": 54.847627, + "spread": 0.44545 + }, + "baluchistan-ihjh": { + "lat": 28.136939, + "lon": 65.730657, + "spread": 0.71471 + }, + "northern-areas-mevu": { + "lat": 35.865741, + "lon": 74.725416, + "spread": 0.25507 + }, + "azad-kashmir-ajpr": { + "lat": 34.156852, + "lon": 74.095226, + "spread": 0.18487 + }, + "k-p-stsm": { + "lat": 34.308141, + "lon": 71.945468, + "spread": 0.40307 + }, + "punjab-ghlp": { + "lat": 31.49879, + "lon": 72.251498, + "spread": 0.60261 + }, + "sind-atud": { + "lat": 25.081435, + "lon": 68.701079, + "spread": 0.44287 + }, + "f-a-t-a-yhan": { + "lat": 33.23681, + "lon": 70.623471, + "spread": 0.26231 + }, + "chiriqui-mxiy": { + "lat": 8.761759, + "lon": -82.242622, + "spread": 0.16054 + }, + "darien-iziz": { + "lat": 8.251226, + "lon": -77.95695, + "spread": 0.12879 + }, + "panama-oriu": { + "lat": 8.983739, + "lon": -79.394269, + "spread": 0.12555 + }, + "veraguas-gsiv": { + "lat": 8.145056, + "lon": -80.826982, + "spread": 0.16689 + }, + "amazonas-nthu": { + "lat": -5.008985, + "lon": -78.045585, + "spread": 0.16094 + }, + "ancash-mtdg": { + "lat": -9.597721, + "lon": -77.817576, + "spread": 0.19238 + }, + "apurimac-aghq": { + "lat": -14.098433, + "lon": -73.122181, + "spread": 0.16128 + }, + "arequipa-avbe": { + "lat": -15.602512, + "lon": -72.636807, + "spread": 0.26588 + }, + "ayacucho-vhko": { + "lat": -13.892387, + "lon": -74.05588, + "spread": 0.22962 + }, + "cajamarca-mjjy": { + "lat": -6.276453, + "lon": -78.828706, + "spread": 0.17054 + }, + "cusco-ypen": { + "lat": -13.358551, + "lon": -72.129068, + "spread": 0.36057 + }, + "huanuco-uwqc": { + "lat": -9.283763, + "lon": -75.928281, + "spread": 0.20641 + }, + "huancavelica-orqp": { + "lat": -12.86444, + "lon": -74.864392, + "spread": 0.15188 + }, + "ica-hijd": { + "lat": -14.128817, + "lon": -75.661562, + "spread": 0.16971 + }, + "junin-rnyf": { + "lat": -11.60618, + "lon": -74.993303, + "spread": 0.20053 + }, + "la-libertad-sqmm": { + "lat": -7.842614, + "lon": -78.245206, + "spread": 0.19994 + }, + "lambayeque-yoml": { + "lat": -6.4489, + "lon": -79.637324, + "spread": 0.14646 + }, + "lima-iioy": { + "lat": -11.567582, + "lon": -76.782124, + "spread": 0.24231 + }, + "loreto-ipkx": { + "lat": -3.950933, + "lon": -73.680054, + "spread": 0.78478 + }, + "madre-de-dios-fpxm": { + "lat": -11.785281, + "lon": -70.545837, + "spread": 0.33868 + }, + "moquegua-xwwn": { + "lat": -16.923616, + "lon": -70.856195, + "spread": 0.14677 + }, + "pasco-ydtb": { + "lat": -10.502537, + "lon": -75.5945, + "spread": 0.14479 + }, + "piura-whve": { + "lat": -4.848057, + "lon": -80.396202, + "spread": 0.21039 + }, + "puno-vaqi": { + "lat": -15.240119, + "lon": -69.749961, + "spread": 0.22927 + }, + "san-martin-orum": { + "lat": -6.853035, + "lon": -76.772535, + "spread": 0.22989 + }, + "tacna-uiaf": { + "lat": -17.525773, + "lon": -70.21054, + "spread": 0.15205 + }, + "ucayali-hmry": { + "lat": -9.618367, + "lon": -73.397542, + "spread": 0.40966 + }, + "abra-cmfg": { + "lat": 17.18861, + "lon": 121.262829, + "spread": 0.15638 + }, + "agusan-del-sur-efza": { + "lat": 8.857169, + "lon": 125.748509, + "spread": 0.1713 + }, + "batanes-orcp": { + "lat": 20.77405, + "lon": 121.849825, + "spread": 0.00935 + }, + "bukidnon-faet": { + "lat": 8.105681, + "lon": 124.540741, + "spread": 0.1183 + }, + "cagayan-sezc": { + "lat": 18.142568, + "lon": 121.248805, + "spread": 0.11032 + }, + "camarines-sur-ulzq": { + "lat": 13.382667, + "lon": 123.554351, + "spread": 0.15622 + }, + "davao-oriental-sxtc": { + "lat": 7.266972, + "lon": 125.898372, + "spread": 0.13952 + }, + "iloilo-xbzy": { + "lat": 11.139463, + "lon": 122.4555, + "spread": 0.13097 + }, + "mindoro-occidental-sjgh": { + "lat": 12.99302, + "lon": 121.00676, + "spread": 0.12424 + }, + "cotabato-wzzy": { + "lat": 6.872878, + "lon": 125.257479, + "spread": 0.18207 + }, + "negros-occidental-erlu": { + "lat": 10.120819, + "lon": 122.995757, + "spread": 0.11803 + }, + "nueva-ecija-ilyq": { + "lat": 15.851094, + "lon": 121.355431, + "spread": 0.16173 + }, + "pangasinan-cmhx": { + "lat": 16.258646, + "lon": 120.640956, + "spread": 0.1307 + }, + "palawan-imjh": { + "lat": 9.437343, + "lon": 118.318704, + "spread": 0.1962 + }, + "quezon-scfg": { + "lat": 14.219854, + "lon": 121.482586, + "spread": 0.20069 + }, + "sultan-kudarat-pnsz": { + "lat": 5.40305, + "lon": 125.3941, + "spread": 0.0062 + }, + "tawi-tawi-khxg": { + "lat": 5.201214, + "lon": 120.094614, + "spread": 0.02853 + }, + "samar-vdxo": { + "lat": 11.797396, + "lon": 125.174033, + "spread": 0.14555 + }, + "zamboanga-del-norte-qvgj": { + "lat": 7.747434, + "lon": 122.849125, + "spread": 0.17959 + }, + "zambales-gjfs": { + "lat": 15.203991, + "lon": 120.488356, + "spread": 0.14488 + }, + "central-cczg": { + "lat": -9.356556, + "lon": 147.729588, + "spread": 0.25653 + }, + "east-new-britain-bphy": { + "lat": -5.043839, + "lon": 151.684053, + "spread": 0.18114 + }, + "eastern-highlands-ohoa": { + "lat": -6.25905, + "lon": 145.220019, + "spread": 0.14519 + }, + "enga-yfvk": { + "lat": -5.6187, + "lon": 144.063053, + "spread": 0.14195 + }, + "east-sepik-azma": { + "lat": -4.089954, + "lon": 143.086471, + "spread": 0.17209 + }, + "gulf-yenl": { + "lat": -7.635903, + "lon": 144.832375, + "spread": 0.18549 + }, + "milne-bay-gubz": { + "lat": -10.161248, + "lon": 149.996512, + "spread": 0.11072 + }, + "morobe-anuo": { + "lat": -6.9237, + "lon": 146.931326, + "spread": 0.21311 + }, + "madang-elun": { + "lat": -5.088779, + "lon": 145.180605, + "spread": 0.2003 + }, + "manus-oxpg": { + "lat": -2.081162, + "lon": 146.967446, + "spread": 0.02648 + }, + "northern-swud": { + "lat": -9.052541, + "lon": 148.481041, + "spread": 0.19599 + }, + "north-solomons-yvrf": { + "lat": -6.181258, + "lon": 155.301337, + "spread": 0.12332 + }, + "sandaun-swut": { + "lat": -3.974295, + "lon": 142.01379, + "spread": 0.21106 + }, + "southern-highlands-wdcq": { + "lat": -5.879381, + "lon": 143.223906, + "spread": 0.1868 + }, + "west-new-britain-qila": { + "lat": -5.537551, + "lon": 150.100169, + "spread": 0.14047 + }, + "western-adsp": { + "lat": -7.793211, + "lon": 142.46638, + "spread": 0.30937 + }, + "lower-silesian-eayz": { + "lat": 51.038278, + "lon": 16.167984, + "spread": 0.1675 + }, + "kuyavian-pomeranian-gfwe": { + "lat": 53.038837, + "lon": 18.495721, + "spread": 0.14045 + }, + "lubusz-wsem": { + "lat": 52.188239, + "lon": 15.443497, + "spread": 0.17067 + }, + "lodz-gveo": { + "lat": 51.576091, + "lon": 19.47603, + "spread": 0.15304 + }, + "lublin-gyez": { + "lat": 51.26633, + "lon": 22.831873, + "spread": 0.19798 + }, + "lesser-poland-ukhv": { + "lat": 49.760782, + "lon": 20.351536, + "spread": 0.13112 + }, + "masovian-uuhs": { + "lat": 52.099921, + "lon": 21.116519, + "spread": 0.24303 + }, + "podlachian-amui": { + "lat": 53.309205, + "lon": 22.676816, + "spread": 0.21192 + }, + "subcarpathian-ajux": { + "lat": 49.976997, + "lon": 22.217034, + "spread": 0.18358 + }, + "pomeranian-saur": { + "lat": 54.127172, + "lon": 18.206459, + "spread": 0.14045 + }, + "swietokrzyskie-yobp": { + "lat": 50.764081, + "lon": 20.554457, + "spread": 0.11155 + }, + "silesian-igbk": { + "lat": 50.271233, + "lon": 18.696349, + "spread": 0.17622 + }, + "warmian-masurian-epsm": { + "lat": 53.831014, + "lon": 20.582395, + "spread": 0.13405 + }, + "greater-poland-ausi": { + "lat": 52.357158, + "lon": 17.409538, + "spread": 0.25272 + }, + "west-pomeranian-mafy": { + "lat": 53.509037, + "lon": 15.718038, + "spread": 0.19802 + }, + "puerto-rico-qqlt": { + "lat": 18.231364, + "lon": -66.346671, + "spread": 0.05982 + }, + "p-yongan-namdo-uodu": { + "lat": 39.275174, + "lon": 126.10741, + "spread": 0.16757 + }, + "p-yongan-bukto-ipdv": { + "lat": 40.074495, + "lon": 125.153375, + "spread": 0.11113 + }, + "chagang-do-erdw": { + "lat": 41.091286, + "lon": 126.528596, + "spread": 0.18099 + }, + "hwanghae-bukto-oudy": { + "lat": 38.235741, + "lon": 125.790969, + "spread": 0.14909 + }, + "kangwon-do-kwdz": { + "lat": 38.977407, + "lon": 127.362373, + "spread": 0.12397 + }, + "hamgyong-namdo-cxda": { + "lat": 40.262144, + "lon": 127.732308, + "spread": 0.19679 + }, + "hamgyong-bukto-yzdb": { + "lat": 41.905335, + "lon": 129.707841, + "spread": 0.19951 + }, + "ryanggang-ggfz": { + "lat": 41.296914, + "lon": 127.965486, + "spread": 0.16421 + }, + "beja-ykgw": { + "lat": 37.814991, + "lon": -8.004439, + "spread": 0.13332 + }, + "castelo-branco-gcgr": { + "lat": 40.001726, + "lon": -7.538526, + "spread": 0.08563 + }, + "evora-qfgt": { + "lat": 38.454919, + "lon": -8.318636, + "spread": 0.12711 + }, + "santarem-sojz": { + "lat": 39.308213, + "lon": -8.564581, + "spread": 0.13612 + }, + "vila-real-ytjc": { + "lat": 41.62065, + "lon": -7.735553, + "spread": 0.11369 + }, + "viseu-uvjd": { + "lat": 40.466589, + "lon": -8.000683, + "spread": 0.1271 + }, + "azores-ayly": { + "lat": 37.828029, + "lon": -25.5007, + "spread": 0.02049 + }, + "madeira-croh": { + "lat": 32.746217, + "lon": -16.9429, + "spread": 0.0227 + }, + "concepcion-vrml": { + "lat": -22.752976, + "lon": -57.192141, + "spread": 0.13935 + }, + "san-pedro-qtmm": { + "lat": -24.197854, + "lon": -56.522261, + "spread": 0.16954 + }, + "caaguazu-xlmh": { + "lat": -25.186281, + "lon": -55.926756, + "spread": 0.12082 + }, + "caazapa-snmi": { + "lat": -26.070575, + "lon": -56.018569, + "spread": 0.12203 + }, + "itapua-jomj": { + "lat": -26.8511, + "lon": -55.722192, + "spread": 0.1503 + }, + "paraguari-vemt": { + "lat": -25.72983, + "lon": -57.137159, + "spread": 0.13252 + }, + "alto-parana-mimn": { + "lat": -25.447819, + "lon": -55.099338, + "spread": 0.11678 + }, + "neembucu-cfml": { + "lat": -26.773174, + "lon": -57.602363, + "spread": 0.16993 + }, + "amambay-kemk": { + "lat": -22.887846, + "lon": -56.187621, + "spread": 0.13091 + }, + "canindeyu-ocmj": { + "lat": -24.212628, + "lon": -55.254378, + "spread": 0.12523 + }, + "presidente-hayes-abmi": { + "lat": -23.677092, + "lon": -58.62383, + "spread": 0.32068 + }, + "alto-paraguay-ezmh": { + "lat": -20.828436, + "lon": -58.81915, + "spread": 0.31363 + }, + "boqueron-uume": { + "lat": -22.32987, + "lon": -60.772457, + "spread": 0.33462 + }, + "gaza-strip-sazs": { + "lat": 31.44538, + "lon": 34.39294, + "spread": 0.03557 + }, + "west-bank-cjlo": { + "lat": 31.916653, + "lon": 35.177733, + "spread": 0.06601 + }, + "ar-rayyan-ynlo": { + "lat": 25.466553, + "lon": 51.200942, + "spread": 0.08601 + }, + "alba-kbti": { + "lat": 45.96309, + "lon": 23.606897, + "spread": 0.13249 + }, + "arges-hxtf": { + "lat": 44.983345, + "lon": 25.488773, + "spread": 0.11617 + }, + "bacau-zdvy": { + "lat": 46.470503, + "lon": 27.10811, + "spread": 0.09724 + }, + "buzau-ipvp": { + "lat": 45.480868, + "lon": 27.378214, + "spread": 0.13856 + }, + "cluj-cgyo": { + "lat": 47.000673, + "lon": 22.926248, + "spread": 0.12284 + }, + "constanta-owyy": { + "lat": 44.680472, + "lon": 28.032681, + "spread": 0.17108 + }, + "dolj-yaab": { + "lat": 44.278275, + "lon": 23.960718, + "spread": 0.11612 + }, + "gorj-qeiw": { + "lat": 44.872826, + "lon": 22.394864, + "spread": 0.15625 + }, + "harghita-ybxj": { + "lat": 46.143362, + "lon": 25.620452, + "spread": 0.17512 + }, + "iasi-gzad": { + "lat": 47.299511, + "lon": 26.440495, + "spread": 0.16415 + }, + "maramures-iwjv": { + "lat": 47.665576, + "lon": 23.361229, + "spread": 0.07838 + }, + "mures-yhjv": { + "lat": 46.778745, + "lon": 24.571459, + "spread": 0.13447 + }, + "timis-seme": { + "lat": 46.002787, + "lon": 21.524787, + "spread": 0.14677 + }, + "teleorman-gcnr": { + "lat": 44.229168, + "lon": 25.760653, + "spread": 0.11032 + }, + "gorno-altay-cstw": { + "lat": 50.846295, + "lon": 87.232292, + "spread": 0.35968 + }, + "altay-mfuq": { + "lat": 52.665553, + "lon": 82.941733, + "spread": 0.38526 + }, + "amur-clbp": { + "lat": 53.164621, + "lon": 127.954387, + "spread": 0.81872 + }, + "arkhangel-sk-lhbk": { + "lat": 63.293818, + "lon": 42.760872, + "spread": 0.58862 + }, + "new-earth-eikt": { + "lat": 80.482239, + "lon": 49.053263, + "spread": 0.08613 + }, + "severnaya-zemlya-awqp": { + "lat": 76.422747, + "lon": 64.048452, + "spread": 0.063829 + }, + "pechora-qwhs": { + "lat": 71.729199, + "lon": 54.395577, + "spread": 0.28805 + }, + "yamal-gosw": { + "lat": 74.826228, + "lon": 58.92094, + "spread": 0.37689 + }, + "astrakhan-anec": { + "lat": 46.896975, + "lon": 47.426069, + "spread": 0.33134 + }, + "bashkortostan-uovk": { + "lat": 54.29331, + "lon": 57.252283, + "spread": 0.49663 + }, + "belgorod-wxjk": { + "lat": 50.603056, + "lon": 37.579644, + "spread": 0.16177 + }, + "bryansk-hxom": { + "lat": 52.782725, + "lon": 33.472435, + "spread": 0.22049 + }, + "buryat-wvvy": { + "lat": 53.554366, + "lon": 108.581789, + "spread": 0.72759 + }, + "chechnya-qixv": { + "lat": 43.327514, + "lon": 45.800254, + "spread": 0.1528 + }, + "chelyabinsk-qnxc": { + "lat": 54.502595, + "lon": 60.276831, + "spread": 0.43832 + }, + "chukchi-autonomous-okrug-mmxs": { + "lat": 65.726611, + "lon": 171.532943, + "spread": 0.83274 + }, + "chuvash-mhxl": { + "lat": 55.370837, + "lon": 47.255839, + "spread": 0.17049 + }, + "dagestan-qfgq": { + "lat": 43.220353, + "lon": 46.689393, + "spread": 0.34739 + }, + "irkutsk-wjtq": { + "lat": 58.268981, + "lon": 107.618811, + "spread": 1.31678 + }, + "ivanovo-uneq": { + "lat": 57.01156, + "lon": 41.962072, + "spread": 0.13819 + }, + "kamchatka-rncn": { + "lat": 59.5504, + "lon": 164.531133, + "spread": 1.40515 + }, + "kabardin-balkar-kgec": { + "lat": 43.530009, + "lon": 43.539187, + "spread": 0.10684 + }, + "karachay-cherkess-gied": { + "lat": 43.890868, + "lon": 41.875086, + "spread": 0.12754 + }, + "krasnodar-nzpm": { + "lat": 45.478346, + "lon": 39.097416, + "spread": 0.34693 + }, + "kemerovo-larh": { + "lat": 54.62607, + "lon": 87.255916, + "spread": 0.4665 + }, + "kaliningrad-ihxa": { + "lat": 54.944571, + "lon": 21.268071, + "spread": 0.09337 + }, + "kurgan-yyxu": { + "lat": 55.40241, + "lon": 64.368623, + "spread": 0.2679 + }, + "khabarovsk-vetk": { + "lat": 54.68804, + "lon": 137.387479, + "spread": 1.59462 + }, + "khanty-mansiy-tkto": { + "lat": 62.129385, + "lon": 70.93305, + "spread": 0.7128 + }, + "kirov-gfvm": { + "lat": 58.417706, + "lon": 49.573909, + "spread": 0.49753 + }, + "khakass-cuel": { + "lat": 53.456143, + "lon": 89.460354, + "spread": 0.40073 + }, + "kalmyk-yjee": { + "lat": 46.515498, + "lon": 44.890238, + "spread": 0.35063 + }, + "kaluga-fmjg": { + "lat": 54.271478, + "lon": 35.359235, + "spread": 0.20355 + }, + "komi-eoeh": { + "lat": 63.566138, + "lon": 55.09966, + "spread": 0.9211 + }, + "kostroma-hmrp": { + "lat": 58.439725, + "lon": 43.724607, + "spread": 0.23562 + }, + "karelia-ohem": { + "lat": 64.044381, + "lon": 33.703021, + "spread": 0.60095 + }, + "kursk-obra": { + "lat": 51.629417, + "lon": 36.226723, + "spread": 0.15273 + }, + "krasnoyarsk-znif": { + "lat": 68.22544, + "lon": 96.066998, + "spread": 2.59541 + }, + "leningrad-mknu": { + "lat": 59.93409, + "lon": 31.980876, + "spread": 0.28704 + }, + "lipetsk-qvwo": { + "lat": 52.749915, + "lon": 39.14441, + "spread": 0.17134 + }, + "maga-buryatdan-wvtk": { + "lat": 62.039304, + "lon": 154.273369, + "spread": 0.75235 + }, + "mariy-el-umod": { + "lat": 56.568213, + "lon": 48.281654, + "spread": 0.15019 + }, + "mordovia-abon": { + "lat": 54.382887, + "lon": 44.158864, + "spread": 0.15154 + }, + "moskovskaya-chlr": { + "lat": 55.545722, + "lon": 37.604268, + "spread": 0.27089 + }, + "murmansk-etas": { + "lat": 68.298788, + "lon": 33.888703, + "spread": 0.39088 + }, + "nenets-qyga": { + "lat": 68.079852, + "lon": 53.270342, + "spread": 0.40594 + }, + "novgorod-cvlo": { + "lat": 58.348946, + "lon": 33.014768, + "spread": 0.24923 + }, + "nizhegorod-clce": { + "lat": 56.170954, + "lon": 44.654182, + "spread": 0.36044 + }, + "novosibirsk-mivi": { + "lat": 54.9251, + "lon": 80.35671, + "spread": 0.39229 + }, + "omsk-aufy": { + "lat": 55.615336, + "lon": 72.89793, + "spread": 0.5151 + }, + "orenburg-nkfp": { + "lat": 52.022886, + "lon": 55.894711, + "spread": 0.39027 + }, + "orel-svfw": { + "lat": 52.804486, + "lon": 36.590939, + "spread": 0.17078 + }, + "perm-cphx": { + "lat": 58.743544, + "lon": 56.077062, + "spread": 0.55791 + }, + "penza-mzew": { + "lat": 53.221431, + "lon": 44.372513, + "spread": 0.16996 + }, + "primor-ye-edix": { + "lat": 45.167077, + "lon": 134.478634, + "spread": 0.61335 + }, + "pskov-kakm": { + "lat": 57.171762, + "lon": 29.265939, + "spread": 0.34366 + }, + "rostov-ylrd": { + "lat": 47.667058, + "lon": 41.168946, + "spread": 0.42727 + }, + "ryazan-ggrj": { + "lat": 54.28343, + "lon": 40.641669, + "spread": 0.19931 + }, + "sakha-yakutia-yxkf": { + "lat": 66.607108, + "lon": 131.225908, + "spread": 1.85332 + }, + "sakhalin-wlzc": { + "lat": 50.609339, + "lon": 142.869837, + "spread": 0.31176 + }, + "samara-koze": { + "lat": 53.439897, + "lon": 50.432617, + "spread": 0.2917 + }, + "saratov-avzz": { + "lat": 51.589725, + "lon": 47.041943, + "spread": 0.30004 + }, + "north-ossetia-arkb": { + "lat": 43.071872, + "lon": 44.366184, + "spread": 0.12667 + }, + "smolensk-qrwu": { + "lat": 54.938883, + "lon": 33.081567, + "spread": 0.266 + }, + "stavropol-mizd": { + "lat": 44.767529, + "lon": 43.193248, + "spread": 0.25893 + }, + "sverdlovsk-eefj": { + "lat": 58.045294, + "lon": 60.90177, + "spread": 0.59343 + }, + "tatarstan-aotw": { + "lat": 55.466812, + "lon": 50.821702, + "spread": 0.26871 + }, + "tambov-evce": { + "lat": 52.62396, + "lon": 41.574337, + "spread": 0.22118 + }, + "tomsk-gews": { + "lat": 58.381014, + "lon": 82.666436, + "spread": 0.53648 + }, + "tula-cvuo": { + "lat": 53.908967, + "lon": 37.609388, + "spread": 0.1874 + }, + "tver-qdaj": { + "lat": 57.14417, + "lon": 35.009178, + "spread": 0.32239 + }, + "tuva-ecty": { + "lat": 51.582735, + "lon": 94.616036, + "spread": 0.40036 + }, + "tyumen-uppf": { + "lat": 57.349948, + "lon": 69.367071, + "spread": 0.48554 + }, + "udmurt-cave": { + "lat": 56.887265, + "lon": 52.895049, + "spread": 0.26855 + }, + "ul-yanovsk-uovf": { + "lat": 53.790465, + "lon": 47.893489, + "spread": 0.23032 + }, + "volgograd-omkg": { + "lat": 49.377717, + "lon": 44.289609, + "spread": 0.37902 + }, + "vladimir-mqvt": { + "lat": 56.055109, + "lon": 40.587847, + "spread": 0.16925 + }, + "vologda-ynvr": { + "lat": 59.937154, + "lon": 40.425423, + "spread": 0.31042 + }, + "voronezh-svdp": { + "lat": 51.100734, + "lon": 39.931885, + "spread": 0.25628 + }, + "yamal-nenets-aqsx": { + "lat": 68.238896, + "lon": 74.261564, + "spread": 1.07857 + }, + "yaroslavl-cksd": { + "lat": 57.701487, + "lon": 38.939953, + "spread": 0.2406 + }, + "yevrey-itcn": { + "lat": 48.634289, + "lon": 132.510628, + "spread": 0.18179 + }, + "chita-qxms": { + "lat": 53.946895, + "lon": 116.511529, + "spread": 0.92607 + }, + "crimea-qsnu": { + "lat": 45.306293, + "lon": 34.443241, + "spread": 0.18201 + }, + "eastern-avgq": { + "lat": -1.789235, + "lon": 30.204508, + "spread": 0.13728 + }, + "western-mygs": { + "lat": -2.25632, + "lon": 29.414015, + "spread": 0.11223 + }, + "western-sahara-gozf": { + "lat": 24.380847, + "lon": -12.497703, + "spread": 0.68945 + }, + "ar-riyad-gsqn": { + "lat": 24.586031, + "lon": 44.532427, + "spread": 0.63253 + }, + "makkah-yuqo": { + "lat": 21.16371, + "lon": 40.935838, + "spread": 0.50237 + }, + "al-madinah-uwqp": { + "lat": 25.138328, + "lon": 39.174338, + "spread": 0.48935 + }, + "ash-sharqiyah-qlqi": { + "lat": 25.609435, + "lon": 49.339924, + "spread": 1.06865 + }, + "al-quassim-imqj": { + "lat": 25.925903, + "lon": 43.184497, + "spread": 0.27164 + }, + "ha-il-eoqk": { + "lat": 27.277539, + "lon": 41.912537, + "spread": 0.3561 + }, + "tabuk-spql": { + "lat": 27.086677, + "lon": 37.306598, + "spread": 0.44447 + }, + "al-hudud-ash-shamaliyah-kdqu": { + "lat": 29.607897, + "lon": 41.910416, + "spread": 0.46631 + }, + "jizan-gfqv": { + "lat": 17.488092, + "lon": 42.556404, + "spread": 0.17485 + }, + "najran-wlss": { + "lat": 18.080105, + "lon": 45.203095, + "spread": 0.25839 + }, + "al-bahah-ajsr": { + "lat": 20.1382, + "lon": 41.44488, + "spread": 0.123 + }, + "al-jawf-misq": { + "lat": 29.590811, + "lon": 38.190426, + "spread": 0.36139 + }, + "asir-yfso": { + "lat": 19.064852, + "lon": 42.823964, + "spread": 0.31146 + }, + "central-darfur-wrpe": { + "lat": 12.326358, + "lon": 23.457789, + "spread": 0.20747 + }, + "north-darfur-mfpn": { + "lat": 14.22264, + "lon": 25.672973, + "spread": 0.40332 + }, + "southern-darfur-iopm": { + "lat": 10.477125, + "lon": 24.330393, + "spread": 0.28337 + }, + "eastern-darfur-kbbu": { + "lat": 11.301611, + "lon": 26.223716, + "spread": 0.27134 + }, + "western-darfur-kvpq": { + "lat": 13.819276, + "lon": 22.828076, + "spread": 0.23204 + }, + "gedarif-oxvo": { + "lat": 14.286926, + "lon": 35.023113, + "spread": 0.29823 + }, + "gezira-chvu": { + "lat": 14.47291, + "lon": 33.119876, + "spread": 0.18284 + }, + "kassala-odzh": { + "lat": 15.948952, + "lon": 35.910652, + "spread": 0.25655 + }, + "khartoum-wpzy": { + "lat": 15.876117, + "lon": 33.025075, + "spread": 0.14776 + }, + "north-kordufan-kmzw": { + "lat": 13.033813, + "lon": 29.5147, + "spread": 0.5332 + }, + "south-kordufan-ygzz": { + "lat": 11.402324, + "lon": 29.923932, + "spread": 0.34037 + }, + "blue-nile-conz": { + "lat": 10.97756, + "lon": 34.20481, + "spread": 0.20654 + }, + "northern-qinm": { + "lat": 19.727427, + "lon": 30.43316, + "spread": 0.56882 + }, + "river-nile-gpnj": { + "lat": 18.5413, + "lon": 33.563581, + "spread": 0.38044 + }, + "white-nile-uwno": { + "lat": 13.606527, + "lon": 32.428227, + "spread": 0.16736 + }, + "red-sea-zspo": { + "lat": 19.232424, + "lon": 36.609315, + "spread": 0.50055 + }, + "sennar-dssz": { + "lat": 13.169465, + "lon": 33.905378, + "spread": 0.2226 + }, + "northern-bahr-el-ghazal-civj": { + "lat": 8.953619, + "lon": 27.366694, + "spread": 0.21638 + }, + "western-bahr-el-ghazal-uwvs": { + "lat": 8.324388, + "lon": 25.796642, + "spread": 0.37892 + }, + "central-equatoria-sogr": { + "lat": 4.624817, + "lon": 30.948871, + "spread": 0.25496 + }, + "eastern-equatoria-ilgp": { + "lat": 4.679122, + "lon": 34.061959, + "spread": 0.24963 + }, + "western-equatoria-yffx": { + "lat": 5.538785, + "lon": 28.439691, + "spread": 0.25412 + }, + "jonglei-setz": { + "lat": 7.919755, + "lon": 32.050448, + "spread": 0.37609 + }, + "lakes-uvxp": { + "lat": 6.560578, + "lon": 30.016535, + "spread": 0.21215 + }, + "upper-nile-kxdz": { + "lat": 9.770474, + "lon": 32.838915, + "spread": 0.31732 + }, + "unity-smuv": { + "lat": 8.835367, + "lon": 29.967079, + "spread": 0.21212 + }, + "warrap-kuzy": { + "lat": 7.989357, + "lon": 28.743657, + "spread": 0.19985 + }, + "fatick-kohi": { + "lat": 14.026424, + "lon": -16.259274, + "spread": 0.11586 + }, + "kaffrine-ufni": { + "lat": 14.161093, + "lon": -15.212971, + "spread": 0.09565 + }, + "kolda-oanf": { + "lat": 13.202113, + "lon": -14.255737, + "spread": 0.09403 + }, + "kedougou-syne": { + "lat": 13.001196, + "lon": -12.472477, + "spread": 0.11335 + }, + "louga-wgwg": { + "lat": 15.375204, + "lon": -15.546486, + "spread": 0.15786 + }, + "matam-qxzf": { + "lat": 15.255746, + "lon": -13.810038, + "spread": 0.17607 + }, + "sedhiou-qgha": { + "lat": 12.822675, + "lon": -16.089564, + "spread": 0.09233 + }, + "saint-louis-iuhj": { + "lat": 16.182561, + "lon": -15.12015, + "spread": 0.10345 + }, + "tambacounda-shqy": { + "lat": 13.879911, + "lon": -13.163542, + "spread": 0.21212 + }, + "thies-mpqd": { + "lat": 14.859095, + "lon": -16.275118, + "spread": 0.13524 + }, + "western-afoi": { + "lat": -9.570862, + "lon": 160.126431, + "spread": 0.06894 + }, + "makira-qcnj": { + "lat": -10.567882, + "lon": 161.876355, + "spread": 0.06166 + }, + "rennell-and-bellona-amuo": { + "lat": -11.653962, + "lon": 160.27875, + "spread": 0.03839 + }, + "temotu-wpdk": { + "lat": -10.74016, + "lon": 165.95746, + "spread": 0.02117 + }, + "eastern-dtaw": { + "lat": 8.185252, + "lon": -10.98252, + "spread": 0.1298 + }, + "northern-yiap": { + "lat": 8.891227, + "lon": -12.318346, + "spread": 0.17505 + }, + "southern-ncas": { + "lat": 7.794784, + "lon": -12.063744, + "spread": 0.15887 + }, + "usulutan-xbjc": { + "lat": 13.773067, + "lon": -88.850344, + "spread": 0.12788 + }, + "somaliland-gapg": { + "lat": 10.353152, + "lon": 45.441104, + "spread": 0.35023 + }, + "bakool-grdf": { + "lat": 4.006317, + "lon": 43.864394, + "spread": 0.1694 + }, + "bari-sedg": { + "lat": 10.19827, + "lon": 50.502285, + "spread": 0.24779 + }, + "bay-uvdr": { + "lat": 3.175671, + "lon": 43.649971, + "spread": 0.22599 + }, + "galguduud-arwl": { + "lat": 4.956169, + "lon": 46.601612, + "spread": 0.23221 + }, + "gedo-yxwp": { + "lat": 2.906033, + "lon": 42.0362, + "spread": 0.21745 + }, + "hiiraan-knsf": { + "lat": 4.331127, + "lon": 45.658513, + "spread": 0.18032 + }, + "jubbada-dhexe-unxw": { + "lat": 1.1784, + "lon": 42.63443, + "spread": 0.164 + }, + "jubbada-hoose-wuxk": { + "lat": -0.550167, + "lon": 41.903325, + "spread": 0.22164 + }, + "mudug-kpld": { + "lat": 6.451519, + "lon": 48.375256, + "spread": 0.31738 + }, + "nugaal-gznd": { + "lat": 8.21767, + "lon": 49.19743, + "spread": 0.21609 + }, + "shabeellaha-dhexe-ueua": { + "lat": 2.789238, + "lon": 45.703977, + "spread": 0.19026 + }, + "shabeellaha-hoose-sxum": { + "lat": 2.206383, + "lon": 44.761975, + "spread": 0.24101 + }, + "juzno-backi-girr": { + "lat": 45.677818, + "lon": 19.7437, + "spread": 0.10881 + }, + "sremski-xjrs": { + "lat": 44.540212, + "lon": 19.8308, + "spread": 0.11429 + }, + "branicevski-nitf": { + "lat": 44.224098, + "lon": 21.423453, + "spread": 0.11397 + }, + "zajecarski-lotj": { + "lat": 43.19749, + "lon": 21.849507, + "spread": 0.19337 + }, + "zlatiborski-gqtk": { + "lat": 43.634261, + "lon": 20.196406, + "spread": 0.14144 + }, + "para-scsi": { + "lat": 5.445334, + "lon": -55.465387, + "spread": 0.19998 + }, + "sipaliwini-lvya": { + "lat": 3.900807, + "lon": -55.818024, + "spread": 0.36866 + }, + "banskobystricky-iono": { + "lat": 48.8511, + "lon": 19.307621, + "spread": 0.15363 + }, + "nitriansky-muds": { + "lat": 48.514086, + "lon": 18.007305, + "spread": 0.15565 + }, + "presov-kpwz": { + "lat": 48.97276, + "lon": 21.024253, + "spread": 0.11156 + }, + "kocevje-gyjj": { + "lat": 45.855018, + "lon": 14.721218, + "spread": 0.07462 + }, + "vasterbotten-ykth": { + "lat": 64.553765, + "lon": 18.687337, + "spread": 0.2943 + }, + "norrbotten-gqvp": { + "lat": 66.707074, + "lon": 21.350737, + "spread": 0.39687 + }, + "uppsala-aeug": { + "lat": 59.786022, + "lon": 18.04683, + "spread": 0.17869 + }, + "sodermanland-sfuh": { + "lat": 59.414226, + "lon": 16.057626, + "spread": 0.16702 + }, + "ostergotland-ohui": { + "lat": 58.238474, + "lon": 16.097516, + "spread": 0.12983 + }, + "jonkoping-eiuj": { + "lat": 57.290772, + "lon": 13.454105, + "spread": 0.18857 + }, + "kronoberg-ykuk": { + "lat": 56.589622, + "lon": 14.846225, + "spread": 0.12396 + }, + "kalmar-umuv": { + "lat": 57.397677, + "lon": 16.183805, + "spread": 0.13951 + }, + "gotland-qouw": { + "lat": 57.391848, + "lon": 18.519252, + "spread": 0.09858 + }, + "skane-ouua": { + "lat": 56.029113, + "lon": 13.492546, + "spread": 0.11596 + }, + "vastra-gotaland-yxuc": { + "lat": 58.410294, + "lon": 12.268974, + "spread": 0.20979 + }, + "varmland-aeuq": { + "lat": 59.731293, + "lon": 13.099037, + "spread": 0.23084 + }, + "dalarna-cluu": { + "lat": 60.876596, + "lon": 14.566763, + "spread": 0.23886 + }, + "gavleborg-ynuf": { + "lat": 61.434245, + "lon": 16.608286, + "spread": 0.21385 + }, + "vasternorrland-upug": { + "lat": 62.962147, + "lon": 17.83109, + "spread": 0.18699 + }, + "jamtland-kquh": { + "lat": 63.14733, + "lon": 14.401588, + "spread": 0.3532 + }, + "lubombo-ybpl": { + "lat": -26.495005, + "lon": 31.505627, + "spread": 0.13345 + }, + "dayr-az-zawr-nthe": { + "lat": 35.30027, + "lon": 40.40032, + "spread": 0.20665 + }, + "hasaka-al-haksa-vlle": { + "lat": 36.614521, + "lon": 41.112011, + "spread": 0.17727 + }, + "homs-hims-vcmm": { + "lat": 34.696115, + "lon": 37.386331, + "spread": 0.22511 + }, + "aleppo-cqlh": { + "lat": 36.2891, + "lon": 37.625908, + "spread": 0.14725 + }, + "hamah-xsli": { + "lat": 35.218842, + "lon": 36.986288, + "spread": 0.09061 + }, + "idlib-yoou": { + "lat": 35.557078, + "lon": 36.409557, + "spread": 0.1493 + }, + "ar-raqqah-lhjy": { + "lat": 36.260076, + "lon": 38.808641, + "spread": 0.14947 + }, + "rif-dimashq-smjb": { + "lat": 33.515573, + "lon": 36.545791, + "spread": 0.15378 + }, + "as-suwayda-jzmr": { + "lat": 32.980386, + "lon": 36.163475, + "spread": 0.11144 + }, + "batha-dyem": { + "lat": 14.082381, + "lon": 18.614031, + "spread": 0.33193 + }, + "barh-el-gazel-nhes": { + "lat": 14.137253, + "lon": 16.527812, + "spread": 0.24024 + }, + "borkou-nuea": { + "lat": 17.972011, + "lon": 18.860496, + "spread": 0.54248 + }, + "chari-baguirmi-aeay": { + "lat": 11.393164, + "lon": 15.950224, + "spread": 0.26288 + }, + "guera-qyrk": { + "lat": 11.458886, + "lon": 18.670441, + "spread": 0.29157 + }, + "hadjer-lamis-avbt": { + "lat": 12.616153, + "lon": 16.041643, + "spread": 0.19119 + }, + "kanem-bhvv": { + "lat": 14.531584, + "lon": 15.140668, + "spread": 0.30633 + }, + "lac-rmsk": { + "lat": 13.579033, + "lon": 14.636339, + "spread": 0.15101 + }, + "logone-oriental-wotz": { + "lat": 8.293609, + "lon": 16.327318, + "spread": 0.17505 + }, + "mandoul-jypx": { + "lat": 8.851519, + "lon": 17.531286, + "spread": 0.11419 + }, + "moyen-chari-vbpz": { + "lat": 9.416331, + "lon": 18.483254, + "spread": 0.25965 + }, + "mayo-kebbi-est-hepb": { + "lat": 10.19441, + "lon": 15.493405, + "spread": 0.15138 + }, + "mayo-kebbi-ouest-tupl": { + "lat": 9.319969, + "lon": 14.840785, + "spread": 0.15163 + }, + "ouaddai-ukka": { + "lat": 13.491833, + "lon": 21.058476, + "spread": 0.18027 + }, + "salamat-nrnf": { + "lat": 10.906297, + "lon": 20.364472, + "spread": 0.30956 + }, + "sila-rfnx": { + "lat": 12.14945, + "lon": 21.535797, + "spread": 0.21731 + }, + "tandjile-trlq": { + "lat": 9.472174, + "lon": 16.595739, + "spread": 0.12603 + }, + "tibesti-teli": { + "lat": 21.169839, + "lon": 16.5742, + "spread": 0.39372 + }, + "wadi-fira-wtem": { + "lat": 15.116169, + "lon": 21.749619, + "spread": 0.20793 + }, + "ennedi-sxtm": { + "lat": 16.718368, + "lon": 21.713111, + "spread": 0.3977 + }, + "centre-kurt": { + "lat": 8.642137, + "lon": 0.934812, + "spread": 0.12745 + }, + "kara-khrl": { + "lat": 9.976358, + "lon": 0.4991, + "spread": 0.15862 + }, + "plateaux-myrm": { + "lat": 7.239826, + "lon": 1.065274, + "spread": 0.12878 + }, + "phra-nakhon-si-ayutthaya-eddh": { + "lat": 14.301034, + "lon": 100.73465, + "spread": 0.14363 + }, + "chachoengsao-uwfm": { + "lat": 12.884711, + "lon": 101.808849, + "spread": 0.20762 + }, + "sa-kaeo-cufl": { + "lat": 13.881852, + "lon": 101.919617, + "spread": 0.12705 + }, + "nakhon-ratchasima-kfip": { + "lat": 14.969739, + "lon": 102.08691, + "spread": 0.17148 + }, + "buri-ram-ghiq": { + "lat": 14.933871, + "lon": 103.229623, + "spread": 0.16197 + }, + "ubon-ratchathani-mzil": { + "lat": 15.137369, + "lon": 104.807923, + "spread": 0.17341 + }, + "chaiyaphum-acin": { + "lat": 15.946041, + "lon": 101.9112, + "spread": 0.11082 + }, + "khon-kaen-msqg": { + "lat": 16.51407, + "lon": 102.580555, + "spread": 0.14331 + }, + "udon-thani-iuqh": { + "lat": 17.470423, + "lon": 102.746908, + "spread": 0.15339 + }, + "loei-avqi": { + "lat": 17.389643, + "lon": 101.629791, + "spread": 0.12451 + }, + "roi-et-gerl": { + "lat": 16.262217, + "lon": 103.6251, + "spread": 0.14689 + }, + "sakon-nakhon-qhrn": { + "lat": 17.643261, + "lon": 103.795391, + "spread": 0.15827 + }, + "mukdahan-ihqz": { + "lat": 15.984373, + "lon": 104.460585, + "spread": 0.1097 + }, + "chiang-mai-qstn": { + "lat": 18.905486, + "lon": 98.817811, + "spread": 0.15065 + }, + "lampang-avtp": { + "lat": 18.123548, + "lon": 99.249294, + "spread": 0.14426 + }, + "uttaradit-swtq": { + "lat": 17.973839, + "lon": 100.297036, + "spread": 0.16572 + }, + "nan-kntk": { + "lat": 18.980919, + "lon": 100.742171, + "spread": 0.09779 + }, + "chiang-rai-uqtm": { + "lat": 19.628509, + "lon": 100.022806, + "spread": 0.1369 + }, + "mae-hong-son-qftf": { + "lat": 18.834964, + "lon": 98.013571, + "spread": 0.13036 + }, + "nakhon-sawan-ckvs": { + "lat": 15.9806, + "lon": 100.069505, + "spread": 0.15332 + }, + "uthai-thani-ymvt": { + "lat": 15.03695, + "lon": 99.734215, + "spread": 0.13067 + }, + "tak-ojvr": { + "lat": 16.735231, + "lon": 98.945706, + "spread": 0.21082 + }, + "phitsanulok-agvp": { + "lat": 17.125923, + "lon": 100.20589, + "spread": 0.14476 + }, + "phetchabun-mcvn": { + "lat": 16.444074, + "lon": 101.193063, + "spread": 0.11583 + }, + "kanchanaburi-udyq": { + "lat": 14.782052, + "lon": 98.990895, + "spread": 0.17197 + }, + "phetchaburi-klyv": { + "lat": 12.831571, + "lon": 99.611761, + "spread": 0.1016 + }, + "nakhon-si-thammarat-utgo": { + "lat": 8.033295, + "lon": 99.558423, + "spread": 0.17548 + }, + "surat-thani-wngk": { + "lat": 9.434869, + "lon": 98.88124, + "spread": 0.15872 + }, + "songkhla-eojn": { + "lat": 6.825027, + "lon": 100.5457, + "spread": 0.10112 + }, + "yala-ywjs": { + "lat": 6.315461, + "lon": 101.359667, + "spread": 0.12401 + }, + "gorno-badakhshan-sgex": { + "lat": 38.164735, + "lon": 72.810073, + "spread": 0.27888 + }, + "khatlon-qlhv": { + "lat": 37.821137, + "lon": 69.275353, + "spread": 0.18086 + }, + "leninabad-dnbu": { + "lat": 39.891034, + "lon": 69.468542, + "spread": 0.2133 + }, + "tadzhikistan-territories-uxcw": { + "lat": 38.897864, + "lon": 70.237407, + "spread": 0.17019 + }, + "ahal-weco": { + "lat": 37.845137, + "lon": 59.970689, + "spread": 0.46679 + }, + "balkan-edcn": { + "lat": 40.098829, + "lon": 54.210674, + "spread": 0.47275 + }, + "tashauz-mmct": { + "lat": 41.41075, + "lon": 58.958017, + "spread": 0.33148 + }, + "chardzhou-mzcb": { + "lat": 38.904455, + "lon": 63.435691, + "spread": 0.44497 + }, + "mary-qxca": { + "lat": 36.958084, + "lon": 62.107591, + "spread": 0.4269 + }, + "ambeno-kpna": { + "lat": -9.36226, + "lon": 124.21832, + "spread": 0.03085 + }, + "viqueque-wkyr": { + "lat": -8.854872, + "lon": 125.674242, + "spread": 0.10546 + }, + "rio-claro-mayaro-ojxf": { + "lat": 10.441754, + "lon": -61.342198, + "spread": 0.07806 + }, + "western-tobago-lmvd": { + "lat": 11.254975, + "lon": -60.6681, + "spread": 0.01797 + }, + "beja-yaie": { + "lat": 36.770039, + "lon": 9.887003, + "spread": 0.13357 + }, + "le-kef-oxic": { + "lat": 35.914474, + "lon": 9.0922, + "spread": 0.09593 + }, + "sidi-bou-zid-ifqk": { + "lat": 35.284036, + "lon": 9.399697, + "spread": 0.18951 + }, + "sfax-ktva": { + "lat": 34.716186, + "lon": 10.407675, + "spread": 0.120772 + }, + "gafsa-ofsu": { + "lat": 34.223653, + "lon": 8.479384, + "spread": 0.13399 + }, + "kebili-ecss": { + "lat": 33.46357, + "lon": 9.229995, + "spread": 0.17663 + }, + "tataouine-qrma": { + "lat": 32.726183, + "lon": 10.503944, + "spread": 0.32138 + }, + "adana-wdhs": { + "lat": 37.109446, + "lon": 35.906133, + "spread": 0.19296 + }, + "afyonkarahisar-gghu": { + "lat": 38.323563, + "lon": 30.58182, + "spread": 0.19016 + }, + "agri-cvhn": { + "lat": 39.63595, + "lon": 43.571108, + "spread": 0.11078 + }, + "ankara-oyhp": { + "lat": 39.770664, + "lon": 32.585836, + "spread": 0.2064 + }, + "antalya-iahq": { + "lat": 36.6698, + "lon": 30.953777, + "spread": 0.13338 + }, + "artvin-aohz": { + "lat": 41.000172, + "lon": 41.548144, + "spread": 0.09721 + }, + "balikesir-exjy": { + "lat": 39.845779, + "lon": 27.513642, + "spread": 0.14869 + }, + "bilecik-wyjz": { + "lat": 40.519962, + "lon": 30.091712, + "spread": 0.16079 + }, + "bitlis-gbjb": { + "lat": 38.847677, + "lon": 42.140419, + "spread": 0.15453 + }, + "bolu-cdjc": { + "lat": 40.993347, + "lon": 31.9615, + "spread": 0.17163 + }, + "bursa-ogje": { + "lat": 40.235743, + "lon": 29.097061, + "spread": 0.11388 + }, + "corum-amjr": { + "lat": 40.709758, + "lon": 34.207635, + "spread": 0.14032 + }, + "denizli-spld": { + "lat": 37.661403, + "lon": 29.548953, + "spread": 0.15386 + }, + "diyarbakir-mrle": { + "lat": 38.596816, + "lon": 40.36221, + "spread": 0.22053 + }, + "erzincan-qvlh": { + "lat": 39.302238, + "lon": 39.372238, + "spread": 0.17467 + }, + "erzurum-kxli": { + "lat": 40.061407, + "lon": 41.535267, + "spread": 0.17534 + }, + "eskisehir-eslf": { + "lat": 39.602689, + "lon": 31.059028, + "spread": 0.10815 + }, + "giresun-wdlv": { + "lat": 40.67029, + "lon": 38.228133, + "spread": 0.10755 + }, + "gumushane-melw": { + "lat": 40.473335, + "lon": 39.638035, + "spread": 0.12192 + }, + "mersin-mqol": { + "lat": 36.821803, + "lon": 33.739148, + "spread": 0.16198 + }, + "izmir-ehof": { + "lat": 38.518595, + "lon": 27.047889, + "spread": 0.15764 + }, + "kars-uiog": { + "lat": 40.806446, + "lon": 43.011388, + "spread": 0.1571 + }, + "kastamonu-okoh": { + "lat": 41.476186, + "lon": 34.11141, + "spread": 0.12366 + }, + "kayseri-kzoa": { + "lat": 38.497018, + "lon": 35.689259, + "spread": 0.15642 + }, + "kirsehir-iywz": { + "lat": 38.97585, + "lon": 34.51266, + "spread": 0.14773 + }, + "konya-ubwb": { + "lat": 38.075511, + "lon": 32.753631, + "spread": 0.25487 + }, + "kutahya-odwc": { + "lat": 39.030881, + "lon": 29.449263, + "spread": 0.16941 + }, + "malatya-kfwd": { + "lat": 38.290884, + "lon": 38.382355, + "spread": 0.17384 + }, + "manisa-cgwe": { + "lat": 38.752768, + "lon": 28.177016, + "spread": 0.1307 + }, + "k-maras-wiwf": { + "lat": 37.598803, + "lon": 37.147487, + "spread": 0.18197 + }, + "mugla-mmwr": { + "lat": 37.086432, + "lon": 28.287527, + "spread": 0.18102 + }, + "samsun-inzb": { + "lat": 40.852381, + "lon": 36.099605, + "spread": 0.18126 + }, + "siirt-yozc": { + "lat": 37.736688, + "lon": 41.93333, + "spread": 0.16118 + }, + "sivas-ofzw": { + "lat": 39.682934, + "lon": 37.521069, + "spread": 0.19241 + }, + "sanliurfa-imci": { + "lat": 37.315938, + "lon": 38.836995, + "spread": 0.13071 + }, + "van-ujcg": { + "lat": 38.124962, + "lon": 43.788141, + "spread": 0.21236 + }, + "yozgat-oecd": { + "lat": 39.800862, + "lon": 35.128731, + "spread": 0.12041 + }, + "aksaray-cbcb": { + "lat": 38.194637, + "lon": 34.32323, + "spread": 0.16491 + }, + "hualien-babs": { + "lat": 23.734808, + "lon": 120.9196, + "spread": 0.12623 + }, + "kaohsiung-city-gnnm": { + "lat": 22.922319, + "lon": 120.71801, + "spread": 0.14271 + }, + "kinmen-mdpx": { + "lat": 24.46734, + "lon": 118.3618, + "spread": 0.01214 + }, + "penghu-oghr": { + "lat": 23.5716, + "lon": 119.621375, + "spread": 0.00635 + }, + "new-taipei-city-wbtm": { + "lat": 24.766628, + "lon": 121.295806, + "spread": 0.12743 + }, + "arusha-ssrk": { + "lat": -3.313359, + "lon": 35.926686, + "spread": 0.24018 + }, + "dodoma-cvrm": { + "lat": -5.904733, + "lon": 35.835119, + "spread": 0.18017 + }, + "iringa-ykrf": { + "lat": -7.835333, + "lon": 35.408806, + "spread": 0.2143 + }, + "kagera-umrg": { + "lat": -2.096116, + "lon": 30.981176, + "spread": 0.20047 + }, + "kigoma-aerb": { + "lat": -4.776219, + "lon": 30.484822, + "spread": 0.21481 + }, + "kilimanjaro-wgrc": { + "lat": -3.571239, + "lon": 37.499567, + "spread": 0.15193 + }, + "kusini-pemba-iltp": { + "lat": -5.275114, + "lon": 39.746157, + "spread": 0.0207 + }, + "lindi-yitn": { + "lat": -9.511795, + "lon": 38.79456, + "spread": 0.28736 + }, + "mara-cgtm": { + "lat": -1.874252, + "lon": 33.834313, + "spread": 0.14892 + }, + "mbeya-getl": { + "lat": -8.409764, + "lon": 33.278606, + "spread": 0.28511 + }, + "morogoro-wbtj": { + "lat": -8.011825, + "lon": 36.819365, + "spread": 0.31555 + }, + "mtwara-eati": { + "lat": -10.617924, + "lon": 39.402312, + "spread": 0.12822 + }, + "mwanza-iyth": { + "lat": -2.496362, + "lon": 32.992855, + "spread": 0.19101 + }, + "pwani-qxtg": { + "lat": -7.183972, + "lon": 38.774325, + "spread": 0.17493 + }, + "rukwa-ygvp": { + "lat": -8.122585, + "lon": 31.657711, + "spread": 0.21698 + }, + "ruvuma-qivq": { + "lat": -10.623128, + "lon": 36.15391, + "spread": 0.25339 + }, + "shinyanga-kdvn": { + "lat": -3.805577, + "lon": 32.783348, + "spread": 0.12324 + }, + "singida-gfvo": { + "lat": -5.909019, + "lon": 34.55923, + "spread": 0.1856 + }, + "tabora-wnvt": { + "lat": -5.174757, + "lon": 32.539949, + "spread": 0.3224 + }, + "tanga-oovu": { + "lat": -5.489012, + "lon": 37.8646, + "spread": 0.19909 + }, + "manyara-mkvr": { + "lat": -4.405611, + "lon": 36.406203, + "spread": 0.25438 + }, + "geita-elvs": { + "lat": -3.229567, + "lon": 31.791207, + "spread": 0.17218 + }, + "katavi-ytvx": { + "lat": -6.419914, + "lon": 31.257317, + "spread": 0.25989 + }, + "njombe-qvvy": { + "lat": -9.427675, + "lon": 34.794144, + "spread": 0.19011 + }, + "simiyu-iyyt": { + "lat": -2.813635, + "lon": 34.16624, + "spread": 0.18495 + }, + "mubende-stqq": { + "lat": 0.693134, + "lon": 31.637872, + "spread": 0.14265 + }, + "mukono-khqz": { + "lat": 0.298723, + "lon": 32.469204, + "spread": 0.14171 + }, + "nakasongola-gjqa": { + "lat": 1.3765, + "lon": 32.769055, + "spread": 0.1483 + }, + "buvuma-gikj": { + "lat": -0.038787, + "lon": 33.550653, + "spread": 0.11131 + }, + "katakwi-xlyx": { + "lat": 1.067052, + "lon": 34.029876, + "spread": 0.14696 + }, + "amuria-izaz": { + "lat": 1.978923, + "lon": 33.274247, + "spread": 0.11099 + }, + "kitgum-hetq": { + "lat": 3.138403, + "lon": 33.661353, + "spread": 0.17493 + }, + "amuru-fprn": { + "lat": 3.403077, + "lon": 31.52128, + "spread": 0.11985 + }, + "napak-yzyn": { + "lat": 2.256185, + "lon": 34.411435, + "spread": 0.13687 + }, + "nwoya-payo": { + "lat": 2.879915, + "lon": 32.59308, + "spread": 0.18349 + }, + "hoima-dcsv": { + "lat": 1.76003, + "lon": 31.65367, + "spread": 0.13129 + }, + "kasese-khsy": { + "lat": 0.4038, + "lon": 30.398574, + "spread": 0.15722 + }, + "mbarara-yqvb": { + "lat": -0.762413, + "lon": 30.22653, + "spread": 0.12582 + }, + "mbarara-mcue": { + "lat": -0.395562, + "lon": 31.051012, + "spread": 0.12691 + }, + "vinnytsya-yoxi": { + "lat": 48.801917, + "lon": 28.723495, + "spread": 0.18809 + }, + "volyn-irxk": { + "lat": 51.040211, + "lon": 25.049008, + "spread": 0.1655 + }, + "luhans-k-whxu": { + "lat": 49.069692, + "lon": 38.950142, + "spread": 0.22307 + }, + "dnipropetrovs-k-uszu": { + "lat": 48.25632, + "lon": 35.010126, + "spread": 0.17029 + }, + "donets-k-gvzw": { + "lat": 48.043039, + "lon": 37.576243, + "spread": 0.23505 + }, + "zhytomyr-iczk": { + "lat": 50.713661, + "lon": 28.4711, + "spread": 0.20587 + }, + "transcarpathia-qncy": { + "lat": 48.40058, + "lon": 23.168936, + "spread": 0.11879 + }, + "zaporizhzhya-cjcw": { + "lat": 47.110105, + "lon": 35.651798, + "spread": 0.18751 + }, + "ivano-frankivs-k-iocz": { + "lat": 48.629749, + "lon": 25.482868, + "spread": 0.18018 + }, + "kiev-yefc": { + "lat": 50.396434, + "lon": 30.479015, + "spread": 0.23185 + }, + "kirovohrad-gwfx": { + "lat": 48.53623, + "lon": 32.114982, + "spread": 0.1466 + }, + "l-viv-wbox": { + "lat": 49.689193, + "lon": 24.009593, + "spread": 0.19181 + }, + "mykolayiv-obnj": { + "lat": 47.319709, + "lon": 31.900521, + "spread": 0.15826 + }, + "odessa-kpqz": { + "lat": 46.522547, + "lon": 29.6779, + "spread": 0.29989 + }, + "poltava-usqb": { + "lat": 49.708346, + "lon": 33.920644, + "spread": 0.17921 + }, + "rivne-ekqw": { + "lat": 50.920471, + "lon": 26.104152, + "spread": 0.19063 + }, + "sumy-kcqr": { + "lat": 51.236615, + "lon": 34.163463, + "spread": 0.22538 + }, + "ternopil-whse": { + "lat": 49.292081, + "lon": 25.670011, + "spread": 0.16479 + }, + "kharkiv-mesc": { + "lat": 49.479522, + "lon": 36.360459, + "spread": 0.19049 + }, + "kherson-ybsa": { + "lat": 46.655473, + "lon": 33.549609, + "spread": 0.18539 + }, + "khmel-nyts-kyy-etsv": { + "lat": 49.461422, + "lon": 27.035438, + "spread": 0.17393 + }, + "cherkasy-wzvf": { + "lat": 49.278393, + "lon": 31.351302, + "spread": 0.17922 + }, + "chernihiv-cevi": { + "lat": 51.321538, + "lon": 32.07101, + "spread": 0.20172 + }, + "artigas-stkw": { + "lat": -30.588494, + "lon": -57.086394, + "spread": 0.09759 + }, + "cerro-largo-sjom": { + "lat": -32.676343, + "lon": -54.350267, + "spread": 0.18011 + }, + "durazno-qcxo": { + "lat": -32.945584, + "lon": -56.030758, + "spread": 0.10682 + }, + "florida-acct": { + "lat": -33.792583, + "lon": -55.892872, + "spread": 0.13148 + }, + "lavalleja-mdrw": { + "lat": -34.035594, + "lon": -54.886344, + "spread": 0.14934 + }, + "paysandu-ikte": { + "lat": -32.517287, + "lon": -57.5895, + "spread": 0.19458 + }, + "rocha-ymzo": { + "lat": -34.128447, + "lon": -53.991073, + "spread": 0.11517 + }, + "salto-aobp": { + "lat": -31.220812, + "lon": -57.112325, + "spread": 0.11185 + }, + "san-jose-qcby": { + "lat": -34.414891, + "lon": -56.366414, + "spread": 0.10915 + }, + "soriano-ixbv": { + "lat": -33.721763, + "lon": -57.255495, + "spread": 0.14496 + }, + "tacuarembo-yekg": { + "lat": -31.992248, + "lon": -55.660652, + "spread": 0.1995 + }, + "alaska-qfen": { + "lat": 60.884841, + "lon": -152.384322, + "spread": 1.66499 + }, + "alabama-uqeu": { + "lat": 31.494007, + "lon": -86.873396, + "spread": 0.35883 + }, + "arkansas-itew": { + "lat": 34.568861, + "lon": -91.2675, + "spread": 0.34889 + }, + "arizona-igee": { + "lat": 34.265346, + "lon": -113.910962, + "spread": 0.56759 + }, + "california-kikh": { + "lat": 36.865319, + "lon": -120.670529, + "spread": 0.94678 + }, + "colorado-wfkf": { + "lat": 38.99975, + "lon": -105.546075, + "spread": 0.4 + }, + "connecticut-apkc": { + "lat": 41.333327, + "lon": -72.87752, + "spread": 0.10527 + }, + "florida-gmqg": { + "lat": 28.63468, + "lon": -83.227578, + "spread": 0.58754 + }, + "georgia-uotf": { + "lat": 31.972981, + "lon": -82.27801, + "spread": 0.46312 + }, + "hawaii-mxvl": { + "lat": 19.563586, + "lon": -155.392921, + "spread": 0.12461 + }, + "iowa-cdyh": { + "lat": 41.860806, + "lon": -93.437611, + "spread": 0.31081 + }, + "idaho-slym": { + "lat": 45.689527, + "lon": -114.723954, + "spread": 0.61536 + }, + "illinois-syye": { + "lat": 39.18519, + "lon": -89.346467, + "spread": 0.45001 + }, + "indiana-ivyc": { + "lat": 38.58424, + "lon": -86.715055, + "spread": 0.33279 + }, + "kansas-ondv": { + "lat": 39.212358, + "lon": -96.113517, + "spread": 0.30003 + }, + "kentucky-abeb": { + "lat": 37.872675, + "lon": -85.938073, + "spread": 0.26476 + }, + "louisiana-wlgr": { + "lat": 30.146989, + "lon": -90.757066, + "spread": 0.40859 + }, + "massachusetts-ggid": { + "lat": 41.988458, + "lon": -70.928714, + "spread": 0.15842 + }, + "maryland-ecia": { + "lat": 38.887029, + "lon": -76.362872, + "spread": 0.18641 + }, + "maine-maiz": { + "lat": 44.679899, + "lon": -68.934792, + "spread": 0.41077 + }, + "michigan-gtil": { + "lat": 45.352622, + "lon": -86.198951, + "spread": 0.65634 + }, + "minnesota-qlig": { + "lat": 47.058585, + "lon": -93.596449, + "spread": 0.58694 + }, + "missouri-ykif": { + "lat": 38.214968, + "lon": -91.644705, + "spread": 0.46326 + }, + "mississippi-scir": { + "lat": 32.490902, + "lon": -90.421038, + "spread": 0.35691 + }, + "montana-abiq": { + "lat": 45.922403, + "lon": -113.299289, + "spread": 0.46156 + }, + "north-carolina-ozkp": { + "lat": 35.378116, + "lon": -77.904451, + "spread": 0.27406 + }, + "north-dakota-ehku": { + "lat": 47.575385, + "lon": -98.549308, + "spread": 0.30509 + }, + "nebraska-wikv": { + "lat": 41.525269, + "lon": -97.343515, + "spread": 0.30003 + }, + "new-hampshire-koki": { + "lat": 43.922058, + "lon": -71.625316, + "spread": 0.1847 + }, + "new-jersey-wlkg": { + "lat": 39.963456, + "lon": -74.691284, + "spread": 0.16503 + }, + "new-mexico-awkn": { + "lat": 32.886889, + "lon": -106.877344, + "spread": 0.56724 + }, + "nevada-uekc": { + "lat": 37.53957, + "lon": -115.86571, + "spread": 0.59588 + }, + "new-york-cqkt": { + "lat": 42.759764, + "lon": -75.164036, + "spread": 0.41962 + }, + "ohio-kgmu": { + "lat": 39.626062, + "lon": -82.425332, + "spread": 0.3905 + }, + "oklahoma-cimv": { + "lat": 34.468744, + "lon": -97.492075, + "spread": 0.33524 + }, + "oregon-qznu": { + "lat": 44.600187, + "lon": -122.537629, + "spread": 0.42359 + }, + "pennsylvania-ylkc": { + "lat": 40.885068, + "lon": -76.215458, + "spread": 0.28167 + }, + "south-carolina-izro": { + "lat": 33.164666, + "lon": -80.574095, + "spread": 0.31844 + }, + "south-dakota-oerr": { + "lat": 43.866148, + "lon": -98.111196, + "spread": 0.34237 + }, + "tennessee-geup": { + "lat": 35.917813, + "lon": -86.462837, + "spread": 0.17023 + }, + "texas-mguh": { + "lat": 29.787065, + "lon": -98.104595, + "spread": 1.0656 + }, + "utah-whas": { + "lat": 40.0004, + "lon": -111.379283, + "spread": 0.49962 + }, + "virginia-oedb": { + "lat": 37.663383, + "lon": -77.946439, + "spread": 0.29018 + }, + "vermont-ujdo": { + "lat": 43.98448, + "lon": -72.690105, + "spread": 0.19327 + }, + "washington-exgl": { + "lat": 47.49733, + "lon": -122.805696, + "spread": 0.34262 + }, + "wisconsin-ekgt": { + "lat": 45.041825, + "lon": -89.746942, + "spread": 0.48119 + }, + "west-virginia-ivga": { + "lat": 38.81878, + "lon": -80.465098, + "spread": 0.3442 + }, + "wyoming-ajgj": { + "lat": 43, + "lon": -107.5528, + "spread": 0.40003 + }, + "bukhoro-udcy": { + "lat": 40.740379, + "lon": 62.900877, + "spread": 0.30349 + }, + "ferghana-ammo": { + "lat": 40.807676, + "lon": 71.651272, + "spread": 0.14063 + }, + "jizzakh-uoed": { + "lat": 40.337554, + "lon": 68.016374, + "spread": 0.17147 + }, + "navoi-ahpf": { + "lat": 41.225391, + "lon": 64.81874, + "spread": 0.42585 + }, + "kashkadarya-uvou": { + "lat": 39.082535, + "lon": 66.435922, + "spread": 0.15893 + }, + "karakalpakstan-cuod": { + "lat": 42.472064, + "lon": 59.489836, + "spread": 0.45728 + }, + "samarkand-ansu": { + "lat": 39.81127, + "lon": 66.451596, + "spread": 0.13333 + }, + "surkhandarya-usso": { + "lat": 38.178557, + "lon": 67.577133, + "spread": 0.18179 + }, + "tashkent-awvo": { + "lat": 41.320166, + "lon": 69.958413, + "spread": 0.20927 + }, + "anzoategui-mrqa": { + "lat": 9.068124, + "lon": -64.371856, + "spread": 0.26015 + }, + "apure-gtqb": { + "lat": 7.13878, + "lon": -69.584349, + "spread": 0.1991 + }, + "barinas-uwqd": { + "lat": 8.09599, + "lon": -69.973629, + "spread": 0.17322 + }, + "bolivar-oyqe": { + "lat": 6.256057, + "lon": -63.377581, + "spread": 0.48872 + }, + "cojedes-abqg": { + "lat": 9.468057, + "lon": -68.362467, + "spread": 0.12214 + }, + "falcon-scqh": { + "lat": 11.17713, + "lon": -69.728918, + "spread": 0.18941 + }, + "guarico-meqi": { + "lat": 9.063073, + "lon": -66.414568, + "spread": 0.23833 + }, + "lara-ggqj": { + "lat": 9.833767, + "lon": -69.828333, + "spread": 0.17796 + }, + "merida-yhqk": { + "lat": 8.64316, + "lon": -71.28848, + "spread": 0.14102 + }, + "miranda-ujql": { + "lat": 10.189557, + "lon": -66.729378, + "spread": 0.12276 + }, + "monagas-kkqm": { + "lat": 9.499213, + "lon": -63.026997, + "spread": 0.1929 + }, + "portuguesa-epqi": { + "lat": 9.104948, + "lon": -69.351159, + "spread": 0.16004 + }, + "sucre-qsqk": { + "lat": 10.449089, + "lon": -63.284907, + "spread": 0.06989 + }, + "tachira-kuql": { + "lat": 7.974365, + "lon": -72.05034, + "spread": 0.11736 + }, + "yaracuy-yxqn": { + "lat": 10.175363, + "lon": -68.370471, + "spread": 0.09128 + }, + "zulia-szqo": { + "lat": 9.826112, + "lon": -72.038815, + "spread": 0.22422 + }, + "venezuela-oxzs": { + "lat": 15.695, + "lon": -63.6377, + "spread": 0.0002 + }, + "delta-amacuro-wdqr": { + "lat": 8.960967, + "lon": -61.424877, + "spread": 0.22679 + }, + "amazonas-qfqs": { + "lat": 3.624884, + "lon": -65.839959, + "spread": 0.45102 + }, + "lao-cai-suzu": { + "lat": 22.148533, + "lon": 104.29257, + "spread": 0.14948 + }, + "son-la-amzp": { + "lat": 21.11793, + "lon": 104.041275, + "spread": 0.14595 + }, + "tuyen-quang-kpzr": { + "lat": 22.399508, + "lon": 105.518743, + "spread": 0.20382 + }, + "lang-son-yfzb": { + "lat": 21.451185, + "lon": 106.916358, + "spread": 0.1524 + }, + "thanh-hoa-okef": { + "lat": 20.115956, + "lon": 105.011433, + "spread": 0.13683 + }, + "nghe-an-ifec": { + "lat": 19.337021, + "lon": 104.922868, + "spread": 0.14082 + }, + "quang-binh-wpei": { + "lat": 17.899238, + "lon": 106.035346, + "spread": 0.17927 + }, + "quang-nam-cneh": { + "lat": 16.103713, + "lon": 107.689158, + "spread": 0.22057 + }, + "kon-tum-ywew": { + "lat": 14.871736, + "lon": 108.183539, + "spread": 0.15074 + }, + "gia-lai-kyhl": { + "lat": 13.84617, + "lon": 108.445196, + "spread": 0.16814 + }, + "djak-lak-qdho": { + "lat": 13.010838, + "lon": 108.692379, + "spread": 0.15467 + }, + "lam-djong-iuhi": { + "lat": 11.913716, + "lon": 108.471196, + "spread": 0.22235 + }, + "long-an-irqd": { + "lat": 10.370142, + "lon": 106.230096, + "spread": 0.12703 + }, + "kien-giang-qaqj": { + "lat": 9.861835, + "lon": 105.23506, + "spread": 0.193154 + }, + "binh-phuoc-qysb": { + "lat": 11.179904, + "lon": 106.84168, + "spread": 0.18161 + }, + "djien-bien-uwxm": { + "lat": 21.963611, + "lon": 103.054822, + "spread": 0.16831 + }, + "ha-noi-shnb": { + "lat": 20.844337, + "lon": 105.989417, + "spread": 0.17389 + }, + "sanma-yzht": { + "lat": -15.183431, + "lon": 166.937262, + "spread": 0.0691 + }, + "tafea-wskq": { + "lat": -18.819862, + "lon": 169.194175, + "spread": 0.03493 + }, + "torba-ckkb": { + "lat": -14.964199, + "lon": 166.930455, + "spread": 0.052451 + }, + "abyan-oexn": { + "lat": 13.724735, + "lon": 45.954753, + "spread": 0.13637 + }, + "dhamar-utko": { + "lat": 14.326341, + "lon": 44.563189, + "spread": 0.14952 + }, + "hadramawt-itbu": { + "lat": 15.996135, + "lon": 49.001535, + "spread": 0.49517 + }, + "hajjah-uqbs": { + "lat": 16.118806, + "lon": 43.6253, + "spread": 0.11743 + }, + "al-hudaydah-aubl": { + "lat": 14.902293, + "lon": 43.353983, + "spread": 0.13741 + }, + "al-jawf-wngz": { + "lat": 16.563225, + "lon": 45.429506, + "spread": 0.16393 + }, + "lahij-saqb": { + "lat": 13.211836, + "lon": 44.642082, + "spread": 0.14252 + }, + "ma-rib-oyto": { + "lat": 15.402969, + "lon": 45.333794, + "spread": 0.16513 + }, + "al-mahrah-gatz": { + "lat": 16.354043, + "lon": 51.431729, + "spread": 0.28979 + }, + "sa-dah-kvqv": { + "lat": 17.088312, + "lon": 43.737744, + "spread": 0.09065 + }, + "shabwah-mpqr": { + "lat": 14.591473, + "lon": 47.190435, + "spread": 0.20424 + }, + "sana-a-ylqp": { + "lat": 15.322377, + "lon": 44.373709, + "spread": 0.13008 + }, + "ta-izz-ctzr": { + "lat": 13.288358, + "lon": 43.961153, + "spread": 0.12187 + }, + "eastern-cape-oqpg": { + "lat": -31.645732, + "lon": 26.416188, + "spread": 0.42271 + }, + "free-state-idrj": { + "lat": -28.432491, + "lon": 27.270075, + "spread": 0.40141 + }, + "gauteng-eruj": { + "lat": -26.067256, + "lon": 28.139859, + "spread": 0.17787 + }, + "limpopo-kvkx": { + "lat": -24.303428, + "lon": 29.265364, + "spread": 0.32641 + }, + "mpumalanga-yrnu": { + "lat": -25.675276, + "lon": 29.890549, + "spread": 0.35473 + }, + "northern-cape-glpq": { + "lat": -29.122056, + "lon": 21.45176, + "spread": 0.81844 + }, + "kwazulu-natal-capj": { + "lat": -28.84317, + "lon": 30.262632, + "spread": 0.40341 + }, + "north-west-ispe": { + "lat": -26.330506, + "lon": 25.50719, + "spread": 0.34772 + }, + "western-cape-qngk": { + "lat": -32.992196, + "lon": 20.716785, + "spread": 0.43513 + }, + "western-cutu": { + "lat": -15.340483, + "lon": 24.061139, + "spread": 0.3568 + }, + "central-svtv": { + "lat": -14.13063, + "lon": 28.6134, + "spread": 0.37438 + }, + "eastern-mxtw": { + "lat": -13.200219, + "lon": 32.304603, + "spread": 0.3338 + }, + "luapula-imtp": { + "lat": -10.522602, + "lon": 29.304837, + "spread": 0.21044 + }, + "northern-eotq": { + "lat": -9.955745, + "lon": 30.547382, + "spread": 0.32436 + }, + "north-western-uptr": { + "lat": -12.905452, + "lon": 25.076538, + "spread": 0.38417 + }, + "southern-orts": { + "lat": -16.449245, + "lon": 26.621027, + "spread": 0.27703 + }, + "copperbelt-gftb": { + "lat": -13.03032, + "lon": 27.878116, + "spread": 0.16766 + }, + "lusaka-ygtc": { + "lat": -15.404437, + "lon": 28.944854, + "spread": 0.13028 + }, + "muchinga-kova": { + "lat": -11.137728, + "lon": 31.871776, + "spread": 0.38921 + }, + "manicaland-czzx": { + "lat": -19.146864, + "lon": 32.441605, + "spread": 0.18441 + }, + "mashonaland-central-mczz": { + "lat": -16.678597, + "lon": 30.982987, + "spread": 0.2103 + }, + "mashonaland-east-afzb": { + "lat": -18.164198, + "lon": 31.350228, + "spread": 0.25908 + }, + "midlands-cmzf": { + "lat": -19.193984, + "lon": 29.559106, + "spread": 0.28504 + }, + "matabeleland-north-otzk": { + "lat": -18.943456, + "lon": 27.700608, + "spread": 0.34717 + }, + "matabeleland-south-qdzj": { + "lat": -20.678607, + "lon": 28.801281, + "spread": 0.28573 + }, + "masvingo-wizm": { + "lat": -20.57301, + "lon": 30.88864, + "spread": 0.26805 + }, + "mashonaland-west-ojzn": { + "lat": -17.269067, + "lon": 29.889591, + "spread": 0.29591 + } +} diff --git a/scripts/build-region-centroids.mjs b/scripts/build-region-centroids.mjs new file mode 100644 index 0000000..030cca5 --- /dev/null +++ b/scripts/build-region-centroids.mjs @@ -0,0 +1,132 @@ +/** + * scripts/build-region-centroids.mjs — build the precomputed region-centroid + * manifest: `public/region-centroids.json` = `{ "": { "lat": , "lon": , "spread": }, ... }`. + * + * For every sub-region (region key, see `public/region-keys.json`) this records + * the point the LLM's map markers (v0.27, #44) will be anchored to: the + * **centroid of the region's largest-area part** (a MultiPolygon's biggest + * island/lake-piece rather than an area-weighted average of all parts), plus + * a `spread` value ≈ 0.1 × the smallest bbox dimension of that part (in + * degrees) — the engine scatters markers of one region within ±(spread/2) + * around the centroid using a deterministic per-marker offset, so several + * markers of the same region do not stack on one pixel. + * + * Method — plain turf on the pre-generated geo file (turf is allowed in .mjs + * scripts; it is deliberately kept OUT of the TS typecheck program, see the + * v0.17 note). For each feature we (1) split a MultiPolygon into its parts + * (a plain Polygon is its own single part), (2) pick the part with the + * greatest `@turf/area`, (3) take `@turf/centroid` of that part and + * `@turf/bbox` of that part, and (4) round everything to 6 decimal places + * (≈ 0.1 m at this scale — far below the map's visual precision). + * + * Output contract (asserted by scripts/region-centroids.test.ts): + * - one entry for **every** region key, keyed identically to + * `public/region-keys.json` (same key set, 1,800 entries); + * - each centroid lies inside its region's (largest part's) bbox; + * - `spread` is a non-negative number of degrees (0 for degenerate parts); + * - values are rounded to at most 6 decimal places. + * + * Deterministic + idempotent: the input files are fixed, turf is a pure + * geometric computation, the output keeps the geo file's (stable) feature + * order and rounds to fixed precision — so a re-run rewrites a byte-identical + * file (the test re-runs this script and compares bytes). + * + * Usage: `node scripts/build-region-centroids.mjs` (or `npm run build:region-centroids`). + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import area from '@turf/area'; +import bbox from '@turf/bbox'; +import centroid from '@turf/centroid'; +import { feature } from '@turf/helpers'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..'); + +const round6 = (v) => Math.round(v * 1e6) / 1e6; + +// --- load the geo + region-key manifest (plain parse; keys join feature->key) -- +const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8')); +const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8')); + +/** + * The polygon parts of a geometry: a MultiPolygon's individual polygons, a + * plain Polygon as its own single part, anything else as an empty list (the + * caller falls back to the whole-feature centroid). + */ +function partsOf(geometry) { + if (geometry?.type === 'MultiPolygon') return geometry.coordinates ?? []; + if (geometry?.type === 'Polygon') return [geometry.coordinates]; + return []; +} + +const manifest = {}; +let skipped = 0; +for (const f of geo.features ?? []) { + const id = f.id == null ? '' : String(f.id); + const key = idToKey[id]; + if (!key) continue; // a geo feature with no manifest entry is skipped + const parts = partsOf(f.geometry); + if (parts.length === 0) { + // Degenerate geometry (a point/line region): anchor at the feature's own + // centroid. Can't happen with the current 1,800-polygon dataset, but the + // fallback keeps the manifest complete if the generator ever changes. + skipped += 1; + const c = centroid(feature(f.geometry)); + const b = bbox(feature(f.geometry)); + manifest[key] = { + lat: round6(c.geometry.coordinates[1]), + lon: round6(c.geometry.coordinates[0]), + spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1), + }; + continue; + } + // Pick the part with the greatest area (km²) and anchor to IT, so a + // multi-part region's markers cluster on its main mass, not between parts. + let best = 0; + let bestArea = -1; + for (let i = 0; i < parts.length; i++) { + const a = area(feature({ type: 'Polygon', coordinates: parts[i] })); + if (a > bestArea) { + bestArea = a; + best = i; + } + } + const part = feature({ type: 'Polygon', coordinates: parts[best] }); + const c = centroid(part); + const b = bbox(part); + manifest[key] = { + lat: round6(c.geometry.coordinates[1]), + lon: round6(c.geometry.coordinates[0]), + spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1), + }; +} + +// --- hard assertions (the test re-checks these against the committed file) --- +const expectedKeys = Object.values(idToKey); +if (Object.keys(manifest).length !== expectedKeys.length) { + throw new Error( + `expected ${expectedKeys.length} centroid entries (one per region key), got ${Object.keys(manifest).length}`, + ); +} +for (const k of expectedKeys) { + if (!(k in manifest)) throw new Error(`missing centroid for region key ${JSON.stringify(k)}`); +} +for (const [k, v] of Object.entries(manifest)) { + if (!Number.isFinite(v.lat) || !Number.isFinite(v.lon) || !Number.isFinite(v.spread)) { + throw new Error(`non-finite centroid for ${k}: ${JSON.stringify(v)}`); + } + if (v.spread < 0) throw new Error(`negative spread for ${k}: ${v.spread}`); +} + +// --- write idempotently (stable order = insertion order = geo file order) --- +writeFileSync(path.join(root, 'public', 'region-centroids.json'), JSON.stringify(manifest, null, 2) + '\n'); + +// --- summary ------------------------------------------------------------------ +const spreads = Object.values(manifest).map((v) => v.spread); +console.log( + `[build-region-centroids] wrote public/region-centroids.json — ${Object.keys(manifest).length} region keys` + + (skipped ? ` (${skipped} non-polygon fallbacks)` : '') + + `, spread min/max ${Math.min(...spreads)} / ${Math.max(...spreads)} deg`, +); diff --git a/scripts/region-centroids.test.ts b/scripts/region-centroids.test.ts new file mode 100644 index 0000000..bb9bd86 --- /dev/null +++ b/scripts/region-centroids.test.ts @@ -0,0 +1,158 @@ +/** + * scripts/region-centroids.test.ts — validation for the committed + * `public/region-centroids.json` (built by `scripts/build-region-centroids.mjs`). + * + * The manifest maps each region key to the anchor point a map marker (v0.27, + * #44) is placed at: the centroid of the region's largest-area part, plus a + * `spread` (≈ 0.1 × that part's smallest bbox dimension, in degrees) that the + * engine uses to scatter multiple markers of one region around the centroid. + * It is a load-bearing data artifact for the marker placement path — this test + * pins its contract. + * + * Deliberately dependency-free: it reads the JSON with plain `fs`/`JSON.parse` + * and computes bbox checks by iterating coordinates directly (no `@turf`/ + * geojson types, which would pull `@types/geojson` into the tsc program and + * break `WorldMap.tsx` — see the v0.17 note). Mirrors the style of + * `scripts/region-adjacency.test.ts`; the determinism test re-runs the (turf + * `.mjs`) builder and compares bytes, matching that the builder is idempotent. + */ +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..'); + +function readJson(rel: string): Record { + return JSON.parse(readFileSync(path.join(root, rel), 'utf8')); +} + +interface CentroidEntry { + lat: number; + lon: number; + spread: number; +} + +/** All [lon, lat] coordinate pairs of a Polygon / MultiPolygon geometry. */ +function allCoords(geometry: { type?: string; coordinates?: unknown }): Array<[number, number]> { + const c = geometry?.coordinates as + | Array> + | Array>> + | undefined; + if (!c) return []; + if (geometry?.type === 'Polygon') { + const rings = c as Array>; + const out: Array<[number, number]> = []; + for (const ring of rings) for (const p of ring) out.push(p); + return out; + } + if (geometry?.type === 'MultiPolygon') { + const polys = c as Array>>; + const out: Array<[number, number]> = []; + for (const poly of polys) for (const ring of poly) for (const p of ring) out.push(p); + return out; + } + return []; +} + +/** The bounding box [minLon, minLat, maxLon, maxLat] of a geometry. */ +function bboxOf(geometry: { type?: string; coordinates?: unknown }): [number, number, number, number] { + let minLon = Infinity; + let minLat = Infinity; + let maxLon = -Infinity; + let maxLat = -Infinity; + for (const [x, y] of allCoords(geometry)) { + if (x < minLon) minLon = x; + if (y < minLat) minLat = y; + if (x > maxLon) maxLon = x; + if (y > maxLat) maxLat = y; + } + return [minLon, minLat, maxLon, maxLat]; +} + +describe('region-centroids.json', () => { + const centroids = readJson('public/region-centroids.json') as Record; + const manifest = readJson('public/region-keys.json') as Record; + const geo = readJson('public/regions.geo.json') as { + features?: Array<{ id?: string | number; geometry?: { type?: string; coordinates?: unknown } }>; + }; + const keys = Object.values(manifest); + + // featureId -> feature, then regionKey -> feature (via the key manifest). + const featureByKey = new Map(); + for (const f of geo.features ?? []) { + const key = manifest[String(f.id)]; + if (key) featureByKey.set(key, f); + } + + it('has exactly one entry per region key (same key set as region-keys.json)', () => { + expect(new Set(Object.keys(centroids))).toEqual(new Set(keys)); + expect(Object.keys(centroids)).toHaveLength(1800); + }); + + it('every entry is a finite { lat, lon, spread } with a non-negative spread, in world bounds', () => { + for (const [k, v] of Object.entries(centroids)) { + expect(Number.isFinite(v.lat), `non-finite lat for ${k}`).toBe(true); + expect(Number.isFinite(v.lon), `non-finite lon for ${k}`).toBe(true); + expect(Number.isFinite(v.spread), `non-finite spread for ${k}`).toBe(true); + expect(v.spread >= 0, `negative spread for ${k}`).toBe(true); + expect(v.lat >= -90 && v.lat <= 90, `lat out of range for ${k}: ${v.lat}`).toBe(true); + expect(v.lon >= -180 && v.lon <= 180, `lon out of range for ${k}: ${v.lon}`).toBe(true); + } + }); + + it('each centroid lies inside its region feature bounding box', () => { + // The recorded point is the centroid of the LARGEST part, which is a subset + // of the whole feature, so it must fall inside the feature's overall bbox. + let checked = 0; + for (const [key, c] of Object.entries(centroids)) { + const feat = featureByKey.get(key); + if (!feat?.geometry) continue; + const [minLon, minLat, maxLon, maxLat] = bboxOf(feat.geometry); + const eps = 1e-4; // allow for the 6-dp rounding in the builder + expect( + c.lat >= minLat - eps && c.lat <= maxLat + eps, + `lat ${c.lat} outside bbox [${minLat}, ${maxLat}] for ${key}`, + ).toBe(true); + expect( + c.lon >= minLon - eps && c.lon <= maxLon + eps, + `lon ${c.lon} outside bbox [${minLon}, ${maxLon}] for ${key}`, + ).toBe(true); + checked++; + } + expect(checked).toBe(1800); + }); + + it('spot-checks a couple of well-known anchors land in the right place', () => { + // Region keys are sub-national (states/provinces), so we spot-check a few + // whose geography is unambiguous. A gross error in the centroid method + // (e.g. averaging across all parts of a large multi-part region, or a + // lon/lat swap) would land one of these far off. + const near = (k: string, lat: number, lon: number, tol: number) => { + const c = centroids[k]; + expect(c, `missing centroid for ${k}`).toBeDefined(); + expect(Math.abs(c!.lat - lat)).toBeLessThan(tol); + expect(Math.abs(c!.lon - lon)).toBeLessThan(tol); + }; + near('ain-lqtt', 46.16, 4.8, 0.5); // FR-01 — eastern France + near('california-kikh', 36.87, -120.67, 0.7); // US-CA — central California + near('alberta-zopq', 52.09, -116.94, 1.5); // CA-AB — Alberta (large province) + near('acre-ekzw', -9.49, -71.11, 0.5); // BR-AC — western Brazil + }); + + it('is deterministic: re-running the builder rewrites a byte-identical file', () => { + const file = path.join(root, 'public', 'region-centroids.json'); + const before = readFileSync(file, 'utf8'); + // The builder is cwd-independent (it resolves the repo root from its own + // location) and idempotent: same input files + pure turf + fixed rounding + // + stable insertion order → identical bytes. + execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-centroids.mjs')], { + cwd: root, + stdio: 'pipe', + }); + const after = readFileSync(file, 'utf8'); + expect(after).toBe(before); + }); +}); diff --git a/server/db.test.ts b/server/db.test.ts index f1176dd..6bca890 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -25,6 +25,7 @@ function makeState(id: string): GameState { version: 1, unowned: [], pendingNationOps: [], + markers: [], }; } @@ -97,6 +98,17 @@ describe('openDb', () => { db.close(); }); + it('(v0.27 #44) a legacy row saved before the markers field existed loads with it defaulted to []', () => { + const db = openDb(':memory:'); + const legacy = makeState('legacy'); + delete (legacy as { markers?: unknown[] }).markers; // a pre-v0.27 save (field absent) + db.save(legacy); + const loaded = db.load('legacy'); + expect(loaded).not.toBeNull(); + expect(loaded!.markers).toEqual([]); + db.close(); + }); + it('delete removes a game by id and reports absence otherwise', () => { const db = openDb(':memory:'); db.save(makeState('a')); diff --git a/server/db.ts b/server/db.ts index eb54a4a..395521c 100644 --- a/server/db.ts +++ b/server/db.ts @@ -46,6 +46,8 @@ export function openDb(filePath: string): Db { // (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game // has none awaiting confirm/reject. if (state.pendingNationOps === undefined) state.pendingNationOps = []; + // (v0.27, #44) Same for map markers: a legacy game has none placed. + if (state.markers === undefined) state.markers = []; return state; } catch { return null; diff --git a/server/engine.test.ts b/server/engine.test.ts index 0d54139..acca724 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { GameState, Preset, RoundItem } from '../shared/types'; -import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol'; +import type { GameState, MarkerType, Preset, RoundItem } from '../shared/types'; +import { makeEvent, markerId, MAX_MARKERS, type MarkerSpec, type NationOp, type TurnResult } from '../shared/protocol'; import type { LlmClient, ToolDefinition, WireMessage } from './llm'; import { makeStubLlmClient } from './stub'; import { @@ -21,6 +21,7 @@ import { removeRoundItem, resolveNationRef, runTurn, + validateMarkerOps, validateNationOps, validateTurnResult, } from './engine'; @@ -87,6 +88,7 @@ function baseState(): GameState { version: 1, unowned: [], pendingNationOps: [], + markers: [], }; } @@ -524,7 +526,7 @@ describe('validateNationOps (v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], + nationOps: [{ kind: 'rename', nationRef: 'France', to: 'French Republic' }], markers: { add: [], remove: [] }, }); expect(withOps).not.toBeNull(); expect(withOps!.nationOps).toEqual([{ kind: 'rename', nationRef: 'France', to: 'French Republic' }]); @@ -545,7 +547,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] }, }); expect(next.nations.de.name).toBe('DE'); // still the old name expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); @@ -565,7 +567,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], // new proposal this turn + nationOps: [{ kind: 'rename', nationRef: 'us', to: 'USA' }], markers: { add: [], remove: [] }, // new proposal this turn }); expect(next.pendingNationOps).toEqual([ { kind: 'rename', nationRef: 'de', to: 'Reich' }, // the old one survived @@ -583,7 +585,7 @@ describe('applyTurnResult (nation-op proposals, v0.23 #16 P4a)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] }, }); expect(next.pendingNationOps).toEqual([{ kind: 'rename', nationRef: 'de', to: 'Reich' }]); }); @@ -1039,7 +1041,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], + nationOps: [], markers: { add: [], remove: [] }, }; applyTurnResult(state, result); expect(JSON.stringify(state)).toBe(snapshot); @@ -1055,7 +1057,7 @@ describe('applyTurnResult', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], + nationOps: [], markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); expect(next.pending).toEqual([]); @@ -1077,7 +1079,7 @@ describe('applyTurnResult', () => { // is not a real key at all. Both are proposed as a gain by DE. territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }], unowned: [], - nationOps: [], + nationOps: [], markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); // the valid key was gained (taken from its sole previous owner); the bogus one was ignored @@ -1102,7 +1104,7 @@ describe('applyTurnResult', () => { // released (not in state.unowned), so it does not enter GameState.unowned. territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], unowned: [], - nationOps: [], + nationOps: [], markers: { add: [], remove: [] }, }; const next = applyTurnResult(state, result); // no validKeys -> nothing filtered expect(next.nations.de.territory).toContain('wales-bbbb'); @@ -1119,7 +1121,7 @@ describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => { stateChanges: [], relations: [], unowned: [], - nationOps: [], + nationOps: [], markers: { add: [], remove: [] }, }; it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => { @@ -1208,7 +1210,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [], + nationOps: [], markers: { add: [], remove: [] }, }; it('a release removes the key from its sole owner and marks it in GameState.unowned', () => { @@ -1323,7 +1325,7 @@ describe('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => { it('a lone release is meaningful (it survives the retry/nudge path)', () => { expect( - isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [] }), + isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'], nationOps: [], markers: { add: [], remove: [] } }), ).toBe(true); }); @@ -1630,9 +1632,9 @@ describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => { describe('isMeaningful', () => { it('is true with an event, false when everything is empty', () => { - expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(true); - expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [] })).toBe(false); + expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(true); + expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], markers: { add: [], remove: [] } })).toBe(false); }); it('(v0.23 #16 P4a) a turn that only proposes a nation-op is meaningful (it survives the retry/nudge path)', () => { @@ -1644,8 +1646,265 @@ describe('isMeaningful', () => { relations: [], territoryChanges: [], unowned: [], - nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], + nationOps: [{ kind: 'rename', nationRef: 'de', to: 'Reich' }], markers: { add: [], remove: [] }, + }), + ).toBe(true); + }); + + it('(v0.27 #44) a turn that only adds a marker is meaningful (it survives the retry/nudge path)', () => { + expect( + isMeaningful({ + date: 'x', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + markers: { add: [{ type: 'city', name: 'Lyon', regionKey: 'rhone-rhne' }], remove: [] }, }), ).toBe(true); }); }); + +describe('markers (point placement, v0.27 #44)', () => { + // a deterministic centroid for one region key, undefined for everything else + const centroidOf = (key: string) => + key === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined; + + // build a MarkerSpec from a (possibly invalid, for the guard tests) type string + const mk = (type: string, name: string, regionKey: string): MarkerSpec => + ({ type: type as MarkerType, name, regionKey }); + + it('adds a valid marker with a stable id, a jittered position within the spread, the region owner, and the placed turn', () => { + const state = baseState(); + state.turn = 4; // placedTurn should equal the resolved turn + state.nations.uk.territory = ['GBR', 'moselle-rygy']; // the region is owned by uk + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + markers: { add: [mk('city', 'Strasbourg', 'moselle-rygy')], remove: [] }, + }; + const next = applyTurnResult(state, result, undefined, centroidOf); + expect(next.markers).toHaveLength(1); + const m = next.markers[0]; + expect(m.id).toBe(markerId('city', 'Strasbourg', 'moselle-rygy')); + expect(m.type).toBe('city'); + expect(m.name).toBe('Strasbourg'); + expect(m.regionKey).toBe('moselle-rygy'); + expect(m.ownerNationId).toBe('uk'); // the region's sole owner + expect(m.placedTurn).toBe(4); + // the position is the centroid jittered within ±spread/2 + expect(m.lat).toBeGreaterThanOrEqual(47.5); + expect(m.lat).toBeLessThanOrEqual(48.5); + expect(m.lon).toBeGreaterThanOrEqual(5.5); + expect(m.lon).toBeLessThanOrEqual(6.5); + }); + + it('is idempotent: re-adding the same (type, name, regionKey) does not duplicate the marker', () => { + const state = baseState(); + state.nations.uk.territory = ['moselle-rygy']; + const add = [mk('city', 'Strasbourg', 'moselle-rygy')]; + const first = applyTurnResult(state, { + date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [], + unowned: [], nationOps: [], markers: { add, remove: [] }, + }, undefined, centroidOf); + expect(first.markers).toHaveLength(1); + // a second turn proposes the exact same marker again + const second = applyTurnResult(first, { + date: '1939-09-05', events: [], stateChanges: [], relations: [], territoryChanges: [], + unowned: [], nationOps: [], markers: { add, remove: [] }, + }, undefined, centroidOf); + expect(second.markers).toHaveLength(1); + expect(second.markers[0].id).toBe(first.markers[0].id); // stable id, no second copy + }); + + it('applyTurnResult drops a new add whose region is unknown or has no centroid, but keeps a valid one', () => { + const state = baseState(); + state.nations.uk.territory = ['moselle-rygy']; + // validKeys knows two keys; the centroid resolves only the first. + const validKeys = new Set(['moselle-rygy', 'other-key']); + const centroidOf2 = (k: string) => + k === 'moselle-rygy' ? { lat: 48, lon: 6, spread: 1 } : undefined; + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + markers: { + add: [ + { type: 'city', name: 'Good', regionKey: 'moselle-rygy' }, // valid + centroid → kept + { type: 'city', name: 'NoCent', regionKey: 'other-key' }, // in validKeys, no centroid → dropped + { type: 'city', name: 'Unknown', regionKey: 'zzz-not-a-key' }, // not in validKeys → dropped + ], + remove: [], + }, + }; + const next = applyTurnResult(state, result, validKeys, centroidOf2); + expect(next.markers).toHaveLength(1); + expect(next.markers[0].name).toBe('Good'); + }); + + it('caps the total marker count at MAX_MARKERS, dropping the newest add that exceeds the cap', () => { + const state = baseState(); + // pre-seed the state with MAX_MARKERS - 1 distinct markers + const seeded: typeof state.markers = []; + for (let i = 0; i < MAX_MARKERS - 1; i++) { + const k = `seed-${i}`; + seeded.push({ + id: markerId('city', `S${i}`, k), + type: 'city', + name: `S${i}`, + regionKey: k, + lat: 0, lon: 0, + ownerNationId: null, + placedTurn: 1, + }); + } + state.markers = seeded; + // a permissive centroid: every region key resolves + const anyCentroid = () => ({ lat: 0, lon: 0, spread: 0 }); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [], + unowned: [], + nationOps: [], + markers: { + add: [ + { type: 'city', name: 'NewA', regionKey: 'new-a' }, + { type: 'city', name: 'NewB', regionKey: 'new-b' }, + ], + remove: [], + }, + }; + const next = applyTurnResult(state, result, undefined, anyCentroid); + expect(next.markers).toHaveLength(MAX_MARKERS); // capped, not MAX_MARKERS + 1 + expect(next.markers.some((m) => m.name === 'NewA')).toBe(true); // the 1st new one kept + expect(next.markers.some((m) => m.name === 'NewB')).toBe(false); // the 2nd (over cap) dropped + }); + + it('removes a marker by id; removing an absent id is a no-op', () => { + const state = baseState(); + state.nations.uk.territory = ['moselle-rygy']; + const idA = markerId('city', 'Strasbourg', 'moselle-rygy'); + const idB = markerId('port', 'Dunkirk', 'moselle-rygy'); + state.markers = [ + { id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 }, + { id: idB, type: 'port', name: 'Dunkirk', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 }, + ]; + const next = applyTurnResult(state, { + date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [], + unowned: [], nationOps: [], markers: { add: [], remove: [idA, 'zzzz-not-there'] }, + }, undefined, centroidOf); + expect(next.markers).toHaveLength(1); + expect(next.markers[0].id).toBe(idB); // only A removed; the bogus id was a no-op + }); + + it('a marker owner follows its region: released to no man\'s land → null; re-claimed → the new owner', () => { + const state = baseState(); + state.nations.uk.territory = ['moselle-rygy']; + const idA = markerId('city', 'Strasbourg', 'moselle-rygy'); + state.markers = [ + { id: idA, type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy', lat: 48, lon: 6, ownerNationId: 'uk', placedTurn: 1 }, + ]; + // 1) uk releases the region → the marker becomes ownerless + const released = applyTurnResult(state, { + date: '1939-09-03', events: [], stateChanges: [], relations: [], territoryChanges: [], + unowned: ['moselle-rygy'], nationOps: [], markers: { add: [], remove: [] }, + }, undefined, centroidOf); + expect(released.markers[0].ownerNationId).toBeNull(); + // 2) de re-claims the region → the marker's owner follows to de + const reclaimed = applyTurnResult(released, { + date: '1939-09-05', events: [], stateChanges: [], relations: [], + territoryChanges: [{ nation: 'de', gain: ['moselle-rygy'], lose: [] }], + unowned: [], nationOps: [], markers: { add: [], remove: [] }, + }, undefined, centroidOf); + expect(reclaimed.markers[0].ownerNationId).toBe('de'); + }); + + it('the user prompt carries the existing markers when provided (and omits the field otherwise)', () => { + const state = baseState(); + const info = { + id: markerId('city', 'Strasbourg', 'moselle-rygy'), + type: 'city' as MarkerType, + name: 'Strasbourg', + regionKey: 'moselle-rygy', + owner: 'uk', + }; + const payload = JSON.parse( + buildUserPrompt(state, [], undefined, undefined, undefined, [info]), + ) as { markers?: Array> }; + expect(payload.markers).toHaveLength(1); + expect(payload.markers![0].name).toBe('Strasbourg'); + expect(payload.markers![0].owner).toBe('uk'); + // a bare call (no markers arg) omits the field entirely + const bare = JSON.parse(buildUserPrompt(state, [])) as { markers?: unknown }; + expect(bare.markers).toBeUndefined(); + }); + + it('the system prompt documents the markers contract (field + placement rule + a type)', () => { + const prompt = buildSystemPrompt(preset); + expect(prompt).toMatch(/named points on the map/i); + expect(prompt).toMatch(/"markers"/); + expect(prompt).toMatch(/"add"/); + expect(prompt).toMatch(/"remove"/); + expect(prompt).toMatch(/gathering/); + expect(prompt).toMatch(/no man's land/); // owner recompute on release + }); + + it('validateMarkerOps cleans a raw markers field: drops bad types/empty names, dedupes, trims removes', () => { + const raw = { + add: [ + { type: 'city', name: 'X', regionKey: 'a' }, + { type: 'not-a-type', name: 'Y', regionKey: 'a' }, // invalid type -> dropped + { type: 'city', name: ' ', regionKey: 'a' }, // empty name -> dropped + { type: 'city', name: 'X', regionKey: 'a' }, // duplicate (same id) -> deduped + { type: 'port', name: 'Z', regionKey: 'b' }, // valid + ], + remove: ['id-1', 42, '', ' id-2 ', 'id-1'], // number/empty dropped, trimmed, deduped + }; + const out = validateMarkerOps(raw); + expect(out.add.map((m) => m.name).sort()).toEqual(['X', 'Z']); + expect(out.remove).toEqual(['id-1', 'id-2']); + }); + + it('validateMarkerOps returns empty ops for a non-object raw value (a string, an array, null)', () => { + for (const bad of ['oops', [{ type: 'city', name: 'X', regionKey: 'a' }], null, undefined]) { + expect(validateMarkerOps(bad)).toEqual({ add: [], remove: [] }); + } + }); + + it('runTurn wires the centroid through: a marker added over the LLM path is placed (owner = region owner)', async () => { + const result = JSON.stringify({ + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [{ nation: 'uk', gain: ['moselle-rygy'], lose: [] }], + unowned: [], + nationOps: [], + markers: { add: [{ type: 'city', name: 'Strasbourg', regionKey: 'moselle-rygy' }], remove: [] }, + }); + const { state } = await runTurn({ + llm: fakeLlm([result]), + preset, + state: baseState(), + items: [], + centroidOf, + }); + expect(state.markers).toHaveLength(1); + expect(state.markers[0].name).toBe('Strasbourg'); + expect(state.markers[0].ownerNationId).toBe('uk'); // uk gained the region this turn + }); +}); diff --git a/server/engine.ts b/server/engine.ts index 3b10df5..af386bb 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -7,6 +7,7 @@ import type { EventOutcome, EventType, GameState, + MapMarker, Nation, NationStats, Preset, @@ -18,9 +19,15 @@ import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types'; import { addDays, clampStat, + fnv1a32, isDateLike, + isMarkerType, makeEvent, + markerId, + MAX_MARKERS, nationForPrompt, + type MarkerInfo, + type MarkerSpec, type NationOp, type TurnEventSpec, type TurnResult, @@ -187,12 +194,14 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s ' {"kind":"split","nationRef":"","parts":[{"name":"","territory":[""]}]}', ' {"kind":"merge","nationRefs":["",""],"survivingRef":"","into":""}', ' ],', + ' "markers": {"add":[{"type":"","name":"","regionKey":""}],"remove":[""]}', '}', '', 'HARD CONSTRAINTS', '- Use only nation ids and region keys that exist in the provided state.', '- A region key identifies one sub-region; the "regions" table in the user message maps each key to " ()". Use those keys in territoryChanges and unowned.', '- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".', + '- Map markers ("markers") are named points on the map, anchored to a region key you own or that the narrative justifies: a capital (a nation\'s main seat of power — at most one per nation), major cities, factory complexes, army deployments, ports, fleet concentrations, rocket launch sites, or a gathering (a large political/social mass event that can enable a non-military takeover). Use only region keys from the "regions" table, keep each name under 40 characters, and place at most a handful of new markers per turn — only where the narrative justifies it. List a marker id from the user message\'s "markers" field in "remove" when it ceases to exist (a city razed, an army dispersed, a site destroyed).', '- Nation-management ops ("nationOps") are PROPOSALS: they only take effect if the player confirms them afterwards. Propose at most 1-2 per turn, only when the narrative justifies it, and narrate each one in "events".', '- Rename: add {"kind":"rename","nationRef":"","to":""} for a revolution, regime change or reunification that changes a nation\'s name.', '- Split: add {"kind":"split","nationRef":"","parts":[{"name":"","territory":[""]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.', @@ -232,6 +241,14 @@ export function buildUserPrompt( * Omitted entirely when undefined. */ noMansLand?: string[], + /** + * (v0.27, #44) The game's current map markers, as the model sees them: + * stable id, type, name, region key and current owner. Emitted verbatim + * (even when empty) so the model can reference a marker's id in + * `TurnResult.markers.remove` or leave an existing marker untouched. + * Omitted entirely when undefined. + */ + markers?: MarkerInfo[], ): string { const player = state.nations[state.playerNationId]; const playerName = player?.name ?? state.playerNationId; @@ -270,6 +287,7 @@ export function buildUserPrompt( if (regionReference) payload.regions = regionReference; if (frontier !== undefined) payload.frontier = frontier; if (noMansLand !== undefined) payload.unowned = noMansLand; + if (markers !== undefined) payload.markers = markers; return JSON.stringify(payload, null, 2); } @@ -393,6 +411,7 @@ export function validateTurnResult(input: unknown): TurnResult | null { territoryChanges: [], unowned: [], nationOps: [], + markers: { add: [], remove: [] }, }; if (Array.isArray(o.events)) { @@ -482,9 +501,51 @@ export function validateTurnResult(input: unknown): TurnResult | null { // list for the player to confirm/reject. result.nationOps = validateNationOps(o.nationOps); + // (v0.27, #44) Map-marker ops: new placements (validated here) and + // removals by stable id. Applied by applyTurnResult after the territory + // moves (a new marker's owner is the region's post-turn owner). + result.markers = validateMarkerOps(o.markers); + return result; } +/** + * (v0.27, #44) Validate the raw `markers` object of a turn into a safe + * `{ add, remove }` shape. Defensive like the rest of the parser — never + * throws. `add`: each spec needs a known marker type, a non-empty `name` + * (capped at 120 chars) and a non-empty `regionKey` (capped at 120 chars); + * specs whose derived id collides with an earlier one in the same batch + * collapse to the first (re-adding is idempotent). `remove`: deduped, + * non-empty marker ids (capped at 120 chars). + */ +export function validateMarkerOps(raw: unknown): { add: MarkerSpec[]; remove: string[] } { + const out: { add: MarkerSpec[]; remove: string[] } = { add: [], remove: [] }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + const o = raw as Record; + if (Array.isArray(o.add)) { + const seen = new Set(); + for (const e of o.add) { + if (!e || typeof e !== 'object') continue; + const s = e as Record; + if (!isMarkerType(s.type)) continue; + const name = str(s.name, 120); + const regionKey = str(s.regionKey, 120); + if (!name || !regionKey) continue; + const id = markerId(s.type, name, regionKey); + if (seen.has(id)) continue; + seen.add(id); + out.add.push({ type: s.type, name, regionKey }); + } + } + if (Array.isArray(o.remove)) { + for (const k of o.remove) { + const s = typeof k === 'string' ? k.trim() : ''; + if (s && !out.remove.includes(s)) out.remove.push(s.slice(0, 120)); + } + } + return out; +} + /** * A turn is "meaningful" if it produced at least an event, a stat change, a * no-man's-land release (v0.22 #16 P3: a release is a real world change even @@ -498,7 +559,10 @@ export function isMeaningful(result: TurnResult): boolean { result.events.length > 0 || result.stateChanges.length > 0 || result.unowned.length > 0 || - result.nationOps.length > 0 + result.nationOps.length > 0 || + // (v0.27, #44) a marker placed or removed this turn is a real world change. + result.markers.add.length > 0 || + result.markers.remove.length > 0 ); } @@ -516,13 +580,25 @@ function genericUnclearEvent(state: GameState): TurnEventSpec { /** * Apply a TurnResult to a GameState immutably: advance the date/turn, append - * events, apply stat changes (clamped), symmetric relations, and territory - * moves (including no-man's-land releases, v0.22 #16 P3). It also appends any - * proposed nation-ops to the pending list (v0.23 #16 P4a) without applying - * them — see the doc comment on `pendingNationOps` for why that list - * deliberately survives across turns. + * events, apply stat changes (clamped), symmetric relations, territory moves + * (including no-man's-land releases, v0.22 #16 P3) and map markers (v0.27 + * #44). It also appends any proposed nation-ops to the pending list (v0.23 + * #16 P4a) without applying them — see the doc comment on `pendingNationOps` + * for why that list deliberately survives across turns. + * + * `centroidOf` (v0.27, #44) resolves a region key to its precomputed centroid + * (`{ lat, lon, spread }`, from public/region-centroids.json via + * server/regions.ts). New markers are positioned at that centroid plus a small + * deterministic offset; a spec whose key has no known centroid is dropped. + * Without it no marker can be placed (all adds are dropped), which keeps the + * engine pure and lets tests inject their own data. */ -export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet): GameState { +export function applyTurnResult( + state: GameState, + result: TurnResult, + validKeys?: ReadonlySet, + centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined, +): GameState { const next: GameState = JSON.parse(JSON.stringify(state)); next.version = (state.version ?? 0) + 1; // every mutation is a new revision const actingTurn = state.turn; // this action resolves the current turn @@ -636,6 +712,73 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys? } next.unowned = [...unowned]; + + // --- Map markers (v0.27, #44) --- + // + // Markers are anchored to a region key and are processed against the FINAL + // post-claim ownership, in this order: + // (1) REMOVE: a marker whose id is in result.markers.remove is dropped + // (a listed-but-absent id is a no-op). + // (2) OWNER RECOMPUTE: every surviving marker's ownerNationId is set to + // its region's current owner — a captured region follows the new + // owner, a region released to no man's land (or a key with 0 or 2+ + // owners) becomes `null`. A marker always follows its region. + // (3) ADD: each new spec (in order) is placed at its region's centroid + // (injected via centroidOf) plus a small deterministic per-axis + // offset, owned by the region's post-turn owner, and stamped with + // the acting turn. Dropped (never throws): a region key that is not + // in validKeys or has no known centroid, an id that already exists + // (a re-add is idempotent — the existing marker stays), and any adds + // beyond MAX_MARKERS (the later ones). + { + const finalOwner = new Map(); + { + const owners = new Map(); + for (const n of Object.values(next.nations)) { + for (const key of n.territory) { + if (!valid(key)) continue; + const list = owners.get(key); + if (list) list.push(n.id); + else owners.set(key, [n.id]); + } + } + for (const [key, list] of owners) finalOwner.set(key, list.length === 1 ? list[0] : null); + } + const removeIds = new Set(result.markers.remove); + const markers: MapMarker[] = []; + for (const m of next.markers ?? []) { + if (removeIds.has(m.id)) continue; + markers.push({ ...m, ownerNationId: finalOwner.get(m.regionKey) ?? null }); + } + // Deterministic ±(spread/2) offset per axis: a FNV-1a-derived unit + // fraction of the marker id (stable across runs and platforms), so + // markers of one region scatter naturally instead of stacking. + const jitter = (id: string, axis: 'lat' | 'lon', spread: number): number => { + const frac = (fnv1a32(`${id}#${axis}`) >>> 8) / 0x00ffffff; + return (frac * 2 - 1) * (spread / 2); + }; + const round6 = (v: number): number => Math.round(v * 1e6) / 1e6; + for (const spec of result.markers.add) { + const id = markerId(spec.type, spec.name, spec.regionKey); + if (markers.some((m) => m.id === id)) continue; // re-add: idempotent, keep the existing + if (markers.length >= MAX_MARKERS) continue; // hard cap: drop the excess + if (!valid(spec.regionKey)) continue; // unknown region key + const c = centroidOf?.(spec.regionKey); + if (!c) continue; // no centroid data for this key + markers.push({ + id, + type: spec.type, + name: spec.name, + regionKey: spec.regionKey, + lat: round6(c.lat + jitter(id, 'lat', c.spread)), + lon: round6(c.lon + jitter(id, 'lon', c.spread)), + ownerNationId: finalOwner.get(spec.regionKey) ?? null, + placedTurn: actingTurn, + }); + } + next.markers = markers; + } + // (v0.23, #16 P4a) Nation-op proposals are NOT applied by the turn — they // are appended to the pending list, which (unlike `pending`) SURVIVES // across turns until the player confirms or rejects each one. `next` is a @@ -965,15 +1108,31 @@ export async function runTurn(opts: { * switches the turn onto the getNeighbours tool path. */ getNeighbours?: (keys: string[]) => string[]; + /** + * (v0.27, #44) Region-key -> precomputed centroid resolver (from + * public/region-centroids.json). Present, the turn's new markers can be + * placed at their region's centroid; absent, marker adds are dropped and + * the existing markers' owners still get recomputed. + */ + centroidOf?: (key: string) => { lat: number; lon: number; spread: number } | undefined; }): Promise<{ state: GameState; result: TurnResult }> { - const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours } = opts; + const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours, centroidOf } = opts; const chatWithTools = llm.chatWithTools; const useTools = getNeighbours !== undefined && chatWithTools !== undefined; const system = buildSystemPrompt(preset, useTools); const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined; // (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty // list on a fresh game) so it knows which unowned regions can be re-claimed. - const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? []); + // (v0.27, #44) likewise the current map markers, so it can reference their + // stable ids in `markers.remove` (or leave them untouched). + const markers = (state.markers ?? []).map((m) => ({ + id: m.id, + type: m.type, + name: m.name, + regionKey: m.regionKey, + owner: m.ownerNationId, + })); + const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers); const nudge = 'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.'; @@ -1018,10 +1177,11 @@ export async function runTurn(opts: { territoryChanges: [], unowned: [], nationOps: [], + markers: { add: [], remove: [] }, }; } - return { state: applyTurnResult(state, result, validRegionKeys), result }; + return { state: applyTurnResult(state, result, validRegionKeys, centroidOf), result }; } // --- Advisor (brainstorm) ----------------------------------------------------- diff --git a/server/index.ts b/server/index.ts index 71b18fd..af8a062 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,7 +22,7 @@ import { createLlmClient } from './llm'; import { makeStubLlmClient } from './stub'; import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine'; import { buildRegionMap } from './map'; -import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions'; +import { allTerritoryKeys, centroidOf, expandCountriesToKeys, hasAdjacency, hasCentroids, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions'; import { createPresetStore } from './presets'; import { openDb, type Db } from './db'; @@ -134,6 +134,8 @@ app.post('/api/games', (req: Request, res) => { // list grows as the LLM proposes renames (and, later, splits/merges) and // shrinks as the player confirms or rejects them. pendingNationOps: [], + // (v0.27, #44) No markers on a fresh game; the LLM places them over time. + markers: [], }; // v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys // of all its sub-regions, so ownership (and the choropleth) is at region @@ -288,6 +290,11 @@ app.post('/api/games/:id/jump', async (req, res) => { // engine the resolver — the turn then runs with the LLM's getNeighbours // tool and the player-frontier prompt field (both switch off without it). getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined, + // (v0.27, #44) when the centroid manifest loaded, hand the engine the + // resolver so a turn can place new map markers at their region's + // centroid (without it, marker adds are dropped; existing markers are + // still maintained). + centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined, }); db.save(state); broadcast(id, 'turn', { state, result }); diff --git a/server/map.test.ts b/server/map.test.ts index f469f4e..85d627a 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -49,6 +49,7 @@ const state: GameState = { version: 1, unowned: [], pendingNationOps: [], + markers: [], }; // The RegionMap built from the fixture: the authoritative region key → owner source. diff --git a/server/regions.test.ts b/server/regions.test.ts index 080a1c3..1bc5e67 100644 --- a/server/regions.test.ts +++ b/server/regions.test.ts @@ -5,8 +5,10 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { allTerritoryKeys, + centroidOf, expandCountriesToKeys, hasAdjacency, + hasCentroids, hasRegionData, loadRegions, neighboursOf, @@ -103,6 +105,7 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { version: 1, unowned: [], pendingNationOps: [], + markers: [], }; // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. expect(allTerritoryKeys(state).size).toBe(58); @@ -133,6 +136,19 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { expect(neighboursOf([])).toEqual([]); }); + it('(v0.27 #44) hasCentroids() is true and centroidOf resolves a real key to a plausible location', () => { + expect(hasCentroids()).toBe(true); + const ain = centroidOf('ain-lqtt'); + expect(ain).toBeDefined(); + expect(ain!.lat).toBeCloseTo(46.16, 1); + expect(ain!.lon).toBeCloseTo(4.8, 1); + expect(ain!.spread).toBeGreaterThan(0); + }); + + it('(v0.27 #44) centroidOf returns undefined for an unknown key', () => { + expect(centroidOf('not-a-real-key-xyz')).toBeUndefined(); + }); + describe('graceful degradation with no region data', () => { beforeEach(() => resetRegions()); @@ -146,6 +162,9 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { // and no adjacency: the LLM tool / frontier field switch off expect(hasAdjacency()).toBe(false); expect(neighboursOf(['moselle-rygy'])).toEqual([]); + // and no centroids: the marker-placement path degrades to a no-op + expect(hasCentroids()).toBe(false); + expect(centroidOf('ain-lqtt')).toBeUndefined(); }); }); @@ -197,6 +216,21 @@ describe('loadRegions resolves the manifest per environment (dev public/ vs prod expect(loadRegions(prodRoot)).toBe(true); expect(hasAdjacency()).toBe(false); expect(neighboursOf(['moselle-rygy'])).toEqual([]); + // and no centroids (not copied into this dist/) -> marker placement degrades + expect(hasCentroids()).toBe(false); + expect(centroidOf('ain-lqtt')).toBeUndefined(); + }); + + it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => { + copyFileSync( + path.join(realRoot, 'public', 'region-centroids.json'), + path.join(prodRoot, 'dist', 'region-centroids.json'), + ); + expect(loadRegions(prodRoot)).toBe(true); + expect(hasCentroids()).toBe(true); + const ain = centroidOf('ain-lqtt'); + expect(ain).toBeDefined(); + expect(ain!.lat).toBeCloseTo(46.16, 1); }); it('returns false and no data when neither public/ nor dist/ holds the files', () => { diff --git a/server/regions.ts b/server/regions.ts index 63a1659..34bb607 100644 --- a/server/regions.ts +++ b/server/regions.ts @@ -2,7 +2,9 @@ // (region-keys.json: stable feature id -> region key), the sub-national geo // (regions.geo.json: feature id -> { name, parent }), and — optionally — the // precomputed region-adjacency manifest (region-adjacency.json: region key -> -// bordering region keys, built by scripts/build-region-adjacency.mjs) — all +// bordering region keys, built by scripts/build-region-adjacency.mjs) and the +// precomputed region-centroid manifest (region-centroids.json: region key -> +// { lat, lon, spread }, built by scripts/build-region-centroids.mjs) — all // from public/ in dev (Vite serves it) or dist/ in prod (Vite copies // public/* -> dist/ at build, and the Docker image keeps only dist/) — and // joins them into the lookups the region-level ownership model needs: @@ -13,6 +15,8 @@ // - neighboursOf(): the union of the bordering keys for a set of keys — the // data behind the LLM's getNeighbours tool and the player-frontier prompt // field (v0.21, #16 P2) +// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread }) +// — the data behind LLM-placed map markers (v0.27, #44) // // Deliberately turf-free: it reads the JSON files with plain JSON.parse and // never imports @turf/@types/geojson (importing one pulls @types/geojson into @@ -39,12 +43,15 @@ interface RegionData { validKeys: Set; /** region key -> bordering region keys (from public/region-adjacency.json). */ adjacency: Record; + /** region key -> anchor point (from public/region-centroids.json). */ + centroids: Record; } let data: RegionData | null = null; /** - * Load (or reload) the region-key manifest, geo, and adjacency. Idempotent. + * Load (or reload) the region-key manifest, geo, adjacency, and centroids. + * Idempotent. * The files are served to the client from `public/` in dev (Vite serves it * directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build * and the Docker image keeps only `dist/`), so probe `public/` then `dist/` @@ -52,7 +59,10 @@ let data: RegionData | null = null; * key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing * or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply * report "no adjacency" (the LLM tool and the frontier prompt field switch - * off; everything else keeps working). Returns true when the core pair loaded + * off; everything else keeps working). The centroid manifest is likewise + * OPTIONAL (v0.27, #44): when missing or broken, `centroids` stays empty and + * `centroidOf`/`hasCentroids` report "no centroids" (new map markers can't be + * placed, but everything else keeps working). Returns true when the core pair loaded * cleanly; a missing/broken pair clears the data and returns false so the * server still boots and degrades gracefully (expansion becomes a no-op, * ownership falls back to the raw input keys). @@ -61,6 +71,7 @@ export function loadRegions(root: string): boolean { const keyFile = 'region-keys.json'; const geoFile = 'regions.geo.json'; const adjacencyFile = 'region-adjacency.json'; + const centroidsFile = 'region-centroids.json'; const base = [ path.join(root, 'public'), // dev: Vite serves public/ at / path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir) @@ -105,7 +116,38 @@ export function loadRegions(root: string): boolean { } } - data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency }; + // Centroids are optional too (v0.27, #44): a missing/broken file means + // "no centroid data" — new markers can't be placed (their adds are + // dropped), but everything else keeps working. + let centroids: Record = {}; + const centroidsPath = path.join(base, centroidsFile); + if (existsSync(centroidsPath)) { + try { + const raw = JSON.parse(readFileSync(centroidsPath, 'utf8')) as Record< + string, + { lat?: unknown; lon?: unknown; spread?: unknown } + >; + // Keep only well-formed entries; a corrupt file degrades to "none". + for (const [k, v] of Object.entries(raw)) { + if ( + v && + typeof v.lat === 'number' && + typeof v.lon === 'number' && + typeof v.spread === 'number' && + Number.isFinite(v.lat) && + Number.isFinite(v.lon) && + Number.isFinite(v.spread) + ) { + centroids[k] = { lat: v.lat, lon: v.lon, spread: v.spread }; + } + } + } catch (err) { + console.error('[regions] failed to load region-centroids.json, continuing without it', err); + centroids = {}; + } + } + + data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids }; return validKeys.size > 0; } catch (err) { console.error('[regions] failed to load region data', err); @@ -189,6 +231,27 @@ export function hasAdjacency(): boolean { return data !== null && Object.keys(data.adjacency).length > 0; } +/** + * True when the region-centroid manifest loaded with at least one entry + * (v0.27, #44). The server's jump route uses this to decide whether to hand + * the engine a centroid resolver (which lets a turn place new map markers) + * or skip marker placement (existing markers are still maintained). + */ +export function hasCentroids(): boolean { + return data !== null && Object.keys(data.centroids).length > 0; +} + +/** + * The precomputed anchor point for a region key: the centroid of its + * largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox + * dimension, in degrees) the engine scatters markers within + * ±(spread/2) of the centroid. `undefined` for an unknown key or when no + * centroid data is loaded (v0.27, #44). + */ +export function centroidOf(key: string): { lat: number; lon: number; spread: number } | undefined { + return data?.centroids[key]; +} + /** * The union of the region keys that share a land border with any of the given * keys — deduplicated, the queried keys themselves excluded, and sorted for diff --git a/server/stub.ts b/server/stub.ts index 3e09a95..d0a4a9a 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -31,6 +31,13 @@ export interface StubPayload { * is covered by unit tests against applyTurnResult, not by the stub. */ unowned?: string[]; + /** + * (v0.27, #44) Current map markers (`{ id, type, name, regionKey, owner }`), + * emitted by the engine's user prompt. The stub ignores this field — it + * never places or removes a marker (see deterministicTurn), so the marker + * path is covered by unit tests against applyTurnResult, not by the stub. + */ + markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>; } function truncate(s: string, n: number): string { @@ -61,6 +68,10 @@ function truncate(s: string, n: number): string { * (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it * always returns `nationOps: []`) — the propose→confirm/reject flow is * covered by unit tests only, keeping the e2e assertions untouched. + * + * (v0.27, #44) The stub likewise NEVER places or removes a map marker (it + * always returns `markers: { add: [], remove: [] }`) — the marker path is + * covered by unit tests only, keeping the e2e assertions untouched. */ export function deterministicTurn(p: StubPayload): TurnResult { const state = p.state; @@ -126,6 +137,7 @@ export function deterministicTurn(p: StubPayload): TurnResult { territoryChanges, unowned: [], // the stub never creates no man's land (see doc comment) nationOps: [], // the stub never proposes nation ops (see doc comment) + markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment) }; } diff --git a/shared/protocol.ts b/shared/protocol.ts index 0267619..7db7bd4 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -6,10 +6,12 @@ import type { EventOutcome, EventType, GameState, + MarkerType, Nation, RelationStatus, StatKey, } from './types'; +import { MARKER_TYPES } from './types'; /** A numeric change to one of a nation's 0–100 stats. */ export type StateChange = @@ -114,6 +116,100 @@ export interface TurnEventSpec { outcome: EventOutcome; } +// --- Map markers (v0.27, #44) ------------------------------------------------- +// +// The LLM places named point markers (cities, factories, armies, ports, +// capitals, fleets, rocket launch sites, gatherings) anchored to a region +// key. It never sees or sets coordinates: the engine derives the position +// from the region's precomputed centroid (public/region-centroids.json) plus a +// small deterministic offset, and it recomputes each marker's owner from the +// region's current owner on every turn (a released region → null). +// +// The marker id is a STABLE, deterministic 4-letter code (FNV-1a 32-bit → +// base 26, the same scheme as the region keys' 4-char suffix) derived from +// (type, name, regionKey) — so re-adding an identical marker is idempotent +// (same id, skipped) and removing by id is unambiguous. + +/** The spec the LLM emits to place a marker: type, name and the region it sits in. */ +export interface MarkerSpec { + type: MarkerType; + name: string; + regionKey: string; +} + +/** + * One existing marker as the user prompt presents it to the model (v0.27, + * #44): its stable `id` (the model may echo it in `TurnResult.markers.remove`), + * type, name, region key, and the id of the nation currently owning its + * region (`null` when the region is unowned / no man's land). + */ +export interface MarkerInfo { + id: string; + type: MarkerType; + name: string; + regionKey: string; + owner: string | null; +} + +/** + * The per-type presentation of a marker: its display label (tooltip / + * dossier), its color, and its glyph character (a symbol-layer text label — + * a small, font-safe character in front of the name). Pure + shared so the + * client's marker layer and any server-side consumers agree on the look. + */ +export const MARKER_TYPE_INFO: Record = { + city: { label: 'City', color: '#cbd5e1', glyph: '●' }, + factory: { label: 'Factory', color: '#fb923c', glyph: '■' }, + army: { label: 'Army', color: '#ef4444', glyph: '▲' }, + port: { label: 'Port', color: '#38bdf8', glyph: '◆' }, + capital: { label: 'Capital', color: '#fbbf24', glyph: '★' }, + fleet: { label: 'Fleet', color: '#22d3ee', glyph: '◗' }, + rocket: { label: 'Rocket site', color: '#a78bfa', glyph: '↥' }, + gathering: { label: 'Gathering', color: '#4ade80', glyph: '◉' }, +}; + +/** Hard cap on a game's total markers (new adds beyond the cap are dropped, v0.27 #44). */ +export const MAX_MARKERS = 400; + +/** + * FNV-1a 32-bit — the same tiny, stable, pure string hash the region keys use + * (see scripts/build-region-keys.mjs). Exported so the engine can derive + * deterministic per-axis jitter fractions from it (marker placement, v0.27 #44). + */ +export function fnv1a32(str: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h >>> 0; +} + +/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */ +function code4(h: number): string { + let out = ''; + for (let i = 0; i < 4; i++) { + out += String.fromCharCode(97 + (((h >>> (i * 8)) & 0xff) % 26)); + } + return out; +} + +/** + * The stable id for a marker: 4 base-26 lowercase chars from the FNV-1a hash + * of `(type, name, regionKey)` (NUL-separated so no field boundary is + * ambiguous). Deterministic across runs and platforms (pure string math), so + * the same (type, name, regionKey) always maps to the same id — re-adding is + * idempotent, removing by id is unambiguous. + */ +export function markerId(type: MarkerType, name: string, regionKey: string): string { + return code4(fnv1a32(`${type}\u0000${name}\u0000${regionKey}`)); +} + +/** True when `t` is one of the eight known marker types (v0.27, #44). */ +export function isMarkerType(t: unknown): t is MarkerType { + return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t); +} + /** The exact JSON object the world-simulator LLM must return for a turn. */ export interface TurnResult { date: string; @@ -141,6 +237,16 @@ export interface TurnResult { * survivor. Empty on turns where the model proposes none. */ nationOps: NationOp[]; + /** + * (v0.27, #44) Map-marker ops this turn. `add` places new named markers + * (the engine positions each at its region's centroid + a small + * deterministic offset, sets the owner to the region's post-turn owner, + * derives the stable id, and drops unknown types/region keys, duplicates, + * and adds beyond MAX_MARKERS); `remove` drops markers by their stable id + * (a no-op for an id that is not present). Both required, `[]` on turns + * with no marker activity. + */ + markers: { add: MarkerSpec[]; remove: string[] }; } /** Per-region map payload: region key -> owning nation + its stable color. */ diff --git a/shared/types.ts b/shared/types.ts index 3292e5f..5e13772 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -68,6 +68,60 @@ export const EVENT_TYPES: EventType[] = [ export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral'; +// --- Map markers (v0.27, #44) ------------------------------------------------- +// +// Named point markers the LLM can place on the map (cities, factories, armies, +// ports, capitals, fleets, rocket launch sites, political/social gatherings). +// A marker is anchored to a **region key** (see `public/region-keys.json`): the +// engine positions it at that region's precomputed centroid (with a small +// deterministic offset) and its `ownerNationId` follows the region's current +// owner (recomputed on every turn; `null` when the region is unowned / no +// man's land). See protocol.ts for the LLM-facing `TurnResult.markers` +// contract and the `markerId` derivation. + +/** The eight marker categories the model may place (v0.27, #44). */ +export type MarkerType = + | 'city' + | 'factory' + | 'army' + | 'port' + | 'capital' + | 'fleet' + | 'rocket' + | 'gathering'; + +/** All marker types, in canonical order (used by the LLM contract + map layer). */ +export const MARKER_TYPES: MarkerType[] = [ + 'city', + 'factory', + 'army', + 'port', + 'capital', + 'fleet', + 'rocket', + 'gathering', +]; + +/** + * A named point marker on the map (v0.27, #44). `id` is a stable, + * deterministic 4-character code derived from `type + name + regionKey` + * (see protocol.markerId) — re-adding the same marker is idempotent and + * removing by id is unambiguous. `lat`/`lon` are filled in by the engine from + * the region's centroid manifest (never by the model); `ownerNationId` is the + * marker's region's current owner (`null` when unowned) and is recomputed by + * the engine on every committed turn, so a marker always follows its region. + */ +export interface MapMarker { + id: string; + type: MarkerType; + name: string; + regionKey: string; + lat: number; + lon: number; + ownerNationId: string | null; + placedTurn: number; +} + /** A narrative event the simulation emitted on some turn. */ export interface EventEntry { id: string; @@ -153,6 +207,16 @@ export interface GameState { * runtime dependencies.) */ pendingNationOps: NationOp[]; + /** + * (v0.27, #44) Named point markers on the map: the LLM places them (via a + * turn's `markers.add`) and removes them (`markers.remove`, by id). The + * engine positions each at its region's centroid (deterministic small + * offset) and recomputes `ownerNationId` from the region's current owner on + * EVERY committed turn (a released region → `null`). Always `[]` on a fresh + * game; rows saved before v0.27 load with this defaulted to `[]` (see + * server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS. + */ + markers: MapMarker[]; } /** diff --git a/src/App.tsx b/src/App.tsx index 9f9b223..8dee9b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -706,6 +706,7 @@ function GameScreen({ nations={game.nations} presetId={game.presetId} unowned={game.unowned ?? []} + markers={game.markers ?? []} onFocusNation={focusNation} /> diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 90872e4..b0d1baf 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -10,12 +10,14 @@ import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url'; maplibregl.setWorkerUrl(workerUrl); import { mapFeatureOwner, + MARKER_TYPE_INFO, + nationColor, regionFillStyle, unownedTooltipLabel, type RegionMap, type RegionOwner, } from '@shared/protocol'; -import type { Nation } from '@shared/types'; +import { MARKER_TYPES, type MapMarker, type Nation } from '@shared/types'; import { cn } from '@/lib/utils'; import { idToKey } from '@/lib/regions'; @@ -45,8 +47,16 @@ type GeoSource = maplibregl.GeoJSONSource; // looked black, and `styledata` was gated on fetching a remote document. An // inline object loads instantly, so `isStyleLoaded()` / `styledata` fire // immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr` -// raster has no vector tiles, so no glyphs endpoint is needed — we don't add -// label layers; the choropleth provides the borders.) +// raster has no vector tiles, so the basemap itself needs no label layers — +// the choropleth provides the borders.) +// +// No `glyphs` URL: the map needs no MapLibre *text* rendering. The basemap is +// raster (its labels are baked into the PNG tiles), and the LLM-placed map +// markers (v0.27, #44) are drawn as canvas-generated icon images in a symbol +// layer — fully self-contained, so they render offline and deterministically +// without depending on any external font/glyph endpoint (the OpenFreeMap +// `fonts/` path is a dead nginx 404, and the basemap's apparent labels come +// from the raster, not from glyphs). const NE2SR_TILES = ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png']; const INLINE_STYLE: maplibregl.StyleSpecification = { @@ -88,6 +98,8 @@ const PLAYER_LINE = 'player-line'; const FLASH_LINE = 'flash-line'; const HOVER_FILL = 'hover-fill'; const HOVER_LINE = 'hover-line'; +const MARKERS_SOURCE = 'markers'; +const MARKER_SYMBOL = 'markers-symbol'; /** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */ function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } { @@ -195,6 +207,186 @@ function featuresByRegionKeys( return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) }; } +// --- Map markers (v0.27, #44) ------------------------------------------------ +// +// The LLM places named point markers (cities, factories, armies, ports, +// capitals, fleets, rocket sites, gatherings) anchored to a region key. The +// engine positions each at its region's centroid (deterministic small offset) +// and recomputes its owner from the region's current owner every turn. The +// map renders them as a single top-most symbol layer: a per-type glyph +// character in front of the name, per-type color, dark-theme halo. + +/** + * The GeoJSON behind the marker symbol layer: one Point per marker at + * `[lon, lat]`, carrying the stable id, type, name, its type's glyph, and the + * current owner's name + stable color (empty when the region is unowned). + * Pure; the caller passes the current marker list + nations record, so a + * renamed/removed owner is picked up on the next paint. + */ +function buildMarkersFC(markers: MapMarker[], nations: Record): FeatureCollection { + const features = markers.map((m) => { + const owner = m.ownerNationId ? nations[m.ownerNationId] : undefined; + return { + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] as [number, number] }, + properties: { + id: m.id, + type: m.type, + name: m.name, + glyph: MARKER_TYPE_INFO[m.type]?.glyph ?? '', + owner: owner?.name ?? '', + ownerId: owner?.id ?? '', + ownerColor: owner ? nationColor(owner.id) : '', + }, + }; + }); + return { type: 'FeatureCollection', features }; +} + +// Small canvas-path helpers for the marker icons (self-contained shapes — no +// unicode/font dependency, so they render identically in any environment). +function roundRectPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +): void { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +function starPath( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + spikes: number, + outer: number, + inner: number, +): void { + let rot = (Math.PI / 2) * -1; + const step = Math.PI / spikes; + ctx.beginPath(); + ctx.moveTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer); + for (let i = 0; i < spikes; i++) { + rot += step; + ctx.lineTo(cx + Math.cos(rot) * inner, cy + Math.sin(rot) * inner); + rot += step; + ctx.lineTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer); + } + ctx.closePath(); +} + +/** + * (v0.27, #44) Draw one marker-type icon into an offscreen canvas: a distinct + * shape per type, filled with the type's color from the shared + * `MARKER_TYPE_INFO` contract, with a dark outline for legibility on any + * choropleth fill. Added to the map as a named image and referenced by the + * symbol layer's `icon-image` — a real symbol layer, per-type color + form, + * with zero external font/glyph dependency. + */ +function drawMarkerIcon(type: MapMarker['type']): ImageData { + const S = 44; + const c = document.createElement('canvas'); + c.width = S; + c.height = S; + const ctx = c.getContext('2d')!; + const cx = S / 2; + const cy = S / 2; + ctx.fillStyle = MARKER_TYPE_INFO[type]?.color ?? '#cbd5e1'; + ctx.strokeStyle = '#0b1220'; + ctx.lineWidth = 2.5; + ctx.lineJoin = 'round'; + switch (type) { + case 'city': + ctx.beginPath(); + ctx.arc(cx, cy, S * 0.3, 0, Math.PI * 2); + break; + case 'factory': { + const h = S * 0.5; + roundRectPath(ctx, cx - h / 2, cy - h / 2, h, h, 5); + break; + } + case 'army': { + const r = S * 0.34; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy + r * 0.8); + ctx.lineTo(cx - r, cy + r * 0.8); + ctx.closePath(); + break; + } + case 'port': { + const r = S * 0.36; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + break; + } + case 'capital': + starPath(ctx, cx, cy, 5, S * 0.36, S * 0.15); + break; + case 'fleet': + case 'gathering': + ctx.beginPath(); + ctx.arc(cx, cy, S * 0.32, 0, Math.PI * 2); + break; + case 'rocket': { + const w = S * 0.14; + ctx.beginPath(); + ctx.moveTo(cx, cy - S * 0.36); + ctx.lineTo(cx + S * 0.22, cy - S * 0.04); + ctx.lineTo(cx + w, cy - S * 0.04); + ctx.lineTo(cx + w, cy + S * 0.34); + ctx.lineTo(cx - w, cy + S * 0.34); + ctx.lineTo(cx - w, cy - S * 0.04); + ctx.lineTo(cx - S * 0.22, cy - S * 0.04); + ctx.closePath(); + break; + } + } + ctx.fill(); + ctx.stroke(); + // fleet and gathering share a circle base; an inner mark tells them apart + // (fleet = a dark porthole dot, gathering = a white bullseye dot). + if (type === 'fleet' || type === 'gathering') { + ctx.beginPath(); + ctx.arc(cx, cy, S * 0.13, 0, Math.PI * 2); + ctx.fillStyle = type === 'fleet' ? '#0b1220' : '#ffffff'; + ctx.fill(); + } + // Hand MapLibre the raw pixels (an untainted canvas, so getImageData is safe) + // rather than the canvas element — `addImage` accepts ImageData directly. + return ctx.getImageData(0, 0, S, S); +} + +/** The hover tooltip for a marker: its type label + name, then the owner chip. */ +function markerTooltipHtml( + type: string, + name: string, + owner: string, + ownerColor: string, +): string { + const label = MARKER_TYPE_INFO[type as MapMarker['type']]?.label ?? 'Marker'; + const ownerLine = owner + ? `
${esc(owner)}
` + : '
No owner
'; + return `
${esc(label)}: ${esc(name)}
${ownerLine}
`; +} + const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)); @@ -254,6 +446,13 @@ interface Props { * component doc for the full behavior. */ onFocusNation?: (id: string, info?: { name?: string }) => void; + /** + * (v0.27, #44) The game's named map markers (`GameState.markers`): the LLM + * places/removes them over time, the engine positions each at its region's + * centroid and keeps its owner in sync with the region's owner. Rendered as + * a top-most symbol layer (per-type glyph + name, per-type color). + */ + markers?: MapMarker[]; } /** @@ -286,6 +485,18 @@ interface Props { * a plain "No owner" sub-line. The colors/labels come from the shared * `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol. * + * (v0.27, #44) LLM-placed **map markers** (`markers` prop = `GameState.markers`) + * render on a top-most symbol layer: each is its type's canvas-generated icon + * (a distinct shape, per-type color from the shared `MARKER_TYPE_INFO` + * contract, dark-outlined) at the engine-derived position (region centroid + + * small deterministic offset). The icons are drawn into named map images + * (`marker-`), so there is no external font/glyph dependency and markers + * render offline and deterministically. Hovering a marker shows a tooltip + * (type label + name + owner chip); clicking one switches the dossier to the + * owning nation (no-op when the marker's region is unowned). The region + * hover/click handlers detect a marker under the cursor (MapLibre layer events + * have no propagation) and act on the marker's data. + * * Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag) * — no floating control and no external API. */ @@ -297,6 +508,7 @@ export default function WorldMap({ className, unowned, onFocusNation, + markers, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -311,12 +523,14 @@ export default function WorldMap({ const presetRef = useRef(presetId); const unownedRef = useRef>(new Set(unowned ?? [])); const onFocusNationRef = useRef(onFocusNation); + const markersRef = useRef(markers ?? []); regionsRef.current = regions; nationsRef.current = nations; playerRef.current = playerNationId; presetRef.current = presetId; unownedRef.current = new Set(unowned ?? []); onFocusNationRef.current = onFocusNation; + markersRef.current = markers ?? []; // Diffing state for the transient territory-change flash (owned by this component // so a resync with identical data never re-flashes). @@ -424,6 +638,39 @@ export default function WorldMap({ const onHover = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; + // (v0.27, #44) A marker rendered on top of this region wins the hover. + // The region polygon is always beneath a marker, so this FILL_LAYER + // delegate fires in both cases; if a marker icon is under the cursor, + // show the marker's tooltip (name + type + owner) instead of the + // region's. `e.point` is the cursor's pixel position, so querying the + // marker layer there tests the icon's (small) hit area, not the whole + // region. + const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] }); + if (markerHits.length > 0) { + const mp = (markerHits[0].properties ?? {}) as { + type?: string; + name?: string; + owner?: string; + ownerColor?: string; + }; + hoverPopup + .setLngLat(e.lngLat) + .setHTML( + markerTooltipHtml( + typeof mp.type === 'string' ? mp.type : '', + typeof mp.name === 'string' ? mp.name : '', + typeof mp.owner === 'string' ? mp.owner : '', + typeof mp.ownerColor === 'string' ? mp.ownerColor : '', + ), + ) + .addTo(map); + map.getCanvas().style.cursor = 'pointer'; + // A marker has no region outline to highlight; clear the previous one + // (and reset lastHoverId so moving back onto a region re-triggers it). + lastHoverId = null; + (map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(emptyFC()); + return; + } const p = f.properties as { name?: string; regionId?: string }; const subId = typeof p?.regionId === 'string' ? p.regionId : ''; if (subId && subId !== lastHoverId) { @@ -466,6 +713,19 @@ export default function WorldMap({ const onClick = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; + // (v0.27, #44) A marker under the cursor: focus its owning nation + // (identical to its region's owner — the engine keeps a marker's owner in + // sync with the region) and carry the marker's name into the dossier. + const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] }); + if (markerHits.length > 0) { + const mp = (markerHits[0].properties ?? {}) as { ownerId?: string; name?: string }; + const owner = typeof mp.ownerId === 'string' ? mp.ownerId : ''; + if (owner) { + const info = typeof mp.name === 'string' ? { name: mp.name } : undefined; + onFocusNationRef.current?.(owner, info); + } + return; + } const region = mapFeatureOwner(regionsRef.current, f); if (!region?.ownerId) return; // Carry the clicked sub-region's name so the dossier can show which region @@ -497,6 +757,9 @@ export default function WorldMap({ if (!map.getSource(FLASH_SOURCE)) { map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() }); } + if (!map.getSource(MARKERS_SOURCE)) { + map.addSource(MARKERS_SOURCE, { type: 'geojson', data: emptyFC() }); + } }; // Stamp ownership + colors onto the regions source (no-op until both the @@ -509,6 +772,26 @@ export default function WorldMap({ src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current)); }; + // Stamp the current markers onto the marker source (v0.27, #44). + const paintMarkers = () => { + const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined; + if (!src) return; + src.setData(buildMarkersFC(markersRef.current, nationsRef.current)); + }; + + // Register the eight canvas-generated marker-type icon images (v0.27, #44). + // Images are cleared on a style (re)load, so this is (re)run from + // `ensureLayers` (which fires on every `styledata`). Self-contained — no + // external font/glyph endpoint — so markers render offline and + // deterministically. + const ensureMarkerImages = async () => { + for (const type of MARKER_TYPES) { + const id = `marker-${type}`; + if (map.getImage(id)) continue; + await map.addImage(id, drawMarkerIcon(type)); + } + }; + const ensureLayers = async () => { if (cancelled) return; ensureSources(); @@ -528,6 +811,7 @@ export default function WorldMap({ } if (cancelled) return; paintRegions(); + paintMarkers(); if (!map.getLayer(FILL_LAYER)) { // The choropleth is the star: a high opacity so the vivid per-nation // colors read clearly even on the dark background / over the terrain. @@ -554,8 +838,9 @@ export default function WorldMap({ } // NOTE: layers are added in bottom→top order (each appends on top). The // stack is: fill, base borders, player border, territory-change flash, - // hover brighten, hover outline. Do not use a `beforeId` here — it throws - // if that layer does not exist yet on a fresh style load. + // hover brighten, hover outline, and (top-most) the LLM-placed map + // markers' symbol layer (v0.27, #44). Do not use a `beforeId` here — it + // throws if that layer does not exist yet on a fresh style load. if (!map.getLayer(PLAYER_LINE)) { map.addLayer({ id: PLAYER_LINE, @@ -589,6 +874,36 @@ export default function WorldMap({ paint: { 'line-color': '#ffffff', 'line-width': 1.6, 'line-opacity': 0.9 }, }); } + // (v0.27, #44) The LLM-placed map markers: a top-most symbol layer. + // Each marker renders its type's canvas-generated icon (a distinct shape, + // per-type color from the shared MARKER_TYPE_INFO contract) with a dark + // outline for legibility on any choropleth fill. The icon images are + // self-contained (no external font/glyph endpoint), so markers render + // offline and deterministically. `icon-image` is `marker-`, the + // named canvas images registered by `ensureMarkerImages` above. + await ensureMarkerImages(); + if (!map.getLayer(MARKER_SYMBOL)) { + map.addLayer({ + id: MARKER_SYMBOL, + type: 'symbol', + source: MARKERS_SOURCE, + layout: { + 'icon-image': ['concat', 'marker-', ['get', 'type']], + 'icon-size': 0.8, + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + }, + }); + } + // Marker interactions: the markers sit above the choropleth, but they + // deliberately share the region (FILL_LAYER) handlers registered below. + // MapLibre layer-scoped events are independent per-layer delegates (no + // propagation, no stopPropagation), so a marker-on-region would fire + // both a marker and a region handler. Instead the region handlers detect + // a marker under the cursor (see onHover/onClick) and act on the + // marker's data; clicking a marker focuses its owning nation via the + // same path as clicking its region (the marker's owner == its region's + // owner). map.off('mousemove', FILL_LAYER, onHover); map.off('mouseout', FILL_LAYER, onHoverOut); map.off('click', FILL_LAYER, onClick); @@ -646,6 +961,19 @@ export default function WorldMap({ } }, [regions, playerNationId, unowned]); + // (v0.27, #44) Repaint the marker symbol layer whenever the marker list + // changes (a turn placed/removed markers) or the nations record changes + // (a confirmed rename/merge changes an owner's display name). No-op until + // the map + source exist (the mount effect's ensureLayers applies the + // current data once the layer structure is in place). + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined; + if (!src) return; + src.setData(buildMarkersFC(markers ?? [], nations)); + }, [markers, nations]); + // Full-viewport, edge-to-edge background layer. `position: fixed` is set // inline because MapLibre's own stylesheet sets `.maplibregl-map` to // `position: relative`, which would otherwise win over a Tailwind class.