Merge remote-tracking branch 'origin/main' into german/hal-623-hud-summary-locale

# Conflicts:
#	src/voice/gevActions.test.mjs
This commit is contained in:
German Khokhlov committed 2026-11-03 14:28:10 +04:00
commit 33381c5981
484 files changed
+26543 -1086

No files matched your search

+26
View File
@@ -265,6 +265,32 @@ AISSTREAM_API_KEY=
# Optional: comma-separated Overpass URLs; use only instances you run or pay for.
OVERPASS_UPSTREAMS=
# Optional: place-lookup endpoints. Lookups try bundled data first, then the
# endpoint set here. Nominatim stays off unless NOMINATIM_URL is set; Photon
# uses its public service when PHOTON_URL is unset.
# NOMINATIM_URL: search endpoint for last-resort place search and voice
# outlines (e.g. https://nominatim.example.org/search). A configured URL
# selects the endpoint explicitly. Unset, empty and invalid values disable
# Nominatim; nothing falls back to nominatim.openstreetmap.org. Use an endpoint
# you operate or are authorized to use. Answers are cached (outlines 90 days,
# places 30 days, no-results 24 h in .gev-cache/nominatim).
# Explicit public-service configuration retains per-install pacing and caps;
# these do not establish an application-wide allowance or authorize public use.
# NOMINATIM_URL=
# Public-instance requests per UTC day, outlines and search together. Not
# applied to your own NOMINATIM_URL.
# NOMINATIM_DAILY_CAP=50
# Public-instance pacing is shared through .gev-cache/nominatim/state.json. If
# the server stopped mid-request (or its disk filled), it leaves a
# state.json.dispatch.* marker and refuses public lookups ("unfinished durable
# Nominatim dispatch") for ten minutes rather than risk breaking the spacing;
# the next lookup after that removes the marker and resumes with fresh spacing.
# PHOTON_URL: keyless place search endpoint used by the browser
# (e.g. https://photon.example.org/api/). Unset uses photon.komoot.io; set it
# empty to disable. Only https endpoints are accepted (the page's
# Content-Security-Policy allows no plain-http origin); any other value disables it.
# PHOTON_URL=
# Optional: who may frame the app in embed mode (?embed=1), as CSP
# frame-ancestors sources such as "https://example.com", or * for any page.
# Unset, no page may frame it. Other pages of the app can never be framed.
+1 -1
View File
@@ -40,7 +40,7 @@ body:
attributes:
label: OS, Node version, browser
description: Include the versions you can find. Write "unknown" for anything unavailable.
placeholder: "Windows 11, Node 24.14.0, Chrome 151"
placeholder: "Windows 11, Node 24.22.2, Chrome 151"
validations:
required: true
- type: input
+2 -1
View File
@@ -34,7 +34,8 @@ If it's bigger, link to it instead.
<!--
Which layer-panel group should it sit in? The current groups are in PANEL_GROUPS
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Utilities.
in src/ui/layerPanel.js: Movement, Cameras, Infrastructure, Events, Weather, Audio,
and Utilities (its rows render in the separate UTILITIES panel).
If none fits, propose a new group and say why.
-->
+4 -4
View File
@@ -20,10 +20,10 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [24.14.0, 26.x]
node: [24.22.2, 26.x]
env:
PUPPETEER_SKIP_DOWNLOAD: '1'
GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.14.0' && '1' || '0' }}
GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.22.2' && '1' || '0' }}
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -76,7 +76,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.0
node-version: 24.22.2
cache: npm
- name: Install locked dependencies and headless Chrome
@@ -131,7 +131,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.0
node-version: 24.22.2
cache: npm
- name: Run the Pinokio install path
+27 -5
View File
@@ -4,13 +4,15 @@ Thanks for being here. God's Eye View is an open foundation for live spatial int
## Getting set up
Use Node.js 24.14.x or 26.x (also enforced by `package.json`).
Use Node.js 24 (24.14.0 or later) or 26.x (also enforced by `package.json`).
Prefer 24.22.1 or later: earlier releases lack the October 21, 2026 security
fixes, including CVE-2026-111497 (a Brotli decoder flaw).
```bash
git clone https://github.com/bilawalsidhu/gods-eye-view.git
cd gods-eye-view
nvm install 24.14.0
nvm use 24.14.0
nvm install 24.22.2
nvm use 24.22.2
npm install
npm run doctor
./scripts/dev-fresh.sh # or: npm run dev (keys are optional)
@@ -27,6 +29,25 @@ through Pinokio; the terminal path above remains the contributor path.
Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.**
### Test prerequisites
- **`npm test`** — unit tests. Runs standalone; no dev server or credentials
required. Exit code is the gate.
- **`npm run test:track`** — the 3D flight-tracking regression harness. It
drives the **real app** in headless Chromium, so it **requires a running
dev server** (default `http://localhost:4173`). To target another instance,
run `npm run test:track -- --url <app-url>` (or
`node scripts/track-regression.mjs --url <app-url>`). It does not depend on
live OpenSky / adsb.lol / AISStream (those are optional or rate-limited
locally); it installs a persistent `fetch` shim that returns synthetic
aircraft in the exact upstream payload shapes the layers parse.
- **`npm run build`** — production Vite build. No server or credentials
required; it validates that the client bundle compiles cleanly.
Each feature also has its own headless gate; see
[Feature regression gates](#feature-regression-gates) below. Docs and changelog
rules are under [Pull requests](#pull-requests).
## Checking a built app locally
Run `npm run build` followed by `npm run preview`. Preview serves the built
@@ -226,8 +247,9 @@ template has a section for them.
instead of attaching it.
3. **The proposed category.** Say which layer-panel group the layer belongs in.
The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today:
Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer
that isn't listed there falls under "Other layers". If none of the groups
Movement, Cameras, Infrastructure, Events, Weather and Audio, plus Utilities,
whose rows render in the separate UTILITIES panel). A layer that isn't
listed there falls under "Other layers". If none of the groups
fits, propose a new one and explain why.
4. **The data source and licence.** Name the provider and endpoint, link its
licence or terms, give the attribution shown in the app, and say whether it
+13 -1
View File
@@ -186,7 +186,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon's public instance, then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` uses the shared Nominatim forward-search queue at no more than one request per second, sends an identifying `User-Agent` and `Referer`, caches answers for five minutes, shares one upstream call between identical searches already in flight, bounds the queue so a burst is refused rather than held, and drops a queued search whose caller has given up. It asks for one result per search and is not used for systematic or bulk queries.
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
@@ -208,6 +209,8 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
@@ -304,6 +307,15 @@ the retrieval date (2026-07-30), exact download URL, license evidence, and the
deterministic transform (`scripts/build-sf-neighborhoods.mjs`: `nhood` → `name`, ~2 m
Douglas-Peucker simplification, 6-decimal rounding).
### Local CCTV source packs (`config/`)
The server can read a local camera list from `CCTV_SOURCES_FILE` (default
`config/cctv_sources.austin.json`, which ships empty) and inline JSON from
`CCTV_SOURCES_JSON`; the two are merged, and inline entries win on a duplicate
id. A non-empty local pack replaces the live open-data camera packs unless
`CCTV_FORCE_AUSTIN=1` is set. The packs under `config/` show the schema, and a
pack's cameras keep their own source's license and attribution.
---
## Weather sources
+1 -1
View File
@@ -4,7 +4,7 @@
# proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there
# is nothing else to containerise. See compose.yaml for the usual entry point.
# Same Node as CI (package.json engines, .github/workflows), pinned by digest.
FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8
FROM node:24.22.2-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c
# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download.
ENV PUPPETEER_SKIP_DOWNLOAD=1
+41 -5
View File
@@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat
## ⚡ Quick Start
**Start without an account or API keys.** Both paths open the same app with
**Start without an account or API keys.** Every path opens the same app with
Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is
unreachable. Flights, military traffic, satellites, earthquakes, public
cameras, radio, and launches are available without keys.
@@ -129,8 +129,9 @@ hardware; it does not establish a minimum GPU requirement.
### Path 2 — Terminal / coding agent
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
Node 25, which is end-of-life.
Use **Node.js 24.x (24.14.0 or later) or 26.x**; 24.22.1 or later (26.12.1 or
later on 26) includes the October 21, 2026 security fixes, among them
CVE-2026-111497. The setup doctor warns about Node 25, which is end-of-life.
```bash
git clone https://github.com/bilawalsidhu/gods-eye-view.git
@@ -233,6 +234,41 @@ the container.
</details>
### Path 4 — GitHub Codespaces (cloud, no local install)
Runs the same dev server in a cloud workspace, so Node and the dependencies
never touch your machine. The globe still renders in your own browser, so it
needs the same graphics there as any other path. GitHub Free includes 120
core-hours a month with no card on file — about 60 hours on the default 2-core
machine. Org and Enterprise plans include no free quota; current terms and
quotas apply.
1. Fork this repository, then open **your fork** on GitHub.
2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**.
3. In the codespace terminal:
```bash
nvm install 24 && nvm use 24
npm ci
npm run doctor
GEV_ALLOWED_HOSTS="$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" npm run dev
```
4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in
the browser. The port is private by default, so the URL asks for your GitHub
login. Keep it private: a public port lets anyone use the app and the keys
in your `.env`.
`GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only
the hostnames it trusts, and a forwarded port arrives on
`$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`. Without it the
page is `Blocked request. This host is not allowed.` Leading-dot wildcards are
ignored on purpose, so pass that exact name.
⚠️ **A codespace is reachable from the internet.** Treat it as the
wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider
Settings does not open on the forwarded URL, so put keys in the workspace
`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the
threat model.
### Then power it up — in the app, not in a file
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
@@ -357,7 +393,7 @@ camera input stops rotation.
> 🗣️ _"Outline the state of Texas."_ · _"Annotate the Texas State Capitol and its grounds"_ — it draws the **actual enclosing boundary**, not a circle. · _"How far is the Eiffel Tower from the Louvre?"_ — a connector arrow appears and it speaks the distance. Everything persists until you say _"clear the map."_
**✍️ Or draw it yourself** — DISPLAY ▸ **Draw**: pick Area, Line or Pin, click the vertices on the real world, double-click to finish, label it. Same whiteboard, same persistence, no microphone needed.
**✍️ Or draw it yourself** — UTILITIES ▸ **Draw**: pick Area, Line or Pin and a colour, click the vertices on the real world, double-click to finish, then name, recolour, hide or fly to each shape from its row. Same whiteboard, same persistence, no microphone needed.
![Zilker Park and Lady Bird Lake drawing onto the 3D city as persistent vector annotations, by voice](docs/media/01-voice-annotate-zilker.gif)
@@ -415,7 +451,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Utilities.
In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import.
**The basemap ladder — what each tier buys you:**
+34
View File
@@ -25,6 +25,32 @@ export function escapeFsDenyPath(path) {
const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT);
/**
* Browser-safe form of the optional Photon endpoint build input. Only https
* is accepted: the page's connect-src allows no plain-http origin, so an
* http endpoint would only fail later, silently, in the browser.
*/
function photonEndpointSetting(value) {
if (value === undefined) return undefined;
const text = String(value ?? '').trim();
if (!text || ['off', 'none', 'disabled'].includes(text.toLowerCase()))
return null;
try {
const url = new URL(text);
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.search ||
url.hash
)
return null;
return url.href;
} catch {
return null;
}
}
/**
* Content-Security-Policy for every document the dev/preview server serves.
* No inline script is permitted, and the only foreign script origins are the
@@ -143,12 +169,14 @@ export function createBrowserViteConfig({
publicDir,
googleApiKey,
cesiumToken,
photonUrl,
mapillaryToken,
host = 'localhost',
port = 4173,
allowedHosts = DEFAULT_ALLOWED_HOSTS,
command,
} = {}) {
const photonSetting = photonEndpointSetting(photonUrl);
return {
plugins: [
// First, so every API response, including a Host refusal, carries them.
@@ -219,6 +247,12 @@ export function createBrowserViteConfig({
__INTLIFY_DROP_MESSAGE_COMPILER__: 'true',
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
// Only expose a validated URL or the explicit disabled sentinel.
...(photonSetting === undefined
? {}
: {
'import.meta.env.PHOTON_URL': JSON.stringify(photonSetting),
}),
// Mapillary client tokens are public by design (see SECURITY.md).
'import.meta.env.MAPILLARY_CLIENT_TOKEN': JSON.stringify(
mapillaryToken ?? '',
@@ -0,0 +1,4 @@
- Keep every submarine-cable segment associated with its source feature after
Cesium expands multipart geometry, correcting segment colors, click destinations
and horizon culling while retaining one reference stem per feature
(@Aliciawque, #1093).
@@ -0,0 +1,4 @@
- The military-installations proxy (`/api/military-installations`) now refuses
cross-site and reverse-proxy-forwarded browser requests with 403 before any
Overpass work, through the same shared gate as `/api/overpass`. Same-origin
app requests and local tools such as curl still work (@Sunil56224972, #1427).
@@ -0,0 +1,3 @@
- Military aircraft whose transponders report a GNSS vertical rate now carry it
in analyst and tracked-aircraft data instead of null (Aditya, @Rohilalala,
#1436).
@@ -0,0 +1,3 @@
- Bike Share stations whose feed publishes no capacity now take it from each
status poll's bikes and docks instead of keeping the first poll's value or
the placeholder 15 (Aditya, @Rohilalala, #1447).
+6
View File
@@ -0,0 +1,6 @@
- Local ADS-B decoder feeds now carry a GNSS-sourced vertical rate
(`geom_rate`) when no barometric rate is sent, so the card shows V/S
instead of a dash (Aditya, @Rohilalala, #1448).
- Local ADS-B, military and the adsb.lol flight fallback read the vertical
rate through one shared readsb field reader, so all three prefer the
barometric rate and fall back to the GNSS rate the same way (#1448).
@@ -0,0 +1,3 @@
- List each submarine cable once in find_submarine_cables. TeleGeography
splits 17 cables (2Africa, Echo, Hawaiki) into several features, so a
search for Echo read "3 submarine cables" (Aditya, @Rohilalala, #1449).
@@ -0,0 +1,6 @@
- Space Missions no longer shows an older satellite with a similar name and a
different known launch year as a new payload's orbit: the launch-year check
on loaded satellites now runs (Aditya, @Rohilalala, #1453).
- Satellite launch years come from one TLE designator parser for both the
loaded catalog and the active-TLE fallback; a blank designator is an
unknown year instead of 2000 (#1453).
+5
View File
@@ -0,0 +1,5 @@
- Between 721px and 900px wide (tablet portrait, phone landscape), the
Location and Visual Presets dock wings show only their icons, so their
labels no longer spill over the voice control or take taps meant for the
mic button. The full name stays in the tooltip and accessible name, and the
Cyber layout keeps its labels (Denis Groshev, @ZloyS, #1457).
@@ -0,0 +1,4 @@
- The Docker image and CI now pin Node 24.22.2, the current 24 LTS patch. It
carries the October 21, 2026 security fixes first released in 24.22.1,
including CVE-2026-111497 in the Brotli decoder (Luis Ortiz, @anobjectn,
#1458).
@@ -0,0 +1,5 @@
- Leave layer aliases the layer id already spells, such as "dams" for
`local-dams`, out of the voice layer hint. The Realtime prompt is 305
characters shorter, leaving room under the 52,000 gate for new
voice-toggleable layers; every alias is still accepted (@quanticsoul4772,
#1462).
+6
View File
@@ -0,0 +1,6 @@
- Gather Directions, Draw and Import in a new UTILITIES panel in the left
column, between Data Layers and Scenes. Draw and Import leave DISPLAY, and
Radio stays in Data Layers under "Audio". Each drawn shape now gets a row
under Draw, like an imported file: name it, recolour it, hide it, fly to it
or remove it there. Area, Line and Pin are icon buttons, and the colour for
the next shape is a row of swatches (asturksever, #1469).
@@ -0,0 +1,4 @@
- Contributor docs: CONTRIBUTING lists what each of the three baseline checks
needs to run, DATA_SOURCES documents loading a local CCTV source pack, and
KNOWN-ISSUES points the floor-placement note at its verification script
instead of a missing report (Gaurav Patidar, @gaurav7902, #210).
+52
View File
@@ -0,0 +1,52 @@
- Add source-backed voice outlines without public Overpass calls: bundled Census and
Who's On First boundaries cover places and neighborhoods, OpenFreeMap tiles
cover streets, buildings, and grounds, and guarded Nominatim lookup handles
remaining named areas with bounded caching and usage limits (bilawalsidhu,
#842).
- Preserve standalone server-token maps, project tile outlines only on demand,
select building components with their holes independently, preserve non-Latin
street-name marks, and enforce public Nominatim spacing at fetch invocation.
Nominatim now requires explicit endpoint configuration (manjunath22466, #842).
- Keep Census labels in filled geometry, handle local dateline outlines in one
coordinate frame, and require whole settlement names or explicit returned
aliases while retaining landmark shorthand (manjunath22466, #842).
- Harden and integrate voice outlines with main (manjunath22466, #842).
- Wait for the original building installation before reporting duplicate results,
including deferred outline installs, without restarting probes
(manjunath22466, #842).
- Carry actual outline provenance to visible geometry and retain inline map
credits only while the owning outline is displayed (manjunath22466, #842).
- Require a named building's resolved anchor inside its tile footprint, outside
courtyard holes, while keeping nearby selection for pointed-at buildings
(manjunath22466, #842).
- Start each queued building's ground-sampling budget when it reaches the head
of the queue, keep Nominatim outlines that cross the antimeridian, let an
unnamed school, university, hospital, cemetery, stadium or zoo area answer a
grounds ask whose head noun names that kind, keep the shared OpenFreeMap tile
caches within the 64 MiB budget of the single source traffic and installations
shared before, fold place names in one shared helper, and exclude Who's On
First sources whose licence is only listed as CC BY or CC0 "equivalent",
including the Ottawa, Saskatoon, Gatineau and Madrid records
(manjunath22466, #842).
- Skip Nominatim place and outline lookups when the server reports that no
Nominatim endpoint is configured, so a default install sends no request it
would refuse (manjunath22466, #842).
- Keep the phone credit line on screen when map and outline credits make it
longer, wrapping it within the screen instead of running off the edge
(manjunath22466, #842).
- Outline nothing from tiles when the tile under the point failed to load, so a
neighbouring building or area never stands in for it, and match each of a
Nominatim name tag's ";"-separated names (manjunath22466, #842).
- Keep bent streets outlined when the geocoded point sits beside the
centreline, never answer a landmark ask with the park or square around it,
pick the same-name Nominatim outline that holds the resolved place, and keep
a new outline when an identical older mark fails to clean up
(samehkhamis, #842).
- Tighten the remaining voice outline review items: Nominatim name checks use
the bundled packs' place-name key ("St. Paul" = "Saint Paul"), "E Street" no
longer matches "East Street" while "E St NW" answers "E Street" near it,
"Burj Khalifa Tower" still finds the tower, no neighbour standing in
for a building cut at the tile edge, no cut area answering grounds, https-only
PHOTON_URL, an interrupted Nominatim dispatch reclaimed after ten minutes,
no node:sqlite startup warning, and lazily decoded shared OpenFreeMap layers
(samehkhamis, #842).
+3
View File
@@ -0,0 +1,3 @@
- Document GitHub Codespaces as a fourth setup path in the README: a cloud
workspace runs the dev server while the globe renders in your own browser
(John Leshan, @johnleshan, #980).
+154 -51
View File
@@ -131,6 +131,26 @@ provider registration rejects compositions that cannot fit rather than truncatin
identities. A malformed or duplicate entry invalidates the complete custom-switch
field. Existing whole-link size limits still apply.
## Utilities panel — October 29, 2026
UTILITIES is its own left-side panel, between Data Layers and Scenes, with
its own rail item (`handyman`). It is a `RailPanel` (`#utilities-panel`) in
`src/ui/views/LayerPanelsView.vue` and holds the Directions layer row, which
`LayerPanel` renders into `#utilities-toggles` instead of the Data Layers
list, then Draw (`DrawControls.vue`) and Import (`GeoImport.vue`), which left
DISPLAY with their ids unchanged. Without `#utilities-toggles` (a host that
omits the panel) Directions stays in Data Layers under its Utilities heading.
Radio, formerly the other row under that heading, stays in Data Layers under
"Audio". Like the other side panels it follows the one-open-panel rule,
remembers its state, and is not part of share links. It hides in clean view
and recording. In Cockpit it stays with Directions alone, as Data Layers does;
Draw and Import hide there, as DISPLAY always did, and opening Utilities folds
the Cockpit context rail away as Data Layers does (and Contact expanding
closes it). Voice `show_data_layers_menu` for Directions opens Utilities, the
panel that holds its row, and `set_panel_open` reaches it as
`utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone
it sits collapsed in the Layers sheet under Data Layers.
## God's Eye View in conversations — October 2, 2026
Tool answers that can be shown in God's Eye View include a view: camera, layers,
@@ -197,6 +217,9 @@ catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
Aircraft altitude-band queries require a reported barometric or geometric altitude
for each match, so a grounded contact that reports none is left out of a band too.
Queries without an altitude band retain unknown-altitude contacts.
`find_submarine_cables` lists and counts each cable once: TeleGeography splits
a few cables (2Africa, Echo) into several features under one id, and such a
cable matches when any of its parts crosses the area.
Catalog calls check their cancellation signal immediately before executing a
tool. Already-cancelled queries do not start; cancellation after execution starts
@@ -293,7 +316,7 @@ settings from active map/contact effects. Sonar settings are session-only and
return to their defaults on reload. Sliders accept integer values matching the
action's ranges.
Wind appears in the Weather group before Utilities. The surface-weather prototype
Wind appears in the Weather group before Audio. The surface-weather prototype
uses keyless NOAA GFS or ECMWF IFS forecasts on an approximately 1° display grid.
It defaults to 10 m wind trails over the existing basemap. Speed shading is an explicit choice; earlier v2 links retain their original speed-shading meaning.
Weather rows contain only the toggle and source/meta line. Configuration lives
@@ -1135,7 +1158,8 @@ The tracked-aircraft descriptor both flight layers hand to the cockpit carries
`verticalRate` and military records as `verticalRateMps`; the query seam maps both to
one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The
rate is reported, never derived: a blank poll keeps the last transmitted value and an
absent rate stays null rather than reading as level flight.
absent rate stays null rather than reading as level flight. A military readsb row
without `baro_rate` reports its GNSS rate as `geom_rate`, which is used instead.
## Transponder emergency highlighting
@@ -1894,6 +1918,8 @@ limit, as well as when a tile decodes or the source resets.
GBFS null or empty availability counts retain an unknown value through station
parsing and rendering; they use the neutral station color rather than reporting
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
A station whose `station_information` publishes no capacity takes bikes plus
docks from each status poll (15 until its status arrives), not from the first.
## Terrain height cache bound
@@ -2790,6 +2816,20 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only
> name the panel (`#location-bar` / `#control-panel`) to reach (2,5,0). Adding
> a new tray rule means checking it against the pinned variants, not just
> against the base rule.
> - **Dock wings are icon-only from 900px down to 721px (2026-10-28):** at
> ≤900px `--dock-side-compact` shrinks the Location and Visual Presets wings
> to 4.25rem, open or closed (an open tray pops out above the dock), which is
> too narrow for their text labels. The labels used to spill over the voice
> control, and the LOCATION label captured taps on the left half of the mic
> button. `src/ui/styles/command-dock-trays.css` now sets the
> labels to `font-size: 0` and drops their `›`/`‹` chevrons in that band, so
> each wing shows only its icon. `CommandDockView.vue` still gives the toggles
> their "Expand/Collapse …" `aria-label` and `title` (`trayWords`), so the
> browser tooltip is the only visible name on hover.
> The Cyber HUD layout is excluded: it sets `--dock-side-compact` to
> 9–9.75rem, which fits its labels. The former ≤620px ellipsis clip is gone:
> the phone shell takes over at 720px, so it never applied to a visible
> dock.
> - **LOCATION mini-status is data-only today (2026-08-20):** the collapsed
> readout now follows a free-text geocode search as well as preset pills
> (`src/locationStatus.js` owns the copy for both), and every other camera
@@ -3429,7 +3469,8 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> style-switch persistence state. Browser/GPU performance comparison remains
> operator-side; the final accounting records only comparable population
> reductions and deterministic Node allocation/solve measurements. Those
> allocation gates are calibrated on Node.js 24.14.x; `package.json` permits
> allocation gates were calibrated on Node.js 24.14.x and run in CI on the
> pinned Node.js 24.22.2 with the same ceilings; `package.json` permits
> supported product runtimes on Node 24 or 26, while the allocation runner
> separately enforces Node 24 for these two calibrated probes. Each probe
> compiles synchronously and discards explicit-GC
@@ -3879,7 +3920,7 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, *
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
- **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera',
'{count} cameras')`, and `msg('Live Flights')` for text stored in data and
'{count} cameras')`, and `msg('Live Flights')` for text stored in data and
translated later. Markup opts in with `data-i18n` (element text) and
`data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English
value). English has no catalog; every other locale is
@@ -3949,31 +3990,31 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -3999,7 +4040,6 @@ MLAT-positioned GPS loss, derived-field exclusions and the NIC 0 exception.
anchors, recovery, keyboard and mobile states; emergency repainting is covered
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
@@ -4165,8 +4205,7 @@ that does not answer in 20 s is asked once more, and one that is not a line
list is not cached. A click on a line reaches
`handlePick(id, {groundPoint})`, and the provider asks the core
(`actions.openNearest`) for its nearest showable photo within 50 m, without
framing: many photos are archived (their file answers 409, while a HEAD says
200) or have no file, so the six nearest are probed at once with a GET cut
framing: many photos are archived (their file answers 409, while a HEAD says 200) or have no file, so the six nearest are probed at once with a GET cut
off at the headers, and where all are gone the panel says KartaView's photos
there are archived. Only a definite answer (403/404/409/410, or 200) is
remembered; a storage timeout or 5xx is asked again. The open photo's
@@ -5057,7 +5096,7 @@ silently demoting every later lookup for the session.
protected-card path. Disable tears the tier down completely.
Coverage/viewshed semantics unchanged.
- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness.
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time.
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Both the loaded-catalog and active-TLE lookups read the launch year from the TLE international designator through one parser (`src/sources/tle.js`); a blank or malformed designator is an unknown year, which skips the year check rather than rejecting the match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline,Line truncated
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
@@ -5080,6 +5119,8 @@ repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's
answers; untagged entries stop serving once an instance is configured.
The shared transport guards `/api/overpass` and `/api/military-installations`.
Both query handlers refuse cross-site and forwarded browser requests through
the shared local-request gate, before any rate-limit, cache or upstream work.
With no configured upstream, each route serves existing cache data (including
expired entries, marked stale with the original cache/retrieval date) without
refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`,
@@ -5392,13 +5433,36 @@ must match exactly, so `12junk, 34oops` and `austin, tx` both reach the
geocoders instead of being answered from the bundle. Degrees/minutes/seconds and
grid references are not parsed. An answer either provider gives is exact, so
nearby-landmark recovery stands aside for it — otherwise a typed coordinate
could be replaced by whatever Places finds near the current view. The portable `./search` export
could be replaced by whatever Places finds near the current view. The portable `./search` export uses
Google first when configured, Photon/OpenStreetMap as fallback (including Google
transport failures or declined requests), and the local `/api/geocode` route
over Nominatim as a last resort when neither answers. That route keeps to the
public instance's usage policy: an identifying `User-Agent` and `Referer`, at
most one request per second shared with the cockpit's reverse lookups, answers
cached for five minutes, one upstream call shared between identical searches
over Nominatim as a last resort when neither answers and an operator explicitly
configures `NOMINATIM_URL`. Unset, empty and invalid values disable Nominatim.
`GET /api/geocode/status` answers `{configured}`; the browser asks once per
page, and when Nominatim is not configured neither the last-resort search nor
the voice outline client asks `/api/geocode`, so a default install sends no
lookup it would refuse. A missing or failed probe leaves the capability unknown
and lookups proceed as before.
Explicit public-service configuration retains an identifying `User-Agent` and `Referer`, at
least 1.1 seconds between public search and outline starts, a shared durable
per-install daily allowance and pause state that fail closed when unusable.
The shared state lock covers actual fetch invocation and recording the next
start. An unfinished dispatch marker (`state.json.dispatch.since-<ms>.*`, left
by a crash or a persistence failure) disables further public requests for ten
minutes after the start it names, far beyond any request's 9-second timeout.
A marker exists only inside the lock, so the next lock owner after that window
removes it and restarts spacing from its own clock; the count was persisted
before the interrupted invocation. A marker naming no start keeps refusing
until removed by hand. These controls do not establish an application-wide
public-service allowance or authorize its use. For manual recovery, stop every
process sharing the state file before removing only a confirmed orphan marker,
and preserve the state file's count, pause and next start.
`stats().persistence.dispatchPending` exposes the marker; an unlocked status
read can also briefly see a live dispatch. Do not reset usage state to restore
access.
Answers are cached
for 30 days (search), 90 days (outline) or 24 hours (genuine no result),
one upstream call shared between identical searches
already in flight, a queue bounded at four waiting searches (a burst past it is
refused with 429 and `Retry-After`, never held), and a queued search dropped
once its caller has given up rather than spending a slot on an answer nobody
@@ -5469,7 +5533,7 @@ request viewbox and continues searching without that geographic preference.
- Runtime entry: `src/main.js` calls `initAnnotations({ viewer, tileset })`, exposes `window.__gevAnnotations`, and passes the engine into the voice action runner.
- Engine contract: `src/annotations/annotationEngine.js` owns annotation state, TTL/fade lifecycle, concurrent anchor resolution, duplicate detection keyed on geometry, cancellation on clear/newer generations, and a hard cap of 120 live marks. Deferred outline upgrades drain FIFO at concurrency 2; queued work retains the owning abort controller and is discarded on a generation change before it can fetch.
- Resolver contract: `src/annotations/annotationResolver.js` converts names/coords/screen pixels into world anchors and optional geometry. The resolver is type-aware: Google Geocode/Places gives a centroid + scope, operator-configured OSM/Overpass supplies admin/place/footprint/street/enclosing-area geometry, route requests use `/api/route`, and ambiguous/far results are rejected or recovered near the current view instead of drawing misleading blobs. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
- Resolver contract: `src/annotations/resolver.js` converts names, coordinates and screen pixels into world anchors and optional geometry. Google Geocode/Places supplies a centroid and scope; route requests use `/api/route`; ambiguous or distant results are rejected or recovered near the current view instead of drawing misleading blobs. Outline resolution keeps this order: bundled admin/place/neighborhood/Natural Earth geometry; OpenFreeMap street or pointed-building geometry where applicable; the configured OSM/Overpass admin/place/footprint/street/enclosing-area ladder; guarded Nominatim for eligible named cities, admin units, landmarks, buildings and grounds; then an OpenFreeMap building fallback that accepts a named building only when its resolved anchor lies in the selected filled footprint. Pointed-at building requests retain nearby selection within 40 m. Tile building and grounds answers also need the tile holding the anchor: when that tile fails to load and only neighbours arrive, no neighbouring building or area stands in. Tile grounds answer a named grounds ask only from an area of the same name, or, when the ask's head noun names a kind of grounds (school, university/college, hospital, cemetery, stadium, zoo; "campus" alone names none), from the part of the smallest unnamed area of that class that holds the anchor (a class word elsewhere in a name, as in "University Avenue Park", does not count); a differently named area never answers. If no rung can supply honest geometry, the mark remains a point or reports the capability unavailable. A real polygon, definitive miss, configured-ladder approximation, rate limit or transient result keeps its existing finality; Nominatim is only tried after higher-priority configured sources cannot answer. Building tile selection returns the selected component and its holes; a shared vector feature ID does not establish that unrelated components are one building. Other trusted multi-part outlines and holes are retained in `anno.polygons`, with `ring` anchored to the part whose filled area contains the resolved point, outside its holes, when possible. Guarded Nominatim keeps every accepted result for an ask, ranks an exact name first, then a name holding every asked word, then a partial match; a partial landmark match whose name lacks the ask's last word is refused unless that word appears in the result's address or type (the place around a feature, such as a park around its zoo, is not the feature, while "Space Needle Seattle" still finds the Space Needle). A building word ("Burj Khalifa Tower") also passes on a single-structure result (one a building ask would accept), never on a campus, zoo or district. Each ask is answered with the cached result among the best-named ones whose filled area holds the resolved anchor (read across the antimeridian), else the first; a worse name never wins by containment alone. Nominatim name checks use the bundled packs' place-name key, so "St. Paul" and "Saint Paul" agree there. A street ribbon that holds neither its ring mean nor the anchor is centred on the street's nearest point to the anchor when the street runs within 100 m of it. In tile street names a lone letter is a direction only beside another name word ("N Lamar Blvd"), so "E Street" is not "East Street", and a quadrant ("E St NW", "SE Main St") may go unsaid in the ask, which then takes only the quadrant of the matching street nearest the point. A building or area cut at the tile edge is not complete (past a tile buffer any vertex on the cut line marks it; on an unbuffered edge, an edge running along the boundary does, not a corner that merely touches it): a cut building nearer the point than any whole one leaves the point, and a cut area does not answer grounds. Missing capability never invents a radius outline. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
- With no detailed-feature capability, annotations retain their pins and say
"Detailed outline unavailable"; location focus reports the same result without
retrying. Missing capability never synthesizes an outline from a radius.
@@ -5508,6 +5572,28 @@ request viewbox and continues searching without that geographic preference.
Berlin inside Brandenburg). Each pack loads on the first ask that needs it,
never at start-up; the analyst region lookup uses the same packs. Gate:
`scripts/qa-admin-outlines.mjs`.
- Bundled place and neighborhood outlines (`src/data/placeBoundaries.js`):
US Census places (`src/data/local_data/us_census_places/`, one file per
state, public domain, `scripts/build-census-places.mjs`) and Who's On First
neighborhoods (`src/data/local_data/wof_neighborhoods/`, quadtree tiles,
per-source licences, `scripts/build-wof-neighborhoods.py`). After the admin
packs, an area ask named at or near the view is drawn from, in order, a
Census place ("Austin"; a state qualifier narrows the search to that state
wherever the camera is: "Springfield, Illinois"), the San Francisco DataSF
pack, then WOF ("Notting Hill", "Le Marais"). "Near" reaches half the view
radius. A district ask takes a neighborhood first and a place only when the
view is inside it; without an entity kind, the candidate holding the view
wins, else the nearer. "City of X" asks for an incorporated place; when
several places still fit and nothing decides, the geocoder answers.
Compound, grounds, monument and "around" asks skip these, as do names that
end like a landmark ("Central Park") unless asked as a district, and a
qualified neighborhood ("Williamsburg, Virginia") goes to the geocoder. A
geocoded city or neighborhood is drawn from the same packs when one carries
the name and holds the point, before any boundary lookup. Only the index and
the state files or tiles around the point are fetched (files under 4 KB are
inlined into the bundle), and the recently used ones are kept (12 state
files, 24 tiles). Gate:
`node scripts/qa-voice-routing.mjs --layer behavior --behavior outlines`.
- Renderer contract: `src/annotations/hybridAnnotationRenderer.js` routes draped `area`/`route` geometry to world-space Cesium rendering and reticles/pins/arrows/callouts to the screen-space SVG renderer. Area labels are screen-space callouts so all captions share one visual language; progressive outline upgrades convert the existing screen group in place when the anchor snaps to the resolved centroid.
- Tooling: voice has `annotate_map` and `clear_annotations` tools. Annotations accumulate and persist by default; clearing is explicit only. Partial failures, approximate synthesized zones, and route fallbacks are returned as structured tool results so the voice layer can be honest.
- Console/dev API: `window.__gevAnnotations.tour()`, `.demo()`, `.annotate()`, `.clear()`, `.count()`, and `.list()` are the deterministic no-mic test surface.
@@ -5515,9 +5601,10 @@ request viewbox and continues searching without that geographic preference.
### Manual Whiteboard Drawing (September 2026)
- DISPLAY ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (Area, Line, Pin), click the vertices on the real world, type an optional label and pick a colour, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. Typing in the label field keeps Backspace; Enter there also finishes. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its markup is in the DISPLAY-rail view (`src/ui/views/DisplayControlsView.vue`), rendered into its `index.html` host before the page is wired. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `diagnostics`) is the no-mouse test seam.
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, de-dup, appear in `.list()`, and clear with `clear_annotations` / `.clear()`.
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
- **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins.
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
- **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew.
@@ -5528,7 +5615,7 @@ request viewbox and continues searching without that geographic preference.
- Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection.
- **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing.
- **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed.
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas, the page body and the tool's own label field. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
- **Enter belongs to whatever has the keyboard.** The finish key is taken only from the canvas and the page body. With a control focused — Clear, a shape button — Enter activates that control, as a keyboard user expects; it does not quietly finish the shape.
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
@@ -5556,7 +5643,7 @@ request viewbox and continues searching without that geographic preference.
### Import Your Own Files (October 2026)
- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy.
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
@@ -5655,7 +5742,6 @@ Vite's development filesystem deny list excludes local `.gev-logs/` and
dependency `dist/` assets. Checkout glob characters are escaped. This does not
apply to `public/` assets or built-preview serving.
- Recommended launcher: `./scripts/dev-fresh.sh` (also: `dev-secure.sh` for stricter bindings, `dev-cctv.sh` for CCTV source-pack tuning)
- A successful Pinokio install writes the owner-only `pinokio/.installed`
marker. The nested launcher menu resolves that marker from its own directory:
@@ -6018,8 +6104,14 @@ easier to meet (detection is now on more often), but does not create it.
Only eligible road geometry receives dots;
reverse one-way lines are reversed. The shared tile source caps each view at
16 tiles per pass (32 OpenFreeMap tiles total), four concurrent requests and 4 MiB per response. OpenFreeMap retains
up to 192 decoded tiles/64 MiB (four times body bytes as a storage estimate);
other tile sources retain the 64-tile/24 MiB defaults. Identical source-version/XYZ
up to 192 decoded tiles/32 MiB in one shared decoded cache (four times body
bytes as a storage estimate); traffic and installations each keep their own
slice of those tiles within 12 MiB and outlines within 8 MiB, charged at the
slice's estimated object size. The charged budgets total 64 MiB, the budget
of the single OpenFreeMap source traffic and installations shared before;
they are estimates, not a measured heap bound, and each consumer slice is a
copy (`structuredClone`) made once per cached tile. Other tile sources retain
the 64-tile/24 MiB defaults. Identical source-version/XYZ
requests share one download/decode, with independent caller cancellation. Parsed road views have a separate 24 MiB/64-entry cap; incomplete
views are not retained as complete snapshots. Buffer geometry is clipped and sub-12 m line slivers dropped; tile
road fragments are not stitched. Roads preserve bends and insert
@@ -6179,7 +6271,10 @@ The lockfile uses DOMPurify 3.4.17, protobufjs 8.8.0, PostCSS 8.5.28,
source-map-js 1.2.2 and nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA
uses Puppeteer 25.10.0; image-processing tools use Sharp 0.35.5. QA scripts await Puppeteer's asynchronous
executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain
24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree.
Node 24 (24.14.0 or later) and 26.x; the Docker image and CI pin 24.22.2,
the current 24 LTS patch, which carries the October 21, 2026 Node.js security
fixes first released in 24.22.1 (including CVE-2026-111497). Use
`npm ci` to reproduce the checked-in dependency tree.
### Traffic city navigation
@@ -6321,7 +6416,9 @@ Receivers publish one record shape from `src/sources/adsbRecords.js`
`lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is
`webusb` (browser SDR) or `feed` (decoder feed).
`normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/
skyaware978 `aircraft.json` document into the same records. Voice
skyaware978 `aircraft.json` document into the same records; a row without
`baro_rate` reads its vertical rate from `geom_rate`, where a GNSS-sourced rate
is sent. Voice
`set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver",
"my antenna").
@@ -6368,6 +6465,12 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache,
and destroying clears it. Load ownership prevents cancelled work from adding
entities after teardown.
Every Cesium entity from multipart cable or landing-point geometry stays tied
to its source feature, including generated IDs and IDs that collide with
Cesium's multipart suffixes. Each segment gets its own feature's styling, click
destination and horizon-culling reference, and each source feature has one
reference stem. The source collections are not modified.
Natural Earth regions and neighborhood polygon lookup are package exports.
Their existing lazy loaders, retry behavior, bundled datasets and attribution
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
@@ -6508,8 +6611,8 @@ when their footprints touch or contain one another, never across a seam toleranc
### Shared native dropdowns and numeric fields
- Scene and CCTV selects share the default `gev-field` surface. HUD layout and
draw colour retain compact density. Radio filter, tuner gain and FM frequency
- Scene and CCTV selects share the default `gev-field` surface. HUD layout
retains compact density; Draw's colour is a row of swatches, not a select. Radio filter, tuner gain and FM frequency
use the same base while retaining their feature-specific skins and layout.
- The CCTV inline number editor is rendered by its pose chip component with
its existing precision and inline density. Enter/blur commit, Escape cancellation, frozen
+3 -3
View File
@@ -71,9 +71,9 @@ Status: Open (owner-accepted 2026-07-08, documented)
a few stragglers take one more poll.
- **Born-grounded first poll:** a contact first seen on the ground with no altitude
data renders at the geoid for ≤1 poll until its floor cell warms.
- Full context, improvement ideas, and the verification oracle
(`scripts/qa-floor-verify.mjs`):
`docs/superpowers/reports/2026-07-08-height-datum-handover.md`.
- Verify floor-placement behavior with `scripts/qa-floor-verify.mjs` against a
running local app; set `QA_BASE_URL` when it is not on the default port
(`http://localhost:4173`).
---
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+46 -34
View File
@@ -111,8 +111,8 @@ const cacheDir = path.resolve(
);
const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
async function fetchPinned(name) {
const { url, sha256 } = SOURCES[name];
export async function fetchPinned(name, source = SOURCES[name]) {
const { url, sha256 } = source;
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
let bytes = await readFile(file).catch(() => null);
if (!bytes) {
@@ -137,7 +137,7 @@ const EARTH_RADIUS_KM = 6371;
const toRad = (d) => (d * Math.PI) / 180;
/** Spherical ring area (km²); matches src/data/naturalEarthRegions.js. */
function ringAreaKm2(ring) {
export function ringAreaKm2(ring) {
const n = ring.length;
if (n < 3) return 0;
let sum = 0;
@@ -151,7 +151,7 @@ function ringAreaKm2(ring) {
}
/** Signed planar (shoelace) area: positive counter-clockwise in lon/lat. */
function signedArea(ring) {
export function signedArea(ring) {
let sum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
sum += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
@@ -209,15 +209,16 @@ function douglasPeucker(points, tolerance) {
* Simplify a closed ring to an OPEN rounded ring. The ring is split at its
* farthest vertex from the start so Douglas-Peucker has two anchored halves
* (a closed ring has no chord to measure against). Returns null when fewer
* than four distinct vertices survive.
* than `minVertices` distinct vertices survive (four by default; a pack that
* keeps triangles passes three).
*/
function simplifyRing(ring, tolerance, decimals) {
function simplifyRing(ring, tolerance, decimals, minVertices = 4) {
let open = ring.slice();
const first = open[0];
const last = open[open.length - 1];
if (open.length > 1 && first[0] === last[0] && first[1] === last[1])
open = open.slice(0, -1);
if (open.length < 4) return null;
if (open.length < minVertices) return null;
let far = 1;
let farDist = -1;
for (let i = 1; i < open.length; i++) {
@@ -247,7 +248,7 @@ function simplifyRing(ring, tolerance, decimals) {
out[0][1] === out[out.length - 1][1]
)
out.pop();
return out.length >= 4 ? out : null;
return out.length >= minVertices ? out : null;
}
/**
@@ -255,16 +256,17 @@ function simplifyRing(ring, tolerance, decimals) {
* area floors (the largest part always stays), orient outers
* counter-clockwise and holes clockwise, sort parts by area descending.
*/
function simplifyPolygons(polygons, params, tolerance, decimals) {
export function simplifyPolygons(polygons, params, tolerance, decimals) {
const minVertices = params.minRingVertices ?? 4;
const parts = [];
for (const poly of polygons) {
const outer = simplifyRing(poly[0], tolerance, decimals);
const outer = simplifyRing(poly[0], tolerance, decimals, minVertices);
if (!outer) continue;
const area = ringAreaKm2(outer);
if (signedArea(outer) < 0) outer.reverse();
const holes = [];
for (const hole of poly.slice(1)) {
const ring = simplifyRing(hole, tolerance, decimals);
const ring = simplifyRing(hole, tolerance, decimals, minVertices);
if (!ring || ringAreaKm2(ring) < params.minHoleKm2) continue;
if (signedArea(ring) > 0) ring.reverse();
holes.push(ring);
@@ -287,18 +289,22 @@ function simplifyPolygons(polygons, params, tolerance, decimals) {
* and a fifth of the minimum tolerance, or failing that unsimplified at two
* more decimals; the caller records `d`.
*/
function simplifyUnit(polygons, params) {
export function unitTolerance(polygons, params) {
const sourceArea = polygons.reduce(
(sum, poly) => sum + ringAreaKm2(poly[0]),
0,
);
const tolerance = Math.min(
return Math.min(
params.maxToleranceDeg,
Math.max(
params.minToleranceDeg,
(params.toleranceFactor * Math.sqrt(sourceArea)) / 111.32,
),
);
}
export function simplifyUnit(polygons, params) {
const tolerance = unitTolerance(polygons, params);
const coarse = simplifyPolygons(polygons, params, tolerance, params.decimals);
if (coarse.length) return { polygons: coarse, decimals: params.decimals };
const fine = simplifyPolygons(
@@ -334,7 +340,7 @@ function encodeRing(ring, decimals) {
return out;
}
function encodeUnit({ polygons, decimals }) {
export function encodeUnit({ polygons, decimals }) {
return polygons.map((poly) => poly.map((ring) => encodeRing(ring, decimals)));
}
@@ -362,7 +368,7 @@ function bboxOf(polygons) {
// ── zip / shapefile / dbf ───────────────────────────────────────────────
/** Read named entries from a zip archive (stored or deflated). */
function unzip(buffer) {
export function unzip(buffer) {
let eocd = buffer.length - 22;
while (eocd >= 0 && buffer.readUInt32LE(eocd) !== 0x06054b50) eocd--;
if (eocd < 0) throw new Error('zip: end of central directory not found');
@@ -394,7 +400,7 @@ function unzip(buffer) {
}
/** Polygon records (shape type 5) as arrays of closed [lon, lat] rings. */
function readShp(buffer) {
export function readShp(buffer) {
const shapes = [];
let offset = 100;
while (offset < buffer.length) {
@@ -431,7 +437,7 @@ function readShp(buffer) {
}
/** dBASE records as plain objects (character and numeric fields). */
function readDbf(buffer) {
export function readDbf(buffer) {
const count = buffer.readUInt32LE(4);
const headerLength = buffer.readUInt16LE(8);
const recordLength = buffer.readUInt16LE(10);
@@ -458,7 +464,7 @@ function readDbf(buffer) {
}
/** Group shapefile rings into polygons: clockwise outers, holes assigned by containment. */
function shpPolygons(rings) {
export function shpPolygons(rings) {
const outers = [];
const holes = [];
for (const ring of rings) {
@@ -475,7 +481,7 @@ function shpPolygons(rings) {
return polygons;
}
function pointInRing(ring, x, y) {
export function pointInRing(ring, x, y) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
@@ -742,18 +748,24 @@ async function writePack(relative, pack) {
);
}
if (!only || only === 'countries')
await writePack(
'src/data/local_data/natural_earth/countries.json',
await buildCountries(),
);
if (!only || only === 'admin1')
await writePack(
'src/data/local_data/natural_earth/states_provinces.json',
await buildAdmin1(),
);
if (!only || only === 'counties')
await writePack(
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
if (!only || only === 'countries')
await writePack(
'src/data/local_data/natural_earth/countries.json',
await buildCountries(),
);
if (!only || only === 'admin1')
await writePack(
'src/data/local_data/natural_earth/states_provinces.json',
await buildAdmin1(),
);
if (!only || only === 'counties')
await writePack(
'src/data/local_data/us_census_counties/counties.json',
await buildCounties(),
);
}
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env node
/**
* Build the bundled US places pack read by `src/data/placeBoundaries.js`:
*
* src/data/local_data/us_census_places/index.json state list, boxes, meta
* src/data/local_data/us_census_places/<ST>.json one file per state
* src/data/local_data/us_census_places/files.js their URLs, for bundlers
*
* Source: US Census Bureau 2025 cartographic boundary places
* (cb_2025_us_place_500k, public domain), pinned by URL and SHA-256, so a
* rerun reproduces the pack byte for byte. The download is cached outside the
* repository (default: the system temp directory).
*
* Geometry uses the county pack's simplification and encoding
* (scripts/build-admin-packs.mjs) with two differences: triangles are kept,
* and a unit whose simplified rings cross themselves or each other is
* simplified again at half the tolerance (finer precision last). Then
* `scripts/pack_geometry.py repair census` checks every feature as a whole
* MultiPolygon under GEOS and repairs the ones that are still invalid (rings
* touching themselves, overlapping parts), so it needs Python with
* shapely==2.1.2 on GEOS 3.13.1.
*
* Usage:
* node scripts/build-census-places.mjs [--cache <dir>] [--python <exe>]
*/
import { execFileSync } from 'node:child_process';
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
PARAMS as ADMIN_PARAMS,
encodeUnit,
fetchPinned,
readDbf,
readShp,
shpPolygons,
simplifyPolygons,
unitTolerance,
unzip,
} from './build-admin-packs.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT = path.join(ROOT, 'src/data/local_data/us_census_places');
export const SOURCE = Object.freeze({
url: 'https://www2.census.gov/geo/tiger/GENZ2025/shp/cb_2025_us_place_500k.zip',
sha256: 'ce0e4019ecd4123d03d53aaa936eed0459b82e3e14b89a3dcd4d5e8b3308627d',
});
/** County-pack parameters; triangles are valid rings here. */
export const PARAMS = Object.freeze({
...ADMIN_PARAMS.counties,
minRingVertices: 3,
maxRepairSteps: 8,
});
/** Census LSAD codes for census designated places (unincorporated). */
const CDP_LSAD = new Set(['55', '57', '62']);
// ── validity ─────────────────────────────────────────────────────────────
function orient(ax, ay, bx, by, cx, cy) {
const v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
return v > 0 ? 1 : v < 0 ? -1 : 0;
}
function onSegment(ax, ay, bx, by, px, py) {
return (
Math.min(ax, bx) <= px &&
px <= Math.max(ax, bx) &&
Math.min(ay, by) <= py &&
py <= Math.max(ay, by)
);
}
/** Whether segments ab and cd share any point (touching counts). */
export function segmentsIntersect(a, b, c, d) {
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
if (o1 !== o2 && o3 !== o4) return true;
if (o1 === 0 && onSegment(a[0], a[1], b[0], b[1], c[0], c[1])) return true;
if (o2 === 0 && onSegment(a[0], a[1], b[0], b[1], d[0], d[1])) return true;
if (o3 === 0 && onSegment(c[0], c[1], d[0], d[1], a[0], a[1])) return true;
if (o4 === 0 && onSegment(c[0], c[1], d[0], d[1], b[0], b[1])) return true;
return false;
}
/**
* Whether segments of two different rings cross or overlap. Rings may touch
* at a point (a hole meeting its outer ring at a vertex is valid).
*/
function segmentsCross(s, t) {
const [a, b, c, d] = [s.a, s.b, t.a, t.b];
const o1 = orient(a[0], a[1], b[0], b[1], c[0], c[1]);
const o2 = orient(a[0], a[1], b[0], b[1], d[0], d[1]);
const o3 = orient(c[0], c[1], d[0], d[1], a[0], a[1]);
const o4 = orient(c[0], c[1], d[0], d[1], b[0], b[1]);
if (o1 && o2 && o3 && o4) return o1 !== o2 && o3 !== o4;
if (o1 === 0 && o2 === 0) {
// Collinear: overlapping by more than a point.
const key = a[0] !== b[0] ? 0 : 1;
const lo = Math.max(Math.min(a[key], b[key]), Math.min(c[key], d[key]));
const hi = Math.min(Math.max(a[key], b[key]), Math.max(c[key], d[key]));
return hi > lo;
}
// One endpoint on the other segment: a crossing only when the rings pass
// through each other there; a touch at a point is allowed.
return false;
}
/**
* Whether the rings of one part ([outer, ...holes], open, integer
* coordinates) are simple: no ring crosses or touches itself away from its
* shared vertices, no two rings cross or overlap (they may touch at a point),
* and no ring is flat.
*/
export function partIsValid(rings) {
const segments = [];
for (let r = 0; r < rings.length; r++) {
const ring = rings[r];
if (ring.length < 3) return false;
let twice = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
twice += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
if (twice === 0) return false;
for (let i = 0; i < ring.length; i++) {
const a = ring[i];
const b = ring[(i + 1) % ring.length];
segments.push({
r,
i,
n: ring.length,
a,
b,
minX: Math.min(a[0], b[0]),
maxX: Math.max(a[0], b[0]),
});
}
}
segments.sort((s, t) => s.minX - t.minX);
for (let x = 0; x < segments.length; x++) {
const s = segments[x];
for (let y = x + 1; y < segments.length; y++) {
const t = segments[y];
if (t.minX > s.maxX) break;
if (s.r === t.r) {
const gap = Math.abs(s.i - t.i);
// Neighbours share exactly one vertex; that is not a crossing — unless
// they fold back over each other.
if (gap === 1 || gap === s.n - 1) {
if (s.n > 3 && collinearOverlap(s, t)) return false;
continue;
}
}
if (segmentsCross(s, t)) return false;
}
}
return true;
}
function collinearOverlap(s, t) {
const shared =
s.a === t.a || s.a === t.b ? s.a : s.b === t.a || s.b === t.b ? s.b : null;
if (!shared) return false;
const p = s.a === shared ? s.b : s.a;
const q = t.a === shared ? t.b : t.a;
if (orient(shared[0], shared[1], p[0], p[1], q[0], q[1]) !== 0) return false;
// Collinear and pointing the same way from the shared vertex: a spike.
return (
(p[0] - shared[0]) * (q[0] - shared[0]) +
(p[1] - shared[1]) * (q[1] - shared[1]) >
0
);
}
function toIntegers(polygons, decimals) {
const f = 10 ** decimals;
return polygons.map((poly) =>
poly.map((ring) =>
ring.map(([lon, lat]) => [Math.round(lon * f), Math.round(lat * f)]),
),
);
}
export function polygonsValid(polygons, decimals) {
return toIntegers(polygons, decimals).every(partIsValid);
}
/**
* Simplify a place like `simplifyUnit`, then halve the tolerance until every
* part is valid; then one more decimal; then the source rings at two more.
* @returns {{polygons: Array, decimals: number, repaired: boolean, valid: boolean}}
*/
export function simplifyPlace(source, params = PARAMS) {
let tolerance = unitTolerance(source, params);
const attempts = [];
for (let step = 0; step <= params.maxRepairSteps; step++) {
attempts.push([tolerance, params.decimals]);
tolerance /= 2;
}
attempts.push([params.minToleranceDeg / 5, params.decimals + 1]);
attempts.push([0, params.decimals + 1]);
attempts.push([0, params.decimals + 2]);
let first = null;
for (let i = 0; i < attempts.length; i++) {
const [tol, decimals] = attempts[i];
const polygons = simplifyPolygons(source, params, tol, decimals);
if (!polygons.length) continue;
first ||= { polygons, decimals };
if (polygonsValid(polygons, decimals))
return { polygons, decimals, repaired: i > 0, valid: true };
}
return first
? { ...first, repaired: false, valid: false }
: {
polygons: [],
decimals: params.decimals,
repaired: false,
valid: false,
};
}
// ── names ────────────────────────────────────────────────────────────────
const GOVERNMENT =
/\s+(?:metropolitan government|unified government|consolidated government|metro government|city and county|urban county)\b.*$/i;
/**
* Everyday names for records whose Census name is formal: "Nashville-Davidson
* metropolitan government (balance)" is also "Nashville"; "Urban Honolulu"
* is "Honolulu".
*/
export function placeAliases(name) {
const out = new Set();
let base = String(name).replace(/\s*\(balance\)\s*$/i, '');
if (base !== name) out.add(base);
const stripped = base.replace(GOVERNMENT, '');
if (stripped !== base) {
out.add(stripped);
base = stripped;
}
if (base !== name && /[-/]/.test(base)) out.add(base.split(/[-/]/)[0].trim());
const urban = /^Urban (.+)$/.exec(name);
if (urban) out.add(urban[1]);
out.delete(name);
return [...out].filter(Boolean).sort();
}
// ── pack ─────────────────────────────────────────────────────────────────
function bboxOf(polygons) {
let w = Infinity;
let s = Infinity;
let e = -Infinity;
let n = -Infinity;
for (const poly of polygons)
for (const [lon, lat] of poly[0]) {
if (lon < w) w = lon;
if (lon > e) e = lon;
if (lat < s) s = lat;
if (lat > n) n = lat;
}
return [w, s, e, n];
}
function union(boxes) {
return [
Math.min(...boxes.map((b) => b[0])),
Math.min(...boxes.map((b) => b[1])),
Math.max(...boxes.map((b) => b[2])),
Math.max(...boxes.map((b) => b[3])),
];
}
/**
* The box of a state's places. Alaska crosses the antimeridian: its box is
* taken with western longitudes shifted past 180 when that is narrower.
*/
function unionBox(boxes) {
const plain = union(boxes);
const shifted = union(
boxes.map(([w, s, e, n]) =>
e < 0 ? [w + 360, s, e + 360, n] : [w, s, e, n],
),
);
return shifted[2] - shifted[0] < plain[2] - plain[0] ? shifted : plain;
}
async function build() {
const params = PARAMS;
const source = await fetchPinned('places', SOURCE);
const files = unzip(source.bytes);
const base = path.basename(new URL(SOURCE.url).pathname, '.zip');
const shapes = readShp(files.get(`${base}.shp`));
const records = readDbf(files.get(`${base}.dbf`));
if (shapes.length !== records.length)
throw new Error('places: shp/dbf record counts differ');
const states = new Map();
const stats = { records: records.length, repaired: [], invalid: [] };
records.forEach((record, index) => {
const unit = simplifyPlace(shpPolygons(shapes[index]), params);
if (!unit.polygons.length) throw new Error(`no geometry: ${record.GEOID}`);
if (unit.repaired) stats.repaired.push(record.GEOID);
if (!unit.valid) stats.invalid.push(record.GEOID);
const alt = placeAliases(record.NAME);
const lsad = record.NAMELSAD.startsWith(record.NAME)
? record.NAMELSAD.slice(record.NAME.length).trim()
: '';
let state = states.get(record.STUSPS);
if (!state) {
state = {
st: record.STUSPS,
fips: record.STATEFP,
name: record.STATE_NAME,
features: [],
boxes: [],
};
states.set(record.STUSPS, state);
}
state.boxes.push(bboxOf(unit.polygons));
state.features.push({
geoid: record.GEOID,
name: record.NAME,
...(alt.length ? { alt } : {}),
full: record.NAMELSAD,
...(lsad ? { lsad } : {}),
...(CDP_LSAD.has(record.LSAD) ? { cdp: true } : {}),
...(unit.decimals !== params.decimals ? { d: unit.decimals } : {}),
polygons: encodeUnit(unit),
});
});
if (stats.invalid.length)
throw new Error(`places still invalid: ${stats.invalid.join(', ')}`);
await mkdir(OUT, { recursive: true });
for (const file of await readdir(OUT))
if (/^[A-Z]{2}\.json$/.test(file)) await rm(path.join(OUT, file));
const list = [...states.values()].sort((a, b) => a.st.localeCompare(b.st));
let bytes = 0;
for (const state of list) {
state.features.sort((a, b) => a.geoid.localeCompare(b.geoid));
const text =
JSON.stringify({
st: state.st,
state: state.name,
fips: state.fips,
features: state.features,
}) + '\n';
bytes += Buffer.byteLength(text);
await writeFile(path.join(OUT, `${state.st}.json`), text);
}
const round = (v) => Math.round(v * 1e4) / 1e4;
const index = {
meta: {
title: 'US Census Bureau cartographic boundary places',
source: base,
url: SOURCE.url,
sha256: source.digest,
license: 'Public domain (U.S. Government work, 17 U.S.C. § 105)',
schema:
'states[]: st (USPS), fips, name, count, bbox [w, s, e, n] (e > 180 across the antimeridian). <st>.json: st, state, fips, features[]: geoid, name (NAME), alt?[] (everyday names for formal records), full (NAMELSAD), lsad? (city, town, CDP, …), cdp? (census designated place), d? (decimals when not meta.decimals), label? [lon, lat] (verified filled anchor in degrees when the outer centroid is unfilled), polygons[][ring] (first ring outer, rest holes; each ring open and encoded as integers in 10^-d degrees, first vertex absolute then [dLon, dLat] deltas)',
decimals: params.decimals,
curation: params,
script: 'scripts/build-census-places.mjs',
},
states: list.map((s) => ({
st: s.st,
fips: s.fips,
name: s.name,
count: s.features.length,
bbox: unionBox(s.boxes).map(round),
})),
};
await writeFile(
path.join(OUT, 'index.json'),
JSON.stringify(index, null, 1) + '\n',
);
await writeFile(path.join(OUT, 'files.js'), filesModule(list));
const count = list.reduce((n, s) => n + s.features.length, 0);
console.log(
`us_census_places: ${count} places in ${list.length} files, ${bytes} bytes; ` +
`${stats.repaired.length} re-simplified to stay valid`,
);
const args = process.argv.slice(2);
const python = args.includes('--python')
? args[args.indexOf('--python') + 1]
: 'python3';
execFileSync(
python,
[path.join(ROOT, 'scripts/pack_geometry.py'), 'repair', 'census'],
{ stdio: 'inherit', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } },
);
}
/**
* The file list as literal URLs, so a bundler emits every state file as an
* asset and the loader can fetch one by its code.
*/
function filesModule(list) {
const lines = [
'// Generated by scripts/build-census-places.mjs; do not edit.',
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
'export const STATE_URLS = {',
...list.map(
(s) => ` ${s.st}: () => new URL('./${s.st}.json', import.meta.url),`,
),
'};',
];
return lines.join('\n') + '\n';
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) await build();
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""Maintainer-only Who's On First neighborhood pack build (Shapely 2.1).
Reads the pinned Who's On First neighbourhood, macrohood and microhood
archives and the WOF sources registry, keeps current polygon records whose
geometry source matches the reviewed open-licence allowlist, simplifies them
to about 15 m, then applies pinned source/ID exclusions after partitioning, and
writes src/data/local_data/wof_neighborhoods/:
index.json format, licences per source, tile list with boxes
<tile>.json records whose box centre falls in that quadtree tile
files.js their URLs, for bundlers
ATTRIBUTION.md per-source credits
Usage:
python scripts/build-wof-neighborhoods.py --cache /tmp/wof-neighborhoods [--verify]
Requires shapely==2.1.2 on GEOS 3.13.1 (checked). --verify rebuilds in memory
and fails unless every shipped file matches byte for byte.
Downloads go to --cache and are checked against the pinned SHA-256. The
neighbourhood archive is about 400 MB.
"""
import argparse
import collections
import hashlib
import json
import math
import subprocess
import tarfile
from pathlib import Path
from shapely import make_valid
from shapely.affinity import scale
from shapely.geometry import MultiPolygon, shape
import pack_geometry
ROOT = Path(__file__).resolve().parent.parent
PACK = ROOT / 'src/data/local_data/wof_neighborhoods'
# The legacy distribution is frozen (last published 2025-10-14) and only
# offers `-latest` names; the SHA-256 pins make a changed file fail loudly.
DIST = 'https://data.geocode.earth/wof/dist/legacy'
ARCHIVES = {
'neighbourhood': {
'url': DIST + '/whosonfirst-data-neighbourhood-latest.tar.bz2',
'sha256': '8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455',
},
'macrohood': {
'url': DIST + '/whosonfirst-data-macrohood-latest.tar.bz2',
'sha256': 'f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679',
},
'microhood': {
'url': DIST + '/whosonfirst-data-microhood-latest.tar.bz2',
'sha256': 'e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac',
},
}
# The registry at a fixed commit.
SOURCES = {
'url': 'https://raw.githubusercontent.com/whosonfirst/whosonfirst-sources/'
'e17af153c144fe6e08aa8db2607b4bf8aecd448d/data/sources-spec-latest.json',
'sha256': 'd8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39',
}
TOLERANCE_M = 15
PRECISION = 5
TILE_MAX_BYTES = 200_000
TILE_MAX_DEPTH = 16
LICENSE_POLICY = json.loads((ROOT / 'scripts/wof-open-licenses.json').read_text())
ALLOWED_LICENSES = frozenset(LICENSE_POLICY['licenses'])
REVIEWED_SOURCES = LICENSE_POLICY.get('reviewedSources', {})
RECORD_EXCLUSIONS = LICENSE_POLICY['recordExclusions']
FIELDS = ['id', 'name', 'country', 'type', 'bbox', 'aliases', 'source', 'rings', 'label']
def fetch(cache, name, url, sha256):
path = cache / name
if not path.exists():
print('Downloading', url, flush=True)
# Plain urllib requests are refused by the WOF host; curl is not.
subprocess.run(['curl', '--fail', '-L', '-o', str(path), url], check=True)
digest = hashlib.sha256()
with open(path, 'rb') as handle:
for chunk in iter(lambda: handle.read(1 << 20), b''):
digest.update(chunk)
if digest.hexdigest() != sha256:
raise ValueError(f'{name}: SHA-256 {digest.hexdigest()} is not the pinned {sha256}')
return path
# ── geometry ────────────────────────────────────────────────────────────
def simplify(geometry):
"""Simplify at TOLERANCE_M in local metres, snap to the pack grid, keep it
valid, and order parts largest first. Returns (rings, label) where the
label is a point inside the largest part, or ([], None)."""
geometry = MultiPolygon(pack_geometry.polygon_parts(make_valid(geometry) if not geometry.is_valid else geometry))
if geometry.is_empty:
return [], None
lat = geometry.centroid.y
sx = 111320 * max(0.001, math.cos(math.radians(lat)))
sy = 110574
metric = scale(geometry, xfact=sx, yfact=sy, origin=(0, 0))
metric = metric.simplify(TOLERANCE_M, preserve_topology=True)
geometry = scale(metric, xfact=1 / sx, yfact=1 / sy, origin=(0, 0))
# To whole pack units (10^-PRECISION degrees), where GEOS is exact.
units = scale(geometry, xfact=10 ** PRECISION, yfact=10 ** PRECISION, origin=(0, 0))
parts = pack_geometry.ordered_parts(pack_geometry.settle(units))
if not parts:
return [], None
rings = [
[pack_geometry.encode_polyline(pack_geometry.open_ring(p.exterior.coords))]
+ [pack_geometry.encode_polyline(pack_geometry.open_ring(r.coords)) for r in p.interiors]
for p in parts
]
# Check what ships: the geometry decoded from the encoded strings.
decoded = [[pack_geometry.decode_polyline(r) for r in poly] for poly in rings]
why = pack_geometry.problems(decoded)
if why:
raise ValueError(f'encoded geometry invalid: {why}')
point = parts[0].representative_point()
point = shape({'type': 'Point', 'coordinates': [round(point.x) / 10 ** PRECISION, round(point.y) / 10 ** PRECISION]})
return rings, [point.x, point.y]
# ── records ─────────────────────────────────────────────────────────────
def read_archive(path, records):
with tarfile.open(path, 'r|bz2') as archive:
for member in archive:
if not member.name.endswith('.geojson') or '-alt-' in member.name:
continue
feature = json.load(archive.extractfile(member))
if feature['geometry']['type'] not in ('Polygon', 'MultiPolygon'):
records.pop(feature['properties']['wof:id'], None)
continue
records[feature['properties']['wof:id']] = feature
def keep(properties):
deprecated = properties.get('edtf:deprecated', 'uuuu') not in ('uuuu', '', None)
superseded = bool(properties.get('wof:superseded_by', []))
return properties.get('mz:is_current', -1) != 0 and not deprecated and not superseded
def source_table(spec):
lookup = {}
for source in spec.values():
for key in (source.get('name'), source.get('prefix')):
if key:
lookup[key] = source
return lookup
def verify_source_reviews(lookup):
"""Pin exceptional source decisions to the exact reviewed registry evidence."""
registry_commit = SOURCES['url'].split('/')[-3]
for key, review in REVIEWED_SOURCES.items():
source = lookup.get(key)
if not source:
raise ValueError(f'{key}: reviewed source is absent from the pinned registry')
if review.get('registryCommit') != registry_commit:
raise ValueError(f'{key}: review does not match the pinned registry commit')
if review.get('registrySha256') != SOURCES['sha256']:
raise ValueError(f'{key}: review does not match the pinned registry SHA-256')
if review.get('rawLicenseType') != source.get('license_type'):
raise ValueError(f'{key}: reviewed raw licence no longer matches the registry')
text = source.get('license_text') or ''
if review.get('licenseTextSha256') != hashlib.sha256(text.encode()).hexdigest():
raise ValueError(f'{key}: reviewed licence text no longer matches the registry')
if review['rawLicenseType'] not in ALLOWED_LICENSES:
raise ValueError(f'{key}: reviewed raw licence is not admitted by policy')
def licence_allowed(value):
"""Only exact raw registry strings reviewed in the policy are accepted."""
return isinstance(value, str) and value in ALLOWED_LICENSES
def source_allowed(key, source):
return (key != 'unknown' and bool(source) and
licence_allowed(source.get('license_type')))
def build_records(archives, lookup):
raw = {}
for kind in ('neighbourhood', 'macrohood', 'microhood'):
read_archive(archives[kind], raw)
print(kind, len(raw), flush=True)
features = []
excluded = collections.Counter()
for wof_id in sorted(raw):
properties = raw[wof_id]['properties']
if not keep(properties):
continue
key = properties.get('src:geom', 'unknown')
if not source_allowed(key, lookup.get(key)):
excluded[key] += 1
continue
name = properties['wof:name']
aliases = sorted({
alias
for k, values in properties.items()
if k.startswith('name:') and isinstance(values, list)
for alias in values
if isinstance(alias, str) and alias != name
})
if not name.strip() and not aliases:
continue # nothing to ask for it by
geometry = shape(raw[wof_id]['geometry'])
if geometry.is_empty:
continue
rings, label = simplify(geometry)
if not rings:
continue
country = properties.get('iso:country', properties.get('wof:country'))
features.append([
wof_id, name, country, properties['wof:placetype'],
[round(v, PRECISION) for v in geometry.bounds], aliases, key, rings, label,
])
return features, excluded
# ── tiles ───────────────────────────────────────────────────────────────
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
def tiles(features, key='', box=(-180.0, -90.0, 180.0, 90.0)):
size = sum(len(compact(f).encode()) for f in features)
if size <= TILE_MAX_BYTES or len(key) >= TILE_MAX_DEPTH:
yield key or 'world', features
return
west, south, east, north = box
mx, my = (west + east) / 2, (south + north) / 2
quads = [[], [], [], []]
for f in features:
x = (f[4][0] + f[4][2]) / 2
y = (f[4][1] + f[4][3]) / 2
quads[(1 if x >= mx else 0) + (2 if y >= my else 0)].append(f)
boxes = [(west, south, mx, my), (mx, south, east, my), (west, my, mx, north), (mx, my, east, north)]
for i, quad in enumerate(quads):
if quad:
yield from tiles(quad, key + str(i), boxes[i])
def union_box(features):
return [
min(f[4][0] for f in features), min(f[4][1] for f in features),
max(f[4][2] for f in features), max(f[4][3] for f in features),
]
def exclude_records(partition):
"""Filter approved source+IDs AFTER the original byte-sized partition."""
if RECORD_EXCLUSIONS['archives'] != {k: a['sha256'] for k, a in ARCHIVES.items()}:
raise ValueError('record exclusions do not match the pinned archives')
if RECORD_EXCLUSIONS['registrySha256'] != SOURCES['sha256']:
raise ValueError('record exclusions do not match the pinned registry')
expected = {}
for source, rule in RECORD_EXCLUSIONS['sources'].items():
if rule['count'] != len(rule['ids']) or len(set(rule['ids'])) != len(rule['ids']):
raise ValueError(f'{source}: invalid exclusion membership')
for record_id in rule['ids']:
if record_id in expected:
raise ValueError(f'{record_id}: duplicate exclusion')
expected[record_id] = source
seen = set()
kept = []
for key, members in partition:
retained = []
for row in members:
record_id, source = row[0], row[6]
if record_id in expected:
if source != expected[record_id] or record_id in seen:
raise ValueError(f'{record_id}: exclusion source or membership changed')
seen.add(record_id)
elif source in RECORD_EXCLUSIONS['sources']:
raise ValueError(f'{record_id}: unreviewed exclusion-source member')
else:
retained.append(row)
if not retained:
raise ValueError(f'{key}: exclusion would change the tile layout')
kept.append((key, retained))
if seen != set(expected):
raise ValueError(f'missing excluded records: {sorted(set(expected) - seen)}')
return kept
# ── credits ─────────────────────────────────────────────────────────────
def licence_label(key, source):
licence = source['license_type']
if key == 'karmashapes':
return 'CC BY 4.0'
return {
'CC-BY': 'CC BY', 'Attribution 3.0 Unported': 'CC BY 3.0', 'Public domain': 'Public Domain',
'Open Government License - British Columbia, v2.0': 'Open Government Licence - British Columbia, v2.0',
}.get(licence, licence)
def exclusion_note():
groups = ', '.join(f'{rule["count"]} `{key}` records' for key, rule in RECORD_EXCLUSIONS['sources'].items())
lead = f'This derived pack excludes {groups}. ' if groups else ''
return f'{lead}{RECORD_EXCLUSIONS["reason"]} Original upstream notices remain unchanged.'
def attribution(counts, lookup):
lines = [
'# Neighborhood boundary credits',
'',
"Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licenses/) "
'and contributors, used under the licences below. Modified: boundaries simplified to about 15 m, repaired and re-encoded.',
'',
'| Source | Records | Licence | Terms |',
'|---|---:|---|---|',
]
for key, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
source = lookup[key]
name = source.get('fullname') or key
terms = source.get('license') or ''
link = f'[terms]({terms})' if terms.startswith('http') else terms
lines.append(f'| {name} (`{key}`) | {count:,} | {licence_label(key, source)} | {link} |')
via = [(key, lookup[key]) for key in sorted(counts) if lookup[key].get('src:via')]
if via:
lines += ['', '## Upstream sources', '']
for key, source in via:
lines += [f'### {source.get("fullname") or key}', '']
for v in source['src:via']:
note = f' ({v["source_note"]})' if v.get('source_note') else ''
context = f'{v["context"]}: ' if v.get('context') else ''
lines.append(f'- {context}{v.get("source_name", "")}{note} {v.get("source_link", "")}'.rstrip())
lines.append('')
while lines[-1] == '':
lines.pop()
lines += ['', '## Source-specific exclusions', '', exclusion_note()]
return '\n'.join(lines).rstrip() + '\n'
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--cache', type=Path, required=True)
parser.add_argument('--verify', action='store_true', help='rebuild in memory and compare with the shipped pack')
args = parser.parse_args()
pack_geometry.require_pinned_tooling()
args.cache.mkdir(parents=True, exist_ok=True)
archives = {
kind: fetch(args.cache, Path(a['url']).name, a['url'], a['sha256'])
for kind, a in ARCHIVES.items()
}
spec = json.loads(fetch(args.cache, 'sources-spec-latest.json', SOURCES['url'], SOURCES['sha256']).read_text())
lookup = source_table(spec)
verify_source_reviews(lookup)
features, excluded = build_records(archives, lookup)
if len({f[0] for f in features}) != len(features):
raise ValueError('duplicate WOF id')
out = {}
tile_list = []
# Partition the full pinned input first: early filtering can collapse tiles.
partition = exclude_records(list(tiles(features)))
features = [row for _, members in partition for row in members]
for key, members in partition:
members.sort(key=lambda f: f[0])
out[f'{key}.json'] = compact({'tile': key, 'features': members}) + '\n'
tile_list.append({'key': key, 'count': len(members), 'bbox': union_box(members)})
counts = collections.Counter(f[6] for f in features)
index = {
'meta': {
'title': "Who's On First neighbourhood, macrohood and microhood polygons",
'archives': ARCHIVES,
'sourcesRegistry': SOURCES,
'recordExclusions': {key: rule['count'] for key, rule in RECORD_EXCLUSIONS['sources'].items()},
'recordExclusionReason': RECORD_EXCLUSIONS['reason'],
'license': 'Per source; see sources and ATTRIBUTION.md',
'format': 'neighborhood-polylines-v1',
'fields': FIELDS,
'precision': PRECISION,
'coordinateOrder': 'lon,lat',
'toleranceM': TOLERANCE_M,
'schema': (
'tiles[]: key, count, bbox [w, s, e, n] of its records. <key>.json: tile, features[] as '
'fields: WOF id, name, ISO country, placetype, bbox, aliases[] (other-language names), '
'geometry source key, rings (polygons, largest first, each [outer, ...holes]; each ring '
'an encoded polyline string: zigzag varint deltas of lon then lat at 10^-precision '
'degrees), label [lon, lat] (a point inside the largest polygon)'
),
'sources': {
key: {
'name': lookup[key].get('fullname') or key,
'license': licence_label(key, lookup[key]),
'terms': lookup[key].get('license') or '',
'records': counts[key],
}
for key in sorted(counts)
},
'script': 'scripts/build-wof-neighborhoods.py',
},
'tiles': tile_list,
}
out['index.json'] = json.dumps(index, ensure_ascii=False, indent=1) + '\n'
out['ATTRIBUTION.md'] = attribution(counts, lookup)
# Literal URLs, so a bundler emits every tile as an asset.
out['files.js'] = '\n'.join([
'// Generated by scripts/build-wof-neighborhoods.py; do not edit.',
"export const INDEX_URL = () => new URL('./index.json', import.meta.url);",
'export const TILE_URLS = {',
*[f" t{t['key']}: () => new URL('./{t['key']}.json', import.meta.url)," for t in tile_list],
'};',
]) + '\n'
if args.verify:
# Reproducibility: a rebuild must match what ships, byte for byte.
shipped = {p.name for p in PACK.glob('*.json')} | {'ATTRIBUTION.md', 'files.js'}
differ = sorted(
name for name in shipped | set(out)
if not (PACK / name).exists() or name not in out
or (PACK / name).read_bytes() != out[name].encode('utf-8')
)
if differ:
raise SystemExit(f'rebuild differs from the shipped pack: {differ[:10]}')
print(f'wof_neighborhoods: rebuild matches all {len(out)} shipped files')
return
PACK.mkdir(parents=True, exist_ok=True)
for old in PACK.glob('*.json'):
old.unlink()
for name, text in out.items():
(PACK / name).write_text(text, encoding='utf-8')
print(f'wof_neighborhoods: {len(features)} records in {len(tile_list)} tiles; '
f'{sum(rule["count"] for rule in RECORD_EXCLUSIONS["sources"].values())} source-specific record exclusions; '
f'excluded {sum(excluded.values())} with an unapproved licence: {dict(excluded)}', flush=True)
if __name__ == '__main__':
main()
+21 -1
View File
@@ -29,6 +29,8 @@
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
"src/annotations/drawMarks.js",
"src/annotations/drawMarks.test.mjs",
"src/annotations/drawMode.js",
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
@@ -1186,5 +1188,23 @@
"src/firstRunLauncherView.test.mjs",
"src/firstRunCopy.js",
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs"
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
"src/data/boundaryLabels.test.mjs",
"src/data/packGeometry.test.mjs",
"src/annotations/placeOutline.test.mjs",
"src/annotations/outlineIdentity.test.mjs",
"src/annotations/outlineRungs.test.mjs",
"src/annotations/outlineRungsFallback.test.mjs",
"src/search/photonSetting.test.mjs",
"src/sources/nominatimOutlines.test.mjs",
"src/sources/openFreeMapOutlines.test.mjs",
"src/sources/openFreeMapOutlinesEdges.test.mjs",
"src/standalone/layerSources.test.mjs",
"src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs"
]
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Maintainer-only topology check and repair for the bundled outline packs.
python scripts/pack_geometry.py check census|wof every shipped feature,
decoded as a whole MultiPolygon, is valid under GEOS, keeps at least
three distinct vertices per ring, and lists its largest part first
python scripts/pack_geometry.py repair census rewrite the Census place
files so every feature passes `check` (run by build-census-places.mjs)
Geometry is checked and repaired in the packs' own integer units (10^-d
degrees), where GEOS arithmetic is exact: a vertex that lies on another edge
after rounding is seen as the touch it is.
Requires Shapely 2.1.2 on GEOS 3.13.1, pinned so repairs are reproducible.
"""
import argparse
import json
import math
import sys
from pathlib import Path
import shapely
from shapely import make_valid, set_precision
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.polygon import orient
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / 'src/data/local_data'
SHAPELY_VERSION = '2.1.2'
GEOS_VERSION = (3, 13, 1)
def require_pinned_tooling():
if shapely.__version__ != SHAPELY_VERSION or shapely.geos_version != GEOS_VERSION:
raise RuntimeError(
f'Use shapely=={SHAPELY_VERSION} on GEOS {".".join(map(str, GEOS_VERSION))}; '
f'found {shapely.__version__} on GEOS {shapely.geos_version}'
)
def compact(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
# ── encodings ────────────────────────────────────────────────────────────
def decode_deltas(encoded):
"""[x0, y0, dx1, dy1, …] → [(x, y), …] in pack units (Census/admin packs)."""
x = y = 0
ring = []
for i in range(0, len(encoded) - 1, 2):
x += encoded[i]
y += encoded[i + 1]
ring.append((x, y))
return ring
def encode_deltas(coords):
out = []
px = py = 0
for fx, fy in coords:
x, y = round(fx), round(fy)
out += [x - px, y - py]
px, py = x, y
return out
def decode_polyline(encoded):
"""Zigzag varint deltas, 5-bit groups offset by 63, lon then lat; pack units."""
values = []
last = [0, 0]
i = 0
while i < len(encoded):
n = shift = 0
while True:
b = ord(encoded[i]) - 63
i += 1
n |= (b & 31) << shift
shift += 5
if b < 32:
break
axis = len(values) % 2
last[axis] += ~(n >> 1) if n & 1 else n >> 1
values.append(last[axis])
return list(zip(values[::2], values[1::2]))
def encode_polyline(coords):
out = []
last = [0, 0]
for x, y in coords:
for i, value in enumerate([round(x), round(y)]):
n = value - last[i]
last[i] = value
n = (n << 1) ^ (n >> 63)
while n >= 32:
out.append(chr((32 | (n & 31)) + 63))
n >>= 5
out.append(chr(n + 63))
return ''.join(out)
# ── geometry ─────────────────────────────────────────────────────────────
def polygon_parts(geometry):
if geometry.geom_type == 'Polygon':
return [] if geometry.is_empty else [geometry]
if hasattr(geometry, 'geoms'):
return [p for g in geometry.geoms for p in polygon_parts(g)]
return []
def open_ring(coords):
coords = [tuple(c[:2]) for c in coords]
return coords[:-1] if len(coords) > 1 and coords[0] == coords[-1] else coords
def ordered_parts(geometry):
"""Polygon parts oriented (outer counter-clockwise), largest area first."""
parts = [orient(p, 1.0) for p in polygon_parts(geometry)]
return sorted(parts, key=lambda p: (-p.area, p.bounds))
def on_grid(geometry):
"""Snap to whole pack units keeping the result valid; polygon parts only."""
snapped = set_precision(geometry, 1, mode='valid_output')
return MultiPolygon(polygon_parts(snapped))
def settle(geometry):
"""A valid MultiPolygon of `geometry` (in pack units) on whole units."""
if not geometry.is_valid:
geometry = make_valid(geometry)
geometry = on_grid(MultiPolygon(polygon_parts(geometry)))
for _ in range(3):
if geometry.is_valid:
break
geometry = on_grid(make_valid(geometry))
return geometry
def rings_ok(polygons):
return all(len(set(ring)) >= 3 for poly in polygons for ring in poly)
def problems(polygons):
"""Why decoded `polygons` ([[outer, *holes], …]) fail the pack contract."""
if not polygons or not rings_ok(polygons):
return 'ring with fewer than three distinct vertices'
try:
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons])
except Exception as error: # noqa: BLE001 - malformed input is a finding
return f'unbuildable: {error}'
if not geometry.is_valid:
return shapely.is_valid_reason(geometry)
areas = [part.area for part in geometry.geoms]
if max(areas) > areas[0]:
return 'largest part is not first'
return None
# ── packs ────────────────────────────────────────────────────────────────
def census_features():
folder = DATA / 'us_census_places'
decimals = json.loads((folder / 'index.json').read_text())['meta']['decimals']
for path in sorted(folder.glob('[A-Z][A-Z].json')):
pack = json.loads(path.read_text(encoding='utf-8'))
yield path, pack, decimals
def census_polygons(feature):
return [[decode_deltas(r) for r in poly] for poly in feature['polygons']]
def census_degrees(feature, decimals):
factor = 10 ** feature.get('d', decimals)
return [[[(x / factor, y / factor) for x, y in ring]
for ring in poly] for poly in census_polygons(feature)]
def ring_contains(ring, lat, lon):
"""Match adminBoundaries.pointInRing on final degree-decoded vertices."""
inside = False
for i in range(len(ring)):
xi, yi = ring[i]
xj, yj = ring[i - 1]
if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
inside = not inside
return inside
def label_is_filled(polygons, label):
if not isinstance(label, (list, tuple)) or len(label) != 2:
return False
lon, lat = label
if not all(isinstance(n, (int, float)) and math.isfinite(n) for n in label):
return False
return any(ring_contains(poly[0], lat, lon)
and not any(ring_contains(hole, lat, lon) for hole in poly[1:])
for poly in polygons)
def census_label(feature, polygons):
"""The effective runtime label, including its area-weighted fallback."""
if isinstance(feature.get('label'), list):
return feature['label']
ring = polygons[0][0]
area = cx = cy = 0
for i in range(len(ring)):
x0, y0 = ring[i - 1]
x1, y1 = ring[i]
cross = x0 * y1 - x1 * y0
area += cross
cx += (x0 + x1) * cross
cy += (y0 + y1) * cross
return [cx / (3 * area), cy / (3 * area)] if area else list(ring[0])
def wof_features():
folder = DATA / 'wof_neighborhoods'
index = json.loads((folder / 'index.json').read_text(encoding='utf-8'))
for tile in index['tiles']:
pack = json.loads((folder / f'{tile["key"]}.json').read_text(encoding='utf-8'))
for feature in pack['features']:
yield feature, index['meta']['precision']
def check(pack):
bad = []
total = 0
if pack == 'census':
for _, data, decimals in census_features():
for feature in data['features']:
total += 1
why = problems(census_polygons(feature))
if not why:
polygons = census_degrees(feature, decimals)
if not label_is_filled(polygons, census_label(feature, polygons)):
why = 'effective label is outside filled geometry'
if why:
bad.append((feature['geoid'], feature['name'], why))
else:
for feature, precision in wof_features():
total += 1
polygons = [[decode_polyline(r) for r in poly] for poly in feature[7]]
why = problems(polygons)
if why:
bad.append((feature[0], feature[1], why))
for row in bad[:20]:
print('invalid', *row)
print(f'{pack}: {total} features, {len(bad)} failing')
return not bad
def repair_census():
repaired = 0
labelled = 0
for path, data, decimals in census_features():
changed = False
for feature in data['features']:
polygons = census_polygons(feature)
if problems(polygons):
geometry = MultiPolygon([Polygon(p[0], p[1:]) for p in polygons if len(set(p[0])) >= 3])
parts = ordered_parts(settle(geometry))
feature['polygons'] = [
[encode_deltas(open_ring(p.exterior.coords))]
+ [encode_deltas(open_ring(r.coords)) for r in p.interiors]
for p in parts
]
feature.pop('label', None)
polygons = census_polygons(feature)
why = problems(polygons)
if why:
raise ValueError(f'{feature["geoid"]} {feature["name"]}: still {why}')
repaired += 1
changed = True
decoded = census_degrees(feature, decimals)
if label_is_filled(decoded, census_label(feature, decoded)):
continue
factor = 10 ** feature.get('d', decimals)
for poly in polygons:
point = Polygon(poly[0], poly[1:]).representative_point()
label = [point.x / factor, point.y / factor]
if label_is_filled(decoded, label):
feature['label'] = label
labelled += 1
changed = True
break
else:
raise ValueError(f'{feature["geoid"]} {feature["name"]}: no verified filled label')
if changed:
path.write_text(compact(data) + '\n', encoding='utf-8')
print(f'us_census_places: {repaired} topology repairs, {labelled} verified interior labels added')
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('command', choices=['check', 'repair'])
parser.add_argument('pack', choices=['census', 'wof'])
args = parser.parse_args()
require_pinned_tooling()
if args.command == 'repair':
if args.pack != 'census':
parser.error('the WOF build repairs its own geometry')
repair_census()
sys.exit(0 if check('census') else 1)
sys.exit(0 if check(args.pack) else 1)
if __name__ == '__main__':
main()
+58 -6
View File
@@ -88,6 +88,10 @@
"src/ui/rail/RailChips.vue",
"src/ui/views/panels/DisplayPanel.vue",
"src/ui/views/panels/GeoImport.vue",
"src/ui/views/panels/DrawControls.vue",
"src/ui/views/panels/DrawMarkList.vue",
"src/ui/views/panels/DrawSwatches.vue",
"src/annotations/drawMarks.js",
"src/ui/stores/display.js",
"src/ui/localSdrPresentation.js",
"src/ui/unitLabels.js",
@@ -179,6 +183,7 @@
"runtime": "node",
"exports": ["./server/providers/live"],
"modules": [
"src/sources/adsbFields.js",
"server/providers/live.js",
"server/providers/common/http.js",
"server/providers/common/query.js",
@@ -198,7 +203,7 @@
},
"adsb-lol-source": {
"exports": ["./sources/adsb-lol"],
"modules": ["src/data/adsbLolFallback.js"],
"modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"],
"external": []
},
"place-providers": {
@@ -431,7 +436,9 @@
"server/providers/overpass/transport.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/rateLimit.js"
"src/sources/rateLimit.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs"
],
"external": []
},
@@ -460,7 +467,16 @@
"src/data/local_data/natural_earth/regions.json",
"src/data/naturalEarthRegions.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js"
"src/data/retryableLoad.js",
"server/providers/common/same-site.js",
"server/providers/common/source-root.js",
"server/providers/regional/nominatimGate.js",
"server/providers/regional/nominatimCache.js",
"server/providers/regional/outlineSelect.js",
"src/localRequestGate.mjs",
"src/data/adminBoundaries.js",
"src/data/placeNames.js",
"src/sources/featureGeometry.js"
],
"external": []
},
@@ -522,6 +538,7 @@
"src/search/geospatial.js",
"src/search/http.js",
"src/search/defaults.js",
"src/sources/nominatimStatus.js",
"src/search/alpsGeocoder.js",
"src/search/coordinateGeocoder.js",
"src/search/coordinateParser.js",
@@ -1248,6 +1265,7 @@
"live-observations": {
"exports": ["./sources/live"],
"modules": [
"src/sources/adsbFields.js",
"src/i18n/marker.js",
"src/data/aircraftEmergency.js",
"src/sources/live/aircraft.js",
@@ -2204,6 +2222,7 @@
"./standalone/catalog"
],
"modules": [
"src/sources/adsbFields.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
"src/ui/placeNames.js",
@@ -2824,6 +2843,7 @@
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/data/inputOwnership.js",
"src/annotations/drawMarks.js",
"src/annotations/drawMode.js",
"src/annotations/drawTool.js",
"src/data/geoFileImport.js",
@@ -3155,7 +3175,15 @@
"src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js"
"src/ui/i18n.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
"src/sources/nominatimStatus.js",
"src/data/placeBoundaries.js",
"src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js",
"src/data/local_data/wof_neighborhoods/files.js"
],
"external": [
"hls.js",
@@ -3180,6 +3208,7 @@
"application-layer-construction": {
"exports": ["./application/layers"],
"modules": [
"src/sources/adsbFields.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
"src/cyberSonar.js",
@@ -3753,7 +3782,15 @@
"src/layers/wind/inspectionMarker.js",
"src/ui/stores/bhoteKoshiPanel.js",
"src/hudReadouts.js",
"src/ui/i18n.js"
"src/ui/i18n.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
"src/sources/nominatimStatus.js",
"src/data/placeBoundaries.js",
"src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js",
"src/data/local_data/wof_neighborhoods/files.js"
],
"external": [
"hls.js",
@@ -3826,7 +3863,21 @@
"src/sources/featureSource.js",
"src/sources/capability.js",
"src/data/cameraArrival.js",
"src/data/cameraFraming.js"
"src/data/cameraFraming.js",
"src/sources/openFreeMap.js",
"src/sources/vectorTiles.js",
"src/sources/phaseTiming.js",
"src/sources/militaryTileGeometry.js",
"src/data/tomtomTiles.js",
"src/data/installationNames.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
"src/sources/nominatimStatus.js",
"src/data/placeBoundaries.js",
"src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js",
"src/data/local_data/wof_neighborhoods/files.js"
],
"external": [
"cesium",
@@ -4722,6 +4773,7 @@
"tools-services": {
"exports": ["./tools/services"],
"modules": [
"src/sources/adsbFields.js",
"src/tools/services.js",
"src/layers/bikeshare/registry.js",
"src/layers/bikeshare/source.js",
+12 -10
View File
@@ -107,7 +107,7 @@ const layerState = () =>
const manager = gev.dataManager;
const module = manager.layers.get('directions').module;
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const controls = module.getRowControls();
const viewer = gev.viewer;
@@ -152,7 +152,7 @@ const layerState = () =>
const clickChip = async (chipId) => {
const clicked = await page.evaluate((id) => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
if (!button || button.disabled) return false;
@@ -232,24 +232,26 @@ try {
});
await sleep(3000);
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
// ── 1. Open the UTILITIES panel, then toggle the row on. ────────────────
const opened = await page.evaluate(() => {
const panel = document.getElementById('data-panel');
const panel = document.getElementById('utilities-panel');
if (!panel) return false;
if (panel.classList.contains('collapsed')) {
panel
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
}
return !document
.getElementById('data-panel')
.getElementById('utilities-panel')
.classList.contains('collapsed');
});
await sleep(700);
check('the DATA LAYERS panel opens from its own header control', opened);
check('the UTILITIES panel opens from its own header control', opened);
const toggled = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const button = row?.querySelector('.data-toggle-btn');
if (!button) return false;
@@ -397,7 +399,7 @@ try {
);
const focused = await page.evaluate(() => {
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
const items = row.querySelectorAll('.data-row-list-item');
items[2]?.focus();
@@ -776,7 +778,7 @@ try {
}
}
const row = document.querySelector(
'#data-toggles [data-layer-id="directions"]',
'#utilities-toggles [data-layer-id="directions"]',
);
return {
entities: left.length,
+198 -25
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
/** Rendered acceptance for UTILITIES ▸ Draw: real clicks, real keys, real teardown. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
@@ -58,6 +58,47 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** A clip of the globe canvas, clear of the panels on every side. */
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
// Clear of the open UTILITIES panel (x 100–380), whose own amber swatch
// dims when its row's shape is hidden.
const LINE_CLIP = { x: 390, y: 200, width: 510, height: 380 };
/** The canvas clip as a PNG, for comparing two moments of the same view. */
const canvasShot = () =>
page.screenshot({ clip: LINE_CLIP, type: 'png', encoding: 'base64' });
/**
* Amber pixels in one shot that are not amber in another of the same view:
* a mark's own pixels, apart from imagery and panels that both shots share.
*/
function amberOnlyIn(shotA, shotB) {
return page.evaluate(
async (a, b) => {
const pixels = async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
};
const amber = (d, i) =>
d[i] > 150 &&
d[i + 1] > 90 &&
d[i + 1] < 220 &&
d[i + 2] < 130 &&
d[i] - d[i + 2] > 70 &&
d[i] - d[i + 1] > 20;
const [da, db] = [await pixels(a), await pixels(b)];
let only = 0;
for (let i = 0; i < da.length; i += 4)
if (amber(da, i) && !amber(db, i)) only += 1;
return only;
},
shotA,
shotB,
);
}
/**
* How many amber pixels the clip contains. The drawn area's stroke and fill are
* the only amber in this scene, so the count going UP after a shape is finished
@@ -196,12 +237,14 @@ try {
);
check('visible tile content settles before drawing', settled);
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
// UTILITIES starts collapsed on a first run: open it the way a user does.
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
@@ -210,7 +253,7 @@ try {
{ timeout: 10_000 },
);
check(
'the Draw control is on the DISPLAY rail and starts off',
'the Draw control is in the UTILITIES panel and starts off',
await page.evaluate(() => {
const toggle = document.getElementById('draw-toggle');
return (
@@ -298,8 +341,6 @@ try {
// ── a pin, finished with Enter ──────────────────────────────────────────
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="pin"]');
await page.click('#draw-label-input');
await page.keyboard.type('Pin one');
await clickWorld(520, 330);
check(
'the pointer is claimed while a session is open',
@@ -313,7 +354,8 @@ try {
// ── a line along a road, with Backspace ─────────────────────────────────
await page.click('.pp-mode-btn[data-shape="line"]');
await clickWorld(360, 300);
// Clear of the open UTILITIES panel, which sits beside the left rail.
await clickWorld(420, 300);
await clickWorld(470, 380);
await clickWorld(600, 470);
const beforeBackspace = (await drawState()).vertices;
@@ -326,16 +368,9 @@ try {
`${beforeBackspace} → ${afterBackspace}`,
);
await clickWorld(640, 500);
await page.click('#draw-label-input');
await page.keyboard.type('Market St');
await page.keyboard.press('Enter');
check(
'a line finished from the label field lands on the board',
await waitForMarks(2),
);
const lineMark = (await marks()).find((mark) =>
mark.label?.startsWith('Market St'),
);
check('a line finished with Enter lands on the board', await waitForMarks(2));
const lineMark = (await marks()).find((mark) => mark.type === 'route');
check(
'a drawn line reports its length and never a travel time',
Boolean(lineMark) &&
@@ -347,10 +382,8 @@ try {
// ── an area, coloured and labelled, finished with a double-click ────────
await page.click('.pp-mode-btn[data-shape="area"]');
await page.select('#draw-color-select', 'amber');
await page.click('.draw-color[data-color="amber"]');
const amberBefore = await amberPixels();
await page.click('#draw-label-input');
await page.keyboard.type('Civic Center block');
await clickWorld(400, 280);
await clickWorld(700, 290);
await clickWorld(720, 520);
@@ -373,14 +406,34 @@ try {
afterDoubleClick === 3,
`${afterDoubleClick} marks`,
);
// ── each drawn shape gets a row: named, recoloured, hidden from there ──
const rowIds = () =>
page.evaluate(() =>
[...document.querySelectorAll('#draw-mark-list [data-mark-id]')].map(
(row) => row.dataset.markId,
),
);
const ids = await rowIds();
check('every drawn shape has a row under Draw', ids.length === 3, ids);
const areaId = ids[2];
const rowControl = (id, control) =>
`#draw-mark-list [data-mark-id="${id}"] [data-control="${control}"]`;
await page.click(rowControl(areaId, 'name'));
await page.keyboard.type('Civic Center block');
await page.keyboard.press('Enter');
await wait(300);
const areaMark = (await marks()).find(
(mark) => mark.label === 'Civic Center block',
);
check(
'the label and colour chosen in the panel are what is drawn',
'the name typed in its row and the colour picked before drawing are what is drawn',
areaMark?.color === 'amber',
JSON.stringify(areaMark),
);
check(
'naming a shape keeps Draw on and the shape in progress untouched',
(await drawState()).active === true,
);
await wait(1200);
const amberAfter = await amberPixels();
check(
@@ -389,6 +442,118 @@ try {
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
);
await shot('01-area-with-label');
// ── the row's own controls: recolour, hide and show, fly to ─────────────
const markState = (id) =>
page.evaluate((id) => {
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
return (
mark && { color: mark.color, hidden: mark.hidden, alpha: mark.alpha }
);
}, id);
// Wait for a mark to reach a state rather than for a fixed time.
const markReaches = (id, want) =>
page
.waitForFunction(
(id, want) => {
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
return (
mark &&
Object.entries(want).every(([key, value]) => mark[key] === value)
);
},
{ timeout: 10_000 },
id,
want,
)
.then(
() => true,
() => false,
);
await page.click(rowControl(areaId, 'swatch'));
await page.click(rowControl(areaId, 'color-green'));
await markReaches(areaId, { color: 'green' });
check(
'a colour picked in the row recolours that shape',
(await markState(areaId))?.color === 'green',
);
await page.click(rowControl(areaId, 'toggle'));
await markReaches(areaId, { hidden: true, alpha: 0 });
const hidden = await markState(areaId);
check(
'hiding a shape keeps it on the board but draws it at zero alpha',
hidden?.hidden === true && hidden.alpha === 0,
JSON.stringify(hidden),
);
await page.click(rowControl(areaId, 'toggle'));
check(
'showing it again brings it straight back',
await markReaches(areaId, { hidden: false, alpha: 1 }),
);
// Fly-to frames the line by its whole path: once the flight settles, the
// middle of the line sits near the middle of the view.
await page.click(rowControl(ids[1], 'fly'));
await page
.waitForFunction(() => !window.__godsEyeView.viewer.camera._currentFlight, {
timeout: 15_000,
})
.catch(() => {});
await wait(300);
const framed = await page.evaluate((id) => {
const { viewer } = window.__godsEyeView;
// No Cesium global in the page: borrow Cartesian3 from a live object.
const Cartesian3 = viewer.camera.positionWC.constructor;
const mark = window.__gevAnnotations.list().find((m) => m.id === id);
const lons = mark.path.map((p) => p.lon);
const lats = mark.path.map((p) => p.lat);
const middle = Cartesian3.fromDegrees(
(Math.min(...lons) + Math.max(...lons)) / 2,
(Math.min(...lats) + Math.max(...lats)) / 2,
);
const at = viewer.scene.cartesianToCanvasCoordinates(middle);
const canvas = viewer.scene.canvas;
return (
at && {
dx: Math.abs(at.x - canvas.clientWidth / 2) / canvas.clientWidth,
dy: Math.abs(at.y - canvas.clientHeight / 2) / canvas.clientHeight,
}
);
}, ids[1]);
check(
'fly to frames that shape: its middle is near the middle of the view',
Boolean(framed) && framed.dx < 0.2 && framed.dy < 0.2,
JSON.stringify(framed),
);
// ── a hidden Line leaves the globe: its flowing stroke keeps its own
// colour, so the check is the pixels, not the mark's state ─────────────
const lineId = ids[1];
await page.click(rowControl(lineId, 'swatch'));
await page.click(rowControl(lineId, 'color-amber'));
await markReaches(lineId, { color: 'amber', alpha: 1 });
await wait(800);
const shown = await canvasShot();
await page.click(rowControl(lineId, 'toggle'));
await markReaches(lineId, { hidden: true, alpha: 0 });
await wait(800);
const hiddenShot = await canvasShot();
// Amber the shown line had and the hidden one lost; amber that appears
// only once it is hidden is noise, and must stay small beside it.
const gone = await amberOnlyIn(shown, hiddenShot);
const noise = await amberOnlyIn(hiddenShot, shown);
check(
'a hidden Line is no longer painted',
gone > 40 && noise < gone * 0.2,
`${gone} amber pixels went with the line, ${noise} appeared`,
);
await page.click(rowControl(lineId, 'toggle'));
await wait(1200);
const back = await amberOnlyIn(await canvasShot(), hiddenShot);
check(
'and paints again when shown',
back > gone * 0.5,
`${back} of ${gone} line pixels back`,
);
await shot('03-pins-and-shapes');
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
@@ -419,6 +584,12 @@ try {
afterSecondEscape.marks === 3,
`${afterSecondEscape.marks} marks`,
);
await page.click(rowControl(ids[0], 'remove'));
await waitForMarks(2);
check(
'remove in a row takes that one shape off the board and out of the list',
(await rowIds()).join() === ids.slice(1).join(),
);
// ── a click that would select something selects nothing while drawing ───
const target = await findClickableTarget();
@@ -480,10 +651,12 @@ try {
// ── Clear wipes the board ───────────────────────────────────────────────
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
+3 -24
View File
@@ -39,14 +39,13 @@ try {
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
'radio-filter',
'sdr-gain-select',
].map((id) => document.getElementById(id));
return {
ids: selects.map((node) => node.id),
shared:
selects.length === 6 &&
selects.length === 5 &&
selects.every((node) => node.classList.contains('gev-field')),
native: selects.every(
(node) =>
@@ -58,7 +57,7 @@ try {
};
});
check(
'all six panel dropdowns retain native select semantics and shared surface',
'all five panel dropdowns retain native select semantics and shared surface',
inventory.shared && inventory.native,
inventory,
);
@@ -282,13 +281,6 @@ try {
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
for (const lang of ['en']) {
await page.evaluate((lang) => {
document.documentElement.lang = lang;
@@ -296,12 +288,7 @@ try {
for (const width of [1440, 620, 390]) {
await page.setViewport({ width, height: 900 });
const layout = await page.evaluate(() => {
const ids = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
];
const ids = ['scene-select', 'cctv-camera-select', 'hud-layout-select'];
return ids.map((id) => {
const node = document.getElementById(id),
rect = node.getBoundingClientRect(),
@@ -432,19 +419,11 @@ try {
syncShare: false,
});
});
if (
await page.$eval(
'#draw-toggle',
(node) => node.getAttribute('aria-pressed') !== 'true',
)
)
await page.click('#draw-toggle');
const translated = await page.evaluate(() => {
const nodes = [
'scene-select',
'cctv-camera-select',
'hud-layout-select',
'draw-color-select',
].map((id) => document.getElementById(id));
return (
document.documentElement.lang === 'ru' &&
+7 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
* Rendered acceptance for UTILITIES ▸ Import: real files through the real file
* input and a real drop, parsed by Cesium in the page.
*
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
@@ -269,10 +269,12 @@ try {
}, VIEW);
await wait(4000);
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
@@ -283,7 +285,7 @@ try {
);
const before = await state();
check(
'Import is on the DISPLAY rail with an empty, hidden list',
'Import is in the UTILITIES panel with an empty, hidden list',
before.rows === 0 &&
!(await page.evaluate(() =>
document
+8
View File
@@ -126,6 +126,14 @@ try {
'ordinary layer rows return after reassembly',
container.querySelectorAll('.data-toggle-row').length > 10,
]);
result.push([
'Directions returns to the Utilities list after reassembly',
Boolean(
document.querySelector(
'#utilities-toggles [data-layer-id="directions"]',
) && !container.querySelector('[data-layer-id="directions"]'),
),
]);
return result;
});
for (const [name, passed] of results) check(name, passed);
+853
View File
@@ -0,0 +1,853 @@
#!/usr/bin/env node
/**
* Traffic-warm OpenFreeMap outline acceptance.
*
* Each sample opens a fresh application page, loads the Texas Capitol z14 tile
* through Street Traffic, then circles a building from that same tile. It
* records application fetches separately from browser transfer bytes,
* body-read/decode/extraction/install measures, and Long Tasks. A second ask
* on the page proves the outline source's own consumer-cache path. A separate
* page holds traffic's target response while the outline consumer subscribes,
* proving that overlap still produces one application fetch and one decode.
*
* Acceptance records actual owned synchronous segments (16.7 ms each), full
* eight-probe completion within 2 s, and separate inclusive latency. No sample may create a
* >=50 ms Long Task, and neither the first nor second outline ask may fetch or
* decode the traffic-warm tile again. The first explicit cold ask's target
* projection must fit one frame; Traffic does no outline projection and the
* warm ask reuses it. Traffic activation Long Tasks are reported separately.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2);
const valueAfter = (flag, fallback) => {
const index = args.indexOf(flag);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
};
if (args.includes('--help') || args.includes('-h')) {
console.log(
'Usage: node scripts/qa-openfreemap-same-tile.mjs [--url http://localhost:4173] [--runs 5] [--headful] [--out qa-shots/openfreemap-same-tile.json]',
);
process.exit(0);
}
const APP_URL = valueAfter('--url', 'http://localhost:4173');
const RUNS = Number(valueAfter('--runs', '5'));
const OUTPUT = path.resolve(
valueAfter('--out', 'qa-shots/openfreemap-same-tile.json'),
);
const FRAME_BUDGET_MS = 16.7;
const LONG_TASK_MS = 50;
const TARGET = Object.freeze({
latitude: 30.27472,
longitude: -97.74035,
z: 14,
x: 3743,
y: 6745,
});
const TARGET_PATH = `/${TARGET.z}/${TARGET.x}/${TARGET.y}.pbf`;
if (!Number.isInteger(RUNS) || RUNS < 3 || RUNS > 20)
throw new Error('--runs must be an integer from 3 through 20');
const targetUrl = new URL(APP_URL);
if (!['http:', 'https:'].includes(targetUrl.protocol))
throw new Error('--url must be HTTP(S)');
const percentile = (values, p) => {
if (!values.length) return null;
const sorted = values.slice().sort((a, b) => a - b);
return sorted[Math.ceil(p * sorted.length) - 1];
};
const round = (value) =>
value == null ? null : Number(Number(value).toFixed(3));
const measures = (entries, name) =>
entries.filter((entry) => entry.name === name).map((entry) => entry.ms);
const sum = (values) => values.reduce((total, value) => total + value, 0);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Numeric DEV evidence from the actual owners; waited frames are latency, not CPU.
function workEvidence(entries, label) {
const work = entries.filter(
(entry) => entry.name === 'outlines:install-work',
);
assert.equal(work.length, 1, `${label}: one completed owned-work trace`);
const trace = work[0].detail;
assert.equal(
trace?.status,
'installed',
`${label}: processed install required`,
);
assert.equal(trace.samplerStatus, 'installed');
assert.equal(trace.rendererStatus, 'installed');
assert.equal(trace.dropped, 0, `${label}: no lost work intervals`);
assert.equal(trace.probes, 8, `${label}: all original probes required`);
assert.ok(Number.isFinite(trace.timeOrigin));
assert.ok(
Number.isFinite(trace.samplerStart) && Number.isFinite(trace.completedAt),
);
assert.ok(
Number.isFinite(trace.callerStart) &&
trace.callerStart <= trace.samplerStart,
);
const phases = {
'engine-schedule': 1,
'ground-step': 8,
'hybrid-complete': 1,
'engine-process': 1,
'engine-finalize': 1,
'engine-settle': 1,
};
assert.equal(
trace.slices?.length,
13,
`${label}: complete disjoint owner intervals`,
);
for (const [phase, count] of Object.entries(phases))
assert.equal(
trace.slices.filter((slice) => slice.phase === phase).length,
count,
`${label}: ${phase}`,
);
let end = -Infinity;
const durations = trace.slices.map((slice) => {
assert.ok(
Number.isFinite(slice.start) &&
Number.isFinite(slice.end) &&
slice.end >= slice.start,
);
assert.ok(
slice.start >= end,
`${label}: no nested or double-counted owner intervals`,
);
end = slice.end;
const duration = slice.end - slice.start;
assert.ok(
duration <= FRAME_BUDGET_MS,
`${label}: ${slice.phase} work slice ${duration} exceeds ${FRAME_BUDGET_MS} ms`,
);
return duration;
});
const completionMs = trace.completedAt - trace.samplerStart;
assert.ok(
completionMs >= 0 && completionMs <= 2000,
`${label}: processed completion ${completionMs} ms exceeds 2000 ms`,
);
// This sum describes owned tail work, never the complete browser task/frame.
const tailStart = trace.slices
.filter((slice) => slice.phase === 'ground-step')
.at(-1).start;
return {
timeOrigin: trace.timeOrigin,
slices: trace.slices,
maxSliceMs: Math.max(...durations),
ownedTailSumMs: trace.slices
.filter((slice) => slice.start >= tailStart)
.reduce((total, slice) => total + slice.end - slice.start, 0),
completionMs,
callerCompletionMs: trace.completedAt - trace.callerStart,
probes: trace.probes,
dropped: trace.dropped,
};
}
await fs.mkdir(path.dirname(OUTPUT), { recursive: true });
const browser = await puppeteer.launch({
headless: args.includes('--headful') ? false : 'new',
protocolTimeout: 240_000,
...(process.env.PUPPETEER_EXECUTABLE_PATH
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
: {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
...(args.includes('--headful')
? []
: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']),
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1440,1000',
],
});
const report = {
appUrl: APP_URL,
target: TARGET,
targetPath: TARGET_PATH,
acceptance: {
maxOwnedSliceMs: FRAME_BUDGET_MS,
samplerCompletionMaxMs: 2000,
legacyInclusive: 'reported-not-gated',
maxLongTaskMs: LONG_TASK_MS,
coldProjectionMaxMs: FRAME_BUDGET_MS,
coldApplicationFetches: 0,
coldOutlineTransferBytes: 0,
coldDecodes: 0,
warmApplicationFetches: 0,
warmDecodes: 0,
overlapApplicationFetches: 1,
overlapDecodes: 1,
},
renderer: null,
samples: [],
errors: [],
};
function relevantMeasures(raw) {
return raw
.filter(
(entry) =>
entry.name.startsWith('roads:') || entry.name.startsWith('outlines:'),
)
.map((entry) => ({
name: entry.name,
ms: entry.duration,
at: entry.startTime,
detail: entry.detail || {},
}));
}
async function beginOutline(page, label) {
await page.evaluate(
({ label, target }) => {
performance.clearMeasures();
window.__qaOutlineLongTasks.length = 0;
window.__qaOutlineStarted = performance.now();
window.__qaOutlinePromise = Promise.resolve(
window.__gevVoiceCommands.runner('annotate_map', {
annotations: [
{
type: 'area',
target: 'this building',
label,
entityKind: 'building',
latitude: target.latitude,
longitude: target.longitude,
},
],
}),
);
},
{ label, target: TARGET },
);
}
async function settleOutline(page, label) {
return page.evaluate(async (label) => {
const result = await window.__qaOutlinePromise;
let annotation = null;
const deadline = performance.now() + 30_000;
while (performance.now() < deadline) {
annotation = (window.__gevAnnotations?.list?.() || []).find(
(item) => item.label === label,
);
if (annotation && !annotation.pendingOutline) break;
await new Promise((resolve) => setTimeout(resolve, 25));
}
if (!annotation || annotation.pendingOutline)
throw new Error(`${label}: outline did not settle`);
const { scene } = window.__godsEyeView.viewer;
await new Promise((resolve) => {
const timer = setTimeout(resolve, 1500);
const remove = scene.postRender.addEventListener(() => {
clearTimeout(timer);
remove();
resolve();
});
scene.requestRender();
});
await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);
return {
commandToRenderMs: performance.now() - window.__qaOutlineStarted,
result,
annotation: {
pending: Boolean(annotation.pendingOutline),
unavailable: Boolean(annotation.outlineUnavailable),
synthesized: Boolean(annotation.synthesized),
footprintKind: annotation.footprintKind || null,
ringPoints: annotation.ring?.length || 0,
parts: annotation.polygons?.length || (annotation.ring ? 1 : 0),
},
measures: performance.getEntriesByType('measure').map((entry) => ({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
detail: entry.detail,
})),
longTasks: window.__qaOutlineLongTasks.slice(),
};
}, label);
}
async function driveOutline(page, label) {
await beginOutline(page, label);
return settleOutline(page, label);
}
try {
for (let run = 0; run <= RUNS; run += 1) {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 1000 });
let overlapMode = false;
let overlapApplicationRequests = 0;
let heldTargetRequest = null;
let resolveHeldTarget;
const heldTarget = new Promise((resolve) => {
resolveHeldTarget = resolve;
});
if (run === 0) {
await page.setRequestInterception(true);
page.on('request', (request) => {
let isTarget = false;
try {
isTarget = new URL(request.url()).pathname.endsWith(TARGET_PATH);
} catch {
// Invalid URLs are passed through for the browser to handle.
}
if (overlapMode && isTarget) {
overlapApplicationRequests += 1;
if (!heldTargetRequest) {
heldTargetRequest = request;
resolveHeldTarget();
return;
}
}
void request.continue();
});
}
await page.evaluateOnNewDocument(() => {
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
window.__qaOutlineLongTasks = [];
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
window.__qaOutlineLongTasks.push({
at: entry.startTime,
ms: entry.duration,
});
}).observe({ type: 'longtask', buffered: false });
window.__qaLongTaskSupported = true;
} catch {
window.__qaLongTaskSupported = false;
}
});
let phase = 'startup';
const network = [];
const pending = new Map();
const client = await page.createCDPSession();
await client.send('Network.enable');
client.on('Network.requestWillBeSent', (event) => {
let url;
try {
url = new URL(event.request.url);
} catch {
return;
}
if (
url.hostname !== 'tiles.openfreemap.org' ||
!url.pathname.endsWith('.pbf')
)
return;
const entry = {
requestId: event.requestId,
phase,
url: url.origin + url.pathname,
targetTile: url.pathname.endsWith(TARGET_PATH),
cached: false,
transferBytes: 0,
responseBytes: 0,
finished: false,
};
pending.set(event.requestId, entry);
network.push(entry);
});
client.on('Network.requestServedFromCache', ({ requestId }) => {
const entry = pending.get(requestId);
if (entry) entry.cached = true;
});
client.on('Network.responseReceived', ({ requestId, response }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.status = response.status;
entry.cached ||= Boolean(
response.fromDiskCache ||
response.fromServiceWorker ||
response.fromPrefetchCache,
);
});
client.on('Network.dataReceived', ({ requestId, dataLength }) => {
const entry = pending.get(requestId);
if (entry) entry.responseBytes += dataLength;
});
client.on('Network.loadingFinished', ({ requestId, encodedDataLength }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.transferBytes = entry.cached ? 0 : encodedDataLength;
entry.finished = true;
pending.delete(requestId);
});
client.on('Network.loadingFailed', ({ requestId, errorText }) => {
const entry = pending.get(requestId);
if (!entry) return;
entry.failure = errorText;
entry.finished = true;
pending.delete(requestId);
});
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(String(error.message)));
const navigation = new URL(APP_URL);
navigation.searchParams.set('welcome', '0');
navigation.searchParams.set('trafficDebug', '1');
navigation.searchParams.set('trafficRoads', 'osm');
await page.goto(navigation.href, {
waitUntil: 'domcontentloaded',
timeout: 90_000,
});
await page.waitForFunction(
() =>
window.__godsEyeView?.viewer &&
window.__godsEyeView?.dataManager &&
window.__gevVoiceCommands?.runner &&
window.__gevAnnotations &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 120_000, polling: 250 },
);
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setDetection({ enabled: false }),
);
await page.evaluate(async (target) => {
const { viewer, dataManager } = window.__godsEyeView;
await dataManager.setEnabled('traffic', false);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
target.longitude,
target.latitude,
700,
),
orientation: {
heading: 0,
pitch: (-65 * Math.PI) / 180,
roll: 0,
},
});
viewer.camera.moveEnd.raiseEvent();
window.__gevAnnotations.clear();
performance.clearMeasures();
}, TARGET);
await sleep(750);
if (run === 0) {
phase = 'overlap';
overlapMode = true;
await page.evaluate(() => {
window.__qaOutlineLongTasks.length = 0;
performance.clearMeasures();
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', true),
);
await Promise.race([
heldTarget,
sleep(30_000).then(() => {
throw new Error('overlap phase did not intercept the target tile');
}),
]);
await beginOutline(page, 'QA Capitol overlap');
await sleep(100);
await heldTargetRequest.continue();
const overlap = await settleOutline(page, 'QA Capitol overlap');
await sleep(750);
const trafficStats = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats(),
);
assert.equal(trafficStats.error, null, 'overlap traffic source error');
overlap.measures = relevantMeasures(overlap.measures);
overlap.work = workEvidence(overlap.measures, 'overlap');
overlap.network = network.filter(
(entry) => entry.phase === 'overlap' && entry.targetTile,
);
const overlapDecodes = overlap.measures
.filter(
(entry) =>
entry.name === 'roads:decode' &&
String(entry.detail?.key).endsWith(
`:${TARGET.z}/${TARGET.x}/${TARGET.y}`,
),
)
.map((entry) => entry.ms);
assert.equal(
overlapApplicationRequests,
1,
'overlapping traffic and outline consumers issue one application request',
);
assert.equal(
overlap.network.length,
1,
'overlapping traffic and outline consumers emit one target network request',
);
assert.equal(
overlapDecodes.length,
1,
'overlapping traffic and outline consumers decode once',
);
assert.equal(overlap.annotation.unavailable, false);
assert.equal(overlap.annotation.footprintKind, 'building');
report.overlap = {
applicationFetches: overlapApplicationRequests,
targetNetworkRequests: overlap.network.length,
transferBytes: sum(overlap.network.map((entry) => entry.transferBytes)),
decodes: overlapDecodes.length,
decodeMs: round(sum(overlapDecodes)),
longTasks: overlap.longTasks.length,
longTaskEntries: overlap.longTasks,
work: overlap.work,
};
await page.close();
continue;
}
phase = 'traffic';
await page.evaluate(() => {
window.__qaOutlineLongTasks.length = 0;
performance.clearMeasures();
});
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('traffic', true),
);
await page.waitForFunction(
() => {
const stats = window.__godsEyeView.dataManager.layers
.get('traffic')
.module.getStats();
return stats.count > 0 && !stats.loading && !stats.error;
},
{ timeout: 180_000, polling: 250 },
);
await sleep(1500);
const trafficMeasures = relevantMeasures(
await page.evaluate(() =>
performance.getEntriesByType('measure').map((entry) => ({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
detail: entry.detail,
})),
),
);
const trafficLongTasks = await page.evaluate(() =>
window.__qaOutlineLongTasks.slice(),
);
const trafficTarget = network.filter(
(entry) => entry.phase === 'traffic' && entry.targetTile,
);
const targetTile = `${TARGET.z}/${TARGET.x}/${TARGET.y}`;
const targetProjectionMeasures = trafficMeasures.filter(
(entry) =>
entry.detail?.tile === targetTile &&
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
);
const targetProjectionMs = sum(
targetProjectionMeasures.map((entry) => entry.ms),
);
assert.ok(
trafficTarget.length >= 1,
`sample ${run}: traffic did not load ${TARGET_PATH}`,
);
assert.equal(
trafficMeasures.filter((entry) =>
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
).length,
0,
`sample ${run}: traffic must leave outline projection to the explicit ask`,
);
phase = 'outline-cold';
const cold = await driveOutline(page, `QA Capitol cold ${run}`);
await sleep(750);
cold.measures = relevantMeasures(cold.measures);
cold.work = workEvidence(cold.measures, 'cold');
cold.network = network.filter(
(entry) => entry.phase === 'outline-cold' && entry.targetTile,
);
cold.longTasks = cold.longTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
}));
await page.evaluate(() => window.__gevAnnotations.clear());
phase = 'outline-warm';
const warm = await driveOutline(page, `QA Capitol warm ${run}`);
await sleep(750);
warm.measures = relevantMeasures(warm.measures);
warm.work = workEvidence(warm.measures, 'warm');
warm.network = network.filter(
(entry) => entry.phase === 'outline-warm' && entry.targetTile,
);
warm.longTasks = warm.longTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
}));
const supported = await page.evaluate(() => window.__qaLongTaskSupported);
assert.equal(supported, true, `sample ${run}: Long Tasks unsupported`);
assert.deepEqual(pageErrors, [], `sample ${run}: browser errors`);
assert.equal(cold.annotation.unavailable, false);
assert.equal(cold.annotation.synthesized, false);
assert.equal(cold.annotation.footprintKind, 'building');
assert.ok(cold.annotation.ringPoints >= 4);
assert.equal(warm.annotation.footprintKind, 'building');
assert.ok(warm.annotation.ringPoints >= 4);
const coldDecodes = measures(cold.measures, 'roads:decode');
const coldInstalls = measures(cold.measures, 'outlines:install');
const warmDecodes = measures(warm.measures, 'roads:decode');
const coldProjectionMeasures = cold.measures.filter(
(entry) =>
entry.detail?.tile === targetTile &&
['outlines:street-extract', 'outlines:polygon-extract'].includes(
entry.name,
),
);
cold.targetProjectionMs = sum(
coldProjectionMeasures.map((entry) => entry.ms),
);
assert.equal(
cold.network.length,
0,
'shared decoded tile performs no second application fetch',
);
assert.equal(sum(cold.network.map((entry) => entry.transferBytes)), 0);
assert.equal(
coldDecodes.length,
0,
'shared decoded tile is not parsed again',
);
assert.equal(
coldProjectionMeasures.filter(
(entry) => entry.name === 'outlines:street-extract',
).length,
1,
'the first explicit cold ask owns target street extraction exactly once',
);
assert.equal(
coldProjectionMeasures.filter(
(entry) => entry.name === 'outlines:polygon-extract',
).length,
1,
'the first explicit cold ask owns target polygon extraction exactly once',
);
assert.ok(
cold.targetProjectionMs <= FRAME_BUDGET_MS,
`sample ${run}: cold target projection ${cold.targetProjectionMs} ms exceeds ${FRAME_BUDGET_MS} ms`,
);
assert.equal(
measures(warm.measures, 'outlines:street-extract').length,
0,
'warm reuse performs no street extraction',
);
assert.equal(
measures(warm.measures, 'outlines:polygon-extract').length,
0,
'warm reuse performs no polygon extraction',
);
assert.ok(coldInstalls.length >= 1, 'cold outline installs geometry');
assert.equal(warm.network.length, 0, 'decoded-cache hit performs no fetch');
assert.equal(warmDecodes.length, 0, 'decoded-cache hit performs no decode');
report.renderer ||= await page.evaluate(() => {
const gl = window.__godsEyeView.viewer.scene.context._gl;
const debug = gl.getExtension('WEBGL_debug_renderer_info');
return debug
? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL)
: gl.getParameter(gl.RENDERER);
});
if (args.includes('--headful')) {
assert.doesNotMatch(
String(report.renderer),
/swiftshader|software rasterizer|llvmpipe/i,
`sample ${run}: headful evidence requires a hardware renderer`,
);
}
report.samples.push({
run,
pageErrors,
traffic: {
applicationFetches: trafficTarget.length,
transferBytes: sum(trafficTarget.map((entry) => entry.transferBytes)),
decodes: measures(trafficMeasures, 'roads:decode').length,
targetProjectionMs: round(targetProjectionMs),
longTasks: trafficLongTasks.map((entry) => ({
at: round(entry.at),
ms: round(entry.ms),
})),
measures: trafficMeasures,
network: trafficTarget,
},
cold: {
...cold,
applicationFetches: cold.network.length,
transferBytes: sum(cold.network.map((entry) => entry.transferBytes)),
decodeMs: round(sum(coldDecodes)),
bodyReadMs: round(sum(measures(cold.measures, 'roads:tile-body-read'))),
streetExtractMs: round(
sum(measures(cold.measures, 'outlines:street-extract')),
),
polygonExtractMs: round(
sum(measures(cold.measures, 'outlines:polygon-extract')),
),
installMs: round(sum(coldInstalls)),
legacyInclusiveMs: round(
sum(coldDecodes) +
sum(measures(cold.measures, 'outlines:street-extract')) +
sum(measures(cold.measures, 'outlines:polygon-extract')) +
sum(coldInstalls),
),
},
warm: {
...warm,
applicationFetches: warm.network.length,
transferBytes: sum(warm.network.map((entry) => entry.transferBytes)),
decodeMs: round(sum(warmDecodes)),
installMs: round(sum(measures(warm.measures, 'outlines:install'))),
},
});
await page.close();
}
const coldInclusive = report.samples.map(
(sample) => sample.cold.legacyInclusiveMs,
);
const outlineLongTasks = report.samples.flatMap((sample) => [
...sample.cold.longTasks,
...sample.warm.longTasks,
]);
const trafficLongTasks = report.samples.flatMap(
(sample) => sample.traffic.longTasks,
);
report.summary = {
samples: report.samples.length,
overlapApplicationFetches: report.overlap?.applicationFetches ?? null,
overlapDecodes: report.overlap?.decodes ?? null,
legacyInclusiveP50Ms: round(percentile(coldInclusive, 0.5)),
legacyInclusiveP95Ms: round(percentile(coldInclusive, 0.95)),
legacyInclusiveMaxMs: round(Math.max(...coldInclusive)),
coldCommandToRenderP95Ms: round(
percentile(
report.samples.map((sample) => sample.cold.commandToRenderMs),
0.95,
),
),
coldApplicationFetches: sum(
report.samples.map((sample) => sample.cold.applicationFetches),
),
coldTransferBytes: sum(
report.samples.map((sample) => sample.cold.transferBytes),
),
coldDecodes: sum(
report.samples.map(
(sample) => measures(sample.cold.measures, 'roads:decode').length,
),
),
warmApplicationFetches: sum(
report.samples.map((sample) => sample.warm.applicationFetches),
),
warmDecodes: sum(
report.samples.map(
(sample) => measures(sample.warm.measures, 'roads:decode').length,
),
),
coldProjectionMaxMs: Math.max(
...report.samples.map((sample) => sample.cold.targetProjectionMs),
),
trafficBaselineLongTasks: trafficLongTasks.length,
trafficBaselineMaxLongTaskMs: round(
trafficLongTasks.length
? Math.max(...trafficLongTasks.map((entry) => entry.ms))
: 0,
),
longTasks: outlineLongTasks.length,
maxLongTaskMs: round(
outlineLongTasks.length
? Math.max(...outlineLongTasks.map((entry) => entry.ms))
: 0,
),
};
const coldSlices = report.samples.map(
(sample) => sample.cold.work.maxSliceMs,
);
report.summary.coldOwnedSliceP95Ms = round(percentile(coldSlices, 0.95));
report.summary.maxOwnedSliceMs = Math.max(
report.overlap.work.maxSliceMs,
...report.samples.flatMap((sample) => [
sample.cold.work.maxSliceMs,
sample.warm.work.maxSliceMs,
]),
);
report.summary.maxSamplerCompletionMs = Math.max(
report.overlap.work.completionMs,
...report.samples.flatMap((sample) => [
sample.cold.work.completionMs,
sample.warm.work.completionMs,
]),
);
assert.ok(report.summary.maxOwnedSliceMs <= FRAME_BUDGET_MS);
assert.ok(report.summary.maxSamplerCompletionMs <= 2000);
assert.ok(
report.summary.coldProjectionMaxMs <= FRAME_BUDGET_MS,
'cold outline projection exceeded one frame',
);
assert.equal(report.summary.longTasks, 0, 'outline ask created a Long Task');
assert.equal(report.summary.coldTransferBytes, 0);
assert.equal(report.summary.coldApplicationFetches, 0);
assert.equal(report.summary.coldDecodes, 0);
assert.equal(report.summary.warmApplicationFetches, 0);
assert.equal(report.summary.warmDecodes, 0);
assert.equal(report.summary.overlapApplicationFetches, 1);
assert.equal(report.summary.overlapDecodes, 1);
report.passed = true;
console.table(
report.samples.map((sample) => ({
run: sample.run,
coldProjectionMs: sample.cold.targetProjectionMs,
appFetches: sample.cold.applicationFetches,
transferBytes: sample.cold.transferBytes,
bodyReadMs: sample.cold.bodyReadMs,
decodeMs: sample.cold.decodeMs,
streetMs: sample.cold.streetExtractMs,
polygonMs: sample.cold.polygonExtractMs,
installMs: sample.cold.installMs,
legacyInclusiveMs: sample.cold.legacyInclusiveMs,
maxOwnedSliceMs: sample.cold.work.maxSliceMs,
renderMs: round(sample.cold.commandToRenderMs),
longTasks: sample.cold.longTasks.length,
})),
);
console.log('OpenFreeMap same-tile acceptance passed:', report.summary);
} catch (error) {
report.passed = false;
report.failure = String(error?.stack || error);
console.error(error);
process.exitCode = 1;
} finally {
await fs.writeFile(OUTPUT, `${JSON.stringify(report, null, 2)}\n`);
await browser.close();
}
+38 -7
View File
@@ -35,15 +35,21 @@ try {
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(() =>
window.__godsEyeView.styleManager.setPanelCollapsed('pp-toggles', false, {
persist: false,
syncShare: false,
}),
);
// DISPLAY holds most segmented controls; Draw's shape group is in UTILITIES.
await page.evaluate(() => {
for (const panel of ['pp-toggles', 'utilities-panel'])
window.__godsEyeView.styleManager.setPanelCollapsed(panel, false, {
persist: false,
syncShare: false,
});
});
const surface = await page.evaluate(() => {
const groups = [...document.querySelectorAll('[role="radiogroup"]')];
// Colour swatches are one-of-many too, but chips, not segments: they are
// checked on their own below.
const groups = [
...document.querySelectorAll('[role="radiogroup"]:not(.draw-colors)'),
];
const buttons = groups.flatMap((group) => [
...group.querySelectorAll('[role="radio"]'),
]);
@@ -69,6 +75,31 @@ try {
JSON.stringify(surface),
);
const swatches = await page.evaluate(() => {
const group = document.getElementById('draw-colors');
const chips = [...(group?.querySelectorAll('[role="radio"]') || [])];
return {
count: chips.length,
round: chips.every(
(chip) => getComputedStyle(chip).borderRadius === '50%',
),
colours: new Set(
chips.map((chip) => getComputedStyle(chip).backgroundColor),
).size,
checked: chips.filter(
(chip) => chip.getAttribute('aria-checked') === 'true',
).length,
};
});
check(
"Draw's colour swatches are five round chips, one checked",
swatches.count === 5 &&
swatches.round &&
swatches.colours === 5 &&
swatches.checked === 1,
JSON.stringify(swatches),
);
await page.evaluate(() =>
document.getElementById('models3d-mode-all').click(),
);
+300 -1
View File
@@ -23,6 +23,9 @@
* node scripts/qa-voice-routing.mjs # both layers, :4415
* node scripts/qa-voice-routing.mjs --layer routing --budget 120
* node scripts/qa-voice-routing.mjs --layer behavior --url http://localhost:4173
* node scripts/qa-voice-routing.mjs --layer behavior --behavior outlines
* node scripts/qa-voice-routing.mjs --layer behavior --behavior acceptance [--headful] [--shots qa-shots/outlines]
* # operator-only: may spend the configured guarded Nominatim allowance
* node scripts/qa-voice-routing.mjs --layer behavior --deixis-only --headful --shots qa-shots/point-and-ask
* node scripts/qa-voice-routing.mjs --layer deixis --budget 12 # pointer_context routing (model turns)
* node scripts/qa-voice-routing.mjs --layer deixis-live --url ... # in-app text turns (model turns)
@@ -72,6 +75,7 @@ const LAYER = getOpt('--layer', 'all'); // routing | behavior | all | deixis | d
const TURN_BUDGET = Number(getOpt('--budget', '120'));
const PHRASES_PER_SESSION = Number(getOpt('--batch', '6'));
const ONLY = getOpt('--only', null); // substring filter on phrase text
const BEHAVIOR = getOpt('--behavior', 'all'); // all | outlines | acceptance
const DEIXIS_ONLY = process.argv.includes('--deixis-only'); // behavior: point-and-ask block only
const HEADFUL = process.argv.includes('--headful'); // real GPU, visible window
const SHOT_DIR = getOpt('--shots', null); // point-and-ask evidence screenshots
@@ -1325,7 +1329,10 @@ async function runBehaviorLayer() {
// Overpass queries through the app and any direct request to an Overpass
// host (the status probe is not a query).
const overpassRequests = [];
const packTimes = [];
const requestStarted = new Map();
page.on('request', (request) => {
requestStarted.set(request, Date.now());
const target = new URL(request.url());
const viaApp =
target.origin === new URL(APP_URL).origin &&
@@ -1336,12 +1343,26 @@ async function runBehaviorLayer() {
`${request.method()} ${target.origin}${target.pathname}`,
);
});
page.on('requestfinished', (request) => {
if (/us_census_places|wof_neighborhoods/.test(request.url()))
packTimes.push({
file: request.url().split('/').pop().split('?')[0],
ms: Date.now() - (requestStarted.get(request) ?? Date.now()),
});
requestStarted.delete(request);
});
const resolverLog = [];
page.on('console', (message) => {
const text = message.text();
if (text.startsWith('[Resolver]')) resolverLog.push(text);
});
try {
// The point-and-ask block skips the first-run launcher so its evidence
// screenshots show the map; the full run keeps the historical URL.
const behaviorUrl = new URL(APP_URL);
if (DEIXIS_ONLY) behaviorUrl.searchParams.set('welcome', '0');
if (DEIXIS_ONLY || BEHAVIOR === 'acceptance')
behaviorUrl.searchParams.set('welcome', '0');
await page.goto(behaviorUrl.href, {
waitUntil: 'domcontentloaded',
timeout: 60000,
@@ -1384,6 +1405,22 @@ async function runBehaviorLayer() {
});
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
if (BEHAVIOR === 'outlines') {
await runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
});
return;
}
if (BEHAVIOR === 'acceptance') {
await runOutlineAcceptance({ page, run, settle, overpassRequests });
return;
}
// (1) fly_to_location lands near the target. Annotations follow IMMEDIATELY
// while the camera is local — the resolver's proximity gate rejects far
// targets BY DESIGN (annotating Austin from over the Alps must fail).
@@ -2295,6 +2332,15 @@ async function runBehaviorLayer() {
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
);
await runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
});
const shotDir = path.join(ROOT, 'qa-shots');
fs.mkdirSync(shotDir, { recursive: true });
await page.screenshot({
@@ -2318,6 +2364,259 @@ async function runBehaviorLayer() {
}
}
/**
* Area asks that the bundled packs answer with no boundary lookup: a US
* Census place, the San Francisco DataSF pack, and Who's On First
* neighborhoods. Each teleports the camera over the place, draws the area,
* and checks the outline, the pack that answered, and that no request went
* to Overpass.
*/
const BUNDLED_OUTLINES = [
{ target: 'Austin', lat: 30.2672, lon: -97.7431, pack: 'us-census' },
{ target: 'the Mission District', lat: 37.7599, lon: -122.4148, pack: 'datasf', entityKind: 'district' },
{ target: 'Notting Hill', lat: 51.5118, lon: -0.2046, pack: 'wof' },
{ target: 'Williamsburg', lat: 40.7081, lon: -73.9571, pack: 'wof' },
{ target: 'Le Marais', lat: 48.8575, lon: 2.358, pack: 'wof' },
];
async function runBundledOutlineScenarios({
page,
run,
settle,
overpassRequests,
resolverLog,
packTimes,
}) {
const timings = [];
for (const scenario of BUNDLED_OUTLINES) {
const label = `behavior: "outline ${scenario.target}" draws the bundled ${scenario.pack} outline`;
await page.evaluate(({ lat, lon }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180,
height: 9000,
}),
});
}, scenario);
await run('clear_annotations', {});
await settle(1500);
const overpassBefore = overpassRequests.length;
const logBefore = resolverLog.length;
const packsBefore = packTimes.length;
const started = Date.now();
const r = await run('annotate_map', {
annotations: [{
type: 'area',
target: scenario.target,
label: scenario.target,
...(scenario.entityKind ? { entityKind: scenario.entityKind } : {}),
}],
});
let ringPoints = 0;
for (let i = 0; i < 40 && ringPoints < 4; i += 1) {
ringPoints = await page.evaluate((target) => {
const items = window.__gevAnnotations?.list?.() || [];
const item = items.find((it) => it.label === target);
return Array.isArray(item?.ring) ? item.ring.length : 0;
}, scenario.target);
if (ringPoints < 4) await settle(250);
}
const ms = Date.now() - started;
const line = resolverLog.slice(logBefore).find((l) => l.includes('bundled')) || '';
const pack = /\((us-census|datasf|wof|natural-earth)[,)]/.exec(line)?.[1] || 'none';
const packFiles = packTimes.slice(packsBefore);
const overpass = overpassRequests.slice(overpassBefore);
timings.push({ target: scenario.target, ms, pack, overpass: overpass.length });
report((r?.drawn ?? 0) >= 1 && ringPoints >= 4 && pack === scenario.pack && overpass.length === 0,
label,
`drawn=${r?.drawn} ring=${ringPoints} pack=${pack} outlineMs=${ms} overpass=${overpass.length} ` +
`packFiles=${packFiles.map((f) => `${f.file}:${f.ms}ms`).join(',') || 'cached'}`);
}
await run('clear_annotations', {});
report(
overpassRequests.length === 0,
'behavior: no request reached Overpass during the run',
`overpass=${overpassRequests.length} ${overpassRequests.slice(0, 2).join(' ')}`,
);
console.log(` bundled outline timings: ${JSON.stringify(timings)}`);
console.log(` pack files loaded: ${packTimes.map((f) => `${f.file}:${f.ms}ms`).join(' ')}`);
}
/**
* Outline acceptance across six countries: each ask teleports the camera
* over the place, draws the area once cold and once warm (after clearing),
* and records the rung that answered, the parts drawn, the latency, and how
* many public Nominatim requests the server spent (its daily count in
* .gev-cache/nominatim/state.json). Nothing may reach Overpass.
*/
const OUTLINE_ASKS = [
// group, target, camera lat/lon/height(m), extra annotation args, shot name
['country', 'Switzerland', 46.8, 8.2, 1_200_000],
['country', 'Iran', 32.4, 53.7, 3_500_000],
['state', 'Texas', 31.0, -99.0, 2_000_000],
['state', 'Bavaria', 48.9, 11.4, 900_000],
['city', 'Austin', 30.2672, -97.7431, 60_000, {}, 'austin-city'],
['city', 'Paris', 48.8566, 2.3522, 40_000],
['city', 'Tokyo', 35.6762, 139.6503, 90_000],
['city', 'São Paulo', -23.5505, -46.6333, 90_000],
['city', 'Mumbai', 19.076, 72.8777, 60_000],
['city', 'Lagos', 6.5244, 3.3792, 60_000],
['neighborhood', 'the Mission District', 37.7599, -122.4148, 8000, { entityKind: 'district' }],
['neighborhood', 'the Marina', 37.8037, -122.4368, 8000, { entityKind: 'district' }],
['neighborhood', 'Notting Hill', 51.5118, -0.2046, 8000, { entityKind: 'district' }, 'notting-hill'],
['neighborhood', 'Williamsburg', 40.7081, -73.9571, 8000, { entityKind: 'district' }],
['neighborhood', 'SoHo', 40.7233, -74.003, 8000, { entityKind: 'district' }],
['neighborhood', 'Le Marais', 48.8575, 2.358, 8000, { entityKind: 'district' }],
['neighborhood', 'Shibuya', 35.6595, 139.7005, 8000, { entityKind: 'district' }],
['landmark', 'the Texas Capitol grounds', 30.2747, -97.7404, 3000, { entityKind: 'compound' }],
['landmark', 'Central Park', 40.7829, -73.9654, 6000, {}, 'central-park'],
['landmark', 'the Eiffel Tower', 48.8584, 2.2945, 3000],
['landmark', 'Stanford', 37.4275, -122.1697, 8000],
['landmark', 'Stanford University', 37.4275, -122.1697, 8000],
['landmark', 'Lady Bird Lake', 30.2638, -97.7526, 6000],
['street', 'Congress Avenue', 30.2672, -97.7425, 3000, { entityKind: 'street' }, 'congress-avenue'],
['street', 'Lombard Street', 37.8021, -122.4187, 3000, { entityKind: 'street' }],
['building', 'this building', 30.27472, -97.74035, 1200,
{ entityKind: 'building', latitude: 30.27472, longitude: -97.74035 }, 'building'],
].map(([group, target, lat, lon, height, extra = {}, shot = null]) => ({ group, target, lat, lon, height, extra, shot }));
const NOMINATIM_STATE = path.join(ROOT, '.gev-cache', 'nominatim', 'state.json');
function nominatimCount() {
try {
return JSON.parse(fs.readFileSync(NOMINATIM_STATE, 'utf8')).count || 0;
} catch {
return 0;
}
}
async function runOutlineAcceptance({
page,
run,
settle,
overpassRequests,
}) {
const trace = [];
page.on('console', (msg) => {
const text = msg.text();
if (/^\[(Outline|Resolver)\]/.test(text)) trace.push(text);
});
const nominatimStart = nominatimCount();
const overpassStart = overpassRequests.length;
if (SHOT_DIR) fs.mkdirSync(path.resolve(ROOT, SHOT_DIR), { recursive: true });
const teleport = (ask) => page.evaluate(({ lat, lon, height }) => {
const { viewer } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180,
height,
}),
});
}, ask);
const rungOf = (lines, state) => {
for (const line of lines) {
const m = /→ (openfreemap|nominatim)\b/.exec(line) ||
/bundled → ([a-z-]+)/.exec(line) ||
/\((us-census|datasf|wof|natural-earth),/.exec(line);
if (m) return m[1];
}
if (state?.synthesized) return 'approximate';
return state?.ring >= 4 ? 'other' : 'point';
};
const once = async (ask) => {
await run('clear_annotations', {});
await teleport(ask);
await settle(1200);
trace.length = 0;
const before = nominatimCount();
const label = `QA ${ask.target}`;
const started = Date.now();
const r = await run('annotate_map', {
annotations: [{ type: 'area', target: ask.target, label, ...ask.extra }],
});
let state = null;
for (let i = 0; i < 120; i += 1) {
state = await page.evaluate((l) => {
const it = (window.__gevAnnotations?.list?.() || []).find((a) => a.label === l);
if (!it) return null;
return {
ring: Array.isArray(it.ring) ? it.ring.length : 0,
parts: Array.isArray(it.polygons) && it.polygons.length ? it.polygons.length : (it.ring ? 1 : 0),
synthesized: Boolean(it.synthesized),
unavailable: Boolean(it.outlineUnavailable),
pending: Boolean(it.pendingOutline),
};
}, label);
if (state && !state.pending) break;
if (!state && r?.drawn === 0 && i > 8) break;
await settle(250);
}
const ms = Date.now() - started;
return {
ms,
drawn: r?.drawn ?? 0,
state,
rung: rungOf(trace, state),
nominatim: nominatimCount() - before,
error: r?.error || r?.results?.find?.((x) => x?.error)?.error || null,
};
};
const table = [];
// --only "Paris|Stanford" reruns a few rows.
const only = ONLY ? ONLY.split('|') : null;
for (const ask of OUTLINE_ASKS.filter((a) => !only || only.some((o) => a.target.includes(o)))) {
const cold = await once(ask);
if (SHOT_DIR && ask.shot) {
await settle(HEADFUL ? 6000 : 2500);
await page.screenshot({ path: path.resolve(ROOT, SHOT_DIR, `${ask.shot}.png`) });
}
const warm = await once(ask);
const rendered = Boolean(cold.state?.ring >= 4) && !cold.state?.synthesized;
const row = {
group: ask.group,
ask: ask.target,
rung: cold.rung,
rendered,
approximate: Boolean(cold.state?.synthesized),
parts: cold.state?.parts ?? 0,
coldMs: cold.ms,
warmMs: warm.ms,
warmRung: warm.rung,
nominatim: cold.nominatim + warm.nominatim,
};
table.push(row);
const detail = `rung=${row.rung} parts=${row.parts} cold=${row.coldMs}ms warm=${row.warmMs}ms ` +
`nominatim=${row.nominatim}${row.approximate ? ' approximate' : ''}` +
(rendered ? '' : ` trace=${trace.slice(-2).join(' | ').slice(0, 300)}`);
if (rendered) report(true, `outlines: ${ask.group} "${ask.target}"`, detail);
else skipped(`outlines: ${ask.group} "${ask.target}" (no exact outline)`, detail);
}
await run('clear_annotations', {});
const overpass = overpassRequests.slice(overpassStart);
report(overpass.length === 0, 'outlines: no request reached Overpass during the run',
`overpass=${overpass.length} ${overpass.slice(0, 2).join(' ')}`);
const used = nominatimCount() - nominatimStart;
report(used <= 30, 'outlines: public Nominatim requests within the run budget (30)', `used=${used}`);
console.log('\n | Group | Ask | Rung | Outline | Parts | Cold ms | Warm ms | Nominatim |');
console.log(' |---|---|---|---|---|---|---|---|');
for (const row of table) {
const outline = row.rendered ? 'yes' : row.approximate ? 'approximate' : 'no (point)';
console.log(` | ${row.group} | ${row.ask} | ${row.rung} | ${outline} | ${row.parts} | ${row.coldMs} | ${row.warmMs} | ${row.nominatim} |`);
}
if (SHOT_DIR)
fs.writeFileSync(path.resolve(ROOT, SHOT_DIR, 'outline-table.json'), JSON.stringify(table, null, 2));
}
// ── Main ────────────────────────────────────────────────────
console.log('\nVoice-surface QA harness');
console.log(` App URL : ${APP_URL}`);
+1 -1
View File
@@ -57,7 +57,7 @@ export function classifyNodeVersion(version = process.versions.node) {
// NEWER than this release has verified is a warning, never a refusal: a
// future Node must not brick a no-terminal install with advice its user
// cannot follow. Too-old stays an error above — old runtimes genuinely fail.
return { level: 'warn', summary: 'newer than this release has verified; Node 24.14.x or 26.x is the tested path' };
return { level: 'warn', summary: 'newer than this release has verified; Node 24.22.x or 26.x is the tested path' };
}
/** Verify that every direct package declared by this checkout is present. */
+51
View File
@@ -0,0 +1,51 @@
{
"reviewedSources": {
"zs": {
"name": "Zetashapes",
"registryCommit": "e17af153c144fe6e08aa8db2607b4bf8aecd448d",
"registrySha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39",
"rawLicenseType": "Public domain",
"licenseTextSha256": "91757f96f74f6dfb4a7206486b78586da0d039e9611e224fc793c7167dcfe615",
"decision": "Approved from the pinned WOF registry text: no added restrictions beyond public-domain US TIGER/Line data and Flickr-derived data; retain both upstream credits."
}
},
"licenses": [
"Attribution 3.0 Unported",
"CC BY",
"CC BY 2.0",
"CC BY 3.0",
"CC BY 3.0 US",
"CC BY 4.0",
"CC-BY",
"CC0",
"CC0 1.0 Universal",
"ODC-By, v1.0",
"ODC-By v1.0",
"OGL - Canada",
"OGL - City of New Westminster",
"OGL - City of Surrey",
"Open Government Licence v3.0",
"Open Government Licence - British Columbia, v2.0",
"Open Government Licence - Canada",
"Open Government Licence - City of Calgary",
"Open Government Licence - City of Regina",
"Open Government Licence - Edmonton",
"Open Government Licence - Ontario, v1.0",
"Open Government License - British Columbia, v2.0",
"Open Government License - Winnipeg",
"PDDL 1.0",
"Public domain",
"Public Domain",
"Singapore Open Data Licence v1"
],
"recordExclusions": {
"reason": "Sources whose pinned registry licence is only an \"equivalent\" designation (for example \"CC BY (equivalent)\" or \"CC0 (equivalent)\"), including can-ons, can-saskodp, can-gatsudd and esp-aytomad, are not admitted: their redistribution terms have not been established for this derived pack.",
"archives": {
"neighbourhood": "8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455",
"macrohood": "f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679",
"microhood": "e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac"
},
"registrySha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39",
"sources": {}
}
}
+16 -3
View File
@@ -1,3 +1,4 @@
import path from 'node:path';
import { openSkyProxy } from './aircraft/opensky.js';
import { celestrakProxy, rocketLaunchesProxy } from './space.js';
import { tomtomProxy } from './traffic.js';
@@ -7,7 +8,7 @@ import { adsbdbProxy } from './aircraft/enrichment.js';
import { overpassProxy } from './overpass.js';
import { militaryInstallationsProxy } from './military-installations.js';
import { regionalBriefProxy } from './regional/briefing.js';
import { geocodeProxy } from './regional/place.js';
import { geocodeProxy, NOMINATIM_CACHE_DIR } from './regional/place.js';
import { weatherEffectsProxy } from './regional/weather-effects.js';
import { cctvProxy } from './cctv.js';
import { defaultSourceRoot } from './common/source-root.js';
@@ -33,8 +34,20 @@ import { windProxy } from './wind.js';
/**
* Construct the local provider plugins in their established order.
* `realtime` configures OpenAI; `gemini` explicitly opts into Gemini Live.
* `nominatimStorageDir` binds an owned absolute directory before construction;
* omission retains the durable per-source cache and policy state.
*/
function localProviderPlugins({ realtime, gemini } = {}) {
function localProviderPlugins({
realtime,
gemini,
nominatimStorageDir = NOMINATIM_CACHE_DIR,
} = {}) {
if (
typeof nominatimStorageDir !== 'string' ||
!nominatimStorageDir.trim() ||
!path.isAbsolute(nominatimStorageDir)
)
throw new TypeError('nominatimStorageDir must be an absolute directory');
return [
openSkyProxy(),
celestrakProxy(),
@@ -46,7 +59,7 @@ function localProviderPlugins({ realtime, gemini } = {}) {
overpassProxy(),
militaryInstallationsProxy(),
regionalBriefProxy(),
geocodeProxy(),
geocodeProxy({ storageDir: nominatimStorageDir }),
weatherEffectsProxy(),
cctvProxy({ sourceRoot: defaultSourceRoot }),
radioBrowserProxy(),
@@ -1,4 +1,5 @@
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSite } from './common/same-site.js';
import {
MILITARY_INSTALLATION_ELEMENT_CAP,
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
@@ -97,6 +98,7 @@ function militaryInstallationsProxy() {
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
if (admitSameSite(req, res)) return;
const url = new URL(req.url, 'http://localhost');
const requested = validMilitaryInstallationBox(url.searchParams);
if (!requested) {
+174
View File
@@ -0,0 +1,174 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { normalizePlaceName as foldName } from '../../../src/data/placeNames.js';
const DAY_MS = 86_400_000;
/** Entry files this cache writes: a 40-hex-digit key hash. */
const CACHE_FILE_RE = /^[0-9a-f]{40}\.json$/;
/** How long an answer is kept, by what it was. */
export const NOMINATIM_CACHE_TTL_MS = Object.freeze({
outline: 90 * DAY_MS,
place: 30 * DAY_MS,
notFound: DAY_MS,
});
/**
* Comparable form of a place name for matching results to the ask: the
* shared place-name key (see `normalizePlaceName` in placeNames.js, so
* "St. Paul" and "Saint Paul" agree), bounded in length.
*
* @param {string} value
* @returns {string}
*/
export function normalizePlaceName(value) {
return foldName(value).slice(0, 200);
}
/**
* Memory + disk cache for Nominatim answers.
*
* Every entry carries its own expiry. Memory is an LRU bounded by entries;
* disk is bounded by entries and bytes, with expired files removed during a
* periodic sweep. A null `dir` keeps everything in memory.
*/
export function createNominatimCache({
dir = null,
maxMemoryEntries = 256,
maxDiskEntries = 4000,
maxDiskBytes = 64 * 1024 * 1024,
maxEntryBytes = 2 * 1024 * 1024,
sweepEvery = 50,
now = Date.now,
} = {}) {
const memory = new Map();
let writesSinceSweep = 0;
let sweeping = null;
const digestFor = (key) =>
crypto.createHash('sha256').update(key).digest('hex');
const fileFor = (key) =>
path.join(dir, `${digestFor(key).slice(0, 40)}.json`);
function remember(key, entry) {
memory.delete(key);
memory.set(key, entry);
while (memory.size > maxMemoryEntries)
memory.delete(memory.keys().next().value);
}
async function get(key) {
const hit = memory.get(key);
if (hit) {
if (hit.expiresAt > now()) {
remember(key, hit);
return hit;
}
memory.delete(key);
}
if (!dir) return null;
try {
const entry = JSON.parse(await fs.readFile(fileFor(key), 'utf8'));
if (entry?.keyDigest !== digestFor(key) || !(entry.expiresAt > now()))
return null;
remember(key, entry);
return entry;
} catch {
return null;
}
}
async function set(key, payload, ttlMs) {
const entry = {
keyDigest: digestFor(key),
payload,
storedAt: now(),
expiresAt: now() + ttlMs,
};
remember(key, entry);
if (!dir) return;
const body = JSON.stringify(entry);
if (Buffer.byteLength(body) > maxEntryBytes) return;
let temp = null;
try {
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
await fs.chmod(dir, 0o700);
const file = fileFor(key);
temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
await fs.writeFile(temp, body, { mode: 0o600, flag: 'wx' });
await fs.rename(temp, file);
temp = null;
} catch {
return;
} finally {
if (temp) await fs.rm(temp, { force: true }).catch(() => {});
}
writesSinceSweep += 1;
if (writesSinceSweep >= sweepEvery) {
writesSinceSweep = 0;
await sweep();
}
}
/** Remove expired files, then the oldest until both disk bounds hold. */
async function sweep() {
if (!dir) return;
if (sweeping) return sweeping;
sweeping = (async () => {
let names;
try {
names = await fs.readdir(dir);
} catch {
return;
}
const files = [];
for (const name of names) {
// Only this cache's own entry files; anything else in the directory
// is left alone.
if (!CACHE_FILE_RE.test(name)) continue;
const file = path.join(dir, name);
let stat;
let text;
try {
[stat, text] = await Promise.all([
fs.stat(file),
fs.readFile(file, 'utf8'),
]);
} catch {
// Unreadable right now (gone, busy, too many open files): an error
// reading is not evidence the entry is bad, so it stays.
continue;
}
let expiresAt;
try {
expiresAt = JSON.parse(text)?.expiresAt;
} catch {
expiresAt = null; // corrupt: removed below
}
if (!(expiresAt > now())) {
await fs.rm(file, { force: true }).catch(() => {});
continue;
}
files.push({ file, size: stat.size, at: stat.mtimeMs });
}
files.sort((a, b) => a.at - b.at);
let bytes = files.reduce((sum, entry) => sum + entry.size, 0);
while (
files.length &&
(files.length > maxDiskEntries || bytes > maxDiskBytes)
) {
const oldest = files.shift();
bytes -= oldest.size;
await fs.rm(oldest.file, { force: true }).catch(() => {});
}
})().finally(() => {
sweeping = null;
});
return sweeping;
}
return { get, set, sweep };
}
+955
View File
@@ -0,0 +1,955 @@
import fs from 'node:fs';
import path from 'node:path';
import { readResponseJsonCapped } from '../common/http.js';
/**
* One gate for every request this server sends to a Nominatim instance.
*
* Place search and voice outlines share it. The service is off until an operator
* configures an endpoint. Explicit public requests coordinate fetch invocation
* across processes sharing this installation's state file, at least 1.1 s apart.
* Independent installations do not share an app-wide limiter; these local guards
* do not establish compliance for an application's combined public traffic.
*/
/** The public instance. Only this host is subject to the public-use limits. */
export const PUBLIC_NOMINATIM_SEARCH =
'https://nominatim.openstreetmap.org/search';
const PUBLIC_NOMINATIM_HOST = 'nominatim.openstreetmap.org';
/**
* The public usage policy asks for a User-Agent or Referer that identifies the
* application. The identity is stable: it is never rotated or varied.
*/
const NOMINATIM_HEADERS = Object.freeze({
'User-Agent':
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
});
/** Absolute policy maximum is one request per second; 1.1 s absorbs jitter. */
const NOMINATIM_MIN_SPACING_MS = 1100;
/** Requests allowed to wait for their turn; past this, refuse at once. */
export const NOMINATIM_MAX_PENDING = 4;
/** A queued request older than this has no reader left; drop it unsent. */
const NOMINATIM_MAX_WAIT_MS = 10_000;
/** Default public requests per install per UTC day, outlines and search together. */
const NOMINATIM_DEFAULT_DAILY_CAP = 50;
/**
* Pause after a refusal without Retry-After. A Retry-After from the server is
* honoured in full, however long: no local maximum may cause an earlier retry.
*/
const DEFAULT_BACKOFF_MS = 60_000;
/** A 403 from the public instance means this identity is blocked: stop longer. */
const BLOCKED_BACKOFF_MS = 3_600_000;
/** Pause after a server error or a failed retry. */
const OUTAGE_BACKOFF_MS = 30_000;
/** How old an unfinished dispatch must be before the next lock owner reclaims it. */
const DISPATCH_RECLAIM_MS = 10 * 60_000;
const DISABLED_VALUES = new Set(['', 'off', 'none', 'disabled', '0']);
/**
* node:sqlite, loaded on first public-instance use. Node 24 still flags it
* experimental and prints that once per process; only that one warning is
* dropped, and only while the module loads.
*/
let sqlite = null;
function sqliteModule() {
if (sqlite) return sqlite;
const emitWarning = process.emitWarning;
process.emitWarning = function (warning, ...rest) {
const type = typeof rest[0] === 'string' ? rest[0] : rest[0]?.type;
if (type === 'ExperimentalWarning' && /SQLite/.test(String(warning)))
return;
return emitWarning.call(this, warning, ...rest);
};
try {
sqlite = process.getBuiltinModule('node:sqlite');
} finally {
process.emitWarning = emitWarning;
}
return sqlite;
}
/**
* Read the search endpoint setting.
*
* Unset, empty or `off` disables every Nominatim request. An explicit endpoint
* must be an http(s) URL
* without credentials, query or fragment; a bare host gets `/search`. A value
* that is not usable disables the service rather than falling back to the
* public instance.
*
* @param {Record<string, string|undefined>} [env]
* @returns {{endpoint: string|null, isPublic: boolean, dailyCap: number, invalid?: boolean}}
*/
export function resolveNominatimSettings(env = process.env) {
const dailyCap = parseDailyCap(env.NOMINATIM_DAILY_CAP);
const raw = env.NOMINATIM_URL;
if (raw === undefined) return { endpoint: null, isPublic: false, dailyCap };
const value = String(raw).trim();
if (DISABLED_VALUES.has(value.toLowerCase()))
return { endpoint: null, isPublic: false, dailyCap };
let url;
try {
url = new URL(value);
} catch {
return { endpoint: null, isPublic: false, dailyCap, invalid: true };
}
if (
!['http:', 'https:'].includes(url.protocol) ||
!url.hostname ||
url.username ||
url.password ||
url.search ||
url.hash
)
return { endpoint: null, isPublic: false, dailyCap, invalid: true };
const publicHost =
url.hostname.toLowerCase().replace(/\.$/, '') === PUBLIC_NOMINATIM_HOST;
if (publicHost && url.protocol !== 'https:')
return { endpoint: null, isPublic: false, dailyCap, invalid: true };
if (url.pathname === '/' || url.pathname === '') url.pathname = '/search';
return {
endpoint: url.href.replace(/\/$/, ''),
// A trailing DNS dot names the same host.
isPublic: publicHost,
dailyCap,
};
}
function parseDailyCap(raw) {
if (raw === undefined || String(raw).trim() === '')
return NOMINATIM_DEFAULT_DAILY_CAP;
const value = Number(raw);
return Number.isFinite(value) && value >= 0
? Math.floor(value)
: NOMINATIM_DEFAULT_DAILY_CAP;
}
function gateError(code, message, extra = {}) {
return Object.assign(new Error(message), { code, ...extra });
}
const HTTP_MONTHS = Object.freeze([
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]);
const HTTP_WEEKDAYS = Object.freeze([
'Sun',
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
]);
const HTTP_WEEKDAYS_LONG = Object.freeze([
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
]);
/** Strict RFC 9110 HTTP-date, including the two obsolete recipient formats. */
function parseHttpDate(raw, now) {
let match = raw.match(
/^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/,
);
let weekday, day, month, year, hour, minute, second;
if (match) {
[, weekday, day, month, year, hour, minute, second] = match;
weekday = HTTP_WEEKDAYS.indexOf(weekday);
} else {
match = raw.match(
/^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/,
);
if (match) {
[, weekday, day, month, year, hour, minute, second] = match;
weekday = HTTP_WEEKDAYS_LONG.indexOf(weekday);
const currentYear = new Date(now).getUTCFullYear();
if (!Number.isFinite(currentYear)) return null;
year = Math.floor(currentYear / 100) * 100 + Number(year);
if (year > currentYear + 50) year -= 100;
} else {
match = raw.match(
/^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/,
);
if (!match) return null;
[, weekday, month, day, hour, minute, second, year] = match;
weekday = HTTP_WEEKDAYS.indexOf(weekday);
}
}
day = Number(day);
month = HTTP_MONTHS.indexOf(month);
year = Number(year);
hour = Number(hour);
minute = Number(minute);
second = Number(second);
if (
weekday < 0 ||
month < 0 ||
year < 1601 ||
hour > 23 ||
minute > 59 ||
second > 59
)
return null;
const at = Date.UTC(year, month, day, hour, minute, second);
const date = new Date(at);
return date.getUTCFullYear() === year &&
date.getUTCMonth() === month &&
date.getUTCDate() === day &&
date.getUTCHours() === hour &&
date.getUTCMinutes() === minute &&
date.getUTCSeconds() === second &&
date.getUTCDay() === weekday
? at
: null;
}
/** Seconds or an HTTP date → milliseconds from now, or null. */
export function parseRetryAfter(value, now = Date.now()) {
if (value == null) return null;
const raw = String(value).trim();
if (!raw) return null;
if (/^\d+$/.test(raw)) {
const seconds = Number(raw);
const milliseconds = seconds * 1000;
return Number.isSafeInteger(milliseconds) ? milliseconds : null;
}
// Numeric-looking but invalid delay-seconds must not be reinterpreted as a date.
if (/^[+-]?(?:\d|\.\d)/.test(raw)) return null;
const at = parseHttpDate(raw, now);
return at === null ? null : Math.max(0, at - now);
}
/** UTC calendar day, the unit of the daily cap. */
function utcDay(ms) {
return new Date(ms).toISOString().slice(0, 10);
}
/**
* Public-service state shared by every process using the same file: today's
* request count, the next reserved start, and any pause the server asked for.
*
* Every change is a read-modify-write under an OS-owned SQLite transaction,
* and the JSON file is replaced atomically, so two dev servers sharing a
* checkout add to one count instead of overwriting each other. A process exit
* releases only that process's transaction; there is no stale path to delete
* and therefore no stale reclaimer that can remove a successor's live lock.
* Memory keeps the highest values this process has seen, so a failed or
* unreadable file never lowers the count or shortens a pause; a persistence
* failure is reported once and in `status()`.
*/
export function createGateStateStore({
file = null,
lockWaitMs = 500,
onError = (error) =>
console.warn(
`[Nominatim] could not persist usage state: ${error?.message || error}`,
),
} = {}) {
let memory = { day: '', count: 0, pausedUntil: 0, nextStartAt: 0 };
let lastError = null;
const report = (error) => {
if (!lastError) onError(error);
lastError = error;
};
const pauseMarkerPrefix = file ? `${path.basename(file)}.pause.` : null;
const dispatchMarkerPrefix = file ? `${path.basename(file)}.dispatch.` : null;
// A crash or persistence failure during dispatch must never reopen admission
// using an older timestamp. A dispatch removes its own marker after
// persisting the post-invocation deadline. A marker is only ever seen under
// the lock after its owner left the lock, so one that names a start older
// than DISPATCH_RECLAIM_MS (far beyond any request's timeout) is reclaimed
// by the next lock owner, which restarts spacing from its own clock.
function pendingDispatches({ strict = false } = {}) {
if (!file) return [];
try {
return fs
.readdirSync(path.dirname(file))
.filter((name) => name.startsWith(dispatchMarkerPrefix));
} catch (error) {
if (error?.code === 'ENOENT') return [];
report(error);
if (strict) throw error;
return [];
}
}
/**
* Under the lock: remove dispatch markers whose start is older than
* DISPATCH_RECLAIM_MS and report whether any were. Any other marker (newer,
* or naming no start) still refuses admission.
*/
function reclaimDispatches(at) {
const names = pendingDispatches({ strict: true });
for (const name of names) {
const since = /^since-(\d+)$/.exec(
name.slice(dispatchMarkerPrefix.length).split('.', 1)[0],
);
const started = since ? Number(since[1]) : NaN;
if (!Number.isSafeInteger(started) || at - started < DISPATCH_RECLAIM_MS)
throw new Error('unfinished durable Nominatim dispatch');
}
for (const name of names)
fs.unlinkSync(path.join(path.dirname(file), name));
return names.length > 0;
}
/**
* A refusal must remain visible even while another process owns the SQLite
* transaction. Immutable marker names make that write atomic and monotonic:
* concurrent writers cannot shorten one another's pause, and the next lock
* owner folds every marker into the normal JSON state.
*/
function pendingPauses({ strict = false } = {}) {
if (!file) return [];
let names;
try {
names = fs.readdirSync(path.dirname(file));
} catch (error) {
if (error?.code === 'ENOENT') return [];
report(error);
if (strict) throw error;
return [];
}
const pauses = [];
for (const name of names) {
if (!name.startsWith(pauseMarkerPrefix)) continue;
const encoded = name.slice(pauseMarkerPrefix.length).split('.', 1)[0];
if (!/^\d+$/.test(encoded)) continue;
const pausedUntil = Number(encoded);
if (!Number.isSafeInteger(pausedUntil)) {
const error = new Error('invalid durable Nominatim pause marker');
report(error);
if (strict) throw error;
continue;
}
pauses.push({
path: path.join(path.dirname(file), name),
pausedUntil,
});
}
return pauses;
}
function markPause(at) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const pausedUntil = Math.ceil(at);
const marker = `${file}.pause.${pausedUntil}.${process.pid}.${process.hrtime.bigint()}`;
fs.writeFileSync(marker, '', { flag: 'wx' });
return pausedUntil;
}
function clearPersistedPauses(pausedUntil) {
for (const marker of pendingPauses()) {
if (marker.pausedUntil > pausedUntil) continue;
try {
fs.unlinkSync(marker.path);
} catch (error) {
if (error?.code !== 'ENOENT') report(error);
}
}
}
const merge = (a, b) => ({
day: a.day >= b.day ? a.day : b.day,
count:
a.day === b.day
? Math.max(a.count, b.count)
: a.day > b.day
? a.count
: b.count,
pausedUntil: Math.max(a.pausedUntil || 0, b.pausedUntil || 0),
nextStartAt: Math.max(a.nextStartAt || 0, b.nextStartAt || 0),
});
const parseState = (parsed) => {
const validDay =
parsed?.day === '' ||
(typeof parsed?.day === 'string' &&
/^\d{4}-\d{2}-\d{2}$/.test(parsed.day) &&
new Date(`${parsed.day}T00:00:00.000Z`)
.toISOString()
.startsWith(parsed.day));
const validCount =
Number.isInteger(parsed?.count) &&
parsed.count >= 0 &&
(parsed.day !== '' || parsed.count === 0);
const validPausedUntil =
Number.isFinite(parsed?.pausedUntil) && parsed.pausedUntil >= 0;
// nextStartAt was added after the original state format. Its absence is
// the one supported migration; a present value must still be trustworthy.
const validNextStartAt =
parsed?.nextStartAt === undefined ||
(Number.isFinite(parsed.nextStartAt) && parsed.nextStartAt >= 0);
if (
!parsed ||
Array.isArray(parsed) ||
typeof parsed !== 'object' ||
!validDay ||
!validCount ||
!validPausedUntil ||
!validNextStartAt
)
throw new Error('invalid durable Nominatim usage state');
return {
day: parsed.day,
count: parsed.count,
pausedUntil: parsed.pausedUntil,
nextStartAt: parsed.nextStartAt ?? 0,
};
};
function readDisk({ strict = false } = {}) {
if (!file) {
if (strict)
throw new Error('no durable Nominatim usage state is configured');
return null;
}
try {
return parseState(JSON.parse(fs.readFileSync(file, 'utf8')));
} catch (error) {
if (error?.code === 'ENOENT') return null;
report(error);
if (strict) throw error;
return null;
}
}
function current() {
const disk = readDisk();
if (disk) memory = merge(memory, disk);
const pending = pendingPauses();
if (pending.length)
memory = merge(memory, {
...memory,
pausedUntil: Math.max(...pending.map((item) => item.pausedUntil)),
});
return memory;
}
function withLock(work) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const waitMs = Number.isFinite(lockWaitMs)
? Math.max(0, Math.floor(lockWaitMs))
: 500;
const database = new (sqliteModule().DatabaseSync)(`${file}.lock.sqlite`, {
timeout: waitMs,
});
let transactionOpen = false;
try {
database.exec('BEGIN IMMEDIATE');
transactionOpen = true;
const result = work();
database.exec('COMMIT');
transactionOpen = false;
return result;
} catch (error) {
if (transactionOpen) {
try {
database.exec('ROLLBACK');
} catch {
// Closing the connection below still releases this owner's lock.
}
}
throw error;
} finally {
try {
database.close();
} catch {
// The transaction has already been committed or rolled back. Closing
// is best effort during exceptional teardown and never removes a lock.
}
}
}
function writeDisk(next) {
const temp = `${file}.${process.pid}.${process.hrtime.bigint()}.tmp`;
try {
fs.writeFileSync(temp, JSON.stringify(next));
fs.renameSync(temp, file);
} finally {
try {
fs.unlinkSync(temp);
} catch (error) {
if (error?.code !== 'ENOENT') report(error);
}
}
}
/** Apply `change` to the freshest state and persist it atomically. */
function update(change) {
if (!file) {
memory = merge(memory, change(memory));
return memory;
}
try {
withLock(() => {
const next = merge(memory, change(current()));
writeDisk(next);
memory = next;
clearPersistedPauses(next.pausedUntil);
lastError = null;
});
} catch (error) {
report(error);
memory = merge(memory, change(memory));
}
return memory;
}
return {
count(day) {
const state = current();
return state.day === day ? state.count : 0;
},
increment(day) {
return update((state) => ({
day,
count: state.day === day ? state.count + 1 : 1,
pausedUntil: state.pausedUntil,
nextStartAt: state.nextStartAt,
})).count;
},
dispatch(
day,
{ clock, dailyCap, minSpacingMs, latestStartAt = Infinity },
start,
) {
if (!file) {
const error = new Error(
'no durable Nominatim usage state is configured',
);
report(error);
return { status: 'unavailable', error };
}
let value;
try {
let result;
withLock(() => {
const reclaimed = reclaimDispatches(clock());
const disk = readDisk({ strict: true });
if (disk) memory = merge(memory, disk);
const pauses = pendingPauses({ strict: true });
const state = merge(memory, {
...memory,
pausedUntil: Math.max(
memory.pausedUntil,
...pauses.map((p) => p.pausedUntil),
),
});
const at = clock();
if (reclaimed) {
state.nextStartAt = Math.max(state.nextStartAt, at + minSpacingMs);
writeDisk(state);
memory = state;
}
if (at > latestStartAt) {
result = { status: 'abandoned' };
return;
}
if (state.pausedUntil > at) {
result = { status: 'paused', retryAfterMs: state.pausedUntil - at };
return;
}
const count = state.day === day ? state.count : 0;
if (count >= dailyCap) {
result = { status: 'cap' };
return;
}
if (state.nextStartAt > at) {
result =
state.nextStartAt > latestStartAt
? { status: 'abandoned' }
: { status: 'wait', waitMs: state.nextStartAt - at };
return;
}
const marker = `${file}.dispatch.since-${at}.${process.pid}.${process.hrtime.bigint()}`;
fs.writeFileSync(marker, '', { flag: 'wx' });
const next = {
day,
count: count + 1,
pausedUntil: state.pausedUntil,
nextStartAt: at + minSpacingMs,
};
writeDisk(next);
memory = next;
const latePause = Math.max(
next.pausedUntil,
...pendingPauses({ strict: true }).map((p) => p.pausedUntil),
);
if (clock() > latestStartAt) result = { status: 'abandoned' };
else if (latePause > clock())
result = { status: 'paused', retryAfterMs: latePause - clock() };
else {
// Invoke, but never await, the transport while still owning the lock.
try {
value = start();
} catch (error) {
value = Promise.reject(error);
}
result = { status: 'started', value };
}
const after = {
...next,
pausedUntil: latePause,
nextStartAt: Math.max(next.nextStartAt, clock() + minSpacingMs),
};
writeDisk(after);
memory = after;
fs.unlinkSync(marker);
clearPersistedPauses(after.pausedUntil);
lastError = null;
});
return result;
} catch (error) {
// The caller aborts its transport on admission failure. Observe a started
// promise even when persistence failed, without masking the store error.
if (value?.then) Promise.resolve(value).catch(() => {});
report(error);
return { status: 'unavailable', error };
}
},
pausedUntil() {
return current().pausedUntil || 0;
},
pauseUntil(at) {
if (file) {
try {
at = markPause(at);
} catch (error) {
report(error);
}
}
update((state) => ({ ...state, pausedUntil: at }));
},
status: () => {
const dispatchPending = pendingDispatches().length > 0;
return {
file,
durable: Boolean(file),
persisted: Boolean(file) && !lastError && !dispatchPending,
dispatchPending,
error: lastError
? String(lastError.message || lastError)
: dispatchPending
? 'unfinished durable Nominatim dispatch'
: null,
};
},
};
}
/**
* Construct a gate. `settings` may be a function so the environment is read
* after the dev server loads `.env`.
*
* @param {object} [options]
* @param {(() => object)|object} [options.settings]
* @param {typeof fetch} [options.fetchImpl]
* @param {() => number} [options.now]
* @param {(ms: number) => Promise<void>} [options.sleep]
* @param {ReturnType<typeof createGateStateStore>} [options.usage]
*/
export function createNominatimGate({
settings = () => resolveNominatimSettings(),
fetchImpl = (...args) => globalThis.fetch(...args),
now = Date.now,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
usage = createGateStateStore(),
minSpacingMs = NOMINATIM_MIN_SPACING_MS,
maxPending = NOMINATIM_MAX_PENDING,
maxWaitMs = NOMINATIM_MAX_WAIT_MS,
timeoutMs = 9000,
} = {}) {
let queue = Promise.resolve();
let pending = 0;
const pausedUntilByEndpoint = new Map();
let upstreamRequests = 0;
let usageStore = usage;
const current = () =>
typeof settings === 'function' ? settings() : settings;
/**
* Pause until `ms` from now. The public service's pause is also written to
* the shared state so a restart or a second process honours it.
*/
function pause(ms, config) {
const until = now() + Math.max(0, ms);
const key = String(config?.endpoint || '');
if (until > (pausedUntilByEndpoint.get(key) || 0))
pausedUntilByEndpoint.set(key, until);
if (config?.isPublic) usageStore.pauseUntil(until);
}
function pauseEnd(config) {
const endpointPause =
pausedUntilByEndpoint.get(String(config?.endpoint || '')) || 0;
return config?.isPublic
? Math.max(endpointPause, usageStore.pausedUntil())
: endpointPause;
}
function pausedError(config) {
return gateError('NOMINATIM_PAUSED', 'Place service is pausing', {
retryAfterMs: Math.max(0, pauseEnd(config) - now()),
});
}
function admissionError(config) {
if (!config.endpoint)
return gateError('NOMINATIM_DISABLED', 'Place service is not configured');
if (now() < pauseEnd(config)) return pausedError(config);
return null;
}
/** One upstream request; classifies refusals and pauses the gate. */
async function send(url, config, maxBytes, signal, queuedAt) {
const controller = new AbortController();
const onAbort = () => controller.abort();
signal?.addEventListener('abort', onAbort, { once: true });
// One deadline covers the headers AND the body: a server that answers its
// headers and then stalls must not hold the shared queue.
let timer;
const start = () => {
upstreamRequests += 1;
timer = setTimeout(() => controller.abort(), timeoutMs);
return fetchImpl(url, {
headers: NOMINATIM_HEADERS,
redirect: 'error',
signal: controller.signal,
});
};
try {
return await exchange(
config,
maxBytes,
signal,
controller.signal,
queuedAt,
start,
);
} catch (error) {
controller.abort();
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
async function exchange(
config,
maxBytes,
signal,
requestSignal,
queuedAt,
start,
) {
const turn = await takeTurn(config, queuedAt, signal, start);
let response;
try {
response = await turn.value;
} catch (cause) {
if (signal?.aborted) throw gateError('NOMINATIM_ABANDONED', 'Abandoned');
throw gateError('NOMINATIM_TRANSPORT', 'Place service unreachable', {
cause,
transport: true,
});
}
if (!response.ok) {
void response.body?.cancel?.().catch(() => {});
const status = response.status;
const retryAfter = parseRetryAfter(
response.headers?.get?.('retry-after'),
now(),
);
if (status === 429 || status === 503) {
pause(retryAfter ?? DEFAULT_BACKOFF_MS, config);
throw gateError('NOMINATIM_REFUSED', 'Place service asked to wait', {
status,
retryAfterMs: Math.max(0, pauseEnd(config) - now()),
});
}
if (status === 403 || status === 418) {
pause(retryAfter ?? BLOCKED_BACKOFF_MS, config);
throw gateError('NOMINATIM_REFUSED', 'Place service refused', {
status,
retryAfterMs: Math.max(0, pauseEnd(config) - now()),
});
}
if (status >= 500) pause(retryAfter ?? OUTAGE_BACKOFF_MS, config);
throw gateError('NOMINATIM_UPSTREAM', `Upstream returned ${status}`, {
status,
});
}
try {
return await readResponseJsonCapped(response, maxBytes, requestSignal);
} catch (cause) {
if (cause?.code === 'RESPONSE_TOO_LARGE')
throw gateError('NOMINATIM_TOO_LARGE', 'Place answer too large');
if (signal?.aborted) throw gateError('NOMINATIM_ABANDONED', 'Abandoned');
throw gateError('NOMINATIM_TRANSPORT', 'Place answer unreadable', {
cause,
transport: true,
});
}
}
/** Revalidate public dispatch under shared ownership after every wake. */
async function takeTurn(config, queuedAt, signal, start) {
while (true) {
if (signal?.aborted || now() - queuedAt > maxWaitMs)
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
const refused = admissionError(config);
if (refused) throw refused;
// An operator-selected endpoint is not governed by the public instance's
// shared budget, pacing, or durable-state requirement.
if (!config.isPublic) {
try {
return { value: start() };
} catch (error) {
return { value: Promise.reject(error) };
}
}
const reserved = usageStore.dispatch(
utcDay(now()),
{
clock: now,
dailyCap: config.dailyCap,
minSpacingMs,
latestStartAt: queuedAt + maxWaitMs,
},
start,
);
if (reserved.status === 'unavailable')
throw gateError(
'NOMINATIM_STATE_UNAVAILABLE',
'Public place service usage state is unavailable',
{ cause: reserved.error },
);
if (reserved.status === 'cap')
throw gateError(
'NOMINATIM_DAILY_CAP',
'Daily place lookup allowance is used',
);
if (reserved.status === 'paused')
throw gateError('NOMINATIM_PAUSED', 'Place service is pausing', {
retryAfterMs: reserved.retryAfterMs,
});
if (reserved.status === 'abandoned')
throw gateError('NOMINATIM_ABANDONED', 'Place search was abandoned');
if (reserved.status === 'started') return reserved;
if (reserved.status === 'wait') await sleep(reserved.waitMs);
}
}
/**
* Queue one JSON request for `buildUrl(endpoint)`. At most one retry, and
* only after a transport failure; a failed retry pauses the gate.
*
* @param {(endpoint: string) => string} buildUrl
* @param {{signal?: AbortSignal, maxBytes?: number}} [options]
*/
function requestJson(buildUrl, { signal, maxBytes = 2 * 1024 * 1024 } = {}) {
const config = current();
const refused = admissionError(config);
if (refused) return Promise.reject(refused);
if (signal?.aborted)
return Promise.reject(
gateError('NOMINATIM_ABANDONED', 'Place search was abandoned'),
);
if (pending >= maxPending)
return Promise.reject(
gateError('NOMINATIM_QUEUE_FULL', 'Place search queue is full'),
);
pending += 1;
const queuedAt = now();
const url = buildUrl(config.endpoint);
const task = queue.then(async () => {
try {
try {
return await send(url, config, maxBytes, signal, queuedAt);
} catch (error) {
if (!error?.transport) throw error;
try {
return await send(url, config, maxBytes, signal, now());
} catch (retryError) {
if (retryError?.transport) pause(OUTAGE_BACKOFF_MS, config);
throw retryError;
}
}
} finally {
pending -= 1;
}
});
queue = task.catch(() => null);
return task;
}
return {
requestJson,
settings: current,
/** Share public admission, daily count and pauses through one install's file. */
persistUsage(file) {
usageStore = createGateStateStore({ file });
},
stats: () => ({
upstreamRequests,
pending,
pausedForMs: Math.max(0, pauseEnd(current()) - now()),
usedToday: usageStore.count(utcDay(now())),
persistence: usageStore.status?.() || null,
}),
};
}
let sharedGate = createNominatimGate();
/** The process-wide gate: search and outlines share one budget and pacer. */
export function sharedNominatimGate() {
return sharedGate;
}
/** Replace the process-wide gate (tests, embedders with their own transport). */
export function setSharedNominatimGate(gate) {
sharedGate = gate || createNominatimGate();
return sharedGate;
}
/** Codes that mean "not now" rather than "no such place". */
export function isNominatimBusy(error) {
return [
'NOMINATIM_QUEUE_FULL',
'NOMINATIM_ABANDONED',
'NOMINATIM_PAUSED',
'NOMINATIM_REFUSED',
'NOMINATIM_DAILY_CAP',
].includes(error?.code);
}
+654
View File
@@ -0,0 +1,654 @@
import { normalizePlaceName } from './nominatimCache.js';
import {
containsAnchor,
pointInRing,
} from '../../../src/data/adminBoundaries.js';
import {
douglasPeucker,
ringAreaM2,
} from '../../../src/sources/featureGeometry.js';
/**
* Which Nominatim results may answer which voice ask, and how their polygons
* are bounded. Pure: no I/O.
*
* A search returns whatever matches the words best. "Mission District" can
* come back as a car-charging station and "Bandra" as a railway station, so a
* result is accepted only when its OSM class (and, for broad classes, its
* type) suits the kind of thing that was asked for, it carries a polygon, and
* its name actually answers the words.
*/
/** Ask kinds the outline route answers. */
export const OUTLINE_KINDS = Object.freeze([
'city',
'admin',
'neighborhood',
'landmark',
'building',
]);
/** Simplification tolerance (degrees) sent upstream, per ask kind. */
export const OUTLINE_POLYGON_THRESHOLD = Object.freeze({
city: 0.0005,
admin: 0.0005,
neighborhood: 0.0005,
landmark: 0.00005,
building: 0.00005,
});
/**
* Size limits for one outline. Vertices beyond the budget are simplified away;
* more parts, or more holes in one part, than these limits refuse the outline
* whole, because a truncated multipart place would misrepresent it.
*/
const OUTLINE_MAX_VERTICES = 6000;
const OUTLINE_MAX_PARTS = 24;
const OUTLINE_MAX_HOLES = 16;
const ANY = '*';
const SETTLEMENT_PLACES = [
'city',
'town',
'village',
'municipality',
'borough',
'hamlet',
];
const CIVIC_AMENITIES = [
'university',
'college',
'school',
'kindergarten',
'hospital',
'clinic',
'place_of_worship',
'monastery',
'townhall',
'courthouse',
'library',
'theatre',
'cinema',
'arts_centre',
'community_centre',
'conference_centre',
'events_venue',
'exhibition_centre',
'music_venue',
'planetarium',
'marketplace',
'prison',
'grave_yard',
'fire_station',
'police',
'embassy',
'public_building',
];
const LANDMARK_TOURISM = [
'attraction',
'theme_park',
'zoo',
'aquarium',
'museum',
'gallery',
'viewpoint',
'camp_site',
'hotel',
'resort',
];
const MAN_MADE_STRUCTURES = [
'tower',
'lighthouse',
'pier',
'bridge',
'works',
'dam',
'observatory',
'wastewater_plant',
'water_works',
'breakwater',
];
/**
* Accepted `class → types` per ask kind. `*` accepts every type of that
* class. Anything absent is rejected: roads, railways, stations, shops,
* guideposts and single facilities never answer an area ask.
*/
const OUTLINE_ACCEPT = Object.freeze({
city: {
boundary: ['administrative'],
place: SETTLEMENT_PLACES,
},
admin: {
boundary: ['administrative'],
place: ['country', 'state', 'province', 'region', 'county', 'district'],
},
neighborhood: {
boundary: ['administrative'],
place: ['suburb', 'quarter', 'neighbourhood', 'borough', 'city_block'],
},
landmark: {
leisure: ANY,
landuse: ANY,
amenity: CIVIC_AMENITIES,
tourism: LANDMARK_TOURISM,
building: ANY,
natural: ANY,
water: ANY,
waterway: ['riverbank', 'dock'],
historic: ANY,
man_made: MAN_MADE_STRUCTURES,
office: ['government', 'diplomatic'],
aeroway: ['aerodrome', 'heliport', 'terminal'],
military: ANY,
boundary: ['national_park', 'protected_area'],
place: ['island', 'islet', 'square'],
},
// Single structures only: no campuses, grounds, parks or camp sites.
building: {
building: ANY,
amenity: [
'place_of_worship',
'townhall',
'courthouse',
'library',
'theatre',
'cinema',
'arts_centre',
'community_centre',
'conference_centre',
'exhibition_centre',
'music_venue',
'planetarium',
'fire_station',
'police',
'embassy',
'public_building',
],
tourism: ['museum', 'gallery', 'hotel', 'attraction'],
historic: ['building', 'castle', 'church', 'fort', 'palace', 'monastery'],
man_made: ['tower', 'lighthouse', 'observatory'],
office: ANY,
},
});
/**
* Administrative evidence per ask kind: a recognized address type is required.
* A supplied numeric place_rank must also fit the ask's broad band (country 4,
* state 8, county 10-12, city 12-16, suburb 18-20, neighbourhood 20-22).
*/
const ADMIN_EVIDENCE = Object.freeze({
city: {
types: ['city', 'town', 'village', 'municipality', 'borough', 'hamlet'],
ranks: [12, 18],
},
admin: {
types: [
'country',
'state',
'province',
'region',
'county',
'district',
'state_district',
],
ranks: [2, 14],
},
neighborhood: {
types: ['suburb', 'quarter', 'neighbourhood', 'city_district', 'borough'],
ranks: [17, 24],
},
});
/** Whether supplied administrative evidence contradicts the accepted identity. */
function adminLevelFits(kind, row) {
const evidence = ADMIN_EVIDENCE[kind];
if (!evidence) return true;
const place = String(row?.category || row?.class).toLowerCase() === 'place';
// A place row already has an accepted type. Missing address evidence must
// not erase it, but explicit contradictory evidence must not bypass checks.
if (!place || Object.hasOwn(row, 'addresstype')) {
if (typeof row?.addresstype !== 'string') return false;
const type = row.addresstype.toLowerCase();
if (
!evidence.types.includes(type) &&
!(place && type === String(row.type).toLowerCase())
)
return false;
}
// Rank can corroborate or contradict an explicit identity, never invent it.
// JSONv2 supplies a number; coercing arrays or strings hides malformed data.
if (!Object.hasOwn(row, 'place_rank')) return true;
const rank = row.place_rank;
return (
Number.isInteger(rank) &&
// Boundary rank bands do not describe every accepted place type.
(place || (rank >= evidence.ranks[0] && rank <= evidence.ranks[1]))
);
}
/** Largest footprint (m²) a non-`building` result may have for a building ask. */
const BUILDING_MAX_AREA_M2 = 150_000;
/** Whether a result's class/type suits the ask kind. */
export function acceptsOutlineClass(kind, osmClass, osmType) {
const rules = OUTLINE_ACCEPT[kind];
if (!rules) return false;
const types = rules[String(osmClass || '').toLowerCase()];
if (!types) return false;
return types === ANY || types.includes(String(osmType || '').toLowerCase());
}
const STOP_WORDS = new Set(['the', 'of', 'de', 'la', 'le', 'el', 'and']);
/** Scripts written without spaces between words. */
const UNSPACED_SCRIPT_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
/** Significant words of the part of a query before the first comma. */
function queryWords(query) {
const head = String(query ?? '').split(',')[0];
return normalizePlaceName(head)
.split(' ')
.filter((word) => word && !STOP_WORDS.has(word));
}
/**
* Words naming what a building is, which OSM rarely carries in the name or
* type ("Burj Khalifa", building=yes). Enclosures (zoo, garden, arch,
* conservatory) are left out: those name a feature inside a park.
*/
const BUILDING_WORDS = [
'building',
'tower',
'church',
'cathedral',
'basilica',
'chapel',
'temple',
'mosque',
'synagogue',
'shrine',
'museum',
'palace',
'castle',
'hall',
'library',
];
/** How well a result's names answer the ask. */
const NAME_MISS = 0;
const NAME_OVERLAP = 1;
const NAME_WHOLE = 2;
const NAME_EXACT = 3;
/**
* Settlements require one complete normalized name or explicit returned alias.
* Landmark/building asks retain their word-overlap and unspaced-script
* shorthand. An exact name ranks first, then one holding every asked word,
* then an overlap.
*/
function outlineNameMatch(row, query, kind) {
const names = new Set();
const add = (value) => {
const name = normalizePlaceName(value);
if (name) names.add(name);
};
add(row?.name);
add(String(row?.display_name || '').split(',')[0]);
// OSM name tags may hold several names separated by ";" ("York;Eboracum").
if (row?.namedetails && typeof row.namedetails === 'object')
for (const value of Object.values(row.namedetails))
for (const part of String(value ?? '').split(';')) add(part);
if (kind === 'city' || kind === 'admin' || kind === 'neighborhood') {
const head = normalizePlaceName(String(query ?? '').split(',')[0]);
return head && names.has(head) ? NAME_EXACT : NAME_MISS;
}
const words = queryWords(query);
if (!words.length) return NAME_MISS;
const whole = words.join(' ');
const unspaced = UNSPACED_SCRIPT_RE.test(whole);
// The last word usually names the feature ("Zoo", "Arch"): a name without
// it is the place around the feature ("Central Park"), unless the word is
// where the result is ("Space Needle Seattle"), what it is ("… Bridge"), or
// a building word on a single-structure result ("Burj Khalifa Tower").
const context = new Set(
normalizePlaceName(
[
String(row?.display_name || '')
.split(',')
.slice(1)
.join(' '),
row?.type,
row?.category || row?.class,
]
.join(' ')
.replace(/_/g, ' '),
).split(' '),
);
// Only a single structure, as a building ask would accept: never a campus,
// zoo or district whose name merely starts the ask ("Duke University").
if (acceptsOutlineClass('building', row?.category || row?.class, row?.type))
for (const word of BUILDING_WORDS) context.add(word);
let match = NAME_MISS;
for (const name of names) {
if (
name
.split(' ')
.filter((word) => !STOP_WORDS.has(word))
.join(' ') === whole
)
return NAME_EXACT;
// Scripts without spaces compare by characters instead.
if (unspaced && name.replace(/ /g, '').includes(whole.replace(/ /g, '')))
match = NAME_WHOLE;
// Whole words only: "York" never matches "Yorkshire Park".
const nameSet = new Set(name.split(' '));
const hits = words.filter((word) => nameSet.has(word)).length;
if (hits === words.length) match = NAME_WHOLE;
const last = words.at(-1);
if (
words.length > 1 &&
hits >= Math.ceil(words.length * 0.6) &&
(unspaced || nameSet.has(last) || context.has(last))
)
match = Math.max(match, NAME_OVERLAP);
}
return match;
}
const finiteLon = (value) =>
Number.isFinite(value) && value >= -180 && value <= 180;
const finiteLat = (value) =>
Number.isFinite(value) && value >= -90 && value <= 90;
/** A valid closed ring of [lon, lat] numbers, or null. */
function cleanRing(ring) {
if (!Array.isArray(ring)) return null;
const out = [];
for (const point of ring) {
if (!Array.isArray(point)) return null;
const [lon, lat] = point;
if (!finiteLon(lon) || !finiteLat(lat)) return null;
out.push([lon, lat]);
}
if (out.length < 4) return null;
const [first, last] = [out[0], out.at(-1)];
if (first[0] !== last[0] || first[1] !== last[1]) out.push([...first]);
return out;
}
/** Polygon/MultiPolygon GeoJSON → `[[outer, ...holes], …]`, largest first. */
function polygonsFromGeoJson(geometry) {
const raw =
geometry?.type === 'Polygon'
? [geometry.coordinates]
: geometry?.type === 'MultiPolygon'
? geometry.coordinates
: null;
if (!Array.isArray(raw) || !raw.length || raw.length > OUTLINE_MAX_PARTS)
return null;
const polygons = [];
for (const polygon of raw) {
if (
!Array.isArray(polygon) ||
!polygon.length ||
polygon.length - 1 > OUTLINE_MAX_HOLES
)
return null;
const outer = cleanRing(polygon[0]);
if (!outer) return null;
const holes = polygon.slice(1).map(cleanRing);
if (holes.some((hole) => !hole)) return null;
polygons.push({ rings: [outer, ...holes], area: ringAreaM2(outer) });
}
polygons.sort((a, b) => b.area - a.area);
return polygons.map((entry) => entry.rings);
}
/** Iterative Douglas–Peucker on a closed ring (keeps it closed, ≥ 4 points). */
function simplifyClosedRing(ring, tolerance) {
if (ring.length <= 8 || tolerance <= 0) return ring;
const out = douglasPeucker(ring, tolerance);
return out.length >= 4 ? out : ring;
}
const countVertices = (polygons) =>
polygons.reduce(
(sum, rings) => sum + rings.reduce((n, ring) => n + ring.length, 0),
0,
);
const edgeSide = (p, q, r) =>
(q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
const onEdge = (p, q, r) =>
r[0] >= Math.min(p[0], q[0]) &&
r[0] <= Math.max(p[0], q[0]) &&
r[1] >= Math.min(p[1], q[1]) &&
r[1] <= Math.max(p[1], q[1]);
/** Intersection, including touching, of two planar closed-ring edges. */
function edgesIntersect(a, b, c, d) {
if (
Math.max(a[0], b[0]) < Math.min(c[0], d[0]) ||
Math.max(c[0], d[0]) < Math.min(a[0], b[0]) ||
Math.max(a[1], b[1]) < Math.min(c[1], d[1]) ||
Math.max(c[1], d[1]) < Math.min(a[1], b[1])
)
return false;
const ac = edgeSide(a, b, c);
const ad = edgeSide(a, b, d);
const ca = edgeSide(c, d, a);
const cb = edgeSide(c, d, b);
return (
(ac === 0 && onEdge(a, b, c)) ||
(ad === 0 && onEdge(a, b, d)) ||
(ca === 0 && onEdge(c, d, a)) ||
(cb === 0 && onEdge(c, d, b)) ||
(ac > 0 !== ad > 0 && ca > 0 !== cb > 0)
);
}
function ringsIntersect(a, b) {
for (let i = 1; i < a.length; i++)
for (let j = 1; j < b.length; j++)
if (edgesIntersect(a[i - 1], a[i], b[j - 1], b[j])) return true;
return false;
}
/**
* Check only generated bounded output, not a new input-geometry policy.
* Touching generated boundaries are conservatively unsupported here.
*/
function simplifiedTopologyFits(polygons) {
for (const [outer, ...holes] of polygons) {
for (const ring of [outer, ...holes]) {
if (ringAreaM2(ring) === 0) return false;
for (let i = 1; i < ring.length; i++)
for (let j = i + 2; j < ring.length; j++) {
if (i === 1 && j === ring.length - 1) continue;
if (edgesIntersect(ring[i - 1], ring[i], ring[j - 1], ring[j]))
return false;
}
}
for (let i = 0; i < holes.length; i++) {
const hole = holes[i];
if (
!hole.every(([lon, lat]) => pointInRing(outer, lat, lon)) ||
ringsIntersect(outer, hole)
)
return false;
for (let j = 0; j < i; j++) {
const other = holes[j];
if (
ringsIntersect(hole, other) ||
pointInRing(other, hole[0][1], hole[0][0]) ||
pointInRing(hole, other[0][1], other[0][0])
)
return false;
}
}
}
return true;
}
/** Simplify until the whole outline fits the vertex cap. */
export function capOutlineVertices(
polygons,
maxVertices = OUTLINE_MAX_VERTICES,
) {
let current = polygons;
let tolerance = 0.00002;
for (let round = 0; round < 12; round++) {
if (countVertices(current) <= maxVertices)
return current === polygons || simplifiedTopologyFits(current)
? current
: null;
current = polygons.map((rings) =>
rings.map((ring) => simplifyClosedRing(ring, tolerance)),
);
tolerance *= 2;
}
// Never turn a resource refusal into an apparently complete partial outline.
return countVertices(current) <= maxVertices &&
simplifiedTopologyFits(current)
? current
: null;
}
/**
* Every result within the limit that suits the ask, names the place and
* carries a polygon — best name match first, then upstream order — with the reason each other result was passed over. `outline` is
* the first of them, or null.
*
* @param {object[]} rows Nominatim jsonv2 results, in upstream order.
* @param {{kind: string, query: string}} ask
*/
export function selectOutlineResult(rows, { kind, query }) {
const skipped = [];
const found = [];
for (const row of Array.isArray(rows) ? rows : []) {
const osmClass = row?.category || row?.class;
const osmType = row?.type;
const label = `${osmClass}=${osmType}`;
if (!acceptsOutlineClass(kind, osmClass, osmType)) {
skipped.push(`${label}: wrong type`);
continue;
}
if (
(String(osmClass).toLowerCase() === 'place' ||
(String(osmClass).toLowerCase() === 'boundary' &&
String(osmType).toLowerCase() === 'administrative')) &&
!adminLevelFits(kind, row)
) {
skipped.push(`${label}: level`);
continue;
}
const nameMatch = outlineNameMatch(row, query, kind);
if (nameMatch === NAME_MISS) {
skipped.push(`${label}: name`);
continue;
}
const parsed = polygonsFromGeoJson(row?.geojson);
if (!parsed) {
skipped.push(`${label}: no polygon`);
continue;
}
// A building ask needs building evidence: the building class, or a
// footprint no larger than a large building.
if (
kind === 'building' &&
String(osmClass).toLowerCase() !== 'building' &&
ringAreaM2(parsed[0][0]) > BUILDING_MAX_AREA_M2
) {
skipped.push(`${label}: not a building`);
continue;
}
const polygons = capOutlineVertices(parsed);
if (!polygons) {
skipped.push(`${label}: too large`);
continue;
}
found.push({
nameMatch,
outline: {
name: String(row.name || '').slice(0, 160) || null,
displayName: String(row.display_name || '').slice(0, 300) || null,
class: String(osmClass),
type: String(osmType),
osm:
row.osm_type && row.osm_id != null
? `${row.osm_type}/${row.osm_id}`
: null,
polygons,
},
});
}
// Stable: equal matches keep upstream order.
found.sort((a, b) => b.nameMatch - a.nameMatch);
const outlines = found.map((entry) => entry.outline);
const ranks = found.map((entry) => entry.nameMatch);
return { outline: outlines[0] || null, outlines, ranks, skipped };
}
/**
* Among the best-named outlines (`ranks` from selectOutlineResult; all of
* them without), the one holding `lat, lon` (the resolved anchor), else the
* first. A worse name never wins by containment alone: an exact building
* that misses a pin by metres is not replaced by the plaza around it.
* Without an anchor the first answers, as before.
*/
export function outlineAt(outlines, lat, lon, ranks = null) {
const list = Array.isArray(outlines) ? outlines : [];
if (!finiteLat(lat) || !finiteLon(lon)) return list[0] || null;
const best = Array.isArray(ranks) ? ranks[0] : undefined;
return (
list.find(
(outline, index) =>
(best === undefined || ranks[index] === best) &&
containsAnchor(outline.polygons, lat, lon),
) ||
list[0] ||
null
);
}
/**
* Coarse view bias: the centre is snapped to a grid (1° for places, 0.05°
* for small grounds) so small camera moves reuse the same cache entry, and
* the viewbox sent upstream is built from the snapped centre.
*/
export function outlineBias(kind, lat, lon) {
// Missing is missing: Number(null) and Number('') would read as (0, 0).
const given = (value) =>
value !== null &&
value !== undefined &&
(typeof value === 'number' || String(value).trim() !== '');
if (!given(lat) || !given(lon)) return null;
const latitude = Number(lat);
const longitude = Number(lon);
if (!finiteLat(latitude) || !finiteLon(longitude)) return null;
const small = kind === 'landmark' || kind === 'building';
const step = small ? 0.05 : 1;
const half = small ? 0.25 : 2;
const snap = (value) => Math.round(value / step) * step;
const cLat = Number(snap(latitude).toFixed(2));
const cLon = Number(snap(longitude).toFixed(2));
const clampLat = (value) => Math.max(-90, Math.min(90, value));
const clampLon = (value) => Math.max(-180, Math.min(180, value));
return {
key: `${cLat},${cLon}`,
viewbox: [
clampLon(cLon - half),
clampLat(cLat + half),
clampLon(cLon + half),
clampLat(cLat - half),
]
.map((value) => Number(value.toFixed(4)))
.join(','),
};
}
+451 -196
View File
@@ -1,114 +1,140 @@
import path from 'node:path';
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
import { coalesceProxyRequest } from '../common/http.js';
import { fetchRegionalJson } from './http.js';
import { admitSameSite } from '../common/same-site.js';
import { defaultSourceRoot } from '../common/source-root.js';
import { naturalRegionAtPoint } from '../../../src/data/naturalEarthRegions.js';
import {
nominatimToGeocodeResult,
nominatimViewboxFromBounds,
} from '../../../src/nominatimGeocode.js';
/**
* The usage policy for the public Nominatim instance asks for a User-Agent or
* Referer that identifies the application, and states that stock library
* agents will not do. Both are sent.
*/
const NOMINATIM_HEADERS = Object.freeze({
'User-Agent':
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
});
/**
* Minimum spacing between upstream calls. The policy states an absolute
* maximum of one request per second; 1.1 s keeps clock jitter from crossing it.
*/
const NOMINATIM_MIN_SPACING_MS = 1100;
/**
* How many searches may be waiting for their turn.
*
* One request per second and an unbounded queue are incompatible: a burst of
* searches would keep the upstream busy long after everyone who asked has given
* up, which is exactly the load the policy asks callers not to create. Past
* this depth a search is refused at once instead of being promised a slot
* minutes away.
*/
const NOMINATIM_MAX_PENDING = 4;
/**
* How long a queued search may wait before it is not worth sending. The
* browser gives up well before this, so anything reaching the front later than
* this is answering nobody.
*/
const NOMINATIM_MAX_WAIT_MS = 10_000;
const NOMINATIM_SEARCH_CACHE_MS = 5 * 60_000;
const NOMINATIM_SEARCH_MAX_CACHE = 80;
import {
NOMINATIM_MAX_PENDING,
isNominatimBusy,
sharedNominatimGate,
} from './nominatimGate.js';
import {
NOMINATIM_CACHE_TTL_MS,
createNominatimCache,
} from './nominatimCache.js';
import {
OUTLINE_KINDS,
OUTLINE_POLYGON_THRESHOLD,
outlineAt,
outlineBias,
selectOutlineResult,
} from './outlineSelect.js';
const NOMINATIM_SEARCH_MAX_QUERY = 200;
// The pacer is deliberately module state while everything else here is
// per-instance. One request per second is a budget for the whole application,
// not for each provider object: two instances each pacing themselves would
// send two requests a second between them. Only last-resort forward searches
// use this queue.
let _nominatimQueue = Promise.resolve();
/** Results considered per outline ask: enough to skip a node or a wrong type. */
const OUTLINE_RESULT_LIMIT = 3;
let _nominatimLastRequestAt = 0;
/** Largest upstream answer read for an outline (polygons are simplified upstream). */
const OUTLINE_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
let _nominatimPending = 0;
/** Bump when cached answers change shape or selection authority. */
const CACHE_SCHEMA = 'v3';
/** Raised when the queue is already as deep as it is allowed to get. */
function queueFullError() {
return Object.assign(new Error('Place search queue is full'), {
code: 'NOMINATIM_QUEUE_FULL',
/**
* The query text actually sent upstream: Unicode-composed, whitespace
* collapsed. Nothing else is changed, so two queries share a cache entry only
* when they are the same upstream request.
*/
export function upstreamQuery(query) {
return String(query ?? '')
.normalize('NFC')
.replace(/\s+/g, ' ')
.trim();
}
/** Cache key: endpoint, schema and the exact upstream parameters. */
function requestKey(gate, kind, params) {
const endpoint = gate.settings?.()?.endpoint || 'none';
return `${CACHE_SCHEMA}|${endpoint}|${kind}|${params}`;
}
/** A decimal coordinate query value, or NaN: never "" or " " as 0. */
function coordinateParam(value) {
return /^-?\d+(\.\d+)?$/.test(value ?? '') ? Number(value) : NaN;
}
function malformed() {
return Object.assign(new Error('Place answer was not understood'), {
code: 'NOMINATIM_MALFORMED',
});
}
/** Raised when a queued search waited so long that nobody is left to answer. */
function abandonedError() {
function abandoned() {
return Object.assign(new Error('Place search was abandoned'), {
code: 'NOMINATIM_ABANDONED',
});
}
/**
* Run one piece of upstream work, never closer than the policy spacing to the
* last one.
*
* `bounded` applies queue-depth and staleness limits to interactive forward searches.
*
* @param {() => Promise<unknown>} work
* @param {{bounded?: boolean, signal?: AbortSignal}} [options]
* Share one upstream call while keeping each caller's cancellation independent.
* The shared controller is aborted only after the last subscriber leaves.
*/
function enqueueNominatim(work, { bounded = false, signal } = {}) {
if (bounded && _nominatimPending >= NOMINATIM_MAX_PENDING)
return Promise.reject(queueFullError());
if (bounded) _nominatimPending += 1;
const queuedAt = Date.now();
const task = _nominatimQueue.then(async () => {
try {
const waitMs = Math.max(
0,
NOMINATIM_MIN_SPACING_MS - (Date.now() - _nominatimLastRequestAt),
);
if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs));
// Nobody is waiting for this any more: sending it would spend the one
// request per second the policy allows on an answer with no reader.
if (bounded && Date.now() - queuedAt > NOMINATIM_MAX_WAIT_MS)
throw abandonedError();
if (signal?.aborted) throw abandonedError();
_nominatimLastRequestAt = Date.now();
return await work();
} finally {
if (bounded) _nominatimPending -= 1;
}
function subscribeSharedRequest(inFlight, key, signal, create) {
if (signal?.aborted) return Promise.reject(abandoned());
let flight = inFlight.get(key);
if (!flight) {
flight = {
controller: new AbortController(),
users: 0,
settled: false,
promise: null,
};
const owned = flight;
owned.promise = Promise.resolve()
.then(() => create(owned.controller.signal))
.finally(() => {
owned.settled = true;
if (inFlight.get(key) === owned) inFlight.delete(key);
});
inFlight.set(key, owned);
}
flight.users += 1;
return new Promise((resolve, reject) => {
let left = false;
const leave = () => {
if (left) return;
left = true;
signal?.removeEventListener('abort', onAbort);
flight.users -= 1;
if (
flight.users === 0 &&
!flight.settled &&
inFlight.get(key) === flight
) {
inFlight.delete(key);
flight.controller.abort();
}
};
const onAbort = () => {
leave();
reject(abandoned());
};
signal?.addEventListener('abort', onAbort, { once: true });
flight.promise.then(
(value) => {
leave();
resolve(value);
},
(error) => {
leave();
reject(error);
},
);
});
_nominatimQueue = task.catch(() => null);
return task;
}
/** Where the dev server keeps Nominatim answers and the daily count. */
export const NOMINATIM_CACHE_DIR = path.join(
defaultSourceRoot,
'.gev-cache',
'nominatim',
);
/** Construct the offline regional context provider from bundled Natural Earth polygons. */
export function createRegionalPlaceProvider() {
return (point) => naturalRegionAtPoint(point.latitude, point.longitude);
@@ -117,137 +143,366 @@ export function createRegionalPlaceProvider() {
export const fetchRegionalPlace = createRegionalPlaceProvider();
/**
* Construct the forward search adapter with a trusted endpoint.
* Snap a `"swLat,swLng|neLat,neLng"` view to a coarse grid so small camera
* moves reuse one cache entry. The viewbox is a soft preference upstream.
*/
function coarseBounds(bounds) {
// Validate the original hint before snapping or clamping can disguise a
// wrapped, inverted or out-of-range box as an unrelated valid viewbox.
if (!nominatimViewboxFromBounds(bounds)) return null;
const match = String(bounds || '')
.trim()
.split('|')
.map((corner) => corner.split(',').map(Number));
if (match.length !== 2 || match.some((c) => c.length !== 2)) return null;
const [[swLat, swLng], [neLat, neLng]] = match;
if (![swLat, swLng, neLat, neLng].every(Number.isFinite)) return null;
const span = Math.max(neLat - swLat, neLng - swLng, 0.01);
const step = 2 ** Math.ceil(Math.log2(span));
const cLat = Math.round((swLat + neLat) / 2 / (step / 4)) * (step / 4);
const cLng = Math.round((swLng + neLng) / 2 / (step / 4)) * (step / 4);
const clamp = (v, limit) => Math.max(-limit, Math.min(limit, v));
const fix = (v) => Number(v.toFixed(4));
return `${fix(clamp(cLat - step / 2, 90))},${fix(clamp(cLng - step / 2, 180))}|${fix(clamp(cLat + step / 2, 90))},${fix(clamp(cLng + step / 2, 180))}`;
}
/**
* Construct the forward search adapter over the shared gate.
*
* The policy asks that results be cached, and warns that a client repeating the
* same query may be treated as faulty, so an answer is remembered and identical
* searches already in flight share one upstream call rather than queueing
* behind each other.
* The policy asks that results be cached, and warns that a client repeating
* the same query may be treated as faulty, so answers are kept (30 days for a
* hit, 24 hours for a genuine no-result; a failure is never kept) and
* identical searches already in flight share one upstream call.
*/
export function createNominatimSearchProvider({
endpoint = 'https://nominatim.openstreetmap.org/search',
requestJson = fetchRegionalJson,
gate = () => sharedNominatimGate(),
cache = createNominatimCache(),
} = {}) {
const cache = new Map();
const inFlight = new Map();
const trimCache = () => {
while (cache.size > NOMINATIM_SEARCH_MAX_CACHE) {
const oldest = cache.keys().next().value;
if (oldest === undefined) break;
cache.delete(oldest);
}
};
const currentGate = () => (typeof gate === 'function' ? gate() : gate);
return async function fetchNominatimSearch(query, bounds, { signal } = {}) {
const cacheKey = `${query.toLowerCase()}|${bounds || ''}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.cachedAt <= NOMINATIM_SEARCH_CACHE_MS) {
return { ...cached.payload, cached: true };
}
const { promise } = coalesceProxyRequest(inFlight, cacheKey, async () => {
const params = new URLSearchParams({
format: 'jsonv2',
q: query,
addressdetails: '1',
limit: '1',
'accept-language': 'en',
});
const viewbox = nominatimViewboxFromBounds(bounds);
if (viewbox) params.set('viewbox', viewbox);
const rows = await enqueueNominatim(
() =>
requestJson(`${endpoint}?${params}`, {
headers: NOMINATIM_HEADERS,
redirect: 'error',
}),
{ bounded: true, signal },
);
const result = nominatimToGeocodeResult(
Array.isArray(rows) ? rows[0] : null,
);
const payload = result
? { status: 'OK', results: [result] }
: { status: 'ZERO_RESULTS', results: [] };
cache.set(cacheKey, { payload, cachedAt: Date.now() });
trimCache();
return payload;
const coarse = coarseBounds(bounds);
const params = new URLSearchParams({
format: 'jsonv2',
q: upstreamQuery(query),
addressdetails: '1',
limit: '1',
'accept-language': 'en',
});
return await promise;
const viewbox = nominatimViewboxFromBounds(coarse);
if (viewbox) params.set('viewbox', viewbox);
const gateNow = currentGate();
const cacheKey = requestKey(gateNow, 'search', params);
const cached = await cache.get(cacheKey);
if (cached) return { ...cached.payload, cached: true };
return await subscribeSharedRequest(
inFlight,
cacheKey,
signal,
async (sharedSignal) => {
const rows = await gateNow.requestJson(
(endpoint) => `${endpoint}?${params}`,
{ signal: sharedSignal },
);
// Only a well-formed empty list is "no such place"; anything else is a
// failure and is never cached.
if (!Array.isArray(rows)) throw malformed();
const result = nominatimToGeocodeResult(rows[0] ?? null);
if (rows.length && !result) throw malformed();
const payload = result
? { status: 'OK', results: [result] }
: { status: 'ZERO_RESULTS', results: [] };
await cache.set(
cacheKey,
payload,
result
? NOMINATIM_CACHE_TTL_MS.place
: NOMINATIM_CACHE_TTL_MS.notFound,
);
return payload;
},
);
};
}
/**
* Construct the outline lookup: one guarded search with polygons for one
* explicit ask, accepting only results whose class suits the ask kind.
* Every accepted result is kept (90 days; genuine no-results 24 hours), and
* each ask is answered with the one holding its anchor, so a same-name place
* elsewhere neither answers nor repeats from the cache.
*/
export function createNominatimOutlineProvider({
gate = () => sharedNominatimGate(),
cache = createNominatimCache(),
} = {}) {
const inFlight = new Map();
const currentGate = () => (typeof gate === 'function' ? gate() : gate);
const answer = (payload, anchorLat, anchorLon, cached) => ({
status: payload.status,
outline: outlineAt(payload.outlines, anchorLat, anchorLon, payload.ranks),
...(payload.skipped ? { skipped: payload.skipped } : {}),
...(cached ? { cached: true } : {}),
});
return async function fetchNominatimOutline(
{ query, kind, lat, lon, anchorLat = null, anchorLon = null },
{ signal } = {},
) {
const bias = outlineBias(kind, lat, lon);
const sent = upstreamQuery(query);
const params = new URLSearchParams({
format: 'jsonv2',
q: sent,
limit: String(OUTLINE_RESULT_LIMIT),
polygon_geojson: '1',
polygon_threshold: String(OUTLINE_POLYGON_THRESHOLD[kind]),
namedetails: '1',
'accept-language': 'en',
});
if (bias) params.set('viewbox', bias.viewbox);
const gateNow = currentGate();
const cacheKey = requestKey(gateNow, `outlines-${kind}`, params);
const cached = await cache.get(cacheKey);
if (cached) return answer(cached.payload, anchorLat, anchorLon, true);
const payload = await subscribeSharedRequest(
inFlight,
cacheKey,
signal,
async (sharedSignal) => {
const rows = await gateNow.requestJson(
(endpoint) => `${endpoint}?${params}`,
{ signal: sharedSignal, maxBytes: OUTLINE_MAX_RESPONSE_BYTES },
);
if (!Array.isArray(rows)) throw malformed();
const { outlines, ranks, skipped } = selectOutlineResult(rows, {
kind,
query: sent,
});
const found = outlines.length
? { status: 'OK', outlines, ranks }
: { status: 'ZERO_RESULTS', outlines: [], skipped };
await cache.set(
cacheKey,
found,
outlines.length
? NOMINATIM_CACHE_TTL_MS.outline
: NOMINATIM_CACHE_TTL_MS.notFound,
);
return found;
},
);
return answer(payload, anchorLat, anchorLon, false);
};
}
export const fetchNominatimSearch = createNominatimSearchProvider();
/** Vite plugin: last-resort place search over the public Nominatim instance. */
export function geocodeProxy({ search = fetchNominatimSearch } = {}) {
const limiter = makeRateLimiter({
function sendJson(res, status, body, headers = {}) {
if (res.writableEnded) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...headers,
});
res.end(JSON.stringify(body));
}
/** Map a gate refusal to the route's answer. Never "not found". */
function sendFailure(res, error, what) {
if (error?.code === 'NOMINATIM_DISABLED') {
sendJson(res, 503, {
error: `${what} is not configured`,
code: 'NOMINATIM_DISABLED',
retryable: false,
});
return;
}
if (error?.code === 'NOMINATIM_DAILY_CAP') {
sendJson(
res,
429,
{
error: `${what} daily allowance is used`,
code: 'NOMINATIM_DAILY_CAP',
retryable: false,
},
{ 'Retry-After': '3600' },
);
return;
}
if (isNominatimBusy(error)) {
const seconds = Math.max(5, Math.ceil((error.retryAfterMs || 0) / 1000));
sendJson(
res,
429,
{ error: `${what} is busy`, code: error.code },
{ 'Retry-After': String(seconds) },
);
return;
}
sendJson(res, 503, {
error: `${what} is temporarily unavailable`,
code: 'NOMINATIM_UNAVAILABLE',
});
}
/**
* Vite plugin: last-resort place search (`/api/geocode`) and voice outlines
* (`/api/geocode/outline`) over the configured Nominatim endpoint.
*
* `storageDir` turns on the disk cache and the persisted daily count; the
* application composition supplies it, tests and embedders may omit it.
*/
export function geocodeProxy({
search,
outline,
storageDir = null,
gate = null,
} = {}) {
const currentGate = () => gate || sharedNominatimGate();
if (storageDir) {
// The shared count and pause live beside, never inside, the answer cache,
// so no cache sweep can reset them.
currentGate().persistUsage(path.join(storageDir, 'state.json'));
const diskCache = createNominatimCache({
dir: path.join(storageDir, 'answers'),
});
void diskCache.sweep();
search ||= createNominatimSearchProvider({
gate: currentGate,
cache: diskCache,
});
outline ||= createNominatimOutlineProvider({
gate: currentGate,
cache: diskCache,
});
}
search ||= gate
? createNominatimSearchProvider({ gate })
: fetchNominatimSearch;
outline ||= createNominatimOutlineProvider({ gate: currentGate });
const searchLimiter = makeRateLimiter({
windowMs: 60_000,
max: 30,
globalMax: 90,
});
// Outlines are explicit voice asks: a person cannot make many a minute.
const outlineLimiter = makeRateLimiter({
windowMs: 60_000,
max: 12,
globalMax: 30,
});
/** Common front door: method, same-site, rate limit, abandonment signal. */
function admit(req, res, limiter) {
if (admitSameSite(req, res)) return null;
if (req.method !== 'GET') {
sendJson(res, 405, { error: 'Method Not Allowed' });
return null;
}
if (!limiter(clientKey(req))) {
sendJson(
res,
429,
{ error: 'Rate limit exceeded' },
{ 'Retry-After': '10' },
);
return null;
}
// A browser that gave up is no longer waiting; the queue reads this
// before spending its slot.
const abandoned = new AbortController();
req.on?.('aborted', () => abandoned.abort());
res.on?.('close', () => abandoned.abort());
return abandoned.signal;
}
async function handleOutline(req, res, url) {
const signal = admit(req, res, outlineLimiter);
if (!signal) return;
const query = String(url.searchParams.get('q') || '').trim();
const kind = String(url.searchParams.get('kind') || '');
if (
!query ||
query.length > NOMINATIM_SEARCH_MAX_QUERY ||
!OUTLINE_KINDS.includes(kind)
) {
sendJson(res, 400, {
error:
'A place query of 1-200 characters and a known kind are required',
});
return;
}
try {
const payload = await outline(
{
query,
kind,
lat: url.searchParams.get('lat'),
lon: url.searchParams.get('lon'),
anchorLat: coordinateParam(url.searchParams.get('alat')),
anchorLon: coordinateParam(url.searchParams.get('alon')),
},
{ signal },
);
sendJson(
res,
200,
{ status: payload.status, outline: payload.outline || null },
{
'Cache-Control': payload.cached ? 'private, max-age=300' : 'no-store',
},
);
} catch (error) {
sendFailure(res, error, 'Outline lookup');
}
}
async function handleSearch(req, res, url) {
const signal = admit(req, res, searchLimiter);
if (!signal) return;
const query = String(url.searchParams.get('q') || '').trim();
if (!query || query.length > NOMINATIM_SEARCH_MAX_QUERY) {
sendJson(res, 400, {
error: 'A place query of 1-200 characters is required',
});
return;
}
try {
const payload = await search(query, url.searchParams.get('bounds'), {
signal,
});
sendJson(
res,
200,
{ status: payload.status, results: payload.results },
{
'Cache-Control': payload.cached ? 'private, max-age=60' : 'no-store',
},
);
} catch (error) {
sendFailure(res, error, 'Place search');
}
}
function install(middlewares) {
middlewares.use('/api/geocode', async (req, res) => {
if (req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
if (!limiter(clientKey(req))) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '10',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
middlewares.use('/api/geocode', async (req, res, next) => {
const url = new URL(req.url || '', 'http://localhost');
const query = String(url.searchParams.get('q') || '').trim();
if (!query || query.length > NOMINATIM_SEARCH_MAX_QUERY) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
error: 'A place query of 1-200 characters is required',
}),
);
return;
}
// A browser that gave up is no longer waiting; the queue reads this
// before spending its slot.
const abandoned = new AbortController();
req.on?.('aborted', () => abandoned.abort());
res.on?.('close', () => abandoned.abort());
try {
const payload = await search(query, url.searchParams.get('bounds'), {
signal: abandoned.signal,
// Capability probe, like /api/overpass/status: clients skip lookups a
// server without a Nominatim endpoint could only refuse.
if (url.pathname === '/status' && req.method === 'GET')
return sendJson(res, 200, {
configured: Boolean(currentGate().settings()?.endpoint),
});
if (res.writableEnded) return;
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': payload.cached ? 'public, max-age=60' : 'no-store',
});
res.end(
JSON.stringify({ status: payload.status, results: payload.results }),
);
} catch (error) {
if (res.writableEnded) return;
const busy =
error?.code === 'NOMINATIM_QUEUE_FULL' ||
error?.code === 'NOMINATIM_ABANDONED';
res.writeHead(busy ? 429 : 503, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(busy ? { 'Retry-After': '5' } : {}),
});
res.end(
JSON.stringify({
error: busy
? 'Place search is busy'
: 'Place search is temporarily unavailable',
}),
);
}
if (url.pathname === '/outline') return handleOutline(req, res, url);
if (url.pathname === '/' || url.pathname === '')
return handleSearch(req, res, url);
if (typeof next === 'function') return next();
sendJson(res, 404, { error: 'Not Found' });
});
}
+13 -3
View File
@@ -12,7 +12,10 @@ import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
const root = fileURLToPath(new URL('../../', import.meta.url));
/** Load this checkout's configuration and attach its local provider middleware. */
export default defineConfig(({ command, mode }) => {
export function standaloneConfig(
{ command, mode },
{ nominatimStorageDir } = {},
) {
const loaded = loadEnv(mode, root, '');
for (const [key, value] of Object.entries(loaded)) {
if (process.env[key] === undefined) process.env[key] = value;
@@ -20,7 +23,11 @@ export default defineConfig(({ command, mode }) => {
const tools = standaloneVoiceTools();
return createBrowserViteConfig({
plugins: [
...localProviderPlugins({ realtime: { tools }, gemini: { tools } }),
...localProviderPlugins({
realtime: { tools },
gemini: { tools },
nominatimStorageDir,
}),
localMcpPlugin(),
googleTokenUnavailablePlugin(),
apiNotFoundPlugin(),
@@ -28,10 +35,13 @@ export default defineConfig(({ command, mode }) => {
],
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
cesiumToken: process.env.CESIUM_ION_TOKEN,
photonUrl: process.env.PHOTON_URL,
mapillaryToken: process.env.MAPILLARY_CLIENT_TOKEN,
host: process.env.HOST,
port: process.env.PORT,
allowedHosts: resolveAllowedHosts(process.env.GEV_ALLOWED_HOSTS),
command,
});
});
}
export default defineConfig(standaloneConfig);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
/**
* The shapes drawn by hand, as UTILITIES ▸ Draw lists them: one row per shape
* with its colour, its name, and show/hide, fly-to and remove, the way Import
* lists its files. The board is the annotation engine's; voice marks are not
* listed, only `manual` ones.
*/
import { msg, t } from '../i18n/index.js';
export const DRAW_COLORS = Object.freeze([
'primary',
'amber',
'cyan',
'green',
'red',
]);
/** Each colour as drawn: the preview, the swatches and the drawn shape. */
export const DRAW_COLOR_CSS = Object.freeze({
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
});
export const DRAW_COLOR_NAMES = Object.freeze({
primary: msg('Primary'),
amber: msg('Amber'),
cyan: msg('Cyan'),
green: msg('Green'),
red: msg('Red'),
});
const SHAPE_NAMES = Object.freeze({
area: msg('Area'),
route: msg('Line'),
pin: msg('Pin'),
});
/** What a row calls a shape that has no name of its own. */
export function drawMarkFallbackName(mark) {
const shape = t(SHAPE_NAMES[mark?.type] || SHAPE_NAMES.pin);
// An unnamed line's label is its length; keep it, it is the useful part.
return mark?.type === 'route' && !mark.name && mark.label
? `${shape} · ${mark.label}`
: shape;
}
/**
* The rows for the hand-drawn marks on the board, in board order. Text is
* worded here, in the current language; the view only lays it out.
* @param {object[]} marks The engine's `list()`.
*/
export function presentDrawMarks(marks = []) {
return marks
.filter((mark) => mark?.manual === true)
.map((mark) => {
const fallback = drawMarkFallbackName(mark);
const title = mark.name || fallback;
const visible = !mark.hidden;
return {
id: mark.id,
name: mark.name || '',
placeholder: fallback,
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
hidden: !visible,
labels: {
name: t('Name of {name}', { name: title }),
swatch: t('Change the colour of {name}', { name: title }),
colors: t('Colour of {name}', { name: title }),
toggle: visible
? t('Hide {name}', { name: title })
: t('Show {name}', { name: title }),
fly: t('Fly to {name}', { name: title }),
remove: t('Remove {name}', { name: title }),
},
};
});
}
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkFallbackName,
presentDrawMarks,
} from './drawMarks.js';
test('an unnamed shape is called by its kind, and an unnamed line by its length too', () => {
assert.equal(drawMarkFallbackName({ type: 'area' }), 'Area');
assert.equal(drawMarkFallbackName({ type: 'pin' }), 'Pin');
assert.equal(
drawMarkFallbackName({ type: 'route', label: '300 m' }),
'Line · 300 m',
);
assert.equal(
drawMarkFallbackName({
type: 'route',
name: 'Walk',
label: 'Walk — 300 m',
}),
'Line',
);
});
test('rows list only hand-drawn marks, in board order, with their words', () => {
const rows = presentDrawMarks([
{
id: 'a1',
type: 'route',
manual: true,
name: null,
label: '950 m',
color: 'amber',
},
{ id: 'a2', type: 'area', manual: false, label: 'Texas', color: 'primary' },
{
id: 'a3',
type: 'pin',
manual: true,
name: 'Camp',
color: 'teal',
hidden: true,
},
]);
assert.deepEqual(
rows.map(({ id, name, placeholder, color, hidden }) => ({
id,
name,
placeholder,
color,
hidden,
})),
[
{
id: 'a1',
name: '',
placeholder: 'Line · 950 m',
color: 'amber',
hidden: false,
},
{
id: 'a3',
name: 'Camp',
placeholder: 'Pin',
color: 'primary',
hidden: true,
},
],
);
assert.equal(rows[0].labels.toggle, 'Hide Line · 950 m');
assert.equal(rows[1].labels.toggle, 'Show Camp');
assert.equal(rows[1].labels.fly, 'Fly to Camp');
assert.equal(rows[1].labels.colors, 'Colour of Camp');
});
test('every colour has its swatch', () => {
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
});
+69 -31
View File
@@ -1,9 +1,10 @@
/**
* Manual whiteboard drawing: the Cesium + DOM half.
*
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. Each finished shape goes through the
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin) and a colour, click the vertices on the real world,
* double-click or press Enter to finish, then name, recolour, hide, fly to or
* remove the shape from its row (drawMarks.js). Each finished shape goes through the
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
* supplied and `manual: true`, so it renders with the whiteboard look,
* persists, de-dups, shows up in `.list()`, and clears with the board.
@@ -49,32 +50,30 @@ import {
removeLastVertex,
} from './drawMode.js';
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const COLORS = ['primary', 'amber', 'cyan', 'green', 'red'];
const PREVIEW = {
primary: '#8be9ff',
amber: '#ffb547',
cyan: '#39d0ff',
green: '#5dff9f',
red: '#ff6b6b',
};
const PREVIEW = DRAW_COLOR_CSS;
/**
* Run the Draw control. Returns a handle with a `destroy()` the application
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
* The tool hands what the controls show (`{ active, shape, color, label,
* hint }`) to `publish`, which the view renders, and answers the controls
* The tool hands what the controls show (`{ active, shape, color, hint,
* marks }`) to `publish`, which the view renders, and answers the controls
* through the actions it gives `connect`, which returns their disconnect.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function, connect?: Function}} deps
* A row's fly-to is an immediate navigation through `navigation`, like
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({
viewer,
annotations,
navigation = null,
publish = () => {},
connect = () => () => {},
}) {
@@ -88,7 +87,6 @@ export function initDrawTool({
let session = null;
let shape = 'area';
let color = 'primary';
let label = '';
let hint = '';
let handler = null;
let lease = null;
@@ -117,7 +115,14 @@ export function initDrawTool({
() => null,
);
const present = () => publish({ active, shape, color, label, hint });
const present = () =>
publish({
active,
shape,
color,
hint,
marks: presentDrawMarks(annotations.list?.() || []),
});
// The hint is kept as the call that words it, so a language switch can
// word it again.
let hintText = () => '';
@@ -240,15 +245,14 @@ export function initDrawTool({
if (reason !== 'too-few') setHint(() => drawHint(session));
return null;
}
const spec = finishSpec(session, { label, color });
// Named afterwards, in the shape's row.
const spec = finishSpec(session, { label: '', color });
if (!spec) return null;
// Start a fresh session BEFORE awaiting, so a second Enter or the second
// half of a double-click cannot submit the same shape twice.
session = createDrawSession(shape);
cursor = null;
syncPreview();
label = '';
present();
const attempt = generation;
try {
const result = await annotations.annotate([spec], {
@@ -287,11 +291,9 @@ export function initDrawTool({
};
// ---- keys: only while drawing, never while typing in another field ----
// The label field is this tool's own: typing there still drives drawing.
const isLabelField = (target) => target?.id === 'draw-label-input';
const typingElsewhere = (event) => {
const t = event.target;
if (!t || isLabelField(t)) return false;
if (!t) return false;
return (
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable
);
@@ -299,12 +301,12 @@ export function initDrawTool({
/**
* Enter is the finish key, but on a focused control Enter means "press this
* control". Keyboard-focusing Clear and pressing Enter must clear the board,
* not finish the shape — so the finish key is only taken from the canvas,
* the page body, or the label field this tool owns.
* not finish the shape — so the finish key is only taken from the canvas or
* the page body.
*/
const enterBelongsToDrawing = (event) => {
const t = event.target;
if (!t || isLabelField(t)) return true;
if (!t) return true;
if (
typeof t.closest === 'function' &&
t.closest('button, a, select, [role="button"], [role="radio"]')
@@ -316,6 +318,9 @@ export function initDrawTool({
};
const onKey = (event) => {
if (!active || destroyed || typingElsewhere(event)) return;
// Keys on the shape list's own controls are the list's (Esc closes a
// colour picker there), not the drawing's.
if (event.target?.closest?.('#draw-mark-list')) return;
// Enter goes to finish() once a shape has been started; finish() owns the
// decision and says WHY it refused. Swallowing the key on a degenerate
// shape left the person pressing Enter at a silent panel. A focused button
@@ -334,7 +339,7 @@ export function initDrawTool({
else setActive(false);
return;
}
if (event.key === 'Backspace' && !isLabelField(event.target)) {
if (event.key === 'Backspace') {
if (removeLastVertex(session)) {
event.preventDefault();
syncPreview();
@@ -431,6 +436,20 @@ export function initDrawTool({
}
}
/**
* Frame one drawn shape: an immediate navigation, so Cockpit can refuse it
* and the current orbit, follow or track lets go first.
*/
function flyToMark(id) {
if (typeof navigation?.runImmediateNavigation === 'function')
return (
navigation.runImmediateNavigation('drawing', () =>
annotations.frame?.(id),
) !== false
);
return annotations.frame?.(id) ?? false;
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
@@ -446,16 +465,26 @@ export function initDrawTool({
toggleDraw: () => setActive(!active),
setDrawShape: chooseShape,
setDrawColor: (value) => {
color = COLORS.includes(value) ? value : 'primary';
color = DRAW_COLORS.includes(value) ? value : 'primary';
present();
syncPreview();
},
setDrawLabel: (text) => {
label = String(text ?? '');
present();
},
clearDraw: clearAll,
renameDrawMark: (id, name) => annotations.restyle?.(id, { name }),
recolorDrawMark: (id, next) => annotations.restyle?.(id, { color: next }),
toggleDrawMark: (id) => {
const mark = annotations.list?.().find((entry) => entry.id === id);
if (mark) annotations.setHidden?.(id, !mark.hidden);
},
flyToDrawMark: flyToMark,
removeDrawMark: (id) => annotations.remove?.(id),
});
// The rows follow the board: a shape drawn, renamed, hidden or removed
// here, by voice or by a clear.
const offBoard =
annotations.onChange?.(() => {
if (!destroyed) present();
}) || (() => {});
setHint(() => drawHint(null));
const api = {
@@ -470,6 +499,14 @@ export function initDrawTool({
},
setActive,
setShape: chooseShape,
/** The hand-drawn marks the list shows, as its rows. */
marks: () => presentDrawMarks(annotations.list?.() || []),
setColor(next) {
color = DRAW_COLORS.includes(next) ? next : 'primary';
present();
syncPreview();
},
flyToMark,
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
@@ -519,6 +556,7 @@ export function initDrawTool({
if (active) setActive(false);
destroyed = true;
offLocale();
offBoard();
disconnect();
releaseSceneHandler();
releasePointer(lease);
+41 -16
View File
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
const tools = read('src/app/tools.js');
const engineAt = tools.indexOf('const annotations = initAnnotations({');
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({\n viewer,\n annotations,\n publish: (view) => displayStore.publishDraw(view),',
'const drawTool = initDrawTool({\n viewer,\n annotations,\n navigation: styleManager,\n publish: (view) => displayStore.publishDraw(view),',
);
assert.ok(engineAt >= 0, 'initAnnotations call missing');
assert.ok(
@@ -62,42 +62,62 @@ test('the engine resolves manual geometry before any name resolution', () => {
});
test('the Draw control markup carries the ids the tool binds to', () => {
// The DISPLAY rail is a Vue view now, rendered into index.html's view host —
// the markup is not in index.html any more.
const html = read('src/ui/views/panels/DisplayPanel.vue');
// UTILITIES ▸ Draw is a Vue view (DrawControls, with DrawMarkList for the
// drawn shapes' rows), rendered into the Utilities panel.
const html = [
read('src/ui/views/panels/DrawControls.vue'),
read('src/ui/views/panels/DrawMarkList.vue'),
].join('\n');
const ids = [
'draw-toggle',
'draw-mode-row',
'draw-colors',
'draw-label-row',
'draw-label-input',
'draw-color-select',
'draw-clear',
'draw-hint',
'draw-mark-list',
];
for (const id of ids) {
assert.match(
html,
new RegExp(`id="${id}"`),
`#${id} missing from index.html`,
`#${id} missing from the Draw views`,
);
}
for (const shape of ['area', 'line', 'pin']) {
// Shapes are icons, named for screen readers and tooltips.
for (const [shape, icon] of [
['area', 'pentagon'],
['line', 'timeline'],
['pin', 'location_on'],
]) {
assert.match(
html,
new RegExp(`'data-shape': '${shape}'`),
new RegExp(`icon: '${icon}', attrs: \\{ 'data-shape': '${shape}' \\}`),
`shape button ${shape} missing`,
);
}
assert.ok(
read('src/ui/views/LayerPanelsView.vue').includes('<DrawControls />'),
'the Utilities panel renders Draw',
);
assert.ok(
!read('src/ui/views/panels/DisplayPanel.vue').includes('draw-toggle'),
'Draw is no longer in DISPLAY',
);
// The rows and the hint render what the tool publishes; the controls
// call the actions the tool connects to the display store.
for (const action of [
"act('toggleDraw')",
"act('setDrawShape', $event)",
"act('setDrawLabel', $event.target.value)",
"act('setDrawColor', $event)",
"act('clearDraw')",
"act('renameDrawMark', row.id, next)",
"act('recolorDrawMark', row.id, color)",
"act('toggleDrawMark', row.id)",
"act('flyToDrawMark', row.id)",
"act('removeDrawMark', row.id)",
]) {
assert.ok(html.includes(action), `DisplayPanel must call ${action}`);
assert.ok(html.includes(action), `the Draw views must call ${action}`);
const name = /act\('([a-zA-Z]+)'/.exec(action)[1];
assert.match(
tool(),
@@ -119,14 +139,15 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
assert.doesNotMatch(
shim,
/draw-hint|pp-text-input/,
/draw-hint|draw-color/,
'draw styles belong to a component stylesheet',
);
const controls = read('src/ui/styles/controls.css');
for (const rule of [
'#draw-mode-row',
'#draw-label-row',
'.pp-text-input',
'.draw-color',
'.draw-mark-name',
'.draw-clear-btn',
'.draw-hint',
'body.gev-drawing',
@@ -149,10 +170,14 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
`${file} still claims a GeoJSON export`,
);
}
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
for (const [file, heading] of [
['README.md', 'UTILITIES ▸ **Draw**'],
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
]) {
const source = read(file);
const at = source.indexOf('DISPLAY ▸ **Draw**');
const start = at >= 0 ? at : source.indexOf('DISPLAY ▸ Draw');
const start = source.indexOf(heading);
assert.ok(start >= 0, `${file} should describe the Draw control`);
const section = source.slice(start, start + 1200);
assert.doesNotMatch(
+106 -24
View File
@@ -96,11 +96,10 @@ function fakeDom() {
byId.set(id, element);
return element;
};
// The control whose presence means the page has Draw, a focused button
// whose Enter is its own, and the tool's label field.
// The control whose presence means the page has Draw, and a focused button
// whose Enter is its own.
make('draw-toggle', 'BUTTON');
make('draw-clear', 'BUTTON');
make('draw-label-input', 'INPUT');
const body = new FakeElement('BODY');
const documentDouble = {
@@ -176,9 +175,16 @@ function fakeViewer() {
}
function fakeAnnotations() {
const calls = { annotate: [], clear: 0 };
const calls = { annotate: [], clear: 0, restyle: [], setHidden: [], frame: [], remove: [] };
const marks = [];
const listeners = new Set();
const changed = () => listeners.forEach((listener) => listener());
const find = (id) => marks.find((mark) => mark.id === id);
return {
calls,
marks,
listeners,
changed,
async annotate(specs, options) {
calls.annotate.push({ specs, options });
return { drawn: specs.length };
@@ -186,11 +192,38 @@ function fakeAnnotations() {
clear() {
calls.clear += 1;
},
list: () => marks,
onChange(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
restyle(id, change) {
calls.restyle.push([id, change]);
Object.assign(find(id), change);
changed();
return true;
},
setHidden(id, hidden) {
calls.setHidden.push([id, hidden]);
find(id).hidden = hidden;
changed();
return true;
},
frame(id) {
calls.frame.push(id);
return true;
},
remove(id) {
calls.remove.push(id);
marks.splice(marks.indexOf(find(id)), 1);
changed();
return true;
},
};
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness() {
function harness({ navigation = null } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
@@ -205,6 +238,7 @@ function harness() {
const tool = initDrawTool({
viewer,
annotations,
navigation,
publish: (view) => (shown.view = view),
connect: (actions) => {
controls.actions = actions;
@@ -426,6 +460,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
h.tool.addVertex(-97.75, 30.26);
assert.equal(pointerOwner(), 'draw');
assert.ok(h.tool.diagnostics().domListeners > 0);
assert.equal(h.annotations.listeners.size, 1, 'the rows follow the board');
await h.tool.destroy();
await Promise.resolve();
@@ -443,6 +478,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
'the viewer got its selecting click back',
);
assert.equal(h.viewer._attached.length, 0, 'the preview data source is detached');
assert.equal(h.annotations.listeners.size, 0, 'and it no longer follows the board');
// Idempotent: a second destroy is harmless.
await h.tool.destroy();
@@ -467,16 +503,15 @@ test('Clear wipes the board and the shape in progress', async () => {
}
});
test('the controls choose the shape, colour and label the next shape is drawn with', async () => {
test('the controls choose the shape and colour the next shape is drawn with; it is named in its row', async () => {
const h = harness();
try {
h.act('toggleDraw');
h.act('setDrawShape', 'line');
h.act('setDrawColor', 'amber');
h.act('setDrawLabel', 'Route A');
assert.deepEqual(
{ ...h.shown.view, hint: undefined },
{ active: true, shape: 'line', color: 'amber', label: 'Route A', hint: undefined },
{ active: true, shape: 'line', color: 'amber', hint: undefined, marks: [] },
'the view shows what the controls chose',
);
h.act('setDrawColor', 'purple');
@@ -494,36 +529,83 @@ test('the controls choose the shape, colour and label the next shape is drawn wi
const spec = h.annotations.calls.annotate[0].specs[0];
assert.equal(spec.type, 'route', 'a drawn line is a manual route');
assert.equal(spec.color, 'cyan');
assert.equal(spec.label, 'Route A');
assert.equal(h.shown.view.label, '', 'a finished shape empties the label');
assert.ok(!spec.label, 'a shape is named afterwards, in its row');
} finally {
h.restore();
}
});
test('keys typed in the label field: Enter finishes, Backspace edits, Escape cancels', async () => {
test('each drawn shape is a row whose actions reach the board, and fly-to is a navigation', async () => {
const navigations = [];
const h = harness({
navigation: {
runImmediateNavigation: (noun, navigate) => {
navigations.push(noun);
return navigate();
},
},
});
try {
const board = h.annotations;
board.marks.push(
{ id: 'a1', type: 'route', manual: true, name: null, label: '950 m', color: 'amber', hidden: false },
{ id: 'a2', type: 'pin', manual: false, label: 'Voice pin', color: 'primary', hidden: false },
);
board.changed();
assert.deepEqual(
h.shown.view.marks.map(({ id, placeholder, hidden }) => ({ id, placeholder, hidden })),
[{ id: 'a1', placeholder: 'Line · 950 m', hidden: false }],
'only hand-drawn marks are listed, an unnamed line by its length',
);
h.act('renameDrawMark', 'a1', 'Ridge walk');
h.act('recolorDrawMark', 'a1', 'green');
h.act('toggleDrawMark', 'a1');
h.act('flyToDrawMark', 'a1');
h.act('removeDrawMark', 'a1');
assert.deepEqual(board.calls.restyle, [
['a1', { name: 'Ridge walk' }],
['a1', { color: 'green' }],
]);
assert.deepEqual(board.calls.setHidden, [['a1', true]]);
assert.deepEqual(board.calls.frame, ['a1']);
assert.deepEqual(navigations, ['drawing'], 'fly-to goes through the navigation policy');
assert.deepEqual(board.calls.remove, ['a1']);
assert.deepEqual(h.shown.view.marks, [], 'the row goes with its mark');
} finally {
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
const label = h.element('draw-label-input');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Backspace', target: h.documentDouble.body });
assert.equal(h.tool.session.vertices.length, 1);
} finally {
h.restore();
}
});
test('keys on the shape list are the list\'s, not the drawing\'s', async () => {
const h = harness();
try {
const listButton = new FakeElement('BUTTON');
listButton.closest = (selector) => (selector === '#draw-mark-list' ? {} : null);
const elsewhere = new FakeElement('INPUT', 'location-search');
h.act('toggleDraw');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Backspace', target: label });
assert.equal(h.tool.session.vertices.length, 2, 'Backspace edits the label');
h.documentDouble.emit('keydown', { key: 'Escape', target: listButton });
assert.equal(h.tool.session.vertices.length, 2, 'Escape in the list keeps the shape');
assert.equal(h.tool.active, true, 'and keeps Draw on');
h.documentDouble.emit('keydown', { key: 'Enter', target: elsewhere });
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 0, 'another field keeps its Enter');
h.documentDouble.emit('keydown', { key: 'Escape', target: label });
assert.equal(h.tool.session.vertices.length, 0, 'Escape drops the open shape');
assert.equal(h.tool.active, true, 'and leaves Draw on');
h.act('setDrawShape', 'line');
h.tool.addVertex(-97.75, 30.26);
h.tool.addVertex(-97.74, 30.26);
h.documentDouble.emit('keydown', { key: 'Enter', target: label });
await Promise.resolve();
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1, 'Enter in the label finishes');
h.documentDouble.emit('keydown', { key: 'Escape', target: h.documentDouble.body });
assert.equal(h.tool.session.vertices.length, 0, 'Escape on the page drops the open shape');
} finally {
h.restore();
}
+139 -7
View File
@@ -1,3 +1,4 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { createWorldAnnotationRenderer } from './worldAnnotationRenderer.js';
import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js';
@@ -28,7 +29,7 @@ export function createHybridAnnotationRenderer(
// anno.id -> { worldProxy?, screenProxy? } — proxies own the sub-renderer state.
const routed = new Map();
function add(anno) {
function add(anno, options = {}) {
const entry = {};
// Route the entry FIRST, then fill it in. A sub-renderer throw (WebGL loss
// mid-add) otherwise stranded whatever the earlier sub-renderer had already
@@ -40,7 +41,20 @@ export function createHybridAnnotationRenderer(
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
// Drape the footprint in world space, but without its own label...
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
const completion = world.add(
entry.worldProxy,
worldOptions(anno, entry, options),
);
if (completion?.then) {
if (options.pendingVisible !== false) {
entry.screenProxy = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
screen.add(entry.screenProxy);
}
return finishBuilding(anno, entry, completion, options);
}
// ...and place the caption as a screen-space callout at the centroid.
if (anno.label) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
@@ -75,27 +89,45 @@ export function createHybridAnnotationRenderer(
* World geometry is new, but the reticle's SVG group is converted in place to
* the centroid callout so there is no overlapping fade-out/fade-in pair.
*/
function update(anno) {
function update(anno, options = {}) {
const entry = routed.get(anno.id);
if (!entry) {
add(anno);
return;
return add(anno, options);
}
if (anno.type !== 'area' || !anno.ring || anno.ring.length < 3) {
if (anno.outlineUnavailable && entry.screenProxy)
if (anno.outlineUnavailable && entry.screenProxy) {
entry.screenProxy = liveProxy(anno, { ring: null });
screen.update(entry.screenProxy);
}
return;
}
if (!entry.worldProxy) {
entry.worldProxy = liveProxy(anno, { label: null });
world.add(entry.worldProxy);
const completion = world.add(
entry.worldProxy,
worldOptions(anno, entry, options),
);
if (completion?.then) {
if (entry.screenProxy) {
const pending = liveProxy(anno, { ring: null, pendingOutline: true });
screen.update(pending);
entry.screenProxy = pending;
}
return finishBuilding(anno, entry, completion, options);
}
}
updateCaption(anno, entry);
}
function updateCaption(anno, entry) {
const screenProxy = liveProxy(anno, { type: 'label', ring: null });
if (entry.screenProxy) {
screen.update(screenProxy, { empty: !anno.label });
} else if (anno.label) {
// Publish before add: a partially created caption must remain removable.
entry.screenProxy = screenProxy;
screen.add(screenProxy);
}
entry.screenProxy = entry.screenProxy
@@ -105,6 +137,106 @@ export function createHybridAnnotationRenderer(
: null;
}
function finishBuilding(anno, entry, completion, options) {
const workTrace = options.workTrace;
const proxy = entry.worldProxy;
return completion
.then((result) => {
const workStart = workTrace ? performance.now() : 0;
try {
if (
routed.get(anno.id) !== entry ||
entry.worldProxy !== proxy ||
options.signal?.aborted ||
!(options.isCurrent?.() ?? true)
) {
world.remove(proxy);
if (routed.get(anno.id) === entry) remove(anno);
if (workTrace) workTrace.rendererStatus = 'cancelled';
return { ...result, status: 'cancelled' };
}
if (result.status !== 'installed') {
world.remove(proxy);
entry.worldProxy = null;
if (workTrace) workTrace.rendererStatus = result.status;
return result;
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
if (workTrace) workTrace.rendererStatus = 'timeout';
return { ...result, status: 'timeout' };
}
const at = performance.now();
try {
updateCaption(anno, entry);
} finally {
const ms = performance.now() - at;
result.timing.workMs += ms;
result.timing.lastSliceMs += ms;
result.timing.maxSliceMs = Math.max(
result.timing.maxSliceMs,
result.timing.lastSliceMs,
);
}
if (performance.now() >= result.timing.deadlineMs) {
world.remove(proxy);
entry.worldProxy = null;
if (options.pendingVisible === false) {
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = null;
} else {
// The caption conversion already reshaped the group; redraw the pending point.
const pending = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = pending;
screen.add(pending);
}
if (workTrace) workTrace.rendererStatus = 'timeout';
return { ...result, status: 'timeout' };
}
if (workTrace) workTrace.rendererStatus = result.status;
return result;
} finally {
if (workTrace)
recordWorkSlice(workTrace, 'hybrid-complete', workStart);
}
})
.catch((error) => {
const workStart = workTrace ? performance.now() : 0;
try {
// A later caption throw must roll back the already installed world parts too.
if (routed.get(anno.id) === entry) {
try {
remove(anno);
} catch {
/* original error wins; failed routes stay addressable */
}
} else world.remove(proxy);
throw error;
} finally {
if (workTrace) {
workTrace.rendererStatus = 'failed';
recordWorkSlice(workTrace, 'hybrid-rollback', workStart);
}
}
});
}
function worldOptions(anno, entry, options) {
const proxy = entry.worldProxy;
return {
...options,
isCurrent: () =>
routed.get(anno.id) === entry &&
entry.worldProxy === proxy &&
(options.isCurrent?.() ?? true),
};
}
/**
* Release both routes. Tolerates PARTIAL state (only one sub-renderer got
* its content) and an absent id, so it doubles as the rollback path for a
@@ -4,6 +4,7 @@ import * as Cesium from 'cesium';
import { createPinia } from 'pinia';
import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js';
import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
/** A hybrid renderer whose screen board is presented to its own store. */
function hybridWithBoard(viewer) {
@@ -90,19 +91,31 @@ function installBrowserGlobals(t) {
/** A viewer whose scene/camera is just enough for both sub-renderers. */
function fakeViewer(lon, lat) {
const credits = new Set();
const camera = cameraLookingAt(lon, lat);
const dataSources = [];
const frames = controlledFrames();
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: { addEventListener() {}, removeEventListener() {} },
postRender: frames.event,
renderRequests: 0,
requestRender() {
scene.renderRequests += 1;
},
};
return {
dataSources,
frames,
viewer: {
credits,
creditDisplay: {
addStaticCredit: (credit) => credits.add(credit),
removeStaticCredit: (credit) => credits.delete(credit),
},
scene,
camera,
trackedEntity: null,
@@ -121,6 +134,31 @@ function fakeViewer(lon, lat) {
};
}
function controlledFrames() {
const listeners = new Set();
return {
event: {
addEventListener(listener) { listeners.add(listener); },
removeEventListener(listener) { listeners.delete(listener); },
},
emit() { for (const listener of [...listeners]) listener(); },
snapshot() { return [...listeners]; },
get size() { return listeners.size; },
};
}
function buildingAnnotation(id, offset = 0) {
const ring = [
[-99 + offset, 31], [-98.999 + offset, 31],
[-98.999 + offset, 31.001], [-99 + offset, 31.001],
[-99 + offset, 31],
];
return {
id, type: 'area', color: 'primary', label: null, alpha: 1,
anchor: { ...naiveRingCentroid(ring), height: 100 },
ring, footprintKind: 'building', buildingHeight: 20, outlineCredit: 'tiles',
};
}
function naiveRingCentroid(ring) {
const totals = ring.reduce(
@@ -130,8 +168,10 @@ function naiveRingCentroid(ring) {
return { lon: totals.lon / ring.length, lat: totals.lat / ring.length, height: 0 };
}
test('hybrid outline upgrade preserves the screen group and adds world geometry', (t) => {
test('hybrid outline upgrade preserves the screen group and adds world geometry', async (t) => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalWindow = globalThis.window;
globalThis.window = { setTimeout: (fn) => { fn(); return 1; } };
const originalBrowserConstructors = new Map([
['HTMLCanvasElement', globalThis.HTMLCanvasElement],
['HTMLImageElement', globalThis.HTMLImageElement],
@@ -143,6 +183,8 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
t.after(() => {
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
for (const [name, original] of originalBrowserConstructors) {
if (original === undefined) delete globalThis[name];
else globalThis[name] = original;
@@ -151,13 +193,15 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
const camera = cameraLookingAt(-99, 31);
const dataSources = [];
const frames = controlledFrames();
const scene = {
camera,
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: { addEventListener() {}, removeEventListener() {} },
postRender: frames.event,
requestRender() {},
};
const viewer = {
scene,
@@ -228,6 +272,62 @@ test('hybrid outline upgrade preserves the screen group and adds world geometry'
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polygon).length, 1);
assert.equal(dataSources[0].entities.values.filter((entity) => entity.polyline).length, 1);
assert.equal(Object.hasOwn(anno, '_entities'), false, 'world entity state stays on the inherited proxy');
const outer = [
[-99, 31],
[-98.999, 31],
[-98.999, 31.001],
[-99, 31.001],
[-99, 31],
];
const hole = [
[-98.9998, 31.0002],
[-98.9996, 31.0002],
[-98.9996, 31.0004],
[-98.9998, 31.0004],
[-98.9998, 31.0002],
];
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
const building = {
...anno,
id: 'anno-multipart-building',
label: 'Building',
anchor: { lon: -99, lat: 31, height: 0 },
ring: outer,
polygons: [[outer, hole], [island]],
footprintKind: 'building',
buildingHeight: 20,
};
const completion = renderer.add(building);
assert.equal(dataSources[0].entities.values.length, 2, 'building geometry waits for all probes');
for (let frame = 0; frame < 8; frame += 1) frames.emit();
assert.equal((await completion).status, 'installed');
const added = dataSources[0].entities.values.slice(2);
const fills = added.filter(
(entity) => entity.polygon && entity.polygon.fill?.getValue() !== false,
);
assert.equal(
fills.length,
2,
'every building part gets a classification volume',
);
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
assert.equal(fills[1].polygon.hierarchy.getValue().holes.length, 0);
assert.equal(
fills[0].polygon.material,
fills[1].polygon.material,
'parts share the fill material',
);
assert.equal(
added.filter((entity) => entity.polyline).length,
3,
'all outer and hole outlines are retained',
);
renderer.remove(building);
assert.equal(
dataSources[0].entities.values.length,
2,
'multipart removal leaves only the earlier area',
);
renderer.destroy();
});
@@ -368,3 +468,319 @@ test('a world add that fails mid-way leaves its landed entities removable', (t)
assert.equal(annotationGroups(store).length, 1);
renderer.destroy();
});
function workTrace() {
return { timeOrigin: performance.timeOrigin, slices: [], dropped: 0 };
}
test('buildings share one probe per frame and preserve probe geometry, filtering and fallback', async (t) => {
installBrowserGlobals(t);
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
t.after(() => renderer.destroy());
const first = buildingAnnotation('sampled-building');
const second = buildingAnnotation('fallback-building', 0.01);
second.outlineCredit = 'osm';
const otherOwner = {};
showOsmCredit(viewer, otherOwner);
const probes = [];
const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90];
viewer.scene.clampToHeight = (position) => {
const cartographic = Cesium.Cartographic.fromCartesian(position);
const lon = Cesium.Math.toDegrees(cartographic.longitude);
const lat = Cesium.Math.toDegrees(cartographic.latitude);
probes.push([lon, lat]);
const outcome = outcomes[probes.length - 1];
if (outcome instanceof Error) throw outcome;
return outcome == null ? undefined : Cesium.Cartesian3.fromDegrees(lon, lat, outcome);
};
// Suppress screen points: these counts concern the eight outside-building
// probes, not the screen renderer's separate anchor-height sampling.
const firstTrace = workTrace();
const secondTrace = workTrace();
let diagnosticWriteFaults = 0;
Object.defineProperty(secondTrace, 'groundDetailDropped', {
get: () => 0,
set() {
diagnosticWriteFaults += 1;
throw new Error('diagnostic sink unavailable');
},
enumerable: true,
});
const firstCompletion = renderer.add(first, { pendingVisible: false, workTrace: firstTrace });
const secondCompletion = renderer.add(second, { pendingVisible: false, workTrace: secondTrace });
assert.equal(probes.length, 0, 'enqueue must not probe synchronously');
assert.equal(dataSources[0].entities.values.length, 0);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
'pending outlines add no tile credit to the other owner');
for (let frame = 0; frame < 16; frame += 1) {
const before = probes.length;
frames.emit();
assert.equal(probes.length, before + 1, 'the two buildings share one total probe budget');
if (frame < 7) assert.equal(dataSources[0].entities.values.length, 0, 'no partial world parts');
}
const [installed, fallback] = await Promise.all([firstCompletion, secondCompletion]);
assert.equal(installed.status, 'installed');
assert.equal(fallback.status, 'installed');
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 2);
assert.equal(installed.timing.probes, 8);
assert.equal(fallback.timing.probes, 8);
assert.ok(diagnosticWriteFaults > 0, 'the diagnostic failure was exercised');
for (const trace of [firstTrace, secondTrace]) {
assert.equal(trace.probes, 8);
assert.equal(trace.samplerStatus, 'installed');
assert.equal(trace.rendererStatus, 'installed');
assert.equal(trace.slices.filter(s => s.phase === 'ground-step').length, 8);
assert.equal(trace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(trace.dropped, 0);
assert.ok(trace.slices.every(s => Number.isFinite(s.start) && Number.isFinite(s.end) && s.end >= s.start));
assert.equal(trace.timeOrigin, performance.timeOrigin);
const parents = trace.slices.filter(s => s.phase === 'ground-step');
for (const [index, parent] of parents.entries()) {
const detail = parent.detail;
assert.equal(detail.index, index);
assert.equal(detail.status, trace === firstTrace ? 'recorded' : 'missing');
assert.equal(detail.clampResult,
trace === firstTrace && index === 4 ? 'threw' : 'returned');
assert.ok(Number.isFinite(detail.clampStart) && Number.isFinite(detail.clampEnd));
assert.ok(parent.start <= detail.clampStart && detail.clampStart <= detail.clampEnd && detail.clampEnd <= parent.end);
if (index === 7) {
assert.equal(detail.commitStatus, 'attempted');
assert.ok(Number.isFinite(detail.commitStart) && Number.isFinite(detail.commitEnd));
assert.ok(detail.clampEnd <= detail.commitStart && detail.commitStart <= detail.commitEnd && detail.commitEnd <= parent.end);
} else {
assert.equal(detail.commitStatus, 'not-run');
assert.equal(detail.commitStart, null);
assert.equal(detail.commitEnd, null);
}
}
}
assert.notEqual(firstTrace.slices, secondTrace.slices);
assert.ok(firstTrace.slices.filter(s => s.phase === 'ground-step').at(-1).end <= secondTrace.slices.find(s => s.phase === 'ground-step').start, 'each queued task records only its own sampling frames');
const centroid = naiveRingCentroid(first.ring);
const lonScale = 111320 * Math.cos(Cesium.Math.toRadians(centroid.lat));
const radius = Math.max(...first.ring.map(([lon, lat]) =>
Math.hypot((lon - centroid.lon) * lonScale, (lat - centroid.lat) * 111320)));
const distance = radius * 1.5 + 12;
const directions = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
for (let index = 0; index < 8; index += 1) {
const [dx, dy] = directions[index];
assert.ok(Math.abs(probes[index][0] - (centroid.lon + dx * distance / lonScale)) < 1e-10);
assert.ok(Math.abs(probes[index][1] - (centroid.lat + dy * distance / 111320)) < 1e-10);
assert.ok(Math.abs(probes[index + 8][0] - probes[index][0] - 0.01) < 1e-10);
assert.ok(Math.abs(probes[index + 8][1] - probes[index][1]) < 1e-10);
}
assert.equal(new Set(probes.slice(0, 8).map((point) => point.join(','))).size, 8);
const fills = dataSources[0].entities.values.filter((entity) => entity.polygon?.fill?.getValue() !== false && entity.polygon);
assert.equal(fills.length, 2);
assert.ok(Math.abs(fills[0].polygon.height.getValue() - 57) < 1e-6, 'four accepted samples select floor(4 * 0.3), height 60 minus 3');
assert.ok(Math.abs(fills[1].polygon.height.getValue() - 77) < 1e-6, 'all unavailable probes retain anchor minus building height fallback');
const afterCompletion = probes.length;
frames.emit();
assert.equal(probes.length, afterCompletion, 'completion releases the sampler');
renderer.remove(first);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1,
'removing the last tile proxy retains the OSM building and other owner');
renderer.remove(second);
assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1);
hideOsmCredit(viewer, otherOwner);
assert.equal(viewer.credits.size, 0);
});
test('a building queued behind others gets its full budget on a slow device', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
// The renderers' per-frame budget checks read performance.now(); it follows
// the same mocked clock as the timers.
let now = 0;
t.mock.method(globalThis.performance, 'now', () => now);
const { viewer, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
viewer.scene.clampToHeight = (position) => position;
const traces = [workTrace(), workTrace(), workTrace()];
const completions = [0, 0.01, 0.02].map((offset, index) =>
renderer.add(buildingAnnotation(`queued-${index}`, offset), {
pendingVisible: false,
workTrace: traces[index],
}));
// Eight probes per building at one probe per frame, at about 5 fps: each
// building samples for 1.5 s, so the third waits 3 s in line. Pending
// promise work runs between frames, as it does in a browser.
for (let building = 0; building < 3; building += 1) {
for (let frame = 0; frame < 8; frame += 1) {
now += 187.5;
t.mock.timers.tick(187.5);
frames.emit();
await new Promise((resolve) => setImmediate(resolve));
}
}
const results = await Promise.all(completions);
assert.deepEqual(results.map((result) => result.status), ['installed', 'installed', 'installed']);
assert.deepEqual(traces.map((trace) => trace.samplerStatus), ['installed', 'installed', 'installed']);
assert.ok(traces[2].samplerHeadAt - traces[2].samplerStart >= 2000, 'the third waited longer than one budget');
assert.ok(traces[2].samplerHeadAt >= traces[1].samplerHeadAt);
});
test('building cancellation, queued deadline and destroy prevent saved callbacks from committing', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
let probeCount = 0;
viewer.scene.clampToHeight = (position) => { probeCount += 1; return position; };
const cancelled = buildingAnnotation('cancelled-building');
const cancelledTrace = workTrace();
const cancelledCompletion = renderer.add(cancelled, { pendingVisible: false, workTrace: cancelledTrace });
const savedCancelled = frames.snapshot();
renderer.remove(cancelled);
assert.equal((await cancelledCompletion).status, 'cancelled');
assert.equal(cancelledTrace.samplerStatus, 'cancelled');
assert.equal(cancelledTrace.rendererStatus, 'cancelled');
assert.equal(cancelledTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
for (const listener of savedCancelled) listener();
assert.equal(probeCount, 0);
assert.equal(viewer.credits.size, 0);
const head = buildingAnnotation('deadline-head');
const tail = buildingAnnotation('deadline-tail', 0.01);
const headCompletion = renderer.add(head, { pendingVisible: false });
const tailTrace = workTrace();
const tailCompletion = renderer.add(tail, { pendingVisible: false, workTrace: tailTrace });
frames.emit();
assert.equal(probeCount, 1);
const savedTimeout = frames.snapshot();
// No further renderer frames arrive: the head's deadline fires, then the
// tail gets its own budget from the moment it reaches the head and still
// times out without frames, even though it never had a chance to sample.
const rendersBefore = viewer.scene.renderRequests;
t.mock.timers.tick(2000);
assert.equal((await headCompletion).status, 'timeout');
assert.ok(viewer.scene.renderRequests > rendersBefore, 'a promoted tail asks for a frame');
assert.equal(tailTrace.samplerStatus, undefined, 'queue wait does not spend the tail budget');
assert.ok(Number.isFinite(tailTrace.samplerHeadAt), 'the tail starts its own budget at the head');
t.mock.timers.tick(2000);
const tailResult = await tailCompletion;
assert.equal(tailResult.status, 'timeout');
assert.equal(tailResult.timing.probes, 0);
assert.equal(tailTrace.samplerStatus, 'timeout');
assert.equal(tailTrace.rendererStatus, 'timeout');
assert.equal(tailTrace.slices.filter(s => s.phase === 'ground-step').length, 0);
assert.equal(tailTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(viewer.credits.size, 0);
for (const listener of savedTimeout) listener();
assert.equal(probeCount, 1, 'saved callbacks cannot revive timed-out tasks');
assert.equal(dataSources[0].entities.values.length, 0);
const pending = buildingAnnotation('destroyed-building');
const destruction = renderer.add(pending, { pendingVisible: false });
const savedDestroy = frames.snapshot();
renderer.destroy();
assert.equal((await destruction).status, 'cancelled');
for (const listener of savedDestroy) listener();
t.mock.timers.tick(2000);
assert.equal(probeCount, 1);
assert.equal(dataSources.length, 0, 'destroy removes the owned data source');
assert.equal(frames.size, 0, 'destroy removes frame listeners');
assert.equal(viewer.credits.size, 0);
});
test('a timed-out building upgrade keeps its existing pending screen group', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
const anno = { ...buildingAnnotation('pending-upgrade'), ring: null, label: 'Building' };
renderer.add(anno);
const [before] = annotationGroups(store);
anno.ring = buildingAnnotation('geometry').ring;
const completion = renderer.update(anno);
t.mock.timers.tick(2000);
assert.equal((await completion).status, 'timeout');
const [after, ...others] = annotationGroups(store);
assert.equal(after.key, before.key);
assert.equal(others.length, 0);
assert.ok(childrenWithClass(after, 'gev-anno-ring').length > 0, 'the honest pending point is not converted');
assert.equal(dataSources[0].entities.values.length, 0);
frames.emit();
assert.equal(dataSources[0].entities.values.length, 0);
renderer.destroy();
});
test('an asynchronous caption-conversion throw rolls back multipart world geometry and remains removable', async (t) => {
installBrowserGlobals(t);
const { viewer, dataSources, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
const anno = { ...buildingAnnotation('caption-failure'), ring: null, label: 'Building' };
renderer.add(anno);
anno.ring = buildingAnnotation('geometry').ring;
const hole = anno.ring.map(([lon, lat]) => [lon + 0.0002, lat + 0.0002]);
const island = anno.ring.map(([lon, lat]) => [lon + 0.002, lat]);
anno.polygons = [[anno.ring, hole], [island]];
const captionTrace = workTrace();
const completion = renderer.update(anno, { workTrace: captionTrace });
// Frame projection is still ordinary pending-point work. Arm the failure
// only after sampling completes, before its promise converts the caption.
for (let frame = 0; frame < 8; frame += 1) frames.emit();
assert.ok(dataSources[0].entities.values.length > 0, 'multipart world geometry landed before caption conversion');
assert.equal(viewer.credits.size, 2, 'credit belongs to the committed world proxy');
// The caption conversion fails when it presents the converted mark.
const publish = store.publish;
const failure = new Error('caption conversion failed');
let armed = true;
store.publish = (board) => {
if (armed) {
armed = false;
throw failure;
}
return publish(board);
};
// Also fail screen teardown once: the routing entry must survive this second
// failure so explicit cleanup can still reach the partial screen state.
const workingSetTimeout = globalThis.window.setTimeout;
globalThis.window.setTimeout = () => { throw new Error('teardown scheduling failed'); };
const rejected = assert.rejects(completion, (error) => error === failure);
await rejected;
assert.equal(captionTrace.rendererStatus, 'failed');
assert.equal(captionTrace.slices.filter(s => s.phase === 'ground-step').length, 8);
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-complete').length, 1);
assert.equal(captionTrace.slices.filter(s => s.phase === 'hybrid-rollback').length, 1);
assert.equal(dataSources[0].entities.values.length, 0, 'all classification, cage and outline parts roll back');
assert.equal(viewer.credits.size, 0, 'caption rollback releases that same proxy');
assert.equal(annotationGroups(store).length, 1, 'failed screen cleanup remains addressable');
globalThis.window.setTimeout = workingSetTimeout;
store.publish = publish;
renderer.remove(anno);
assert.equal(annotationGroups(store).length, 0);
renderer.remove(anno);
renderer.destroy();
});
test('a deadline passed during caption conversion leaves a drawn pending point', async (t) => {
installBrowserGlobals(t);
let now = 0;
t.mock.method(globalThis.performance, 'now', () => now);
const { viewer, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
// Unlabeled: the conversion empties the screen group rather than relabelling it.
const anno = { ...buildingAnnotation('late-timeout'), ring: null };
renderer.add(anno);
anno.ring = buildingAnnotation('geometry').ring;
const completion = renderer.update(anno);
for (let frame = 0; frame < 8; frame += 1) frames.emit();
// The clock passes the deadline while the conversion presents the board.
const publish = store.publish;
store.publish = (board) => {
now = 1e9;
return publish(board);
};
assert.equal((await completion).status, 'timeout');
store.publish = publish;
const live = annotationGroups(store).filter((mark) => !mark.className?.includes('gev-out'));
assert.equal(live.length, 1);
assert.ok(childrenWithClass(live[0], 'gev-anno-ring').length > 0, 'the pending point still draws');
renderer.remove(anno);
renderer.destroy();
});
+164
View File
@@ -0,0 +1,164 @@
// Multipart outline identity and street highlights in the annotation engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createAnnotationEngine,
promoteStreetHighlight,
} from './annotationEngine.js';
function installAnimationFrameStubs(t) {
const raf = globalThis.requestAnimationFrame;
const caf = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
t.after(() => {
if (raf === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = raf;
if (caf === undefined) delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = caf;
});
}
const renderer = { add() {}, update() {}, remove() {}, sync() {} };
const outer = [
[0, 0],
[0.01, 0],
[0.01, 0.01],
[0, 0.01],
[0, 0],
];
const hole = (d) => [
[0.004, 0.004],
[0.004 + d, 0.004],
[0.004 + d, 0.004 + d],
[0.004, 0.004],
];
test('outlines sharing an outer ring but not their holes stay two marks', async (t) => {
installAnimationFrameStubs(t);
const shapes = {
a: [[outer, hole(0.001)]],
b: [[outer, hole(0.002)]],
same: [[outer, hole(0.001)]],
};
const resolveTarget = async ({ target }) => ({
lon: 0.005,
lat: 0.005,
height: 0,
label: target,
source: 'fake',
ring: outer,
polygons: shapes[target],
footprintKind: 'area',
});
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
await engine.annotate({ type: 'area', target: 'a', label: 'A' });
await engine.annotate({ type: 'area', target: 'b', label: 'B' });
assert.equal(engine.list().length, 2, 'different holes: different marks');
await engine.annotate({ type: 'area', target: 'same', label: 'Same' });
assert.equal(engine.list().length, 2, 'identical parts and holes: one mark');
});
test('a street highlight asks for the street outline', () => {
assert.deepEqual(
promoteStreetHighlight({ type: 'highlight', target: 'Congress Avenue' }),
{ type: 'area', target: 'Congress Avenue', entityKind: 'street' },
);
assert.equal(
promoteStreetHighlight({
type: 'highlight',
target: 'Lombard St, San Francisco',
}).type,
'area',
);
assert.equal(
promoteStreetHighlight({
type: 'highlight',
target: 'the Alamo',
entityKind: 'street',
}).type,
'area',
);
for (const spec of [
{ type: 'highlight', target: "St Peter's Basilica" },
{ type: 'highlight', target: 'Zilker Park' },
{ type: 'highlight', target: 'Congress Avenue', footprint: false },
{ type: 'highlight', target: 'Congress Avenue', entityKind: 'building' },
{ type: 'pin', target: 'Congress Avenue' },
])
assert.equal(promoteStreetHighlight(spec), spec, JSON.stringify(spec));
});
test('the engine resolves a bare street highlight with its footprint', async (t) => {
installAnimationFrameStubs(t);
const seen = [];
const resolveTarget = async (args) => {
seen.push(args);
return {
lon: -97.74,
lat: 30.27,
height: 0,
label: args.target,
source: 'fake',
ring: null,
};
};
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
await engine.annotate({
type: 'highlight',
target: 'Congress Avenue',
label: 'Congress',
});
assert.equal(seen[0].footprint, true);
assert.equal(seen[0].entityKind, 'street');
});
test('a duplicate that fails to clean up never costs the new outline', async (t) => {
installAnimationFrameStubs(t);
const polygons = [[outer]];
const outline = { ring: outer, polygons, footprintKind: 'area' };
const point = { lon: 0.005, lat: 0.005, height: 0, source: 'fake' };
// "Old" draws at once; "New" resolves the same polygon later.
const resolveTarget = async ({ target }) =>
target === 'old'
? { ...point, label: target, ...outline }
: {
...point,
label: target,
resolveOutline: async () => ({ ...point, ...outline }),
};
const stuck = new Set();
const outcomes = [];
const engine = createAnnotationEngine({
viewer: {},
renderer: {
...renderer,
add(anno) {
if (anno.label === 'Old') stuck.add(anno);
},
remove(anno) {
if (stuck.has(anno)) throw new Error('renderer refused');
},
},
resolveTarget,
});
engine.onOutlineEvent((event) => outcomes.push(event));
await engine.annotate({ type: 'area', target: 'old', label: 'Old' });
await engine.annotate({ type: 'area', target: 'new', label: 'New' });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
const fresh = engine.list().find((mark) => mark.label === 'New');
assert.equal(fresh?.footprintKind, 'area', 'the new mark keeps its outline');
assert.notEqual(fresh.outlineUnavailable, true);
assert.deepEqual(
outcomes.filter((event) => event.label === 'New').map((e) => e.status),
['resolved'],
);
});
+371
View File
@@ -0,0 +1,371 @@
import {
bufferCorridor,
ringAreaM2,
ringCentroid,
} from '../sources/featureGeometry.js';
import { isUnavailableCapability } from '../sources/capability.js';
import { containsAnchor } from '../data/adminBoundaries.js';
import { lonLatToTile } from '../data/tomtomTiles.js';
import {
BUILDING_MAX_DISTANCE_M,
GROUNDS_WORDS_RE,
OUTLINE_TILE_ZOOM,
STREET_RADIUS_M,
boxAround,
buildingFromTiles,
createOpenFreeMapOutlineSource,
enclosingAreaFromTiles,
groundsClassesFor,
streetFromTiles,
} from '../sources/openFreeMapOutlines.js';
import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js';
export { containsAnchor };
/**
* Outline rungs that need no Overpass, around the resolver's existing ladder:
*
* bundled rungs (inside the base ladder)
* → OpenFreeMap tiles for streets and pointed-at buildings
* → operator Overpass, when configured (the base ladder, unchanged)
* → the server's guarded Nominatim outline (cities, landmarks, grounds,
* parks, lakes, campuses, named buildings)
* → OpenFreeMap building at the point, for a named building nobody outlined
* → the base answer: an honest point marked "outline unavailable".
*
* Every rung returns the resolver's footprint shape
* `{ ring, polygons?, kind, heightM, synthesized }` and hands it to the
* resolver's `finish`, which applies the scope caps and drift bound and
* re-centres the anchor. Nothing here fetches without an explicit ask.
*/
/** Half-width (m) of the ribbon drawn along a street. */
const STREET_HALF_WIDTH_M = 11;
/** A Nominatim "grounds" answer smaller than this is a building, not grounds. */
const GROUNDS_MIN_AREA_M2 = 20_000;
/** Words that point at the thing rather than name it. */
const DEICTIC_RE =
/^(?:(?:this|that|the|my)\s+)?(?:building|house|structure|tower|place|one|thing|spot|here|there|it)$/i;
/** Whether the ask names a thing (vs. "this building" at a pointer). */
export function isNamedAsk(target) {
const text = String(target || '').trim();
return Boolean(text) && !DEICTIC_RE.test(text) && /\p{L}/u.test(text);
}
/** The words sent to Nominatim: the ask without grounds words or articles. */
export function outlineQueryText(target) {
return String(target || '')
.replace(GROUNDS_WORDS_RE, ' ')
.replace(/^\s*the\s+/i, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Which Nominatim ask kind (if any) suits a resolver scope.
* Neighbourhoods and streets are not sent: bundled packs and tiles own them.
*/
export function nominatimKindFor({ scope, groundsLike, pointLike, around }) {
if (pointLike) return null;
// "Around" asks get a buffer, except grounds: the grounds ARE the thing.
if (around && !groundsLike) return null;
if (scope === 'city') return 'city';
if (scope === 'state' || scope === 'county' || scope === 'country')
return 'admin';
if (groundsLike) return 'landmark';
if (scope === 'building') return 'building';
if (scope === 'compound' || scope === 'auto') return 'landmark';
return null;
}
/**
* Parts with the one holding the anchor outside its holes first. A boundary
* can be largest at sea (Tokyo's island municipalities), so size alone does
* not pick the part the ask is about; with no part holding the anchor, the
* order is kept.
*/
export function anchorPartFirst(polygons, points) {
for (const { lat, lon } of points) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const at = polygons.findIndex((polygon) =>
containsAnchor([polygon], lat, lon),
);
if (at > 0) return [polygons[at], ...polygons.filter((_, i) => i !== at)];
if (at === 0) return polygons;
}
return polygons;
}
/** Whether a base answer is final (a real outline, or "try again later"). */
function keepBase(result) {
if (result === undefined) return true;
if (result?.rateLimited === true) return true;
if (isUnavailableCapability(result)) return false;
return Boolean(result?.ring) && !result.synthesized;
}
/** Street polylines → one ribbon polygon per chain (longest first). */
export function streetFootprint(street) {
const polygons = street.lines
.filter((line) => line.length >= 2)
.map((line) => [bufferCorridor(line, STREET_HALF_WIDTH_M)]);
if (!polygons.length) return null;
return {
ring: polygons[0][0],
polygons,
lines: street.lines,
kind: 'area',
heightM: null,
synthesized: false,
};
}
// Rungs built without their own tiles share one outline source, so a second
// resolver instance cannot add another outline cache to the tile budget.
let defaultOutlineTiles = null;
const sharedOutlineTiles = () =>
(defaultOutlineTiles ??= createOpenFreeMapOutlineSource());
export function createOutlineRungs({
tiles = sharedOutlineTiles(),
nominatim = createNominatimOutlineClient(),
} = {}) {
/**
* Decoded tiles around a point, or null when the tiles cannot be read. With
* `needAnchor`, a partial load that lost the point's own tile is null too: a
* neighbouring tile alone cannot show what is at the point, so a nearby
* building or area would stand in for it.
*/
async function tilesAround(lat, lon, radiusM, signal, needAnchor = false) {
try {
const {
tiles: decoded,
partial,
loadedTiles,
} = await tiles.fetchBounds(boxAround(lat, lon, radiusM), {
zoom: OUTLINE_TILE_ZOOM,
signal,
});
if (needAnchor && partial && Array.isArray(loadedTiles)) {
const anchor = lonLatToTile(lon, lat, OUTLINE_TILE_ZOOM);
if (!loadedTiles.some((t) => t.x === anchor.x && t.y === anchor.y))
return null;
}
return decoded;
} catch (error) {
signal?.throwIfAborted();
if (error?.name === 'AbortError') throw error;
return null;
}
}
async function streetRung(ctx) {
const name = ctx.matchName || ctx.target;
if (!isNamedAsk(name)) return null;
const decoded = await tilesAround(
ctx.lat,
ctx.lon,
STREET_RADIUS_M,
ctx.signal,
);
if (!decoded) return null;
const street = streetFromTiles(decoded, {
name,
lat: ctx.lat,
lon: ctx.lon,
});
return street ? streetFootprint(street) : null;
}
async function buildingRung(ctx, requireContainment = false) {
const decoded = await tilesAround(
ctx.lat,
ctx.lon,
BUILDING_MAX_DISTANCE_M * 2,
ctx.signal,
true,
);
const building = decoded && buildingFromTiles(decoded, ctx);
if (!building || (requireContainment && building.contains !== true))
return null;
return {
ring: building.ring,
polygons: building.polygons,
kind: 'building',
heightM: building.heightM,
synthesized: false,
};
}
/** The open area enclosing `seed` (the named feature) or the anchor. */
async function groundsRung(ctx, seed = null) {
const at = seed || { lat: ctx.lat, lon: ctx.lon };
const decoded = await tilesAround(at.lat, at.lon, 600, ctx.signal, true);
const area =
decoded &&
enclosingAreaFromTiles(decoded, {
lat: at.lat,
lon: at.lon,
name: outlineQueryText(ctx.target || ctx.matchName),
// The kind the ask names ("… School grounds") lets unnamed tile
// landuse of that class answer; read before grounds words are cut.
unnamedClasses: groundsClassesFor(ctx.target || ctx.matchName),
minAreaM2: GROUNDS_MIN_AREA_M2,
});
if (!area || !containsAnchor(area.polygons, ctx.lat, ctx.lon)) return null;
return {
ring: area.ring,
polygons: area.polygons,
kind: 'area',
heightM: null,
synthesized: false,
};
}
async function nominatimRung(ctx, kind) {
const query = outlineQueryText(
kind === 'city' || kind === 'admin'
? ctx.matchName || ctx.target
: ctx.target || ctx.matchName,
);
if (!isNamedAsk(query)) return null;
const bias = ctx.view || { lat: ctx.lat, lon: ctx.lon };
const outline = await nominatim.lookup(
{
query,
kind,
lat: bias.lat,
lon: bias.lon,
anchorLat: ctx.lat,
anchorLon: ctx.lon,
},
{ signal: ctx.signal },
);
if (!outline?.polygons?.length) return outline; // null | undefined | unavailable | rateLimited
// Named outlines must retain the resolved place's authority,
// including holes; a nearby same-name polygon is not the selected place.
if (!containsAnchor(outline.polygons, ctx.lat, ctx.lon)) return null;
const polygons = anchorPartFirst(outline.polygons, [
{ lat: ctx.lat, lon: ctx.lon },
]);
const ring = polygons[0][0];
// A grounds ask answered by a building-sized polygon found the named
// building, not its grounds: keep its position to find what encloses it.
if (ctx.groundsLike && ringAreaM2(ring) < GROUNDS_MIN_AREA_M2)
return { groundsSeed: ringCentroid(ring) };
return {
ring,
polygons,
kind:
kind === 'building' || outline.class === 'building'
? 'building'
: 'area',
heightM: null,
synthesized: false,
outlineName: outline.name,
};
}
/**
* Run the ladder for one resolved anchor.
*
* @param {object} ctx scope, target, matchName, lat, lon, named anchor facts,
* view centre, signal, and `credit(kind)`.
* @param {{base: () => Promise<any>, finish: (fp: object, scope: string) => any}} ladder
*/
async function resolve(ctx, { base, finish }) {
const { scope } = ctx;
const named = ctx.fromName && isNamedAsk(ctx.target);
// A cancelled ask (board cleared, superseded) stops between rungs, so it
// never spends a Nominatim request.
const alive = () => ctx.signal?.throwIfAborted();
const accept = (fp, asScope = scope, provider) => {
alive();
if (!fp) return null;
const patch = finish(fp, asScope);
if (patch?.ring) {
patch.outlineCredit = provider === 'openfreemap' ? 'tiles' : 'osm';
ctx.credit?.(patch.outlineCredit);
// One line per answered outline, like the resolver's own trace.
console.log(
`[Outline] "${ctx.target || ''}": scope=${asScope} → ${provider}` +
` (${patch.footprintKind}, ${patch.polygons?.length || 1} part(s))`,
);
return patch;
}
return null;
};
// Tiles first for streets and for a building pointed at rather than named.
if (scope === 'street') {
const street = accept(await streetRung(ctx), 'street', 'openfreemap');
if (street) return street;
} else if (scope === 'building' && !named && !ctx.around) {
const building = accept(
await buildingRung(ctx),
'building',
'openfreemap',
);
if (building) return building;
}
alive();
const baseResult = await base();
alive();
if (keepBase(baseResult)) return baseResult;
// A transient or busy Nominatim answer is kept aside while the tile
// fallbacks run; it is returned only if nothing else answers.
let retryLater = null;
let groundsSeed = null;
const kind = named ? nominatimKindFor(ctx) : null;
if (kind) {
const found = await nominatimRung(ctx, kind);
alive();
if (found?.groundsSeed) {
groundsSeed = found.groundsSeed;
} else if (found?.ring) {
const patch = accept(
found,
ctx.groundsLike ? 'compound' : scope,
'nominatim',
);
if (patch) return patch;
} else if (found === undefined || found?.rateLimited) {
retryLater = { value: found };
}
}
// Grounds absent from the named lookup: a matching named tile area.
if (ctx.groundsLike && named) {
// Around the named building when Nominatim found it, else the anchor.
let grounds = groundsSeed
? accept(await groundsRung(ctx, groundsSeed), 'compound', 'openfreemap')
: null;
grounds ||= accept(await groundsRung(ctx), 'compound', 'openfreemap');
if (grounds) return grounds;
}
// A named building nobody outlined: the footprint holding its point. Never for
// a grounds ask, where the building is the wrong shape.
if (scope === 'building' && named && !ctx.around && !ctx.groundsLike) {
const building = accept(
await buildingRung(ctx, true),
'building',
'openfreemap',
);
if (building) return building;
}
// Keep the mark retryable only when nothing else could answer. A
// definitive miss or an approximation from the configured ladder stands
// rather than inviting repeated lookups.
if (retryLater && isUnavailableCapability(baseResult))
return retryLater.value;
return baseResult;
}
return { resolve };
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,174 @@
// Tile fallbacks still run after a busy or failed Nominatim answer, and
// grounds asks phrased "around" still reach their outline rungs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createOutlineRungs, nominatimKindFor } from './outlineRungs.js';
import { decodeOpenFreeMapOutlineTile } from '../sources/openFreeMapOutlines.js';
const TILES = [6745, 6746].map((y) =>
decodeOpenFreeMapOutlineTile(
readFileSync(
new URL(
`../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`,
import.meta.url,
),
),
14,
3743,
y,
),
);
const UNAVAILABLE = { unavailable: true, retryable: false };
const CAPITOL = { lat: 30.27472, lon: -97.74035 };
function setup(answer, tiles = TILES) {
const tileCalls = [];
const asks = [];
const rungs = createOutlineRungs({
tiles: {
fetchBounds: async (box) => {
tileCalls.push(box);
return { tiles };
},
},
nominatim: {
lookup: async (ask) => {
asks.push(ask);
return answer;
},
},
});
return { rungs, tileCalls, asks };
}
const ladder = {
base: async () => UNAVAILABLE,
finish: (fp) => ({ ring: fp.ring, footprintKind: fp.kind }),
};
const ctx = (overrides) => ({
scope: 'building',
target: 'Texas State Capitol',
matchName: 'Texas State Capitol',
fromName: true,
...CAPITOL,
view: CAPITOL,
...overrides,
});
for (const [label, answer] of [
['busy', { rateLimited: true, retryAfterMs: 5000 }],
['transient', undefined],
]) {
test(`a ${label} Nominatim answer does not skip the tile fallbacks`, async () => {
const building = setup(answer);
const found = await building.rungs.resolve(ctx(), ladder);
assert.equal(building.asks.length, 1);
assert.ok(building.tileCalls.length > 0, 'tiles were consulted');
assert.equal(found.footprintKind, 'building');
const grounds = setup(answer);
const lawn = await grounds.rungs.resolve(
ctx({ target: 'the Texas Capitol grounds', groundsLike: true }),
ladder,
);
assert.ok(grounds.tileCalls.length > 0, 'grounds tiles were consulted');
assert.equal(lawn, answer, 'unnamed grounds preserve the retryable answer');
});
}
test('with no tile answer either, the busy result keeps the mark retryable', async () => {
const busy = { rateLimited: true, retryAfterMs: 5000 };
const rungs = createOutlineRungs({
tiles: { fetchBounds: async () => ({ tiles: [] }) },
nominatim: { lookup: async () => busy },
});
assert.equal(await rungs.resolve(ctx(), ladder), busy);
});
test('"around the Capitol grounds" is still a grounds ask', async () => {
assert.equal(
nominatimKindFor({ scope: 'building', groundsLike: true, around: true }),
'landmark',
);
assert.equal(nominatimKindFor({ scope: 'compound', around: true }), null);
const { rungs, asks } = setup(null);
const lawn = await rungs.resolve(
ctx({
target: 'the Texas Capitol grounds',
groundsLike: true,
around: true,
}),
ladder,
);
assert.equal(asks.length, 1);
});
test('named grounds around a building seed still require the original anchor', async () => {
// A place-search anchor ~250 m from the dome, outside the Capitol lawn.
const offLawn = { lat: 30.27265, lon: -97.73925 };
const d = 0.0003;
const dome = {
polygons: [
[
[
[CAPITOL.lon - d, CAPITOL.lat - d],
[CAPITOL.lon + d, CAPITOL.lat - d],
[CAPITOL.lon + d, CAPITOL.lat + d],
[CAPITOL.lon - d, CAPITOL.lat + d],
[CAPITOL.lon - d, CAPITOL.lat - d],
],
],
],
class: 'office',
};
const lawnRadius = 0.001;
const namedTiles = [
{
areas: [
{
name: 'Texas Capitol',
class: 'park',
clipped: false,
rings: [
[
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
[CAPITOL.lon + lawnRadius, CAPITOL.lat - lawnRadius],
[CAPITOL.lon + lawnRadius, CAPITOL.lat + lawnRadius],
[CAPITOL.lon - lawnRadius, CAPITOL.lat + lawnRadius],
[CAPITOL.lon - lawnRadius, CAPITOL.lat - lawnRadius],
],
],
},
],
},
];
const withoutSeed = setup(null, namedTiles);
assert.equal(
await withoutSeed.rungs.resolve(
ctx({
...offLawn,
target: 'the Texas Capitol grounds',
groundsLike: true,
}),
ladder,
),
UNAVAILABLE,
'the anchor alone is not inside the grounds',
);
const { rungs, tileCalls } = setup(dome, namedTiles);
const lawn = await rungs.resolve(
ctx({ ...offLawn, target: 'the Texas Capitol grounds', groundsLike: true }),
ladder,
);
assert.ok(tileCalls.length > 0, 'the matching named enclosure was consulted');
assert.deepEqual(
tileCalls,
withoutSeed.tileCalls,
'a rejected polygon cannot add a shifted grounds-seed lookup',
);
assert.equal(
lawn,
UNAVAILABLE,
'a building seed cannot move the original anchor into the named grounds',
);
});
+687
View File
@@ -0,0 +1,687 @@
// Area annotations draw bundled US Census place and neighborhood outlines
// (San Francisco DataSF first, then Who's On First) with no network lookup.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
import { polygonsContain } from '../data/adminBoundaries.js';
import { streetFootprint } from './outlineRungs.js';
function viewerAt(lat, lon, height = 20_000) {
return {
camera: {
positionCartographic: {
latitude: (lat * Math.PI) / 180,
longitude: (lon * Math.PI) / 180,
height,
},
},
};
}
/** A resolver whose boundary lookups and geocoder record every call. */
function harness(place = null, { outlineRungs } = {}) {
const lookups = [];
const featureSource = {};
for (const method of FEATURE_SOURCE_METHODS)
featureSource[method] = async () => {
lookups.push(method);
return null;
};
const geocodes = [];
const placeSearch = {
async geocode(query) {
geocodes.push(query);
return { place };
},
async textSearch() {
geocodes.push('text-search');
return [];
},
};
const { resolveAnnotationTarget } = createAnnotationResolver({
featureSource,
...(outlineRungs ? { outlineRungs } : {}),
});
const resolve = (target, viewer, extra = {}) =>
resolveAnnotationTarget({
placeSearch,
viewer,
target,
footprint: true,
deferFootprint: true,
...extra,
});
return { resolve, lookups, geocodes };
}
const AUSTIN = viewerAt(30.2672, -97.7431, 3000);
const SAN_FRANCISCO = viewerAt(37.76, -122.42, 3000);
const LONDON = viewerAt(51.51, -0.16, 3000);
function assertBundledArea(resolved, label) {
assert.equal(resolved?.source, 'bundled', label);
assert.equal(resolved.label, label);
assert.equal(resolved.footprintKind, 'area');
assert.equal(resolved.synthesized, false);
assert.equal(
resolved.outlineCredit || null,
null,
'bundled data is not an OSM outline',
);
assert.equal(resolved.resolveOutline, undefined, 'drawn at once');
assert.deepEqual(resolved.ring[0], resolved.ring.at(-1), 'ring closed');
assert.ok(resolved.polygons.length >= 1);
}
test('a city name that is only a neighborhood alias goes to the geocoder', async () => {
const { resolve, geocodes } = harness();
// Sé lists "São Paulo" among its names; the ask means the city.
const saoPaulo = viewerAt(-23.55, -46.633, 60_000);
const city = await resolve('São Paulo', saoPaulo);
assert.notEqual(city?.label, 'Se');
assert.ok(geocodes.includes('São Paulo'));
// A district ask still takes the alias.
const district = await resolve('São Paulo', saoPaulo, {
entityKind: 'district',
});
assert.equal(district?.label, 'Se');
});
test('"Austin" over Austin draws the Census place with no lookup', async () => {
const { resolve, lookups, geocodes } = harness();
for (const target of ['Austin', 'Austin, Texas', 'City of Austin']) {
const resolved = await resolve(target, AUSTIN);
assertBundledArea(resolved, 'Austin');
assert.ok(resolved.polygons.length >= 4, 'every part of the city');
}
// A qualified name needs no view.
const springfield = await resolve('Springfield, Illinois', AUSTIN);
assertBundledArea(springfield, 'Springfield');
assert.ok(Math.abs(springfield.lat - 39.78) < 0.2);
assert.deepEqual(geocodes, []);
assert.deepEqual(lookups, []);
});
test("the SF DataSF pack answers before Who's On First in San Francisco", async () => {
const { resolve, lookups, geocodes } = harness();
const sf = JSON.parse(
readFileSync(
new URL(
'../data/local_data/neighborhoods/san-francisco.json',
import.meta.url,
),
),
);
const mission = sf.features.find((f) => f.properties.name === 'Mission');
const missionRing = mission.geometry.coordinates[0];
for (const target of ['the Mission District', 'Mission', 'the Mission']) {
const resolved = await resolve(target, SAN_FRANCISCO, {
entityKind: 'district',
});
assertBundledArea(resolved, 'Mission');
assert.deepEqual(resolved.polygons[0][0], missionRing, target);
}
// A name only WOF has in San Francisco falls through to it.
const dogpatch = await resolve('Dogpatch', SAN_FRANCISCO);
assertBundledArea(dogpatch, 'Dogpatch');
// A park asked for as a compound is not a neighborhood of its name.
await resolve('Mission Dolores Park', SAN_FRANCISCO, {
entityKind: 'compound',
});
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 1);
assert.deepEqual(lookups, []);
});
test("Who's On First neighborhoods resolve near the view", async () => {
const { resolve, geocodes } = harness();
assertBundledArea(await resolve('Notting Hill', LONDON), 'Notting Hill');
assertBundledArea(
await resolve('Le Marais', viewerAt(48.857, 2.352, 3000)),
'Le Marais',
);
assertBundledArea(
await resolve('Williamsburg', viewerAt(40.71, -73.96, 3000)),
'Williamsburg',
);
assert.deepEqual(geocodes, []);
});
test('campuses, qualified hoods and far names still go to the geocoder', async () => {
const { resolve, geocodes } = harness();
// Stanford is a CDP, but a campus ask wants the campus.
const stanford = await resolve('Stanford', viewerAt(37.43, -122.17, 3000), {
entityKind: 'compound',
});
assert.notEqual(stanford?.source, 'bundled');
await resolve('the Capitol grounds', AUSTIN);
await resolve('Notting Hill', AUSTIN);
assert.equal(geocodes.filter((q) => q !== 'text-search').length, 3);
// A state settles a place name wherever the camera is: not Brooklyn's
// Williamsburg, the Virginia city.
const virginia = await resolve(
'Williamsburg, Virginia',
viewerAt(40.71, -73.96, 3000),
);
assertBundledArea(virginia, 'Williamsburg');
assert.ok(Math.abs(virginia.lat - 37.27) < 0.1, 'Virginia');
});
test('a geocoded city or neighborhood upgrades to the bundled outline', async () => {
const city = harness({
lat: 30.2672,
lng: -97.7431,
label: 'Austin, TX, USA',
name: 'Austin',
types: ['locality', 'political'],
});
const controller = new AbortController();
const credits = [];
const cancelled = await city.resolve(
'Austin',
{
...viewerAt(39, -100, 5_000_000),
creditDisplay: { addStaticCredit: (credit) => credits.push(credit) },
},
{ signal: controller.signal },
);
const pending = cancelled.resolveOutline();
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.deepEqual(
credits,
[],
'a cancelled bundled continuation cannot mutate viewer credits',
);
// Seen from far away: the view does not settle the name, the geocoder does.
const resolved = await city.resolve('Austin', viewerAt(39, -100, 5_000_000));
assert.equal(resolved.source, 'geocode');
const outline = await resolved.resolveOutline();
assert.equal(outline.adminArea, 'Austin');
assert.ok(outline.polygons.length >= 4);
assert.deepEqual(city.lookups, [], 'no boundary lookup');
const hood = harness({
lat: 51.5118,
lng: -0.2046,
label: 'Notting Hill, London, UK',
name: 'Notting Hill',
types: ['neighborhood', 'political'],
});
const nottingHill = await hood.resolve(
'Notting Hill',
viewerAt(48, 10, 5_000_000),
);
const hoodOutline = await nottingHill.resolveOutline();
assert.equal(hoodOutline.adminArea, 'Notting Hill');
assert.deepEqual(hood.lookups, []);
});
test('a district ask prefers a neighborhood; a place must hold the view', async () => {
const { resolve, geocodes } = harness();
// "Richmond" over SF's Richmond District: never Richmond, California.
const richmond = await resolve('Richmond', viewerAt(37.78, -122.48, 3000), {
entityKind: 'district',
});
assertBundledArea(richmond, 'Richmond District');
assert.ok(Math.abs(richmond.lat - 37.78) < 0.05, 'not the East Bay city');
// Omitted kind at a street-level view: the district in view, too.
const bare = await resolve('Richmond', viewerAt(37.78, -122.48, 3000));
assertBundledArea(bare, 'Richmond District');
// Over the East Bay city, the city.
const wide = await resolve('Richmond', viewerAt(37.94, -122.35, 40_000));
assert.equal(wide?.source, 'bundled');
assert.ok(wide.lat > 37.88, 'Richmond, California');
assert.deepEqual(geocodes, []);
});
test('landmark-named neighborhoods wait for the landmark ladder', async () => {
const manhattan = viewerAt(40.78, -73.965, 3000);
const cases = [
['Central Park', {}],
['Central Park', { entityKind: 'compound' }],
['Central Park', { intent: 'around_the_thing' }],
['Hyde Park', {}],
['Notting Hill', { intent: 'around_the_thing' }],
];
for (const [target, extra] of cases) {
const { resolve, geocodes } = harness();
const viewer = target === 'Central Park' ? manhattan : LONDON;
const resolved = await resolve(target, viewer, extra);
assert.notEqual(
resolved?.source,
'bundled',
`${target} ${JSON.stringify(extra)}`,
);
assert.ok(geocodes.length >= 1, `${target} is geocoded`);
}
// Named as a district, the WOF neighborhood answers.
const { resolve } = harness();
const park = await resolve('Central Park', manhattan, {
entityKind: 'district',
});
assert.equal(park?.source, 'bundled');
// "Park Slope" ends in no landmark word: still a neighborhood.
const slope = await resolve('Park Slope', viewerAt(40.671, -73.98, 3000));
assertBundledArea(slope, 'Park Slope');
});
/** Outline rungs whose Nominatim answers `answer` (null: nothing found). */
function rungsAnswering(answer) {
const asked = [];
return {
asked,
resolve: async (ctx, { base, finish }) => {
asked.push({ scope: ctx.scope, target: ctx.target });
const baseResult = await base();
if (baseResult?.ring && !baseResult.synthesized) return baseResult;
return answer ? finish(answer, ctx.scope) : baseResult;
},
};
}
const PARIS_PLACE = {
lat: 48.8566,
lng: 2.3522,
label: 'Paris, France',
name: 'Paris',
types: ['locality', 'political'],
};
const square = (lat, lon, d) => [
[lon - d, lat - d],
[lon + d, lat - d],
[lon + d, lat + d],
[lon - d, lat + d],
[lon - d, lat - d],
];
test('a courtyard outline with no filled candidate anchor remains a point', async () => {
const lat = 0.0005;
const lon = 0.0005;
const ring = square(lat, lon, 0.0005);
const hole = square(lat, lon, 0.0002);
const polygons = [[ring, hole]];
const { resolve } = harness(null, {
outlineRungs: rungsAnswering({
ring,
polygons,
kind: 'building',
heightM: 20,
}),
});
const options = { latitude: lat, longitude: lon, entityKind: 'building' };
const pending = await resolve(
'this building',
viewerAt(lat, lon, 1000),
options,
);
const outline = await pending.resolveOutline();
assert.equal(
outline,
null,
'neither the mean nor the courtyard point is filled',
);
const inline = await resolve('this building', viewerAt(lat, lon, 1000), {
...options,
deferFootprint: false,
});
assert.equal(inline.ring, null);
assert.deepEqual(
[inline.lat, inline.lon],
[lat, lon],
'keep the honest point',
);
for (const resolved of [outline, inline]) {
if (resolved?.polygons)
assert.ok(polygonsContain(resolved.polygons, resolved.lat, resolved.lon));
}
});
test('a bent street keeps its outline when the anchor sits beside the centreline', async () => {
// 500 m east then 500 m north: the ring mean lands off the 22 m ribbon.
const lat0 = 30.27;
const lon0 = -97.74;
const dLon = 500 / (111320 * Math.cos((lat0 * Math.PI) / 180));
const dLat = 500 / 111320;
const line = [
[lon0, lat0],
[lon0 + dLon / 2, lat0],
[lon0 + dLon, lat0],
[lon0 + dLon, lat0 + dLat / 2],
[lon0 + dLon, lat0 + dLat],
];
const footprint = streetFootprint({ lines: [line] });
// A geocoded point 15 m north of the first leg, outside the ribbon.
const lat = lat0 + 15 / 111320;
const lon = lon0 + dLon / 2;
assert.equal(polygonsContain(footprint.polygons, lat, lon), false);
const { resolve } = harness(
{ lat, lng: lon, name: 'Bent Street', label: 'Bent St', types: ['route'] },
{ outlineRungs: rungsAnswering(footprint) },
);
const pending = await resolve('Bent Street', viewerAt(lat, lon, 3000), {
entityKind: 'street',
});
const outline = await pending.resolveOutline();
assert.equal(outline?.ring, footprint.ring, 'the street is still drawn');
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
assert.ok(Math.abs(outline.lat - lat0) < 1e-7, 'centred on the street');
assert.ok(Math.abs(outline.lon - lon) < 1e-7);
// A same-name street that never comes near the anchor is not that street.
const far = lat0 + 300 / 111320;
const away = harness(
{
lat: far,
lng: lon,
name: 'Bent Street',
label: 'Bent St',
types: ['route'],
},
{ outlineRungs: rungsAnswering(footprint) },
);
const distant = await away.resolve('Bent Street', viewerAt(far, lon, 3000), {
entityKind: 'street',
});
assert.equal((await distant.resolveOutline())?.ring ?? null, null);
});
test('local dateline outlines share a frame for area, centering and filled anchors', async () => {
const ring = [
[179.9995, -0.0005],
[-179.9995, -0.0005],
[-179.9995, 0.0005],
[179.9995, 0.0005],
[179.9995, -0.0005],
];
const hole = [
[179.9998, -0.0003],
[-179.9998, -0.0003],
[-179.9998, 0.0003],
[179.9998, 0.0003],
[179.9998, -0.0003],
];
const polygons = [[ring, hole]];
const { resolve } = harness(null, {
outlineRungs: rungsAnswering({
ring,
polygons,
kind: 'building',
heightM: 20,
}),
});
for (const deferFootprint of [true, false]) {
for (const [lat, lon, filled] of [
[0.0004, 179.9996, true],
[0, 179.99999, false],
[0.0004, 0, false],
]) {
const result = await resolve('this building', viewerAt(lat, lon, 1000), {
latitude: lat,
longitude: lon,
entityKind: 'building',
deferFootprint,
});
const outline = deferFootprint ? await result.resolveOutline() : result;
if (!filled) {
assert.equal(outline?.ring ?? null, null);
continue;
}
assert.equal(outline.ring, ring);
assert.equal(outline.polygons, polygons);
assert.deepEqual(
[outline.lat, outline.lon],
[lat, lon],
'keep the filled shell anchor',
);
}
}
const plain = harness(null, {
outlineRungs: rungsAnswering({ ring, kind: 'building', heightM: 20 }),
});
const pending = await plain.resolve(
'this building',
viewerAt(0, 179.9999, 1000),
{
latitude: 0,
longitude: 179.9999,
entityKind: 'building',
},
);
const centered = await pending.resolveOutline();
assert.equal(centered.ring, ring);
assert.ok(
Math.abs(centered.lon) > 179.999 &&
centered.lon >= -180 &&
centered.lon < 180,
);
});
test('a coarse bundled admin-1 city waits for a finer outline', async () => {
const overParis = viewerAt(48.8566, 2.3522, 40_000);
// The outer-ring vertex mean is in the hole; the resolved anchor is in
// the shell. Completion must not move that valid point into the hole.
const ring = square(48.8566, 2.3822, 0.04);
const hole = square(48.8566, 2.3822, 0.02);
const polygons = [[ring, hole]];
const rungs = rungsAnswering({ ring, polygons, kind: 'area', heightM: null });
const fine = harness(PARIS_PLACE, { outlineRungs: rungs });
const resolved = await fine.resolve('Paris', overParis);
assert.equal(
resolved.source,
'geocode',
'the 11-vertex admin-1 is not drawn at once',
);
const outline = await resolved.resolveOutline();
assert.deepEqual(outline.ring, ring, 'the finer outline wins');
assert.deepEqual(outline.polygons, polygons);
assert.equal(outline.lat, PARIS_PLACE.lat);
assert.equal(outline.lon, PARIS_PLACE.lng);
assert.ok(polygonsContain(outline.polygons, outline.lat, outline.lon));
assert.deepEqual(rungs.asked, [{ scope: 'city', target: 'Paris' }]);
const inline = await fine.resolve('Paris', overParis, {
deferFootprint: false,
});
assert.deepEqual(inline.polygons, polygons);
assert.equal(inline.lat, outline.lat);
assert.equal(inline.lon, outline.lon);
// Nothing finer (Nominatim off, busy or capped): the bundled shape, not a point.
const coarse = harness(PARIS_PLACE, { outlineRungs: rungsAnswering(null) });
const fallback = await (
await coarse.resolve('Paris', overParis)
).resolveOutline();
assert.equal(fallback.adminArea, 'Paris');
assert.ok(fallback.ring.length < 32);
// No geocoder answer at all: the bundled shape at once.
const offline = harness(null, { outlineRungs: rungsAnswering(null) });
const direct = await offline.resolve('Paris', overParis);
assert.equal(direct.source, 'bundled');
assert.equal(direct.label, 'Paris');
const overDallas = viewerAt(32.7767, -96.797, 20_000);
assert.equal(
await offline.resolve('Paris', overDallas),
null,
'a missing geocode cannot select the distant coarse homonym',
);
assert.equal(
await coarse.resolve('Paris', overDallas),
null,
'a rejected foreign geocode cannot restore the same distant polygon',
);
const outsideParis = harness(
{ ...PARIS_PLACE, lat: 48.9, lng: 2.6 },
{ outlineRungs: rungsAnswering(null) },
);
const anchored = await outsideParis.resolve('Paris', overParis);
assert.equal(anchored.source, 'geocode');
assert.equal(
await anchored.resolveOutline(),
undefined,
'a nearby anchor outside Paris preserves the transient base, not its coarse polygon',
);
});
test('detailed admin-1 units and admin asks keep the bundled shape', async () => {
const { resolve } = harness(null, { outlineRungs: rungsAnswering(null) });
for (const [name, lat, lon] of [
['Berlin', 52.52, 13.4],
['Bavaria', 48.9, 11.4],
['Texas', 31, -99],
]) {
const resolved = await resolve(name, viewerAt(lat, lon, 400_000));
assert.equal(resolved?.source, 'bundled', name);
}
// Countries are never coarse cities, however few vertices.
const singapore = await resolve('Singapore', viewerAt(1.35, 103.82, 60_000));
assert.equal(singapore?.source, 'bundled');
// An explicit admin ask takes the admin unit as it is.
const dc = await resolve('Paris', viewerAt(48.8566, 2.3522, 40_000), {
entityKind: 'state',
});
assert.equal(dc?.source, 'bundled');
});
test('a campus ask reaches the campus, not the Census place of its town', async () => {
const stanfordTown = {
lat: 37.4241,
lng: -122.1661,
label: 'Stanford, CA, USA',
name: 'Stanford',
types: ['locality', 'political'],
};
const campus = square(37.4275, -122.1697, 0.01);
for (const ask of ['Stanford University', 'Stanford campus']) {
const rungs = rungsAnswering({ ring: campus, kind: 'area', heightM: null });
const { resolve } = harness(stanfordTown, { outlineRungs: rungs });
const resolved = await resolve(ask, viewerAt(37.43, -122.17, 8000));
assert.notEqual(resolved?.source, 'bundled', ask);
const outline = await resolved.resolveOutline();
assert.deepEqual(outline.ring, campus, ask);
assert.equal(
outline.outlineCredit,
null,
'an injected outline is not inferred OSM',
);
assert.equal(
rungs.asked[0].scope,
'compound',
`${ask}: past the Census rung`,
);
}
// "Stanford" said to be a compound is the campus too.
const kindRungs = rungsAnswering({
ring: campus,
kind: 'area',
heightM: null,
});
const byKind = harness(stanfordTown, { outlineRungs: kindRungs });
const compound = await (
await byKind.resolve('Stanford', viewerAt(37.43, -122.17, 8000), {
entityKind: 'compound',
})
).resolveOutline();
assert.deepEqual(compound.ring, campus);
assert.equal(kindRungs.asked[0].scope, 'compound');
const labelRungs = rungsAnswering({
ring: campus,
kind: 'area',
heightM: null,
});
const byLabel = harness(stanfordTown, { outlineRungs: labelRungs });
const labelled = await byLabel.resolve(
'Stanford',
viewerAt(37.43, -122.17, 8000),
{ labelHint: 'Stanford campus' },
);
assert.notEqual(labelled.source, 'bundled');
assert.deepEqual((await labelled.resolveOutline()).ring, campus);
assert.equal(labelRungs.asked[0].scope, 'compound');
// "Stanford" alone is the town (the Stanford CDP), and a town named after a
// college stays a town.
const { resolve } = harness(stanfordTown, {
outlineRungs: rungsAnswering(null),
});
const town = await (
await resolve('Stanford', viewerAt(39, -100, 5_000_000))
).resolveOutline();
assert.equal(town.adminArea, 'Stanford');
const station = harness(
{
lat: 30.628,
lng: -96.3344,
label: 'College Station, TX, USA',
name: 'College Station',
types: ['locality', 'political'],
},
{ outlineRungs: rungsAnswering(null) },
);
const collegeStation = await (
await station.resolve('College Station', viewerAt(39, -100, 5_000_000))
).resolveOutline();
assert.equal(collegeStation.adminArea, 'College Station');
});
test('a grounds ask never takes the state the geocoder typed', async () => {
const texas = {
lat: 31.0,
lng: -99.0,
label: 'Texas, USA',
name: 'Texas',
types: ['administrative_area_level_1', 'political'],
};
for (const [ask, extra] of [
['the Texas Capitol grounds', {}],
['Texas Capitol', { entityKind: 'compound' }],
// The grounds wording is only in the label.
['Texas Capitol', { labelHint: 'Capitol grounds' }],
]) {
const rungs = rungsAnswering(null);
const { resolve } = harness(texas, { outlineRungs: rungs });
const resolved = await resolve(ask, viewerAt(31, -99, 3_000_000), extra);
assert.notEqual(resolved?.source, 'bundled', ask);
const outline = await resolved.resolveOutline();
assert.notEqual(outline?.adminArea, 'Texas', ask);
assert.equal(rungs.asked[0].scope, 'compound', ask);
}
});
test('configured OSM geometry retains its origin through inline, deferred and cached base results', async () => {
const ring = square(31, -99, 0.001);
let calls = 0;
const resolver = createAnnotationResolver({
boundarySource: {
query: async () => {
calls++;
return [
{
type: 'way',
id: 1,
tags: { name: 'Library', building: 'yes' },
geometry: ring.map(([lon, lat]) => ({ lon, lat })),
},
];
},
},
outlineRungs: { resolve: (_ctx, { base }) => base() },
});
for (const deferFootprint of [false, true]) {
const result = await resolver.resolveAnnotationTarget({
viewer: viewerAt(31, -99, 3000),
latitude: 31,
longitude: -99,
target: 'Library',
entityKind: 'building',
footprint: true,
deferFootprint,
});
const outline = deferFootprint ? await result.resolveOutline() : result;
assert.equal(outline.footprintKind, 'building');
assert.equal(outline.outlineCredit, 'osm');
assert.deepEqual(outline.ring, ring);
}
assert.equal(calls, 1, 'cached geometry retains its original provider');
});
+418 -21
View File
@@ -11,6 +11,7 @@ import {
approximateAreaM2,
approximateDistanceM,
pointInPolygon,
nearestPointOnLines,
} from '../sources/featureGeometry.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
@@ -19,18 +20,84 @@ import {
lookupNaturalRegionOutline,
findNaturalRegion,
} from '../data/naturalEarthRegions.js';
import { findAdminArea, findAdminAreaAt } from '../data/adminBoundaries.js';
import {
findAdminArea,
findAdminAreaAt,
polygonsContain as polygonsContainPoint,
} from '../data/adminBoundaries.js';
import {
findNeighborhoodArea,
findNeighborhoodAreaAt,
findPlaceArea,
findPlaceAreaAt,
NEIGHBORHOOD_NEAR_KM,
normalizePlaceName,
PLACE_NEAR_KM,
} from '../data/placeBoundaries.js';
import {
registerDynamicCredit,
NATURAL_EARTH_CREDIT,
US_CENSUS_CREDIT,
OSM_CREDIT,
OPENMAPTILES_CREDIT,
WOF_CREDIT,
} from '../data/dataCredits.js';
import { createOutlineRungs } from './outlineRungs.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/** Prepare only local dateline outlines for planar admission and centering. */
function localOutlineFrame(fp) {
const original = { ring: fp?.ring, polygons: fp?.polygons, reference: null };
if (!Array.isArray(fp?.ring) || fp.ring.length < 3) return original;
let west = Infinity;
let east = -Infinity;
for (const [lon] of fp.ring) {
west = Math.min(west, lon);
east = Math.max(east, lon);
}
if (east - west <= 180) return original;
const reference = fp.ring[0][0];
west = Infinity;
east = -Infinity;
const shift = (ring) =>
ring.map(([lon, lat]) => {
const x = lon - 360 * Math.round((lon - reference) / 360);
west = Math.min(west, x);
east = Math.max(east, x);
return [x, lat];
});
const ring = shift(fp.ring);
const polygons = Array.isArray(fp.polygons)
? fp.polygons.map((part) => part.map(shift))
: fp.polygons;
// Shapes still spanning half the globe retain their existing behavior. Source coordinates
// are never rewritten, including holes and unrelated multipart geometry.
if (!Number.isFinite(east - west) || east - west >= 180) return original;
return { ring, polygons, reference };
}
/** Ms the analyst region lookup waits on geocode + admin boundary. */
const REGION_FALLBACK_BUDGET_MS = 3_000;
/** How far a street may run from its anchor when the ribbon holds neither. */
const STREET_SNAP_MAX_M = 100;
/**
* Names that end like a landmark rather than a district: "Central Park",
* "Lake Merritt"-style suffixes are checked on the last word only, so "Park
* Slope" and "Notting Hill" stay districts.
*/
const LANDMARK_NAME =
/\b(?:park|gardens?|square|plaza|lake|river|bay|beach|pier|harbou?r|airport|station|stadium|arena|cemetery|zoo|university|college|campus|palace|castle|cathedral|church|temple|mosque|bridge|market|mall|cent(?:er|re)|tower|museum|reservoir|forest|woods?|heath|common|green)$/i;
/** The name part of an ask, before any comma qualifier. */
function stripQualifiers(query) {
return String(query || '')
.split(',')[0]
.trim();
}
/** Entity facts that rule out a state/county reading of the ask. */
const NON_ADMIN_ENTITY_KINDS = new Set([
'building',
@@ -47,8 +114,12 @@ export function createAnnotationResolver({
boundarySource,
signal: lifetime,
}),
// Overpass-free rungs (OpenFreeMap tiles, guarded Nominatim) around the ladder.
outlineRungs = createOutlineRungs(),
} = {}) {
requireFeatureSource(featureSource);
const featureOutlineCredit =
featureSource.outlineCredit === 'osm' ? 'osm' : null;
lifetime?.throwIfAborted();
/**
@@ -164,6 +235,21 @@ export function createAnnotationResolver({
// locality suffixes; the types classify the entity (point-like vs area-like).
let placesPrimary = null;
let placeTypes = [];
// A coarse bundled admin-1 shape that carries a city's name (Natural
// Earth's 11-vertex "Paris"): kept as the fallback while the finer rungs
// (Census, Nominatim) try first.
let coarseCityShape = null;
let bundledCenter = null;
const coarseCityHolds = (point) =>
coarseCityShape &&
point &&
polygonsContainPoint(coarseCityShape.polygons, point.lat, point.lon);
const compoundAsk =
entityKind === 'compound' ||
isCampusAsk(target) ||
isGroundsLikeQuery(target) ||
isGroundsLikeQuery(labelHint);
const askNamesAdmin = Boolean(adminScopeFromAsk(target, entityKind));
// Instrumentation (logged once per target at the end): which sources were tried and what they returned.
const trace = {
query: String(target || '').trim(),
@@ -181,15 +267,32 @@ export function createAnnotationResolver({
footprint &&
(!Number.isFinite(lat) || !Number.isFinite(lon)) &&
trace.query &&
!compoundAsk &&
!NON_ADMIN_ENTITY_KINDS.has(entityKind)
) {
const center =
pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
bundledCenter = center;
const admin = await findAdminArea(trace.query, { near: center }).catch(
() => null,
);
signal?.throwIfAborted();
if (admin) return bundledAdminTarget(viewer, admin, trace.query);
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape = admin;
else if (admin)
return bundledAdminTarget(viewer, admin, trace.query, signal);
// Bundled US places and neighborhoods named at or near the view. A
// campus, grounds or monument ask ("Stanford" as a compound, "the
// Capitol grounds") wants that feature, not a district of its name,
// and "the area around X" is drawn by the ladder below.
if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) {
const area = await bundledAreaNear(trace.query, center, {
entityKind,
viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null,
});
signal?.throwIfAborted();
if (area) return bundledAdminTarget(viewer, area, trace.query, signal);
}
}
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
@@ -317,7 +420,10 @@ export function createAnnotationResolver({
}
}
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon))
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
: null;
// Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g.
// several Texas Capitol monuments landed blocks-to-miles apart, looking "all over the map"). When
@@ -382,7 +488,9 @@ export function createAnnotationResolver({
`osmSnap=${trace.osmSnap} → FINAL source=rejected (proximity gate, ${drift.driftKm.toFixed(0)}km > ${drift.limitKm.toFixed(0)}km)`,
);
}
return null;
return coarseCityHolds(bundledCenter)
? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal)
: null;
}
}
@@ -402,7 +510,18 @@ export function createAnnotationResolver({
// an admin boundary for a city/state, the enclosing compound for a mall/campus, a single
// building for a premise. The voice model's entityKind (an entity FACT) refines an
// unresolved 'auto' scope only — real geocode types always win.
const baseScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
// A campus or grounds ask ("Stanford University", "Stanford campus",
// "the Capitol grounds", in the target or its label) or a compound by
// entity fact wants that compound even when the geocoder types the town
// or the state: never a Census place, neighborhood or admin boundary.
const typedScope = refineScope(scopeFromTypes(geocodeTypes), entityKind);
const baseScope =
compoundAsk &&
['country', 'state', 'county', 'city', 'neighborhood'].includes(
typedScope,
)
? 'compound'
: typedScope;
// Point-like targets (monuments/statues/memorials/…) resolve POINT-FIRST: only an
// (almost) exactly-named, monument-scale polygon may replace the point; a nearby polygon
// sharing locality words must not (docs/field-test-rootcause-2026-06-30.md §1).
@@ -431,7 +550,7 @@ export function createAnnotationResolver({
* The inline path awaits it right here; progressive callers (deferFootprint) invoke it
* AFTER the anchor mark is drawn and upgrade the mark in place.
*/
const resolveOutline = async () => {
const resolveBaseOutline = async () => {
signal?.throwIfAborted();
let scope = baseScope;
const isAdmin =
@@ -461,6 +580,7 @@ export function createAnnotationResolver({
signal?.throwIfAborted();
if (ne) {
registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT);
traceBundledOutline(ne.name, 'natural-earth');
const neCentroid = ringCentroid(ne.ring);
return {
ring: ne.ring,
@@ -516,7 +636,20 @@ export function createAnnotationResolver({
lon,
scope,
).catch(() => null);
if (admin) return bundledAdminOutline(viewer, admin);
signal?.throwIfAborted();
if (admin && !askNamesAdmin && isCoarseCityShape(admin))
coarseCityShape ||= admin;
else if (admin) return bundledAdminOutline(viewer, admin, signal);
}
// A geocoded US city, town or CDP: the Census place that holds it.
if (scope === 'city') {
const place = await findPlaceAreaAt(
[target, matchName],
lat,
lon,
).catch(() => null);
signal?.throwIfAborted();
if (place) return bundledAdminOutline(viewer, place, signal);
}
// Pure admin: only an admin boundary is correct — never fall back to a
// building/landuse (a city is never a single building).
@@ -527,7 +660,21 @@ export function createAnnotationResolver({
// resolve here instantly to a REAL boundary, sidestepping the slow/flaky live-Overpass
// path that times out and falls back to points (see docs/field-test-2-analysis.md).
const ext = await lookupNeighborhoodRing(lat, lon, matchName);
if (ext) fp = { ring: ext.ring, kind: 'area', heightM: null };
signal?.throwIfAborted();
if (ext) {
traceBundledOutline(ext.name || matchName, 'datasf');
fp = { ring: ext.ring, kind: 'area', heightM: null };
}
// THEN a bundled Who's On First neighborhood carrying the name at the point.
if (!fp) {
const wof = await findNeighborhoodAreaAt(
[target, matchName],
lat,
lon,
).catch(() => null);
signal?.throwIfAborted();
if (wof) return bundledAdminOutline(viewer, wof, signal);
}
// Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each
// returns a footprint, null (definitively no polygon), or undefined (transient
// upstream failure). Synthesize a blob (the "Mission" problem, research §3b) ONLY
@@ -653,6 +800,12 @@ export function createAnnotationResolver({
}
}
}
return finishOutline(fp, scope);
};
/** Validate a footprint for its scope and re-centre it (resolveOutline's patch). */
const finishOutline = (fp, scope) => {
const frame = localOutlineFrame(fp);
// Scope sanity: reject a real footprint whose area is wildly wrong for the asked
// scope (a building/compound/neighborhood intent must never draw a state-sized
// blob). Synthesized discs are deliberately sized and exempt.
@@ -661,15 +814,44 @@ export function createAnnotationResolver({
!fp.synthesized &&
Array.isArray(fp.ring) &&
fp.ring.length >= 3 &&
exceedsScopeArea(fp, scope)
exceedsScopeArea(frame, scope)
) {
fp = null;
}
if (isUnavailableCapability(fp) || isRateLimitedOutcome(fp)) return fp;
if (fp === undefined) return undefined; // TRANSIENT — a backoff retry may still find it
if (!fp || !Array.isArray(fp.ring) || fp.ring.length < 3) return null;
const centroid = ringCentroid(fp.ring);
let centroid = ringCentroid(frame.ring);
if (!centroid) return null;
// An outer-ring mean can lie in a hole or outside a concave part. Keep
// a filled original anchor or decline, without changing ordinary centering
// or inventing a new interior point.
if (
Array.isArray(frame.polygons) &&
!polygonsContainPoint(frame.polygons, centroid.lat, centroid.lon)
) {
const anchorLon =
frame.reference === null
? lon
: lon - 360 * Math.round((lon - frame.reference) / 360);
if (polygonsContainPoint(frame.polygons, lat, anchorLon)) {
centroid = { lat, lon };
} else if (Array.isArray(fp.lines)) {
// A bent street's ribbon rarely holds its ring mean, and a geocoded
// anchor can sit beside the centreline: centre on the street itself,
// only when it runs beside the anchor.
const onStreet = nearestPointOnLines(fp.lines, lat, lon);
if (!onStreet || onStreet.distanceM > STREET_SNAP_MAX_M) return null;
centroid = { lat: onStreet.lat, lon: onStreet.lon };
} else {
return null;
}
}
if (
frame.reference !== null &&
(centroid.lon < -180 || centroid.lon >= 180)
)
centroid.lon = ((((centroid.lon + 180) % 360) + 360) % 360) - 180;
// Same drift bound as the anchor gate: a polygon whose centroid would drag a good
// anchor out of plausibility is the wrong feature — drop it, keep the point.
if (
@@ -683,17 +865,57 @@ export function createAnnotationResolver({
footprintKind: fp.kind,
buildingHeight: fp.heightM,
synthesized: Boolean(fp.synthesized),
outlineCredit: fp.outlineCredit || null,
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
lat: centroid.lat,
lon: centroid.lon,
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
};
};
// Streets and pointed-at buildings from tiles, then this ladder, then the
// guarded Nominatim outline (see outlineRungs.js).
const outlineContext = {
scope: baseScope,
target,
matchName,
lat,
lon,
fromName: fromGeocode || source === 'places' || source === 'osm-local',
pointLike,
groundsLike,
around: intent === 'around_the_thing',
view: viewportProximity(viewer),
signal,
credit: (kind) =>
registerDynamicCredit(
viewer,
kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT,
),
};
const resolveOutline = async () => {
const result = await outlineRungs.resolve(outlineContext, {
base: resolveBaseOutline,
finish: finishOutline,
});
signal?.throwIfAborted();
// Nothing finer answered (no polygon, Nominatim off, busy or capped):
// the coarse bundled shape beats a point.
if (
coarseCityHolds({ lat, lon }) &&
!(result?.ring && !result.synthesized)
)
return bundledAdminOutline(viewer, coarseCityShape, signal);
return result;
};
let ring = null;
let footprintKind = null; // 'building' | 'area'
let buildingHeight = null; // meters, only for buildings
let outlineUnavailable = false;
let synthesized = false; // true = buffered/approximate area, render dashed/feathered
let polygons = null; // every part of a multi-part outline, when known
let outlineCredit = null;
if (footprint && !deferFootprint) {
const fp = await resolveOutline();
outlineUnavailable = isUnavailableCapability(fp);
@@ -702,6 +924,8 @@ export function createAnnotationResolver({
footprintKind = fp.footprintKind;
buildingHeight = fp.buildingHeight;
synthesized = fp.synthesized;
polygons = fp.polygons || null;
outlineCredit = fp.outlineCredit || null;
// Re-center the anchor on the resolved footprint centroid.
lat = fp.lat;
lon = fp.lon;
@@ -729,6 +953,8 @@ export function createAnnotationResolver({
source,
synthesized,
outlineUnavailable,
outlineCredit,
...(polygons ? { polygons } : {}),
viewport: placeViewport,
...(footprint && deferFootprint ? { resolveOutline } : {}),
};
@@ -768,13 +994,110 @@ export function createAnnotationResolver({
const GROUNDS_RADIUS_M = 300; // "X grounds/compound/campus" loose disc when OSM has no polygon
const GROUNDS_RADIUS_MIN_M = 150; // viewport-derived grounds disc is clamped to this band so a tiny
const GROUNDS_RADIUS_MAX_M = 1200; // place can't shrink to a dot, nor a city-wide viewport balloon
const COARSE_CITY_MAX_KM2 = 5_000; // city-sized admin-1 units (see isCoarseCityShape)
const COARSE_CITY_MAX_VERTICES = 32;
/** Credit the pack a bundled boundary came from. */
function creditAdminSource(viewer, admin) {
registerDynamicCredit(
viewer,
admin.source === 'us-census' ? US_CENSUS_CREDIT : NATURAL_EARTH_CREDIT,
);
const credit = {
'natural-earth': NATURAL_EARTH_CREDIT,
'us-census': US_CENSUS_CREDIT,
wof: WOF_CREDIT,
}[admin.source];
if (credit) registerDynamicCredit(viewer, credit);
}
/**
* The bundled US place or neighborhood an ask names at or near the view,
* from Census places, the San Francisco DataSF pack (first in SF), then
* Who's On First. How far "near" reaches follows the view (half its
* radius); a state-qualified place ("Springfield, Illinois") needs no
* view. A district ask takes a neighborhood first and a place only when
* the view is inside it ("Richmond" over SF is not Richmond, CA). Without
* an entity kind the candidate that holds the view wins, else the nearer.
* A name that ends like a landmark ("Central Park", "Lake Merritt") is
* left to the landmark ladder unless the ask is a district.
*/
async function bundledAreaNear(
query,
center,
{ entityKind = null, viewRadiusKm = null } = {},
) {
const district = entityKind === 'district';
if (!district && LANDMARK_NAME.test(stripQualifiers(query))) return null;
const reach = Number.isFinite(viewRadiusKm) ? viewRadiusKm / 2 : null;
const clamp = (km, lo, hi) =>
km === null ? hi : Math.min(hi, Math.max(lo, km));
const placeReach = district ? 0 : clamp(reach, 3, PLACE_NEAR_KM);
const place = await findPlaceArea(query, {
near: center,
nearKm: placeReach,
}).catch(() => null);
const holds = (area) =>
Boolean(center) &&
polygonsContainPoint(area.polygons, center.lat, center.lon);
// A district ask only takes a place the view is inside (or one a state
// qualifier named outright).
const placeOk =
place && (!district || holds(place) || /,/.test(query)) ? place : null;
if (!center) return placeOk;
let hood = null;
const sf = await lookupNeighborhoodRing(center.lat, center.lon, query, {
exact: true,
}).catch(() => null);
if (sf) hood = sfNeighborhoodArea(sf);
else
hood = await findNeighborhoodArea(query, {
near: center,
nearKm: clamp(reach, 1, NEIGHBORHOOD_NEAR_KM),
}).catch(() => null);
// Outside a district ask, an alternate name unrelated to the name is too
// weak: Se in Sao Paulo lists "Sao Paulo" among its names, and "outline
// Sao Paulo" means the city ("Richmond" for Richmond District still counts).
// Whole words: an alias hit on "York" does not keep "Yorkville".
if (
!district &&
hood?.byAlias &&
!` ${normalizePlaceName(hood.name)} `.includes(
` ${normalizePlaceName(stripQualifiers(query))} `,
)
)
hood = null;
if (!hood || !placeOk) return district ? hood || placeOk : placeOk || hood;
if (district) return hood;
const hoodHolds = holds(hood);
const placeHolds = holds(placeOk);
if (hoodHolds !== placeHolds) return hoodHolds ? hood : placeOk;
const distance = (area) =>
approximateDistanceM(
center.lat,
center.lon,
area.label.lat,
area.label.lon,
);
return distance(hood) <= distance(placeOk) ? hood : placeOk;
}
/** An SF DataSF ring in the bundled-area shape `bundledAdminTarget` takes. */
function sfNeighborhoodArea({ ring, name }) {
const lons = ring.map((p) => p[0]);
const lats = ring.map((p) => p[1]);
return {
kind: 'neighborhood',
name,
region: null,
source: 'datasf',
polygons: [[ring]],
ring,
bbox: [
Math.min(...lons),
Math.min(...lats),
Math.max(...lons),
Math.max(...lats),
],
label: ringCentroid(ring),
candidates: 1,
};
}
/**
@@ -784,8 +1107,10 @@ export function createAnnotationResolver({
* draw them. Like the Natural Earth rung, it bypasses the scope caps and the
* centroid drift bound: the unit IS the asked scope.
*/
function bundledAdminOutline(viewer, admin) {
function bundledAdminOutline(viewer, admin, signal) {
signal?.throwIfAborted();
creditAdminSource(viewer, admin);
traceBundledOutline(admin.name, admin.source, admin.polygons?.length);
const { lat, lon } = admin.label;
return {
ring: closeRing([...admin.ring]), // copy: closeRing mutates, the pack is shared
@@ -800,9 +1125,34 @@ export function createAnnotationResolver({
};
}
/**
* Whether a bundled unit is too coarse to stand for a city of its name: a
* Natural Earth admin-1 at city size (up to 5,000 km²) drawn with fewer
* than 32 vertices. Paris (11), Buenos Aires (11), Washington DC (9),
* Vienna and Prague (23) and Seoul (28) are; Berlin (48), Hamburg (43),
* Bremen (39) and every larger state are not. Countries never are.
*/
function isCoarseCityShape(admin) {
if (admin?.source !== 'natural-earth' || admin.kind !== 'state')
return false;
if (!(admin.areaKm2 <= COARSE_CITY_MAX_KM2)) return false;
const vertices = (admin.polygons || []).reduce(
(n, poly) => n + poly.reduce((m, ring) => m + ring.length, 0),
0,
);
return vertices < COARSE_CITY_MAX_VERTICES;
}
/** One trace line per outline a bundled pack answered (same shape as outlineRungs). */
function traceBundledOutline(name, source, parts = 1) {
console.log(
`[Outline] "${name}": bundled → ${source} (${parts || 1} part(s))`,
);
}
/** A complete resolved target for a bundled administrative unit (no outline pending). */
function bundledAdminTarget(viewer, admin, query) {
const outline = bundledAdminOutline(viewer, admin);
function bundledAdminTarget(viewer, admin, query, signal) {
const outline = bundledAdminOutline(viewer, admin, signal);
const [west, south, east, north] = admin.bbox;
console.log(
`[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` +
@@ -1229,7 +1579,12 @@ export function createAnnotationResolver({
? 40
: 120;
ring = simplifyRing(ring, tolM);
const fp = { ring, kind: 'area', heightM: null };
const fp = {
ring,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
// Scope sanity BEFORE caching: a city-suffixed neighborhood name ("Downtown San
// Francisco") can match the CITY admin. Caching an oversized ring would poison the
@@ -1313,7 +1668,14 @@ export function createAnnotationResolver({
bestRing = closeRing(coords.map((p) => [p.lon, p.lat]));
}
}
return bestRing ? { ring: bestRing, kind: 'area', heightM: null } : null;
return bestRing
? {
ring: bestRing,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
}
: null;
}
/**
@@ -1356,7 +1718,12 @@ export function createAnnotationResolver({
}
}
if (bestRing) {
const fp = { ring: bestRing, kind: 'area', heightM: null };
const fp = {
ring: bestRing,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
cacheWrite(footprintCache, cacheKey, fp);
return fp;
}
@@ -1387,6 +1754,7 @@ export function createAnnotationResolver({
ring: bufferCorridor(line, 11),
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
cacheWrite(footprintCache, cacheKey, fp);
return fp;
@@ -1525,7 +1893,12 @@ export function createAnnotationResolver({
areaM2 < bestArea * 0.999 ||
(areaM2 <= bestArea * 1.05 && nameMatch && !bestNameMatch); // near-tie: name-match wins
if (better) {
best = { ring: ringLonLat, kind: 'area', heightM: null };
best = {
ring: ringLonLat,
kind: 'area',
heightM: null,
outlineCredit: featureOutlineCredit,
};
bestArea = areaM2;
bestNameMatch = nameMatch;
}
@@ -1551,6 +1924,19 @@ export function createAnnotationResolver({
/** A target that reads like an enclosing GROUNDS / COMPOUND / CAMPUS (e.g. "Texas Capitol grounds")
* rather than a single building — used to synthesize a loose-footprint disc when OSM has no real
* polygon, instead of failing outright. */
/**
* A university, college or campus named as such: "Stanford University",
* "Boston College", "University of Texas", "Stanford campus". Towns named
* after one ("College Station", "University Park") are not.
*/
function isCampusAsk(query) {
const text = stripQualifiers(String(query || '')).trim();
return (
/\b(?:university|college|campus)$/i.test(text) ||
/^(?:the\s+)?(?:university|college)\s+of\b/i.test(text)
);
}
function isGroundsLikeQuery(query) {
return /\b(grounds|compound|campus|complex|quad|plaza)\b/i.test(
String(query || ''),
@@ -1774,6 +2160,7 @@ export function createAnnotationResolver({
best = {
ring: closeRing(coords.map((p) => [p.lon, p.lat])),
kind: isBuilding ? 'building' : 'area',
outlineCredit: featureOutlineCredit,
heightM: isBuilding
? (element.heightM ??
Math.max(10, Math.min(70, Math.sqrt(Math.max(1, areaM2)) * 0.6)))
@@ -2097,6 +2484,16 @@ export function createAnnotationResolver({
if (admin)
return { name: q, ring: [...admin.ring], source: 'bundled-admin' };
}
if (scope === 'city') {
const place = await findPlaceAreaAt(
[q, geo.primaryName],
geo.lat,
geo.lon,
).catch(() => null);
signal?.throwIfAborted();
if (place)
return { name: q, ring: [...place.ring], source: 'bundled-admin' };
}
const fp = await fetchAdminArea(geo.lat, geo.lon, q, scope, signal).catch(
() => null,
);
+105 -14
View File
@@ -174,9 +174,10 @@ export function createScreenAnnotationRenderer(
if (anno.type === 'area' && anno.ring && anno.ring.length >= 3) {
// Synthesized (buffered/approximate) areas render DASHED + fainter, with no
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
parts.poly = svgPart('polygon', {
parts.poly = svgPart('path', {
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
fill: c,
'fill-rule': 'evenodd',
stroke: c,
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
'stroke-width': '2.5',
@@ -358,7 +359,7 @@ export function createScreenAnnotationRenderer(
// Area-type updates route through the hybrid proxy only.
}
function project(lon, lat) {
function project(lon, lat, allowOffscreen = false) {
// Anchor at the real surface height (cached + validated), so marks sit on
// the ground/building instead of at sea level. The settle-once cache keeps
// it stable (no per-frame jitter that earlier broke nearby projections).
@@ -382,7 +383,11 @@ export function createScreenAnnotationRenderer(
// Reject absurd off-screen projections (anchor not in view).
const w = scene.canvas.clientWidth || scene.canvas.width;
const h = scene.canvas.clientHeight || scene.canvas.height;
if (win.x < -w || win.x > 2 * w || win.y < -h || win.y > 2 * h) return null;
if (
!allowOffscreen &&
(win.x < -w || win.x > 2 * w || win.y < -h || win.y > 2 * h)
)
return null;
return { x: win.x, y: win.y };
}
@@ -483,7 +488,9 @@ export function createScreenAnnotationRenderer(
for (const rec of records.values()) {
const { anno, group, parts } = rec;
if (rec.hidden) {
// A mark hidden from Draw's list is not projected, so it neither shows
// nor pushes a visible callout aside in the placement below.
if (rec.hidden || anno.hidden) {
group.display = 'none';
continue;
}
@@ -496,20 +503,40 @@ export function createScreenAnnotationRenderer(
);
if (anno.type === 'area' && parts.poly) {
const pts = [];
const paths = [];
let cx = 0;
let cy = 0;
let n = 0;
for (const [lon, lat] of anno.ring) {
const p = project(lon, lat);
if (!p) continue;
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
cx += p.x;
cy += p.y;
n++;
const polygons = anno.polygons?.length ? anno.polygons : [[anno.ring]];
for (const polygon of polygons) {
const projectedRings = polygon.map((ring) =>
ring.map(([lon, lat]) => project(lon, lat, polygon.length > 1)),
);
// Keep the historical visible-outer projection, but never fill over
// an unprojectable hole or hide another independently visible part.
const outer = projectedRings[0].filter(Boolean);
const holes = projectedRings.slice(1);
if (
outer.length < 3 ||
(holes.length && projectedRings[0].some((p) => !p)) ||
holes.some((ring) => ring.length < 3 || ring.some((p) => !p))
)
continue;
for (const projected of [outer, ...holes]) {
paths.push(
`M ${projected.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' L ')} Z`,
);
}
if (!n) {
for (const p of outer) {
cx += p.x;
cy += p.y;
n++;
}
}
}
if (pts.length >= 3) {
parts.poly.set.points = pts.join(' ');
if (paths.length) {
parts.poly.set.d = paths.join(' ');
group.display = '';
if (parts.label)
positionCallout(parts.label, cx / n, cy / n - 6, true);
@@ -620,6 +647,7 @@ export function createScreenAnnotationRenderer(
}
}
if (placed.length > 1) decollideCallouts(placed);
placeCalloutsAboveVoicePreview(placed, w, h);
publish();
// Tracked-entity z-order — measured once the view shows this frame's
@@ -931,6 +959,69 @@ function decollideCallouts(callouts) {
}
}
/** Keep an overlapping callout above the actual open Voice Preview, when it fits. */
function placeCalloutsAboveVoicePreview(callouts, width, height) {
if (!callouts.length) return;
const panel = document.getElementById('gev-voice-panel');
const svg = document.querySelector('.gev-screen-whiteboard-svg');
if (
!panel ||
!svg ||
panel.hidden ||
panel.dataset.view !== 'preview' ||
panel.checkVisibility?.({
opacityProperty: true,
visibilityProperty: true,
}) === false
)
return;
const rect = panel.getBoundingClientRect(),
bounds = svg.getBoundingClientRect();
if (
rect.width <= 0 ||
rect.height <= 0 ||
bounds.width <= 0 ||
bounds.height <= 0
)
return;
// The overlay uses preserveAspectRatio="none": each axis scales independently.
const sx = width / bounds.width,
sy = height / bounds.height;
const blocked = {
_x: (rect.left - bounds.left) * sx,
_y: (rect.top - bounds.top) * sy,
width: rect.width * sx,
height: rect.height * sy,
};
const intersects = (a, b) =>
a._x < b._x + b.width &&
a._x + a.width > b._x &&
a._y < b._y + b.height &&
a._y + a.height > b._y;
for (const callout of callouts) {
if (!intersects(callout, blocked) || callout.width > width - 16) continue;
const candidate = {
_x: Math.max(8, Math.min(callout._x, width - callout.width - 8)),
_y: blocked._y - callout.height - 8,
width: callout.width,
height: callout.height,
};
if (
candidate._y < 8 ||
candidate._y + candidate.height > height - 8 ||
callouts.some(
(other) => other !== callout && intersects(candidate, other),
)
)
continue;
positionCallout(callout, candidate._x, candidate._y, false);
if (callout._leader) {
callout._leader.set.x2 = candidate._x.toFixed(1);
callout._leader.set.y2 = (candidate._y + candidate.height).toFixed(1);
}
}
}
/** Record the arrowhead marker for a color, unless one is already defined. */
function ensureArrowMarker(markers, key, c) {
if (!markers.has(key)) markers.set(key, c);
@@ -390,3 +390,188 @@ test("an earlier renderer leaves a later renderer's board alone", async (t) => {
assert.equal(groups().length, 1);
assert.equal(groups()[0].querySelector('text').textContent, 'Kept');
});
test('a multipart area with holes draws every visible part and keeps holes open', async (t) => {
const { store, svg } = board(t);
// A fixed projection: 0.001 degrees spans 100 px.
projectTo(t, (_scene, world) => {
const c = Cesium.Cartographic.fromCartesian(world);
return {
x: 100 + Cesium.Math.toDegrees(c.longitude) * 1e5,
y: 100 + Cesium.Math.toDegrees(c.latitude) * 1e5,
};
});
const camera = cameraOver(0, 0);
const renderer = createScreenAnnotationRenderer(
{ scene: sceneFor(camera), camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const outer = [[0, 0], [0.001, 0], [0.001, 0.001], [0, 0.001], [0, 0]];
const hole = [[0.0002, 0.0002], [0.0004, 0.0002], [0.0004, 0.0004], [0.0002, 0.0004], [0.0002, 0.0002]];
const island = outer.map(([lon, lat]) => [lon + 0.002, lat]);
const anno = {
id: 'anno-multipart-screen',
label: 'Multipart',
type: 'area',
color: 'primary',
alpha: 1,
anchor: { lon: 0, lat: 0, height: 0 },
ring: outer,
polygons: [[outer, hole], [island]],
synthesized: false,
};
renderer.add(anno);
await settle();
const area = () => svg().querySelector('.gev-anno-area');
const subpaths = () => (area().getAttribute('d').match(/M /g) || []).length;
assert.equal(area().tagName.toLowerCase(), 'path');
assert.equal(area().getAttribute('fill-rule'), 'evenodd');
assert.equal(subpaths(), 3, 'screen fill retains both parts and the hole');
// The first part is behind the globe: the label centres on the next part.
anno.polygons = [[island.map(([lon, lat]) => [lon + 180, lat])], [outer, hole]];
renderer.sync();
await settle();
assert.equal(subpaths(), 2, 'an island behind the globe cannot hide the visible part and its hole');
assert.notEqual(area().parentNode.style.display, 'none');
assert.doesNotMatch(
area().parentNode.querySelector('.gev-anno-callout').getAttribute('transform'),
/NaN/,
);
anno.polygons = [[outer, hole]];
projectTo(t, (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return { x: lon > 0.0009 ? 2401 : 0, y: 0 };
});
renderer.sync();
await settle();
assert.equal(subpaths(), 2, 'finite offscreen shell coordinates cannot hide a visible holed part');
assert.notEqual(area().parentNode.style.display, 'none');
anno.polygons = [[outer, hole], [island]];
projectTo(t, (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return Math.abs(lon - 0.0002) < 1e-7 ? undefined : { x: 0, y: 0 };
});
renderer.sync();
await settle();
assert.equal(subpaths(), 1, 'an unprojectable hole omits its entire part, not the independently visible island');
assert.notEqual(area().parentNode.style.display, 'none');
});
test('Preview avoids the map callout without moving its world anchor', async (t) => {
const { store, svg, groups } = board(t);
projectTo(t, (_scene, world) => {
const lon = Cesium.Math.toDegrees(Cesium.Cartographic.fromCartesian(world).longitude);
return { x: 400, y: lon > 0.0005 ? 166 : 200 };
});
const camera = cameraOver(0, 0);
const scene = sceneFor(camera, {
canvas: { clientWidth: 1280, clientHeight: 720, width: 1280, height: 720 },
});
const panel = document.body.appendChild(document.createElement('section'));
panel.id = 'gev-voice-panel';
panel.dataset.view = 'preview';
panel.hidden = true;
let panelRect = { left: 210, top: 120, width: 100, height: 120 };
let panelVisible = true;
panel.getBoundingClientRect = () => panelRect;
panel.checkVisibility = () => panelVisible;
const renderer = createScreenAnnotationRenderer(
{ scene, camera, trackedEntity: null },
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const anno = { id: 'preview-label', type: 'label', color: 'primary', label: 'Texas', alpha: 1, anchor: { lon: 0, lat: 0, height: 0 } };
renderer.add(anno);
await settle();
// Non-unit, independent axis scales and a nonzero client offset: the
// overlay uses preserveAspectRatio="none", not letterboxing.
svg().getBoundingClientRect = () => ({ left: 10, top: 20, width: 640, height: 400 });
renderer.sync();
await settle();
const [group] = groups();
const callout = () => group.querySelector('.gev-anno-callout');
const leader = group.querySelector('.gev-anno-leader');
const dot = group.querySelector('.gev-anno-dot');
const natural = callout().getAttribute('transform');
const anchor = structuredClone(anno.anchor);
panel.hidden = false;
renderer.sync();
await settle();
assert.equal(callout().getAttribute('transform'), 'translate(418.0, 146.0)');
assert.equal(leader.getAttribute('x2'), '418.0');
assert.equal(leader.getAttribute('y2'), '172.0');
assert.equal(leader.getAttribute('x1'), '400.0');
assert.equal(leader.getAttribute('y1'), '200.0');
assert.equal(dot.getAttribute('cx'), '400.0');
assert.equal(dot.getAttribute('cy'), '200.0');
assert.deepEqual(anno.anchor, anchor);
assert.ok(groups()[0] === group, 'the group is kept, not replaced');
panel.hidden = true;
renderer.sync();
await settle();
assert.equal(callout().getAttribute('transform'), natural, 'closing Preview restores natural placement');
for (const variant of ['settings', 'invisible', 'no-overlap', 'no-room', 'collision']) {
panel.hidden = false;
panel.dataset.view = variant === 'settings' ? 'settings' : 'preview';
panelVisible = variant !== 'invisible';
panelRect = variant === 'no-overlap'
? { left: 510, top: 120, width: 100, height: 120 }
: variant === 'no-room'
? { left: 210, top: 25, width: 100, height: 180 }
: { left: 210, top: 120, width: 100, height: 120 };
if (variant === 'collision') renderer.add({ ...anno, id: 'above-slot', anchor: { lon: 0.001, lat: 0, height: 0 } });
renderer.sync();
await settle();
assert.equal(callout().getAttribute('transform'), natural, variant);
}
});
test('a mark hidden from Draw is not drawn or placed, and comes back when shown', async (t) => {
// happy-dom measures every SVG box the same, so callout spreading cannot
// settle here; a hidden mark simply has to stay out of drawing and
// placement, which is what keeps it from pushing a visible callout aside.
const { store, svg } = board(t);
projectTo(t, { x: 200, y: 300 });
const camera = cameraOver(0, 0);
let project = null;
const renderer = createScreenAnnotationRenderer(
{
scene: sceneFor(camera, {
postRender: {
addEventListener: (listener) => (project = listener),
removeEventListener() {},
},
}),
camera,
trackedEntity: null,
},
{ store, activeTrackedReadoutId: () => null },
);
t.after(() => renderer.destroy());
const camp = {
id: 'camp',
type: 'pin',
color: 'amber',
label: 'Camp',
alpha: 1,
hidden: true,
anchor: { lon: 0, lat: 0, height: 0 },
};
renderer.add(camp);
project();
await settle();
const drawn = () =>
[...svg().querySelectorAll('g.gev-anno')].some(
(group) =>
group.textContent.includes('Camp') && group.style.display !== 'none',
);
assert.equal(drawn(), false, 'the hidden mark is not drawn');
camp.hidden = false;
project();
await settle();
assert.equal(drawn(), true, 'shown again, it is drawn');
});
+360 -87
View File
@@ -1,3 +1,5 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { ringCentroid } from '../sources/featureGeometry.js';
import * as Cesium from 'cesium';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
@@ -51,6 +53,232 @@ export function createWorldAnnotationRenderer(viewer) {
const dataSource = new Cesium.CustomDataSource('gev-annotations');
viewer.dataSources.add(dataSource);
const osmAnnotations = new Set();
// Every mark with entities on the globe, so sync() can take a hidden one out.
const drawn = new Set();
const pendingGround = new Map();
const groundQueue = [];
let observingGround = false;
let destroyed = false;
function sampleBuilding(anno, options) {
pendingGround.get(anno)?.finish('cancelled');
return new Promise((resolve, reject) => {
const queuedAt = performance.now();
// The 2 s budget starts when this task reaches the head of the queue, so
// waiting behind other buildings on a slow device never spends it.
let started = null;
const workTrace = options.workTrace;
if (workTrace) workTrace.samplerStart = queuedAt;
let groundDetailSlots = 0;
let groundDetailReady = true;
if (workTrace) {
try {
workTrace.groundDetailDropped = 0;
} catch {
groundDetailReady = false;
}
}
const task = {
anno,
points: groundProbePositions(anno.ring),
samples: [],
index: 0,
};
const timing = {
workMs: 0,
maxSliceMs: 0,
lastSliceMs: 0,
probes: 0,
queuedMs: 0,
deadlineMs: null,
};
task.current = () =>
!destroyed &&
!options.signal?.aborted &&
pendingGround.get(anno) === task &&
(options.isCurrent?.() ?? true);
task.begin = () => {
if (started !== null) return;
started = performance.now();
timing.queuedMs = started - queuedAt;
timing.deadlineMs = started + 2000;
if (workTrace) workTrace.samplerHeadAt = started;
task.timer = setTimeout(() => task.finish('timeout'), 2000);
// A task promoted outside a frame (its predecessor timed out or was
// cancelled) still needs frames in request-render mode.
try {
viewer.scene.requestRender?.();
} catch {
/* the enqueue path reports render failures */
}
};
task.finish = (status, error) => {
if (pendingGround.get(anno) !== task) return;
pendingGround.delete(anno);
const position = groundQueue.indexOf(task);
groundQueue.splice(position, 1);
clearTimeout(task.timer);
if (position === 0) groundQueue[0]?.begin();
options.signal?.removeEventListener('abort', task.abort);
if (!groundQueue.length && observingGround) {
viewer.scene.postRender.removeEventListener(onGroundFrame);
observingGround = false;
}
timing.elapsedMs = performance.now() - (started ?? queuedAt);
if (workTrace) {
workTrace.probes = timing.probes;
workTrace.samplerStatus = status;
}
if (error) reject(error);
else resolve({ status, timing });
};
task.abort = () => task.finish('cancelled');
task.step = () => {
const workStart = workTrace ? performance.now() : 0;
// Nested call/commit wall-time only; the original parent remains the gate.
const detail =
workTrace && groundDetailSlots++ < 8
? {
index: task.index,
status: groundDetailReady ? 'recorded' : 'missing',
clampStart: null,
clampEnd: null,
clampResult: 'not-run',
commitStart: null,
commitEnd: null,
commitStatus: 'not-run',
}
: undefined;
const note = detail
? (key) => {
try {
const at = performance.now();
if (Number.isFinite(at)) detail[key] = at;
else detail.status = 'missing';
} catch {
detail.status = 'missing';
}
}
: null;
if (workTrace && !detail) {
try {
workTrace.groundDetailDropped += 1;
} catch {
// Missing attribution must not change the sampler outcome.
}
}
try {
if (!task.current()) return task.finish('cancelled');
if (performance.now() - started >= 2000)
return task.finish('timeout');
const sliceStart = performance.now();
let error;
try {
if (
viewer.scene.clampToHeightSupported &&
typeof viewer.scene.clampToHeight === 'function'
) {
const [lon, lat] = task.points[task.index++];
timing.probes += 1;
try {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
let c;
note?.('clampStart');
if (detail) detail.clampResult = 'threw';
try {
c = viewer.scene.clampToHeight(position);
if (detail) detail.clampResult = 'returned';
} finally {
note?.('clampEnd');
}
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000)
task.samples.push(h);
}
} catch {
/* surface not ready: preserve the existing sample filter */
}
} else task.index = task.points.length;
if (performance.now() - started >= 2000) {
task.finish('timeout');
return;
}
if (!task.current()) return task.finish('cancelled');
if (task.index === task.points.length) {
if (detail) detail.commitStatus = 'attempted';
note?.('commitStart');
task.samples.sort((a, b) => a - b);
const ground = task.samples.length
? task.samples[Math.floor(task.samples.length * 0.3)]
: null;
add(anno, {
sampledGroundHeight: ground,
isCurrent: task.current,
});
if (performance.now() >= timing.deadlineMs) {
task.finish('timeout');
remove(anno);
return;
}
}
} catch (caught) {
error = caught;
} finally {
timing.lastSliceMs = performance.now() - sliceStart;
timing.workMs += timing.lastSliceMs;
timing.maxSliceMs = Math.max(timing.maxSliceMs, timing.lastSliceMs);
}
if (error) {
task.finish('failed', error);
try {
remove(anno);
} catch {
/* retain the original commit error */
}
} else if (task.index === task.points.length)
task.finish('installed');
} finally {
// Commit includes sort/selection/add/deadline/finish or rollback,
// including intervening bookkeeping; it is not exclusive entity cost.
if (detail?.commitStatus === 'attempted') note('commitEnd');
if (workTrace)
recordWorkSlice(
workTrace,
'ground-step',
workStart,
undefined,
detail,
);
}
};
pendingGround.set(anno, task);
groundQueue.push(task);
if (groundQueue[0] === task) task.begin();
options.signal?.addEventListener('abort', task.abort, { once: true });
if (!task.current()) return task.finish('cancelled');
if (!observingGround) {
viewer.scene.postRender.addEventListener(onGroundFrame);
observingGround = true;
}
try {
viewer.scene.requestRender?.();
} catch (error) {
task.finish('failed', error);
}
});
}
function onGroundFrame() {
// One building probe TOTAL, not one per mark. Other renderers have their own work.
const task = groundQueue[0];
try {
task?.step();
if (groundQueue.length) viewer.scene.requestRender?.();
} catch (error) {
for (const pending of [...groundQueue]) pending.finish('failed', error);
}
}
// Register the GevRouteFlow fabric once so Cesium's Material.fromType() can build the
// material the route pipeline renders. The animated `time` uniform is read straight
@@ -81,7 +309,7 @@ export function createWorldAnnotationRenderer(viewer) {
}, false);
}
function add(anno) {
function add(anno, options = {}) {
const base = colorFor(anno);
// Published to the mark BEFORE anything is added, and mutated in place as
// each entity lands: a mid-add failure (bad geometry, lost context) must
@@ -89,21 +317,42 @@ export function createWorldAnnotationRenderer(viewer) {
// path cannot reach them and they stay on the globe forever.
const entities = [];
anno._entities = entities;
drawn.add(anno);
if (
anno.ring?.length >= 3 &&
anno.footprintKind === 'building' &&
!Object.hasOwn(options, 'sampledGroundHeight')
) {
return sampleBuilding(anno, options);
}
if (
anno.ring &&
anno.ring.length >= 3 &&
anno.footprintKind === 'building'
) {
const addBuildingEntity = (definition) => {
if (!(options.isCurrent?.() ?? true))
throw new Error('Building install cancelled');
const entity = dataSource.entities.add(definition);
if (!(options.isCurrent?.() ?? true)) {
dataSource.entities.remove(entity);
throw new Error('Building install cancelled');
}
return entity;
};
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint
// extrusion is only 2.5D and misses domes; classification colors the
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const parts =
Array.isArray(anno.polygons) && anno.polygons.length
? anno.polygons
: [[anno.ring]];
const groundH =
sampleGroundOutside(viewer.scene, anno.ring) ??
options.sampledGroundHeight ??
(Number.isFinite(anno.anchor.height)
? anno.anchor.height - (anno.buildingHeight || 25)
: 0);
@@ -112,55 +361,73 @@ export function createWorldAnnotationRenderer(viewer) {
// empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
),
},
}),
const fillMaterial = new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
const lineMaterial = new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
});
for (const [outer, ...holes] of parts) {
const buffered = bufferRing(outer, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(
addBuildingEntity({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
positions,
holes.map(
(hole) =>
new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(hole.flat()),
),
),
),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: fillMaterial,
},
}),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
addBuildingEntity({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
for (const ring of [outer, ...holes]) {
entities.push(
addBuildingEntity({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(
closedRing(ring).flat(),
),
width: 3,
material: lineMaterial,
clampToGround: true,
classificationType: CLASSIFY,
},
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
);
}
}
} else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park / state) → flat fill draped on
// the tiles + a glowing outline. Draping is right here: you can't extrude
@@ -247,6 +514,7 @@ export function createWorldAnnotationRenderer(viewer) {
width: 9,
material: new FlowMaterialProperty(
PALETTE[anno.color] || PALETTE.primary,
() => anno.alpha ?? 1,
),
clampToGround: true,
classificationType: CLASSIFY,
@@ -300,6 +568,19 @@ export function createWorldAnnotationRenderer(viewer) {
}
entities.push(pointMarker(anno, base));
}
if (
entities.length &&
anno.ring?.length >= 3 &&
!anno.synthesized &&
(anno.outlineCredit === 'tiles' || anno.outlineCredit === 'osm') &&
(options.isCurrent?.() ?? true)
) {
// The actual world proxy owns both geometry and credit, including rollback.
osmAnnotations.add(anno);
showOsmCredit(viewer, anno, {
openMapTiles: anno.outlineCredit === 'tiles',
});
}
}
// Captions belong to the hybrid renderer's screen-space callouts.
@@ -318,8 +599,10 @@ export function createWorldAnnotationRenderer(viewer) {
}
function remove(anno) {
pendingGround.get(anno)?.finish('cancelled');
hideOsmCredit(viewer, anno);
osmAnnotations.delete(anno);
drawn.delete(anno);
if (!anno?._entities) return;
for (const entity of anno._entities) {
try {
@@ -331,13 +614,25 @@ export function createWorldAnnotationRenderer(viewer) {
anno._entities = null;
}
/**
* CallbackProperty drives per-frame alpha and pulse. A hidden mark is taken
* out whole, so no material that keeps its own colour (the route's flow),
* ground classification or pick can still show it.
*/
function sync() {
// No-op: CallbackProperty drives per-frame alpha/pulse animation.
for (const anno of drawn) {
const show = !anno.hidden;
for (const entity of anno._entities || [])
if (entity.show !== show) entity.show = show;
}
}
function destroy() {
destroyed = true;
for (const task of [...pendingGround.values()]) task.finish('cancelled');
for (const anno of osmAnnotations) hideOsmCredit(viewer, anno);
osmAnnotations.clear();
drawn.clear();
try {
viewer.dataSources.remove(dataSource, true);
} catch {
@@ -407,8 +702,11 @@ export function ensureFlowFabricRegistered() {
* returned a standalone Material Cesium never rendered, or treated `result` as a
* Material — both left the real uniforms untouched, so nothing animated.)
*/
export function FlowMaterialProperty(colorCss) {
export function FlowMaterialProperty(colorCss, alphaOf = () => 1) {
this._color = Cesium.Color.fromCssColorString(colorCss).withAlpha(0.95);
// The mark's live alpha (fade in and out, hidden), applied every frame.
this._alphaOf = alphaOf;
this._live = this._color.clone();
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(FlowMaterialProperty.prototype, {
@@ -431,7 +729,8 @@ FlowMaterialProperty.prototype.getValue = function getValue(time, result) {
// `time` is read straight from the wall clock so the dashes flow every rendered
// frame (the scene renders continuously; no requestRenderMode here).
if (!Cesium.defined(result)) result = {};
result.color = this._color;
this._live.alpha = this._color.alpha * this._alphaOf();
result.color = this._live;
result.time = performance.now() / 1000; // the per-frame animated value
result.repeat = 64.0;
result.duty = 0.46;
@@ -458,25 +757,11 @@ function decimateRing(ring, n) {
}
/**
* Sample the ground height (m) just OUTSIDE a building footprint. Sampling at
* the centroid would clamp onto the roof, so we probe several points beyond the
* footprint radius and take a low percentile (≈ ground). Returns null if the
* tiles under those points aren't loaded yet.
* The original eight outside-footprint probes. Keep their coordinates and order
* while scheduling the expensive scene picks across rendered frames.
*/
function sampleGroundOutside(scene, ring) {
if (
!scene?.clampToHeightSupported ||
typeof scene.clampToHeight !== 'function'
)
return null;
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
function groundProbePositions(ring) {
const { lon: clon, lat: clat } = ringCentroid(ring);
const latS = 111320;
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
let maxR = 0;
@@ -494,23 +779,11 @@ function sampleGroundOutside(scene, ring) {
[0.7, -0.7],
[-0.7, 0.7],
];
const samples = [];
for (const [dx, dy] of dirs) {
return dirs.map(([dx, dy]) => {
const lon = clon + (dx * out) / lonS;
const lat = clat + (dy * out) / latS;
try {
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
}
} catch {
/* tile not ready */
}
}
if (!samples.length) return null;
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length * 0.3)];
return [lon, lat];
});
}
/**
@@ -0,0 +1,70 @@
// A mark hidden from Draw's list leaves the globe whole: every entity it put
// there is taken out on sync and comes back when shown, and the line's
// flowing stroke, which keeps its own colour, follows the mark's alpha.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import {
FlowMaterialProperty,
createWorldAnnotationRenderer,
} from './worldAnnotationRenderer.js';
const noop = () => {};
function viewer(t) {
for (const name of ['HTMLCanvasElement', 'HTMLImageElement', 'ImageBitmap', 'OffscreenCanvas']) {
const original = globalThis[name];
globalThis[name] = class {};
t.after(() => { if (original === undefined) delete globalThis[name]; else globalThis[name] = original; });
}
const before = globalThis.document;
globalThis.document = { addEventListener: noop, removeEventListener: noop };
t.after(() => { if (before === undefined) delete globalThis.document; else globalThis.document = before; });
return {
creditDisplay: { addStaticCredit: noop, removeStaticCredit: noop },
entities: new C.EntityCollection(),
dataSources: new C.DataSourceCollection(),
camera: { moveEnd: new C.Event(), positionCartographic: { height: 1000 } },
scene: { requestRender: noop, canvas: { addEventListener: noop, removeEventListener: noop }, preRender: new C.Event(),
primitives: { add: (p) => p, remove: noop }, screenSpaceCameraController: { enableInputs: true } },
};
}
const marks = () => [
{ id: 'route', type: 'route', color: 'amber', alpha: 1, anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] },
{ id: 'area', type: 'area', color: 'green', alpha: 1, anchor: { lat: 0.005, lon: 0.005 }, ring: [[0, 0], [0.01, 0], [0.01, 0.01], [0, 0]] },
{ id: 'pin', type: 'pin', color: 'primary', alpha: 1, anchor: { lat: 0.02, lon: 0.02 } },
];
test('a hidden mark has every entity taken off on sync, and back when shown', (t) => {
const renderer = createWorldAnnotationRenderer(viewer(t));
const [route, area, pin] = marks();
for (const anno of [route, area, pin]) renderer.add(anno);
const shown = (anno) => anno._entities.map((entity) => entity.show);
for (const anno of [route, area, pin]) assert.ok(anno._entities.length > 0, anno.id);
route.hidden = true;
area.hidden = true;
renderer.sync();
assert.ok(shown(route).every((show) => show === false), 'the line is off');
assert.ok(shown(area).every((show) => show === false), 'the area is off');
assert.ok(shown(pin).every((show) => show === true), 'a visible mark stays');
route.hidden = false;
renderer.sync();
assert.ok(shown(route).every((show) => show === true), 'the line is back');
renderer.remove(area);
renderer.sync();
assert.equal(area._entities, null, 'a removed mark is no longer synced');
renderer.destroy();
});
test('the line\'s flowing stroke follows its mark\'s alpha', () => {
let alpha = 1;
const material = new FlowMaterialProperty('#ffb547', () => alpha);
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.95) < 1e-9);
alpha = 0;
assert.equal(material.getValue(null, {}).color.alpha, 0, 'hidden: nothing painted');
alpha = 0.5;
assert.ok(Math.abs(material.getValue(null, {}).color.alpha - 0.475) < 1e-9, 'fading');
assert.equal(new FlowMaterialProperty('#fff').getValue(null, {}).color.alpha, 0.95, 'no alpha source: opaque as before');
});
+5 -3
View File
@@ -62,17 +62,19 @@ export function createApplicationTools({
const token = displayStore.connect(actions);
return () => displayStore.detach(token);
};
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button.
// lifetime rather than to whoever last pressed the button. A row's fly-to
// goes through the shell's navigation policy, as Import's does.
const drawTool = initDrawTool({
viewer,
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
});
defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application
// lifetime removes them and the drop listeners on the viewer container.
const geoFileImport = initGeoFileImport({
+6 -1
View File
@@ -1746,8 +1746,13 @@ export function flyRoute(
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
: 'normal';
// A route hidden from Draw's list is not on screen, so it is not flown.
const routes = (annoList || []).filter(
(a) => a.type === 'route' && Array.isArray(a.path) && a.path.length >= 2,
(a) =>
a.type === 'route' &&
!a.hidden &&
Array.isArray(a.path) &&
a.path.length >= 2,
);
if (!routes.length) {
return {
+33 -3
View File
@@ -633,17 +633,22 @@ test('Cockpit side surfaces behave as two single-expanded accordions', () => {
);
assert.match(
radioBindings,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);/,
/'gev:cockpit-context-expanded'[\s\S]*?setPanelCollapsed\('data-panel', true\);\s*this\.actions\.setPanelCollapsed\('utilities-panel', true\);/,
);
assert.match(
ui,
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
/!nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed[\s\S]*?this\.cockpitView\.setContextCollapsed\(true\);/,
);
assert.match(
ui,
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*panelId === 'data-panel'[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
/nextCollapsed\s*&&\s*this\.cockpitView\?\.active\s*&&\s*COCKPIT_LEFT_PANEL_IDS\.has\(panelId\)[\s\S]*?_cockpitContextCollapsedForDataPanel[\s\S]*?this\.cockpitView\.setContextCollapsed\(false\);/,
'closing Data Layers must restore Contact only after an automatic collapse',
);
assert.match(
ui,
/const COCKPIT_LEFT_PANEL_IDS = new Set\(\['data-panel', 'utilities-panel'\]\);/,
'Utilities folds Contact away in Cockpit as Data Layers does',
);
assert.match(
setContextCollapsed.toString(),
/const wasCollapsed = this\.contextCollapsed;[\s\S]*?this\.active && wasCollapsed && !this\.contextCollapsed[\s\S]*?'gev:cockpit-context-expanded'/,
@@ -678,6 +683,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
for (const panelId of [
'data-panel',
'cctv-panel',
'utilities-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
@@ -1379,3 +1385,27 @@ test('cockpit state cannot report entryAllowed while already active', () => {
assert.match(state, /'contacts-inactive'/);
assert.match(state, /'no-tracked-aircraft'/);
});
test('Contact folded for one Cockpit left panel comes back only once neither is open', async () => {
assert.match(
ui,
/if \(!this\._cockpitContextCollapsedForDataPanel\)\s*this\._cockpitContextCollapsedForDataPanel =\s*!this\.cockpitView\.contextCollapsed;/,
'opening the second left panel keeps the fold the first recorded',
);
const { PanelChrome } = await import('./ui/panelChrome.js');
const previous = globalThis.document;
const panels = {};
globalThis.document = { getElementById: (id) => panels[id] ?? null };
const panel = (...classes) => ({ classList: { contains: (name) => classes.includes(name) } });
try {
const otherOpen = (id) => PanelChrome.prototype._otherCockpitLeftPanelOpen(id);
panels['data-panel'] = panel('collapsed');
assert.equal(otherOpen('utilities-panel'), false, 'Data Layers closed: restore');
panels['data-panel'] = panel('collapsed', 'layout-auto-collapsed');
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers comes back: keep Contact folded');
panels['data-panel'] = panel();
assert.equal(otherOpen('utilities-panel'), true, 'Data Layers open: keep it folded');
} finally {
globalThis.document = previous;
}
});
+4
View File
@@ -243,6 +243,8 @@ test('Codex login hides spawn errors and can cancel only its owned child', async
const child = new EventEmitter();
await assert.rejects(
startCodexChatGptLogin({
environment: {},
home: '/home/fixture',
executable: 'codex',
spawnImpl() {
queueMicrotask(() =>
@@ -264,6 +266,8 @@ test('Codex login hides spawn errors and can cancel only its owned child', async
running.emit('exit', null);
};
const login = await startCodexChatGptLogin({
environment: {},
home: '/home/fixture',
executable: 'codex',
spawnImpl() {
queueMicrotask(() => running.emit('spawn'));
+95 -14
View File
@@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) {
}
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
function pointInRing(ring, lat, lon) {
export function pointInRing(ring, lat, lon) {
if (!Array.isArray(ring) || ring.length < 3) return false;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
@@ -150,17 +150,60 @@ export function polygonsContain(polygons, lat, lon) {
return false;
}
/**
* Whether polygons contain a point. A part whose outer ring crosses the
* antimeridian ([179.99, …], [-179.99, …]) is read contiguously in its own
* longitude frame, with the point moved into that frame; every other part,
* including one genuinely wider than 180°, is tested as stored. Crossing is
* read from an edge jumping more than 180°, which assumes densely noded rings
* (OSM, Nominatim, tiles); a ring that is both wider than 180° and crossing, or
* a non-crossing ring with a single edge over 180°, is not supported.
*/
export function containsAnchor(polygons, lat, lon) {
if (!Number.isFinite(lon)) return false;
for (const part of polygons || []) {
const outer = part?.[0];
if (!Array.isArray(outer) || !outer.length) continue;
const reference = outer[0][0];
const shift = (x) => x - 360 * Math.round((x - reference) / 360);
const span = (ring, f) => {
let west = Infinity;
let east = -Infinity;
for (const [x] of ring) {
west = Math.min(west, f(x));
east = Math.max(east, f(x));
}
return east - west;
};
// An edge jumping more than 180° wraps the line; a polar ring whose
// edges run along ±180 collapses when reframed and is read as stored.
const jumps = outer.some(
([x], i) => i > 0 && Math.abs(x - outer[i - 1][0]) > 180,
);
const framedSpan = jumps ? span(outer, shift) : 0;
const crosses = framedSpan > 0 && framedSpan < 180;
const framed = crosses
? part.map((ring) => ring.map(([x, y]) => [shift(x), y]))
: part;
if (polygonsContain([framed], lat, crosses ? shift(lon) : lon)) return true;
}
return false;
}
/**
* A unit's geometry, decoded on first use. Longitudes are unwrapped around
* the largest part for the box so units cut at the antimeridian (Alaska,
* Chukotka) get a box on the right side of the globe.
* Chukotka) get a box on the right side of the globe. An entry from another
* encoding supplies `decode()` returning the same `[outer, ...holes][]`.
*/
function geometryOf(entry) {
export function geometryOf(entry) {
if (entry.geometry) return entry.geometry;
const decimals = entry.feature.d ?? entry.decimals;
const polygons = entry.feature.polygons.map((poly) =>
poly.map((ring) => decodeRing(ring, decimals)),
);
const polygons = entry.decode
? entry.decode()
: entry.feature.polygons.map((poly) =>
poly.map((ring) => decodeRing(ring, decimals)),
);
const ref = polygons[0]?.[0]?.[0]?.[0] ?? 0;
let west = Infinity;
let south = Infinity;
@@ -178,7 +221,7 @@ function geometryOf(entry) {
if (lat > north) north = lat;
}
}
const label = entry.feature.label
const label = Array.isArray(entry.feature.label)
? { lon: entry.feature.label[0], lat: entry.feature.label[1] }
: ringCentroid(polygons[0][0]);
entry.geometry = {
@@ -208,7 +251,7 @@ function ringCentroid(ring) {
}
/** Km from a point to a unit's box (0 inside it). */
function boxDistanceKm(bbox, lat, lon) {
export function boxDistanceKm(bbox, lat, lon) {
const [west, south, east, north] = bbox;
const mid = (west + east) / 2;
const x = lon - 360 * Math.round((lon - mid) / 360);
@@ -399,8 +442,8 @@ const COUNTY_WORDS = [
];
const COUNTY_WORD_RE = new RegExp(`\\b(${COUNTY_WORDS.join('|')})\\b`);
const STATE_PREFIX_RE =
/^(?:free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
const STATE_SUFFIX_RE = /^(.+) (?:state|province|prefecture|territory)$/;
/^(free state|state|commonwealth|province|territory|prefecture|region|land|canton|department) of (.+)$/;
const STATE_SUFFIX_RE = /^(.+) (state|province|prefecture|territory)$/;
/**
* Split an ask into what it names and how.
@@ -461,14 +504,17 @@ export function parseAdminQuery(text) {
if (prefix)
return {
kind: 'state',
name: prefix[1],
name: prefix[2],
qualifiers,
postal: null,
countyWord: null,
full: head,
form: 'prefix',
adminType: prefix[1],
};
const suffix = STATE_SUFFIX_RE.exec(head);
// South Africa's canonical province name is literally "Free State"; do not
// strip its final word as though the ask explicitly named an admin type.
const suffix = head === 'free state' ? null : STATE_SUFFIX_RE.exec(head);
if (suffix)
return {
kind: 'state',
@@ -478,6 +524,7 @@ export function parseAdminQuery(text) {
countyWord: null,
full: head,
form: 'suffix',
adminType: suffix[2],
};
return {
kind: 'any',
@@ -489,6 +536,30 @@ export function parseAdminQuery(text) {
};
}
/** Explicit wording is stronger than a stale same-name geometry. */
function explicitStateTypeFits(entry, parsed) {
if (!parsed.adminType) return true;
const words = new Set(
normalizeAdminName(entry.feature?.type)
.split(/[^a-z]+/)
.filter(Boolean),
);
const accepted =
{
'free state': ['state'],
state: ['state'],
commonwealth: ['state'],
province: ['province'],
territory: ['territory'],
prefecture: ['prefecture'],
region: ['region'],
land: ['state'],
canton: ['canton'],
department: ['department', 'departamento'],
}[parsed.adminType] || [];
return accepted.some((word) => words.has(word));
}
/** A qualifier names the unit's country ("Bavaria, Germany") or the unit
* itself ("Washington, DC"). */
function qualifierMatches(entry, qualifier) {
@@ -505,7 +576,7 @@ function qualifierMatches(entry, qualifier) {
* Rank candidates: a unit containing `near` first, then the match tier,
* then distance from `near`, then larger area. Returns the ranked list.
*/
function rank(candidates, near) {
export function rank(candidates, near) {
const hasNear = Number.isFinite(near?.lat) && Number.isFinite(near?.lon);
const scored = candidates.map(({ entry, tier }) => {
const geometry = geometryOf(entry);
@@ -626,6 +697,10 @@ async function stateCandidates(parsed, { allowAmbiguous = false } = {}) {
list = list.filter((i) =>
parsed.qualifiers.every((q) => qualifierMatches(i.entry, q)),
);
// Preserve explicit identity at the selector seam. In particular,
// "Bagmati Province" must not select Natural Earth's historical NP-BA
// Bagmati Zone merely because the point and normalized name overlap.
list = list.filter((i) => explicitStateTypeFits(i.entry, parsed));
if (allowAmbiguous) return list;
// A bare name answers alone only for a prominent unit — a US state, a
// Canadian province, Bavaria — never a French département, an English
@@ -738,6 +813,12 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
.map(parseAdminQuery)
.filter((p) => p?.name);
if (!parsedList.length) return null;
// If the ask explicitly names an admin type, do not let the geocoder's
// untyped alias reopen an incompatible historical same-name boundary.
const explicitStates = parsedList.filter(
(parsed) => parsed.kind === 'state' && parsed.adminType,
);
const stateParses = explicitStates.length ? explicitStates : parsedList;
const containing = (items) => {
const out = [];
for (const item of items)
@@ -782,7 +863,7 @@ export async function findAdminAreaAt(names, lat, lon, scope) {
return toResult(rank(found, { lat, lon })[0], found.length);
}
const states = [];
for (const parsed of parsedList)
for (const parsed of stateParses)
states.push(
...(await stateCandidates(
{ ...parsed, qualifiers: [] },
+22
View File
@@ -225,6 +225,28 @@ test('geocoder-confirmed lookup: the named unit that contains the point', async
assert.equal(await findAdminAreaAt(['Georgia'], 41.7, 44.8, 'state'), null);
const newYork = await findAdminAreaAt(['New York'], 42.9, -75.5, 'state');
assert.equal(newYork?.id, 'US-NY');
assert.equal(
(
await findAdminAreaAt(
['Ontario Province', 'Ontario'],
43.6532,
-79.3832,
'state',
)
)?.id,
'CA-ON',
);
// Natural Earth still carries the historical NP-BA Bagmati Zone. An ask
// for today's province must not silently select that incompatible geometry.
assert.equal(
await findAdminAreaAt(
['Bagmati Province', 'Bagmati'],
27.7172,
85.324,
'state',
),
null,
);
// A county the geocoder typed, named without the county word.
const travis = await findAdminAreaAt(
['Travis', 'Travis County'],
+3 -2
View File
@@ -1,3 +1,5 @@
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
const KNOT_TO_MPS = 0.514444;
const FOOT_TO_M = 0.3048;
const FPM_TO_MPS = 0.00508;
@@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm =
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
const track = finiteNumber(aircraft?.track);
return [
+162
View File
@@ -0,0 +1,162 @@
// Outline names are drawn by the DOM annotation overlay, never as Cesium
// text labels: the boundary data modules, the outline resolver and the
// annotation renderers must not import or construct Cesium label primitives,
// and drawing a bundled outline must create none at runtime.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
/** Pure data modules: no Cesium at all. */
const DATA_MODULES = [
'./adminBoundaries.js',
'./placeBoundaries.js',
'./neighborhoodPolygons.js',
'./local_data/us_census_places/files.js',
'./local_data/wof_neighborhoods/files.js',
];
/** Modules that use Cesium for outlines but must not draw text with it. */
const CESIUM_MODULES = [
'../annotations/resolver.js',
'../annotations/worldAnnotationRenderer.js',
'../annotations/hybridAnnotationRenderer.js',
];
/** The DOM overlay: callout elements, never Cesium label primitives. */
const OVERLAY_MODULES = ['../annotations/screenAnnotationRenderer.js'];
const LABEL_PRIMITIVES = [
/\bLabelCollection\b/,
/\bLabelGraphics\b/,
/\bLabelStyle\b/,
/\bLabelVisualizer\b/,
];
/** Entity label options, inline or assigned later. */
const ENTITY_LABELS = [
// `label: { text: … }` or `label: new …`
/\blabel\s*:\s*\{[^{}]*\btext\s*:/s,
/\blabel\s*:\s*new\b/,
// `label: labelOptions` (anything but `null`)
/\blabel\s*:\s*(?!null\b)[A-Za-z_$][\w$]*\s*[,}\n]/,
// `entity.label = …`
/\.label\s*=(?!=)/,
];
test('boundary data modules never import Cesium', () => {
for (const path of DATA_MODULES) {
const source = read(path);
assert.doesNotMatch(source, /from\s+['"]cesium['"]/, path);
assert.doesNotMatch(source, /import\(\s*['"]cesium['"]/, path);
}
});
test('outline modules and renderers construct no Cesium labels', () => {
for (const path of [...DATA_MODULES, ...CESIUM_MODULES, ...OVERLAY_MODULES])
for (const pattern of LABEL_PRIMITIVES)
assert.doesNotMatch(read(path), pattern, `${path}: ${pattern}`);
for (const path of [...DATA_MODULES, ...CESIUM_MODULES])
for (const pattern of ENTITY_LABELS)
assert.doesNotMatch(read(path), pattern, `${path}: ${pattern}`);
});
test('the static check catches a Cesium label however it is written', () => {
const offending = [
'viewer.scene.primitives.add(new Cesium.LabelCollection());',
"viewer.entities.add({ position, label: { text: 'Austin' } });",
'const graphics = new LabelGraphics({ text });',
'viewer.entities.add({ position, label: labelOptions });',
'entity.label = new Cesium.LabelGraphics(options);',
'entity.label = options;',
];
const all = [...LABEL_PRIMITIVES, ...ENTITY_LABELS];
for (const source of offending)
assert.ok(
all.some((pattern) => pattern.test(source)),
source,
);
// Data, not Cesium labels: a result's label point, a stripped proxy label.
for (const source of [
'label: geometry.label,',
'liveProxy(anno, { label: null });',
'if (entity.label === x) {}',
])
assert.ok(!ENTITY_LABELS.some((p) => p.test(source)), source);
});
test('drawing bundled place and neighborhood outlines creates no Cesium label', async (t) => {
// Cesium's material setup checks for these browser image types.
for (const name of [
'HTMLCanvasElement',
'HTMLImageElement',
'ImageBitmap',
'OffscreenCanvas',
]) {
if (name in globalThis) continue;
globalThis[name] = class {};
t.after(() => delete globalThis[name]);
}
const featureSource = {};
for (const method of FEATURE_SOURCE_METHODS)
featureSource[method] = async () => null;
const { resolveAnnotationTarget } = createAnnotationResolver({
featureSource,
});
const primitives = [];
const dataSources = [];
const viewer = {
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(
-97.7431,
30.2672,
3000,
),
},
scene: { primitives: { add: (p) => primitives.push(p) } },
dataSources: {
add: (ds) => dataSources.push(ds),
remove() {},
},
};
const renderer = createWorldAnnotationRenderer(viewer);
for (const [target, lon, lat] of [
['Austin', -97.7431, 30.2672],
['Notting Hill', -0.2, 51.511],
]) {
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
lon,
lat,
3000,
);
const resolved = await resolveAnnotationTarget({
// The resolver reads only the camera (no scene to pick from).
viewer: { camera: viewer.camera },
target,
footprint: true,
deferFootprint: true,
});
assert.equal(resolved.source, 'bundled', target);
renderer.add({
id: target,
type: 'area',
label: resolved.label,
ring: resolved.ring,
polygons: resolved.polygons,
footprintKind: resolved.footprintKind,
synthesized: resolved.synthesized,
anchor: { lon: resolved.lon, lat: resolved.lat, height: 0 },
color: 'primary',
});
}
assert.equal(primitives.length, 0, 'no primitive collections');
const entities = dataSources.flatMap((ds) => ds.entities.values);
assert.ok(entities.length >= 2, 'the outlines were drawn');
for (const entity of entities) assert.equal(entity.label, undefined);
renderer.destroy();
});
+16 -5
View File
@@ -25,11 +25,11 @@ import * as Cesium from 'cesium';
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
* @type {{ key: string, html: string }[]}
*/
const OSM_CREDIT = {
export const OSM_CREDIT = {
key: 'openstreetmap',
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
};
const OPENMAPTILES_CREDIT = {
export const OPENMAPTILES_CREDIT = {
key: 'openfreemap',
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
};
@@ -538,15 +538,26 @@ export const NATURAL_EARTH_CREDIT = {
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
};
/** Registered when the first bundled US county outline resolves (public
* domain — credited as a courtesy). */
/** Registered when the first bundled US county or place outline resolves
* (public domain — credited as a courtesy). */
export const US_CENSUS_CREDIT = {
key: 'us-census-counties',
html:
'US county boundaries from the ' +
'US county and place boundaries from the ' +
'<a href="https://www.census.gov/geographies/mapping-files/time-series/geo/cartographic-boundary.html" target="_blank" rel="noopener">U.S. Census Bureau</a> (public domain)',
};
/** Registered when the first bundled Who's On First neighborhood outline
* resolves. The link leads to the pack's per-source credits and licences and
* says how the boundaries were modified. */
export const WOF_CREDIT = {
key: 'wof-neighborhoods',
html:
'Neighborhood boundaries: ' +
'<a href="https://whosonfirst.org/docs/licenses/" target="_blank" rel="noopener">Who\'s On First</a> and contributors, simplified (' +
'<a href="https://github.com/bilawalsidhu/gods-eye-view/blob/main/src/data/local_data/wof_neighborhoods/ATTRIBUTION.md" target="_blank" rel="noopener">sources and licences</a>)',
};
/**
* Per-feed transit credit, registered the first time that feed's vehicles
* render (see `src/data/transitFeeds.js` for the license of each).
+11
View File
@@ -41,3 +41,14 @@
2026-09-25. Each record names its XYZ tile and expected traffic eligibility.
Modified, test-only OpenStreetMap data; same ODbL attribution and database
share-alike terms as the OpenFreeMap fixtures above.
- `ofm-outlines-austin-14-3743-{6745,6746}.pbf` — OpenFreeMap / OpenMapTiles
z14 tiles from
`https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf`,
retrieved 2026-09-28. Trimmed to the `transportation_name` features for
Congress Avenue, South Congress Avenue and East 11th Street, and the
`building` features within about 150 m of the Texas Capitol and the
`landcover` park polygon around it, preserving
original geometry and dictionaries. Used by the street and building outline
tests. Same OpenFreeMap / OpenMapTiles / OpenStreetMap attribution and ODbL
1.0 share-alike terms as the fixtures above. Test-only.
@@ -0,0 +1,175 @@
¬
transportation_name\œÝ”�——–———"> Ú €BŠ-3oxÇ:ÑNƒøçÀË‚£~—ˆ�ô© ~§Ð‡b—èó:©
ref_lengthsubclassrefclassnamename_int
name:latinname_dename_enname:es route_1_refroute_2_networkroute_1_network route_2_refnetworklayer"0"
junction"
233"
motorway"
232"0"
234B"
234A"
South Interstate 35"
secondary_construction"
The Circle"
footway"
path"$
"Lady Bird Lake Hike and Bike Trail"
cycleway"
Waller Creek Greenbelt Trail"&
$Ann & Roy Butler Hike and Bike Trail"
North Interstate 35"
path_construction"
Woodland Trail"
Harper's Branch Connection"
steps"
Snake Path"
Sendero del Serpiente"
Nellie Street Crossing"
Woodland Avenue"
Alameda Drive"
minor"
Forest Avenue"
South 2nd Street"
Post Road"
Taylor Street"
Pecan Grove Road"
East Avenue"
Fletcher Street"
Lockhart Drive"
Alta Vista Avenue"
Retama Street"
Haywood Avenue"
Ramona Street"
Ravine Drive"
Betty Jo Drive"
Spence Street"
Rebel Road"
Bickler Road"
Clifton Street"
Columbus Street"
Reagan Terrace"
Daniel Drive"
East Mary Street"
Lindell Avenue"
Drake Avenue"
Mariposa Drive"
Travis Heights Boulevard"
Christopher Street"
Music Lane"
Flores Street"
Sunset Lane"
Mission Ridge"
Oak Heights Drive"
Bonniview Street"
Algarita Avenue"
Edgecliff Terrace"
Melissa Lane"
Sunny Lane"
Le Grande Avenue"
Avondale Road"
Milam Place"
Fairmount Avenue"
River Street"
Garden Street"
Rosedale Terrace"
Nash Hernandez Senior Road"
West Monroe Street"
Gillespie Place"
Newning Avenue"
West James Street"
College Avenue"
Sylvan Drive"
Leland Street"
Bonham Terrace"
Sunny Vale Street"
Bartlett Street"
Ebony Street"
Lupine Lane"
Terrace Drive"
Newton Street"
Parkinson Drive"
Inglewood Street"
Manlove Street"
Academy Drive"
Clermont Avenue"
Copeland Street"
South 3rd Street"
Crockett Street"
East Gibson Street"
West Gibson Street"
Summit Street"
Chelsea Lane"
Willow Street"
San Marcos Street"
East Side Drive"
Lone Oak Drive"
East Elizabeth Street"
West Elizabeth Street"
Lambie Street"
Wilson Street"
Eva Street"
Roma Street"
Waller Street"
Elmhurst Drive"
Kenwood Avenue"
Brackenridge Street"
Hillside Avenue"
East Milton Street"
West Milton Street"
Fairlawn Lane"
Euclid Avenue"
Rutherford Place"
Cummings Street"
Nickerson Street"
Nellie Street"
Park Lane"
Bellaire Drive"
West Johanna Street"
Post Oak Street"
Rainey Street"
Taylor Gaines Street"
Harwood Place"
Brooklyn Street"
Jewell Street"
LBJ Way"
Bierce Street"
Festival Beach Road"
Art Dilly Drive"
Haskell Street"
Holly Street"
tertiary"
West Mary Street"
West Live Oak Street"
East Live Oak Street"
East Monroe Street"
Bouldin Avenue"
East Annie Street"
West Annie Street"
Dawson Road"
secondary"
South 1st Street"
West Oltorf Street"
East Riverside Drive"
primary"
South Congress Avenue"
Barton Springs Road"
35"
Purple Heart Trail"
US:US"
US:I"
290"
us-interstate"0"
Skyline Views"
Blunn Creek Connection"
Waterfront Promenade"
Hartman Bridge"0(€ x
+6 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import {
LOCAL_RECEIVERS_ROUTE,
createLocalReceiversHandler,
@@ -8,6 +9,10 @@ import {
parseLocalReceiverFeeds,
} from '../../server/providers/local-receivers.js';
import { localProviderPlugins } from '../../server/providers/local.js';
import { makeFixtureRoot } from '../tooling/fixtureRoot.mjs';
const nominatimStorageDir = await makeFixtureRoot('gev-receivers-composition-');
test.after(() => rm(nominatimStorageDir, { recursive: true, force: true }));
const FIXTURE = readFileSync(
new URL('./fixtures/adsb-austin-dump1090-aircraft.json', import.meta.url),
@@ -118,7 +123,7 @@ test('construction mounts the route once and neither parses nor fetches', (t) =>
t.mock.method(globalThis, 'fetch', () => {
throw Error('construction must not fetch');
});
const plugins = localProviderPlugins();
const plugins = localProviderPlugins({ nominatimStorageDir });
assert.equal(
plugins.filter((plugin) => plugin.name === 'local-receivers-proxy').length,
1,
@@ -0,0 +1 @@
{"st":"AK","state":"Alaska","fips":"02","features":[{"geoid":"0200065","name":"Adak","full":"Adak city","lsad":"city","polygons":[[[-1766965,518536,115,-143,3,-288,815,0,533,339,-114,0,-127,-30,-39,-2,-35,-34,-50,-11,-6,-11,-45,-3,16,41,-38,-6,-28,11,5,56,19,6,4,14,31,24,-8,17,19,7,6,10,68,10,-65,38,-44,-15,8,-20,-25,-19,-25,3,-15,11,-24,-11,-32,0,-36,11,-96,3,-27,-11,-40,10,-49,0,-37,-11,-67,4,-16,-29,-59,-8,-48,9,-14,17,-8,19,10,42,123,22,23,13,94,8,18,19,22,44,-22,21,-8,65,12,20,0,51,76,163,23,15,157,49,40,4,37,-5,36,24,63,24,115,22,75,-7,31,-11,2,-41,16,-15,45,8,10,9,-23,16,9,29,14,14,52,24,-16,30,5,17,25,18,8,25,24,20,-32,22,-34,3,-96,71,-41,98,-10,0,-17,49,-70,21,-455,7,-56,44,-56,10,-78,-32,-176,-51,-96,1,-9,-21,24,-39,-7,-48,23,-17,37,2,61,-18,23,2,12,-20,35,-26,4,-23,28,-14,-1,-22,-22,-33,7,-12,26,-11,23,0,22,-19,-13,-14,-111,0,-468,-437]]]},{"geoid":"0200650","name":"Akhiok","full":"Akhiok city","lsad":"city","polygons":[[[-1542668,569259,517,0,528,72,0,114,39,18,-7,13,36,12,-11,22,-21,-1,-5,20,-19,-4,53,42,9,18,-25,25,16,10,-1111,0]]]},{"geoid":"0200760","name":"Akiachak","full":"Akiachak CDP","lsad":"CDP","cdp":true,"polygons":[[[-1614587,609108,106,-27,47,9,21,-11,45,0,45,-14,-58,1,-41,10,-48,-4,38,-30,36,-13,147,-22,67,11,80,48,61,16,64,-4,91,-37,107,-82,66,27,34,28,-16,17,-54,22,-68,8,-33,35,42,19,63,14,30,-10,18,-26,5,-29,17,-16,25,-3,13,16,-23,19,19,18,61,4,35,-6,53,12,-4,10,-54,36,-3,42,-121,158,-29,-1,-30,22,19,18,-24,-2,-18,10,3,37,-117,-10,-76,18,-22,-4,-17,-24,-27,-2,-38,11,-15,34,-49,6,-46,-6,-79,-34,53,-21,-9,-24,37,-17,-32,-23,20,-27,-34,-3,-40,16,-20,-17,-23,-1,-7,-15,25,-13,-27,-13,-41,-6,4,-23,-20,-27,-55,44,-57,19,-7,-22,-24,-17,-3,-58,-96,-14]]]},{"geoid":"0200870","name":"Akiak","full":"Akiak city","lsad":"city","polygons":[[[-1612344,608917,445,0,-1,289,-446,0]]]},{"geoid":"0201090","name":"Akutan","full":"Akutan city","lsad":"city","polygons":[[[-1659430,541813,247,0,0,-578,246,0,0,-145,731,0,0,-145,738,0,0,145,736,0,-5,19,15,18,128,53,24,19,-5,14,-43,14,-9,25,9,34,-102,26,-36,-25,-35,-4,-19,-23,-7,-26,-131,-19,-83,-42,-131,-7,-7,32,-17,18,-62,0,-33,-34,-5,-33,-73,-24,-169,45,-93,4,-81,25,-61,-1,-36,23,-64,7,-60,24,-52,0,-59,17,-171,-2,-19,26,7,30,38,21,78,13,195,-20,31,-18,45,1,38,18,57,-5,29,15,95,2,64,16,57,41,114,40,88,-5,26,6,-45,33,-43,-3,-16,14,-10,30,-90,85,-38,10,-45,-6,-105,43,-67,-5,-68,22,-100,57,-81,-22,-38,-20,-33,3,-27,21,0,24,29,32,45,22,41,4,21,14,2,15,-45,17,-59,-14,-81,-48,-202,-163,-45,-3,-233,51,-55,25,-17,21,5,28,-57,22,-59,38,-24,32,2,21,55,35,26,7,10,54,26,23,31,10,19,25,-12,31,5,25,-67,76,-159,39,-69,7,-57,-19,-67,-36,-109,-12,-81,9,-43,14,-7,65,-40,31,0,19,-22,17,-47,-17,-22,-31]],[[-1656791,542343,100,-53,316,4,114,49,19,19,2,21,38,18,24,29,38,18,140,-22,95,-35,57,-50,22,-40,0,-46,-34,-30,-42,-16,-41,-44,-78,-3,-188,-89,-71,-19,-36,-45,-45,-29,-10,-31,50,-27,62,-77,50,-42,74,-29,145,-11,26,-24,-2,-40,-17,-57,-33,-36,-81,-50,-36,-42,-93,-66,-45,-15,-69,10,-102,-21,-43,-22,86,-10,43,-13,43,-23,0,-24,23,-17,45,-8,41,-22,28,-63,48,-10,12,-48,40,-21,31,22,57,4,240,-78,112,-4,17,31,33,12,90,3,36,22,14,57,26,23,33,11,19,46,-19,21,-59,26,-50,8,-90,-1,-12,18,19,18,26,4,29,21,-29,16,-5,21,69,15,50,29,41,6,38,-7,90,-46,-1,663,-493,0,-1,289,-106,0,0,145,-494,0,0,-145,-489,0]]]},{"geoid":"0201200","name":"Alakanuk","full":"Alakanuk city","lsad":"city","polygons":[[[-1647500,627112,305,0,4,-722,1568,0,0,144,308,0,-3,433,315,0,-1,289,-1249,1,1,144,-1248,0]]]},{"geoid":"0201305","name":"Alatna","full":"Alatna CDP","lsad":"CDP","cdp":true,"polygons":[[[-1530015,665958,25,-62,23,-27,81,-60,52,-5,44,-17,21,-46,120,-63,7,-16,-17,-35,8,-10,1,-71,39,-23,-47,-33,0,-16,68,-18,86,0,56,-9,-1,-7,94,-14,91,-1,64,-15,79,8,30,-16,41,6,72,-8,68,10,51,-5,120,37,44,0,24,11,36,-4,22,9,106,-19,20,-19,18,-2,18,-39,38,-12,74,-7,16,-11,-25,-3,-25,-53,-58,-19,-17,-20,-72,-5,-40,-27,2,-16,-37,-6,27,-19,-43,-4,11,-25,-43,-17,20,-13,-42,-2,4,-17,-92,-88,210,-6,187,5,329,-33,53,2,68,21,16,32,45,15,14,34,-6,42,-15,37,-25,36,-14,7,10,33,62,37,118,54,57,45,35,12,34,-1,71,-15,-8,-11,74,-4,70,8,53,12,100,68,76,26,158,12,68,-19,-1,345,163,0,13,15,-97,98,-33,-8,-642,14,-2462,147,5,-35,-47,-30]]]},{"geoid":"0201390","name":"Alcan Border","full":"Alcan Border CDP","lsad":"CDP","cdp":true,"polygons":[[[-1413223,626355,17,-62,101,-91,134,-59,3,-37,-57,-55,72,-56,-24,-3,16,-19,-12,-17,28,-10,-36,-2,23,-8,18,-26,14,1,-17,-23,70,-32,4,18,21,-17,5,17,52,-22,19,10,14,-10,29,1,24,14,14,-9,-26,-13,25,0,-24,-14,24,-11,42,0,-42,-20,66,5,-1,-16,24,-5,3,12,68,-26,-28,-13,36,-22,-9,-8,-53,-1,-22,-18,51,8,33,-5,9,-22,-14,-12,57,-15,19,9,30,-2,-14,-11,58,0,31,-21,-1,25,17,1,3,-16,18,-10,46,4,17,-4,-23,-5,64,-19,-10,12,46,4,-1,-13,38,-9,8,16,-27,12,66,2,14,17,0,-22,20,-5,75,6,6,12,27,-20,-19,-7,19,-12,-31,6,-17,-11,19,4,29,-16,4,12,19,-15,69,-14,-23,-24,31,-1,-17,11,61,2,22,10,5,-14,55,-12,-13,-27,36,-6,-2,16,30,-17,-1,12,25,-4,1,-12,67,13,14,-20,33,-6,56,16,-4,11,-38,6,62,23,33,3,3,9,38,-3,39,24,130,-27,30,-14,37,Line truncated
@@ -0,0 +1 @@
{"st":"AL","state":"Alabama","fips":"01","features":[{"geoid":"0100100","name":"Abanda","full":"Abanda CDP","lsad":"CDP","cdp":true,"polygons":[[[-855420,331037,13,-55,47,-73,4,-26,72,-49,-9,-23,-40,-43,-9,-29,46,9,98,0,42,22,0,43,83,21,-1,29,-96,106,-35,19,-34,-12,25,44,47,5,30,17,-7,45,-271,-6,0,-27]]]},{"geoid":"0100124","name":"Abbeville","full":"Abbeville city","lsad":"city","polygons":[[[-852958,315472,246,0,0,31,86,0,31,-64,-7,-118,77,1,2,38,88,-6,4,68,134,-5,-4,-53,91,-5,2,26,27,-2,-2,-26,34,-3,3,23,31,-2,2,28,-22,1,-9,14,-32,3,16,281,-9,147,-81,4,-7,72,-71,3,-12,72,-38,0,10,-71,-506,12,2,-190,-45,-4,-2,-124,-38,1]]]},{"geoid":"0100460","name":"Adamsville","full":"Adamsville city","lsad":"city","polygons":[[[-870488,336355,26,-1,16,8,1,-19,44,0,1,-72,212,0,-3,12,12,24,21,-1,1,-36,-23,1,0,-38,-47,1,0,-36,17,-16,17,-59,-32,1,1,-37,46,-1,8,65,18,-29,19,0,0,9,93,23,1,-181,11,-11,16,0,6,11,40,0,0,-18,-16,0,-20,-27,-9,0,0,-18,-30,18,2,-27,87,-1,1,54,5,0,1,-23,36,12,1,-25,23,0,2,18,14,-4,-19,-28,23,-5,-1,-36,-32,0,-11,15,-22,-15,0,-36,-22,0,0,-37,36,-36,48,-1,-1,-62,7,-10,55,-1,20,-49,11,27,36,21,5,-13,19,0,8,-9,10,14,-2,-16,53,-23,0,-22,-20,-16,-18,-42,8,-6,118,-20,43,-60,18,-9,36,-1,10,19,2,28,15,7,32,-21,63,2,-6,-4,27,-20,18,0,34,17,-5,18,-25,8,-1,9,17,7,-26,1,0,7,25,-1,0,14,-40,4,-11,18,-4,66,27,-5,9,10,6,-14,38,-6,0,-158,24,-59,16,0,5,-85,-10,-19,12,6,9,-15,-16,-5,-1,-50,-13,-7,39,-18,36,14,-18,10,13,23,-13,19,1,175,97,-11,1,9,-22,0,0,9,32,0,1,27,-154,1,1,112,43,-1,0,37,-18,0,0,-21,-21,0,0,14,-19,-5,15,13,0,29,28,-4,4,10,-32,1,2,69,-38,-18,-20,21,55,37,4,-18,80,-5,-2,16,-77,15,0,11,17,9,-4,21,-23,-7,10,-5,-1,-36,-15,0,1,30,-12,13,0,-13,-17,-4,11,21,-42,15,-1,-24,32,-2,0,-9,-63,-6,-5,34,7,27,-28,15,4,51,-39,9,-4,64,-58,-3,-29,4,-16,10,-5,-8,-20,7,3,14,84,-9,-4,38,-87,2,-1,60,-14,-3,-15,10,-6,16,-5,-26,-8,7,-42,-1,-83,-19,44,-1,1,-21,-25,-1,-20,10,1,-29,11,0,0,-18,-45,0,0,18,-37,9,-3,-26,-13,0,0,35,43,0,0,35,-40,0,0,10,-33,27,-27,0,8,-8,-14,-15,-3,23,-108,4,0,37,85,-3,0,66,8,6,81,1,-2,207,11,11,12,0,22,71,-141,3,1,53,-86,22,-44,0,-4,22,-37,-34,-5,-19,3,62,-27,-36,-24,-13,-28,21,-47,64,-35,35,-19,11,-20,-3,-27,-22,-48,-90,8,-19,19,0,-7,-20,17,1,0,5,34,-5,3,20,41,-18,-52,-36,-100,-38,-24,-34,-19,-47,1,-55,-21,0,-40,-23],[-869779,335825,89,-1,-1,73,174,-2,-1,-36,-86,1,-3,-110,-174,2]],[[-869343,335929,44,-1,1,37,35,-1,8,35,13,0,1,-19,15,-1,0,-16,15,0,-1,-37,43,-1,0,-30,-54,4,10,-9,152,-3,-7,11,28,-1,1,79,43,-1,0,-38,25,0,0,14,17,-12,-1,75,-40,5,-1,47,22,-1,-22,17,6,21,-28,0,0,-37,-56,1,1,65,-52,9,0,37,-43,0,0,-18,-23,0,22,-5,-1,-50,43,-1,1,-36,-108,3,-22,14,0,-13,-45,0,1,-28,23,1,3,-14,-69,5]],[[-869707,336730,6,-82,8,-5,-12,-14,-21,-80,66,0,14,26,43,35,-21,50,42,20,-8,13,-55,2,4,-18,-35,-3,-20,41,0,42,36,0,-3,10,-33,6,0,20,-19,9,-12,26,8,13,-16,0]]]},{"geoid":"0100484","name":"Addison","full":"Addison town","lsad":"town","polygons":[[[-872034,342013,49,-1,0,-44,15,-15,14,-1,6,15,12,0,4,-28,-15,-10,-33,-2,6,-24,21,0,22,28,10,0,1,-35,-13,-25,11,-39,64,0,-9,-7,13,-26,7,23,-7,10,7,79,44,0,1,27,38,0,0,-76,14,14,-2,26,35,1,4,19,16,0,0,15,156,0,0,-33,45,0,0,30,10,-2,14,14,0,15,-24,9,-45,-8,-8,14,0,68,-121,-1,1,71,43,4,0,-12,24,0,0,37,26,20,0,-13,14,0,1,15,-12,0,32,23,26,-13,15,29,-32,14,-94,-73,0,42,-85,0,0,-36,-133,1,0,-32,-51,0,0,-41,-42,-1,0,-34,-79,7,-17,-8]]]},{"geoid":"0100676","name":"Akron","full":"Akron town","lsad":"town","polygons":[[[-877497,328726,160,-2,1,29,-107,134,-53,-58]]]},{"geoid":"0100820","name":"Alabaster","full":"Alabaster city","lsad":"city","d":5,"polygons":[[[-8687877,3323076,438,-6,-8,-594,434,-7,-1,-140,769,-20,-8,-144,-99,-323,-240,6,-1,-257,268,-1,39,-153,163,-219,-185,3,-4,-36,-523,5,36,-59,477,-9,-6,-30,-285,-60,-89,2,187,-155,114,-12,-1,-67,43,-146,-181,208,-493,8,1,374,-301,1,-133,-40,5,-1042,432,-20,13,-2548,431,-15,-8,366,492,-10,83,357,-321,7,-41,-268,-218,-14,-19,1032,489,-38,-37,-73,300,20,-16,100,-115,341,260,-2,-3,-378,434,-7,-2,-368,291,-6,4,379,133,-47,-9,-703,452,-10,2,-351,1141,-31,153,-117,-5,-247,434,-10,6,366,177,52,-34,1,35,60,220,69,-119,-121,241,101,-60,-177,1,-500,481,-5,-8,-62,282,-6,4,553,-328,8,-1,94,-320,0,-62,158,-11,80,1019,-77,-185,-273,41,-538,141,-270,115,-60,347,78,57,-161,131,-6,-282,-224,311,-5,1,245,243,134,-151,177,-46,-33,-126,-38,0,387,29,172,242,-10,-1,-143,208,-11,32,240,432,-22,9,-862,16,-3,2,129,422,-13,-4,178,438,-6,1,180,589,-12,-571,1125,757,-25,-2,47,43,-1,27,-642,167,-252,214,-92,81,3,-7,361,445,-16,0,381,59,-109,888,-46,-166,307,241,102,78,-119,663,-16,7,735,-895,17,-5,125,-537,24,-129,215,-145,126,-67,-163,-496,-59,65,133,-136,5,-84,362,237,48,-186,210,-250,521,536,-34,-3,333,-67,55,-263,-19,-1,75,-220,-11,1,-80,-309,-11,-9,-186,202,-229,-176,-160,-186,151,-419,9,8,130,-689,34,-623,769,462,-2,-1,194,-234,30,-66,-116,-176,-106,-126,205,7,250,287,-98,307,-12,2,184,68,46,-4,116,-65,-4,-4,390,1094,-22,25,336,-32,4,4,-337,-1094,21,3,-395,69,-107,-150,-44,-176,150,-48,-206,-34,262,-137,166,-257,425,90,123,632,-15,-1,723,425,3,-3,356,437,Line truncated
@@ -0,0 +1 @@
{"st":"AR","state":"Arkansas","fips":"05","features":[{"geoid":"0500190","name":"Acorn","full":"Acorn CDP","lsad":"CDP","cdp":true,"polygons":[[[-942258,346517,72,-69,24,-34,32,-78,-5,-80,-34,-42,37,-22,88,17,30,-2,25,-11,10,-28,15,-8,16,0,38,34,10,49,-9,13,0,35,44,12,25,19,15,45,-16,29,-17,3,-18,28,-4,29,18,4,1,47,-93,2,-18,39,-107,1,-1,-66,-15,5,-38,53,-36,14,-62,-2,-61,-19]]]},{"geoid":"0500250","name":"Adona","full":"Adona city","lsad":"city","polygons":[[[-929103,350346,221,-3,3,109,-222,4]]]},{"geoid":"0500580","name":"Alexander","full":"Alexander city","lsad":"city","polygons":[[[-924723,346094,46,-2,0,-37,277,-2,-4,34,7,16,-19,18,-13,-5,12,30,-21,0,3,28,44,22,1,36,-35,1,-7,15,45,2,6,19,-49,0,50,44,-47,38,-22,-15,2,44,-8,15,9,9,3,61,-52,-8,-2,-31,8,0,8,-47,-27,0,-27,-11,0,-9,43,-1,-1,-52,32,-12,-21,-23,-191,-143,-49,1]]]},{"geoid":"0500700","name":"Alicia","full":"Alicia town","lsad":"town","polygons":[[[-910886,358875,100,-5,2,110,-56,3,30,36,-10,6,4,-8,-28,-33,-39,2]]]},{"geoid":"0500730","name":"Alix","full":"Alix CDP","lsad":"CDP","cdp":true,"polygons":[[[-937366,354212,40,-1,5,-38,12,1,-1,-33,45,-2,0,34,45,0,-24,54,-17,10,1,18,43,23,-132,7,-1,-20,21,0,-1,-30,-34,-7]]]},{"geoid":"0500820","name":"Alleene","full":"Alleene CDP","lsad":"CDP","cdp":true,"polygons":[[[-942841,337672,290,-175,24,37,4,39,14,12,-10,21,41,19,16,32,77,27,-313,76,-18,-6,-18,-25,-24,-11,-19,4,-5,-21,-39,-12]]]},{"geoid":"0500940","name":"Allport","full":"Allport town","lsad":"town","polygons":[[[-917973,345415,110,-1,4,-39,17,7,-1,-58,-17,1,17,-6,-1,-46,-20,1,0,-10,23,0,2,119,45,0,1,51,10,8,-48,1,-6,19,-1,-33,-26,-3,0,-8,-104,2,-5,6,0,-6]]]},{"geoid":"0500970","name":"Alma","full":"Alma city","lsad":"city","polygons":[[[-942543,354939,9,-12,134,-2,-14,-6,-1,-12,24,-15,-1,-77,40,-1,8,-36,18,-12,24,6,-2,-67,134,-3,-1,-36,24,0,0,-14,-24,0,0,-23,45,0,1,54,-30,1,-11,18,86,-1,2,72,-27,1,5,22,-22,-1,1,20,135,9,24,57,66,-2,4,36,82,6,91,35,65,12,1,44,-49,76,-110,-2,0,-8,42,-2,0,-58,-67,1,-19,-25,37,-17,47,-8,-49,-16,9,-30,-71,-7,-155,15,0,21,-150,-23,-15,-16,-17,6,2,18,37,9,0,17,20,12,2,44,17,32,-22,17,1,17,26,16,15,1,9,21,47,-8,16,11,-31,15,17,21,-19,45,-13,-5,0,-23,-32,-17,-58,-51,-15,-18,-8,-28,-57,52,8,-31,13,-5,0,-9,-12,-6,-44,2,0,18,-33,0,-11,18,-2,-72,-40,1,-14,-26,-28,1,-7,19,6,32,-12,12,-32,0,2,-35,20,-1,0,-24,17,5,-2,-8,-39,1,-1,8,-20,1,0,18,-22,1,-2,-46,45,-1,-1,-28,-66,2]]]},{"geoid":"0501030","name":"Almyra","full":"Almyra town","lsad":"town","polygons":[[[-914176,344020,133,0,2,74,-133,0]]]},{"geoid":"0501060","name":"Alpena","full":"Alpena town","lsad":"town","polygons":[[[-933157,362819,84,0,6,-31,43,-1,1,31,44,0,2,74,92,0,2,80,-270,-5]]]},{"geoid":"0501090","name":"Alpine","full":"Alpine CDP","lsad":"CDP","cdp":true,"polygons":[[[-933999,342255,19,-8,17,2,35,-29,142,10,7,-52,115,27,41,79,3,24,-77,-7,1,70,-21,-19,-145,-26,-36,-15,-9,4,-14,-33,-23,-4,-9,-9,-18,5,-31,-10]]]},{"geoid":"0501150","name":"Altheimer","full":"Altheimer city","lsad":"city","polygons":[[[-918728,343168,161,-5,0,-26,155,-11,2,71,76,-3,3,118,-44,1,1,72,-14,0,23,41,-310,-229,-53,-18]]]},{"geoid":"0501210","name":"Altus","full":"Altus city","lsad":"city","polygons":[[[-937892,354759,58,-12,-1,-12,31,-15,-13,-18,15,-6,37,-72,-18,0,-1,-24,31,-1,-1,-11,-30,1,1,-11,41,-31,3,22,40,-62,-2,-50,-28,-16,-17,1,-1,-37,44,-1,-2,-72,223,-7,2,73,-44,1,4,109,-134,4,2,37,-37,1,-1,-28,-11,3,-22,44,27,-1,1,18,-41,1,-37,74,35,-1,1,19,-44,1,3,54,43,-1,2,36,45,-2,2,37,-90,3,-1,-37,-23,1,-1,-33,-12,1,9,18,-18,15,2,36,-12,1,-2,-53,-58,18]]]},{"geoid":"0501270","name":"Amagon","full":"Amagon town","lsad":"town","polygons":[[[-911119,355604,30,-3,8,12,18,2,1,41,-60,0]]]},{"geoid":"0501360","name":"Amity","full":"Amity city","lsad":"city","polygons":[[[-934804,342563,11,24,34,0,-1,-17,-18,-37,304,-5,0,32,-22,6,1,35,17,-1,26,-22,2,58,-44,1,1,36,-44,1,1,36,44,0,0,18,44,-1,1,55,-44,1,1,26,-44,1,-1,-27,-263,4,-4,-135]]]},{"geoid":"0501457","name":"Anthonyville","full":"Anthonyville town","lsad":"town","polygons":[[[-903425,350372,21,-1,0,12,25,0,1,36,-47,1]]]},{"geoid":"0501540","name":"Antoine","full":"Antoine town","lsad":"town","polygons":[[[-934283,340278,88,0,-1,61,61,7,-26,34,-30,20,-88,2]]]},{"geoid":"0501630","name":"Aplin","full":"Aplin CDP","lsad":"CDP","cdp":true,"polygons":[[[-930023,349657,38,-10,30,1,32,-30,38,-13,19,-31,43,1,14,8,8,26,14,2,-1,-25,16,-16,34,-7,-1,35,23,7,18,-6,25,-27,10,0,35,31,-26,14,7,33,30,55,-10,26,-79,9,-15,31,-34,-11,-1,15,-19,0,-35,-26,-211,1]]]},{"geoid":"0501660","name":"Appleton","full":"Appleton CDP","lsad":"CDP","cdp":true,"polygons":[[[-929068,354342,11,-23,32,-14,28,-32,26,-10,22,-52,33,-39,8,10,34,-23,2,-20,44,-21,41,-62,40,-35,113,-57,114,-24,6,336,-44,-8,-64,6,17,31,-9,21,11,19,-397,5,-59,10]]]},{"geoid":"0501870","name":"Arkadelphia","full":"Arkadelphia city","lsad":"city","polygons":[[[-931112,341076,49,-1,38,36,44,-2,0,22,17,0,5,14,18,-1,-7,-13,10,-9,-16,0,-28,-48,42,-2,1,36,64,-2,1,36,22,0,-1,-36,42,-1,0,-37,45,-1,1,18,88,-3,Line truncated
@@ -0,0 +1 @@
{"st":"AS","state":"American Samoa","fips":"60","features":[{"geoid":"6000100","name":"Aasu","full":"Aasu village","lsad":"village","polygons":[[[-1707739,-143135,28,-8,32,1,42,-13,32,7,-8,-11,14,-21,10,17,62,4,31,15,28,-47,26,31,3,48,40,69,7,43,12,14,59,10,-101,68,-12,-9,-30,-4,-21,9,-27,-24,-11,-24,-11,-1,-19,46,-14,4,-31,-11,-16,9,10,28,-10,11,-18,-4,-10,-20,-26,8,-10,-4,-57,-138,2,-86]]]},{"geoid":"6002500","name":"Afao","full":"Afao village","lsad":"village","polygons":[[[-1708043,-143324,28,5,21,-11,0,-9,27,178,-5,11,-68,2,-9,-21]]]},{"geoid":"6003300","name":"Afono","full":"Afono village","lsad":"village","polygons":[[[-1706592,-142641,29,-13,24,-60,78,15,13,79,63,77,-16,71,-6,-2,4,-42,-10,-13,-17,-1,0,25,-24,13,-5,-20,6,-20,-8,-22,-17,-5,-12,9,-12,-11,-3,-25,-19,-4,-14,12,-1,16,7,48,12,20,-8,8,-42,-93,-34,-33]]]},{"geoid":"6004500","name":"Agugulu","full":"Agugulu village","lsad":"village","polygons":[[[-1708215,-143310,20,-2,12,-12,10,4,-34,131,-13,-34]]]},{"geoid":"6004900","name":"Alao","full":"Alao village","lsad":"village","polygons":[[[-1705782,-142619,110,-61,12,-27,15,9,-5,12,30,88,2,17,-10,11,-15,-9,-22,5,-68,-7,-28,-21]]]},{"geoid":"6006500","name":"Alega","full":"Alega village","lsad":"village","polygons":[[[-1706461,-142699,10,-36,49,-130,11,19,0,33,9,11,41,-8,-66,109,-46,3]]]},{"geoid":"6007300","name":"Alofau","full":"Alofau village","lsad":"village","polygons":[[[-1706049,-142725,13,-48,-36,-31,49,-37,35,23,24,6,-1,105,-37,26,-71,-38]]]},{"geoid":"6008100","name":"Amaluia","full":"Amaluia village","lsad":"village","polygons":[[[-1707946,-143337,10,-14,6,12,13,0,14,-7,-1,-13,10,-10,62,179,27,39,-33,9,-37,31,-19,6,-8,-2,2,-42,-11,-29,9,-24,-4,-20,-21,-14,-17,-78]]]},{"geoid":"6008900","name":"Amanave","full":"Amanave village","lsad":"village","polygons":[[[-1708462,-143233,46,-11,23,7,33,-23,36,13,23,-20,8,-31,27,33,-13,6,21,54,-24,6,-119,-22,-19,10,-49,-13]]]},{"geoid":"6009700","name":"Amaua","full":"Amaua village","lsad":"village","polygons":[[[-1706316,-142664,31,-46,44,-33,1,11,30,20,-13,50,-21,39,-49,-32]]]},{"geoid":"6010500","name":"Amouli","full":"Amouli village","lsad":"village","polygons":[[[-1705964,-142812,8,10,45,5,13,21,30,10,-5,11,24,16,23,6,16,-3,1,-11,23,-1,20,8,-27,28,-30,61,-116,-69,-26,13,-5,-12]]]},{"geoid":"6011300","name":"Anua","full":"Anua village","lsad":"village","polygons":[[[-1706893,-142718,35,3,-24,61,-16,6]]]},{"geoid":"6012100","name":"Aoa","full":"Aoa village","lsad":"village","polygons":[[[-1705939,-142720,116,69,22,-1,19,33,21,17,-103,60,2,-29,14,-30,-26,-16,-31,-3,-11,43,-47,-131]]]},{"geoid":"6012800","name":"Aoloau","full":"Aoloau village","lsad":"village","polygons":[[[-1707946,-142928,29,-44,15,-135,27,-4,37,-31,145,-44,80,-11,0,38,8,11,-32,-7,-102,20,6,17,-2,86,57,138,-62,-37,-13,21,-27,-10,-6,-23,-21,-8,-22,77,-21,-2,-6,-25,-42,-3,-7,6,-12,-8,-29,-2]]]},{"geoid":"6013700","name":"Asili","full":"Asili village","lsad":"village","polygons":[[[-1707975,-143316,25,1,4,-22,19,101,21,14,4,20,-9,24,11,29,-2,42,-37,-9,-33,-34,5,-11,-27,-178]]]},{"geoid":"6014500","name":"Atu'u","full":"Atu'u village","lsad":"village","polygons":[[[-1706876,-142674,20,-37,14,14,-16,46,-19,-1]]]},{"geoid":"6015300","name":"Aua","full":"Aua village","lsad":"village","polygons":[[[-1706671,-142699,19,-10,10,-26,-14,-27,10,-26,-5,-14,4,-39,14,-37,43,56,51,108,-24,60,-58,18,-78,-5,-18,-54]]]},{"geoid":"6016100","name":"Auasi","full":"Auasi village","lsad":"village","polygons":[[[-1705823,-142651,30,-61,27,-28,2,11,30,18,9,-2,-5,22,-52,72,-19,-33]]]},{"geoid":"6016900","name":"Auma","full":"Auma village","lsad":"village","polygons":[[[-1707860,-143368,33,37,-15,41,-12,-6,-7,15,-32,-86]]]},{"geoid":"6017700","name":"Aumi","full":"Aumi village","lsad":"village","polygons":[[[-1706544,-142729,12,-40,31,-57,-12,-52,4,-17,40,21,17,-8,22,18,28,-1,-59,166,-78,-15]]]},{"geoid":"6018500","name":"Aunu'u","full":"Aunu'u village","lsad":"village","polygons":[[[-1705617,-142857,30,-24,26,-5,16,11,39,-17,24,9,23,26,2,25,-33,36,-16,-3,-29,16,-50,-14,-17,-13,-8,-28]]]},{"geoid":"6020100","name":"Auto","full":"Auto village","lsad":"village","polygons":[[[-1706324,-142776,41,-21,8,33,34,21,-44,33,-31,46,-38,-14]]]},{"geoid":"6020900","name":"Avaio","full":"Avaio village","lsad":"village","polygons":[[[-1706341,-142810,21,-3,37,16,-41,21,-30,98,-53,-23]]]},{"geoid":"6022500","name":"Faga'alu","full":"Faga'alu village","lsad":"village","polygons":[[[-1706952,-142918,44,-25,42,2,49,-19,46,10,-3,8,-54,28,18,25,16,1,38,31,-119,-17,-20,26,-32,-25,-14,-40]]]},{"geoid":"6023300","name":"Faga'itua","full":"Faga'itua village","lsad":"village","polygons":[[[-1706199,-142663,20,-45,32,27,33,-4,4,-17,8,14,69,41,-22,0,-41,25,-56,-6,-52,6]]]},{"geoid":"6024900","name":"Fagali'i","full":"Fagali'i village","lsad":"village","polygons":[[[-1708274,-143171,16,-34,17,1,62,32,-58,147,-34,-51,-22,-5,-30,-28]]]},{"geoid":"6025700","name":"Fagamalo","full":"Fagamalo village","lsad":"village","polygons":[[[-1708122,-1Line truncated
@@ -0,0 +1 @@
{"st":"AZ","state":"Arizona","fips":"04","features":[{"geoid":"0400730","name":"Aguila","full":"Aguila CDP","lsad":"CDP","cdp":true,"polygons":[[[-1131855,339431,59,-3,31,7,-39,-19,1,-59,38,0,0,-73,217,3,1,143,-43,0,-1,42,-84,-18,-178,0]]]},{"geoid":"0400870","name":"Ajo","full":"Ajo CDP","lsad":"CDP","cdp":true,"polygons":[[[-1129509,323966,33,-131,4,-72,19,-21,-4,-11,11,-24,30,-20,41,-46,29,-13,40,14,37,-35,29,-55,19,-12,65,0,41,-22,22,-1,21,-22,19,-7,36,-7,17,-12,68,-10,40,-21,60,-1,65,-16,53,6,23,-17,29,-5,55,10,111,-22,38,41,22,10,84,11,40,26,16,38,-7,61,-99,89,-122,24,-32,27,-39,0,30,113,74,126,86,307,2,24,-18,60,2,25,31,104,-37,45,-69,7,-4,54,-94,67,-153,2,19,-68,-1,-186,16,1,-1,-71,-303,-3,-28,-42,-10,-32,8,-25,-11,-51,-44,-49,-43,-29,-37,-46,-22,-4,-6,-17,-89,-28,-117,29,-56,-22,-18,-28,-12,-2]]]},{"geoid":"0400940","name":"Ak Chin","full":"Ak Chin CDP","lsad":"CDP","cdp":true,"polygons":[[[-1120184,322839,19,-9,53,9,18,-38,43,16,17,19,11,49,-4,12,-33,7,10,41,-64,9,2,-37,11,-19,-30,1,-18,-33,-19,6]]]},{"geoid":"0401090","name":"Ak-Chin Village","full":"Ak-Chin Village CDP","lsad":"CDP","cdp":true,"polygons":[[[-1121173,330003,692,-2,2,363,-86,0,0,35,-87,-1,0,71,-37,-12,15,-13,-6,-9,-244,-2,-32,-53,-12,-4,-19,-22,-29,-4,-4,-20,15,-40,-166,2,-1,-17]]]},{"geoid":"0401170","name":"Alamo Lake","full":"Alamo Lake CDP","lsad":"CDP","cdp":true,"polygons":[[[-1136127,342297,10,-11,48,-15,16,-18,47,4,30,-15,11,7,13,32,-21,9,8,28,37,9,5,-12,50,-22,40,1,-5,-9,8,-8,97,-5,410,-167,-2,-20,12,3,16,22,-7,25,-14,14,-7,38,18,28,212,-186,54,-12,71,14,60,-61,12,78,33,60,-3,75,14,37,-8,35,44,-73,107,-52,70,-10,-76,249,307,-145,-4,31,-51,122,-22,34,-1,17,-10,14,-67,33,45,23,-2,21,-11,12,7,20,12,6,46,-11,35,21,-19,40,49,53,7,50,-7,42,21,28,53,24,32,-3,26,18,39,-10,43,8,10,13,1,21,-30,22,-5,20,2,33,23,14,-24,39,16,8,-68,18,-40,-2,-27,12,-134,-22,-58,7,-135,39,-15,0,-32,-20,-19,1,-12,24,-24,10,-51,-22,-87,20,-47,-23,-86,0,-49,19,-25,-4,-62,60,-29,2,-8,-16,-40,-1,-25,17,-41,-5,-84,-36,-75,-91,1,-9,-69,-108,-16,-3,-63,-52,-43,-45,-27,-10,-4,-47,-7,-10,-37,-15,-27,-32,-59,-45,-13,-19,-34,-145,-105,-62,-22,-39,-56,-38,-11,-2,-13,24,-48,-7]]]},{"geoid":"0401560","name":"Ali Chuk","full":"Ali Chuk CDP","lsad":"CDP","cdp":true,"polygons":[[[-1125721,318126,139,-30,-25,-21,45,-10,15,22,36,-7,15,10,18,28,1,43,22,18,-1,40,-23,30,-45,12,-6,-15,-38,-33,3,24,-136,12]]]},{"geoid":"0401570","name":"Ali Chukson","full":"Ali Chukson CDP","lsad":"CDP","cdp":true,"polygons":[[[-1118201,319030,52,-35,94,-9,15,12,6,32,34,31,21,-3,19,-17,60,22,-18,60,56,-5,7,38,17,23,-75,85,-19,4,-8,-23,-32,-5,-42,-20,-30,-67,-12,-11,-64,-8,-45,11,-18,-12,12,-64,-22,-20]]]},{"geoid":"0401620","name":"Ali Molina","full":"Ali Molina CDP","lsad":"CDP","cdp":true,"polygons":[[[-1117875,318989,-19,-13,9,-25,69,18,86,11,-6,-14,48,15,-19,10,-22,44,-27,30,12,54,-43,-9,13,-23,-46,3,-48,-20,-26,-23]]]},{"geoid":"0401920","name":"Alpine","full":"Alpine CDP","lsad":"CDP","cdp":true,"polygons":[[[-1091539,338431,35,11,0,-27,18,12,41,5,50,-5,41,-22,29,-4,16,23,-61,31,16,21,-13,2,-6,26,12,27,-21,-3,-19,-28,-33,8,-17,18,-22,-18,-19,7,-1,-44,-12,16,0,-23,-35,-6]]]},{"geoid":"0401990","name":"Amado","full":"Amado CDP","lsad":"CDP","cdp":true,"polygons":[[[-1110769,316988,9,-138,24,6,25,24,65,-3,16,-169,36,-180,82,-2,-24,81,25,74,-16,138,-65,45,-6,22,25,20,2,38,-11,24,71,-2,56,-35,13,5,-1,181,43,-1,-30,65,10,3,9,24,-9,15,32,39,-192,0,-36,-71,-26,-77,-15,9,-14,-6,4,-10,-53,-35,-72,-14]]]},{"geoid":"0402270","name":"Anegam","full":"Anegam CDP","lsad":"CDP","cdp":true,"polygons":[[[-1120524,323637,329,1,-4,98,-47,7,-8,46,-22,-8,18,143,-37,-16,-20,-29,-19,-11,-112,-34]]]},{"geoid":"0402410","name":"Antares","full":"Antares CDP","lsad":"CDP","cdp":true,"polygons":[[[-1138169,354165,84,21,87,-2,4,97,-94,1,0,-10,-82,0]]]},{"geoid":"0402430","name":"Anthem","full":"Anthem CDP","lsad":"CDP","cdp":true,"polygons":[[[-1121434,338483,-13,-147,8,-62,89,1,0,147,522,5,1,254,-43,0,0,36,-478,-5,0,72,-120,0]]]},{"geoid":"0402830","name":"Apache Junction","full":"Apache Junction city","lsad":"city","polygons":[[[-1115849,334164,43,5,-1,-292,17,0,0,-12,24,-22,3,-55,-80,0,1,-63,164,1,0,-36,-44,0,0,-36,-119,0,7,-582,27,-2,0,-69,583,0,-17,33,-19,101,-44,140,-153,234,688,0,-3,316,-126,0,0,36,-43,0,0,218,-86,0,-1,217,86,0,-1,218,-28,0,-14,-10,-1,-63,-86,0,0,73,-734,-1,-1,-300,-22,0,0,-26,-20,0]]]},{"geoid":"0403320","name":"Arivaca","full":"Arivaca CDP","lsad":"CDP","cdp":true,"polygons":[[[-1113553,315889,38,-28,-5,-10,102,-75,-36,-7,-19,-17,4,-22,18,-17,12,-46,-1,-57,32,-3,8,-19,-3,-46,55,-72,21,-125,16,-13,8,-39,16,-1,31,25,52,14,24,23,57,8,35,-4,-4,24,46,-6,41,6,39,-32,6,-18,17,-4,48,-68,22,-6,36,20,54,1,59,19,-8,16,17,14,16,5,11,-12,-52,135,-30,54,-18,4,4,22,-10,12,-21,0,-22,34,0,75,49,-20,76,10,51,-17,79,18,105,-23,41,0,13,-8,95,-5,10,72,-9,21,0,171,-75,0,-2,13,30,17,-11,31,33,25,-10,9,23,57,-16,75,-42,-12,-18,10,-25,-3,-31,16,-690,-23,-91,-69,-6,-16,-24,-15,-4,5,-35,-25,-197,-24,-33,-23]]]},{"geoiLine truncated
@@ -0,0 +1 @@
{"st":"CA","state":"California","fips":"06","features":[{"geoid":"0600135","name":"Acalanes Ridge","full":"Acalanes Ridge CDP","lsad":"CDP","cdp":true,"polygons":[[[-1220847,379081,41,-17,-1,-32,78,1,-4,5,19,4,-18,28,36,23,-51,0,-21,21,-30,-10,-68,2]]]},{"geoid":"0600156","name":"Acampo","full":"Acampo CDP","lsad":"CDP","cdp":true,"polygons":[[[-1212882,381773,1,-26,-13,0,2,-72,63,-1,0,-25,13,-5,77,1,-21,58,50,0,-3,117,-91,0,7,-20,-13,-7,-86,0]]]},{"geoid":"0600212","name":"Acton","full":"Acton CDP","lsad":"CDP","cdp":true,"polygons":[[[-1182577,344818,117,-1,10,-36,-3,-108,83,1,0,-40,174,-7,-1,30,117,4,2,-32,12,0,3,-68,216,-4,0,-20,356,-17,6,141,298,2,2,141,8,2,210,2,0,-73,170,0,0,219,-411,-2,-19,20,-7,31,-81,76,-14,0,-214,199,-87,-2,1,-39,-133,1,2,76,-92,-2,0,36,-43,2,1,36,-176,4,0,37,-132,1,0,-37,-353,2,-6,-6,6,-17,0,-411,-44,0]]]},{"geoid":"0600296","name":"Adelanto","full":"Adelanto city","lsad":"city","polygons":[[[-1175068,345575,31,0,1,-287,393,-2,2,72,559,0,0,-73,-265,0,0,-110,-252,1,1,35,-155,3,0,-37,-110,2,0,-109,868,-3,1,364,352,-1,1,142,-100,2,-1,148,-243,-2,0,73,-22,0,-1,219,109,0,0,146,-18,0,-1,296,327,-2,-11,24,4,19,-17,103,-393,1,0,-368,-176,-1,-177,3,-2,370,-529,1,-6,-293,89,0,0,-147,89,0,5,-293,-136,-2,-1,-73,-222,-1]]]},{"geoid":"0600310","name":"Adin","full":"Adin CDP","lsad":"CDP","cdp":true,"polygons":[[[-1209714,411843,335,-1,4,63,58,-1,38,14,22,18,17,33,35,28,-41,2,-83,64,-4,12,-13,-7,-38,9,8,-41,-33,-30,-24,-10,-17,0,-123,52,-142,0]]]},{"geoid":"0600394","name":"Agoura Hills","full":"Agoura Hills city","lsad":"city","polygons":[[[-1188004,341394,130,-31,176,-17,132,62,182,-80,16,-16,20,14,63,0,0,-11,46,1,-2,-63,43,0,0,37,-13,24,13,7,0,42,-43,-1,0,19,-51,3,-79,46,0,114,44,0,40,135,-602,3,-39,-222]]]},{"geoid":"0600450","name":"Agua Dulce","full":"Agua Dulce CDP","lsad":"CDP","cdp":true,"polygons":[[[-1183432,344674,-1,-63,27,38,32,13,74,8,-10,31,21,10,26,-2,-1,143,17,35,162,-1,-2,-72,197,2,20,26,9,55,31,26,122,47,102,15,22,9,28,24,1,352,-6,17,8,44,-9,7,-18,-5,-79,16,-14,-4,-30,-34,2,-12,-50,-55,-42,-6,-20,26,1,10,-37,3,-47,-15,1,16,-24,-10,-10,3,-32,-20,-4,10,-57,-24,-28,17,-56,12,-43,-39,-22,-10,-10,-16,-55,-26,-15,-20,-243,-3,-63,-16,-28,21,-32,-32,12,-29,-12,-17,2,-213,-111,-4,-32,-16,-15,-21,-7,-35,5,-78,-14,-136]]]},{"geoid":"0600464","name":"Aguanga","full":"Aguanga CDP","lsad":"CDP","cdp":true,"polygons":[[[-1169035,334466,13,7,9,-15,25,-5,5,-17,36,10,44,-26,117,-15,0,-28,-47,2,-9,-23,-32,-7,-6,-37,9,-19,30,-11,3,-13,620,0,13,15,20,6,6,41,-37,12,-4,12,8,27,-19,0,-20,13,18,13,0,171,-19,4,0,40,-37,16,-3,21,24,3,15,-11,30,28,14,-4,6,12,15,4,-7,9,36,22,-10,38,-15,7,-22,41,-18,-17,-10,-34,-21,-11,-17,6,-13,17,-10,-7,-52,7,3,63,-7,11,-83,-19,-53,-40,-56,0,-45,-18,-1,19,-18,18,-17,-2,-37,-32,-20,8,-19,-13,-15,8,-5,-8,-29,-1,12,-13,-16,-19,14,-49,-3,-20,39,-1,37,-24,-58,-6,-19,-63,-18,52,-73,79,-99,2,5,-138,-53,-13,16,-34,-1,-16,-15,-9,-24,1,-19,16,-22,1,12,-18,-15,-5]]]},{"geoid":"0600478","name":"Ahwahnee","full":"Ahwahnee CDP","lsad":"CDP","cdp":true,"polygons":[[[-1197528,373759,53,-62,1,-32,17,-12,-3,-24,44,-49,-24,-68,-18,-10,41,-55,21,16,23,-16,20,-2,16,15,11,-18,41,-14,79,-49,8,-20,65,-26,20,5,-10,-9,1,-30,130,1,-3,71,31,12,15,-8,64,22,43,-37,61,34,36,7,-18,6,-76,-6,-58,14,-44,-10,-23,8,20,20,5,74,15,-4,9,14,24,9,14,22,17,9,2,29,28,35,-102,85,-52,120,-2,13,9,7,4,24,45,18,26,20,8,19,-30,26,-38,9,-3,8,-10,-6,-25,9,-35,-10,-46,19,-24,-11,-6,32,-29,-11,-10,-13,-22,16,-23,-9,-34,-39,-1,-39,-13,-11,-2,-36,-43,-19,-11,-40,-23,7,-19,15,-6,18,-24,11,1,55,-34,9,-50,-7,-6,-13,-20,0,-29,-45,8,-7,2,-60]]]},{"geoid":"0600535","name":"Airport","full":"Airport CDP","lsad":"CDP","cdp":true,"polygons":[[[-1209782,376347,35,0,0,-37,69,-2,15,9,0,23,-17,0,0,15,-35,0,0,7,-32,-4,-1,10,-34,10]]]},{"geoid":"0600562","name":"Alameda","full":"Alameda city","lsad":"city","polygons":[[[-1223309,377951,-7,-80,36,-57,102,-17,24,-31,90,-5,65,-38,111,-36,-2,-41,59,-7,37,42,44,-24,31,-42,280,-97,-10,-15,-168,-69,7,-38,50,-39,75,-78,6,5,117,-85,79,78,15,0,3,204,31,-1,-8,148,-24,41,-139,68,-30,26,-13,28,-4,39,-14,17,-47,22,-27,-1,-61,-23,-84,34,-97,52,-78,5,-150,-16,-275,55,-45,16]]]},{"geoid":"0600618","name":"Alamo","full":"Alamo CDP","lsad":"CDP","cdp":true,"polygons":[[[-1220555,378731,25,-74,-11,-14,9,-10,8,-208,90,-5,1,-45,55,2,21,-7,98,-91,35,13,8,-12,12,32,47,45,-33,34,27,12,22,30,-19,16,35,13,33,1,44,-15,78,4,1,-81,101,3,0,72,209,7,-5,-17,9,-1,-4,-5,13,12,41,13,0,116,-44,15,-17,-5,-46,19,-23,23,-156,-5,-8,66,-84,14,12,25,-47,14,13,33,-30,8,-29,20,-35,-19,-18,-33,14,-27,-97,16,-2,-70,-30,-1,0,56,-29,14,-1,-69,-24,-7,-3,21,-66,-1,-15,-21,-18,30,-19,11,-7,-5,-35,36,2,12,-32,14,-29,52,-24,-15,14,-45,-20,-16]]]},{"geoid":"0600674","name":"Albany","full":"Albany city","lsad":"city","polygons":[[[-1223269,378887,107,-38,34,-36,159,22,140,-10,11,25,-9,4,10,29,-7,29,-46,-3,3,76,-32,-9,-63,13,-169,-16,36,-43,-16,-26,-43,18,-115,3,0,-29]]]},{"geoid":"0600716","name":"Albion","full":"Albion CDP","lsad":"CDP","cdpLine truncated
@@ -0,0 +1 @@
{"st":"CO","state":"Colorado","fips":"08","features":[{"geoid":"0800320","name":"Acres Green","full":"Acres Green CDP","lsad":"CDP","cdp":true,"polygons":[[[-1049042,395510,154,-2,15,30,13,3,4,9,-8,1,1,31,-32,1,-30,32,-42,6,-2,-9,-30,-1,-23,-10,0,-19,-26,0]]]},{"geoid":"0800620","name":"Aetna Estates","full":"Aetna Estates CDP","lsad":"CDP","cdp":true,"polygons":[[[-1046783,397364,94,0,0,-15,5,0,0,50,-99,-4]]]},{"geoid":"0800760","name":"Aguilar","full":"Aguilar town","lsad":"town","polygons":[[[-1046626,373994,26,3,32,-18,9,17,92,1,-34,28,0,22,-14,0,17,26,-3,22,-76,0,0,-49,-20,1,-5,-16,-24,0]]]},{"geoid":"0800870","name":"Air Force Academy","full":"Air Force Academy CDP","lsad":"CDP","cdp":true,"polygons":[[[-1048985,390014,9,-13,50,-9,9,-15,-5,-20,14,-17,40,6,4,-18,-7,-18,-24,-23,-2,-25,34,-28,8,-26,52,-11,67,-33,5,-43,38,-25,93,-17,24,-21,26,-7,5,-9,36,3,11,11,82,-30,4,-9,16,0,28,-30,13,90,22,71,-21,6,15,54,-15,125,-169,279,-136,-26,-141,16,-101,28,-61,-13,-4,-51,12,-22,-13,-57,-17,-24,-5,-21]]]},{"geoid":"0800925","name":"Akron","full":"Akron town","lsad":"town","polygons":[[[-1032256,401721,122,-20,-96,-5,3,-187,39,0,7,41,44,0,-7,-40,50,-1,-9,-66,-51,1,0,27,-35,0,-2,-75,90,-1,14,177,84,-11,5,64,40,0,-1,-69,29,-1,1,17,15,-6,2,93,-132,1,1,35,-45,0,0,36,-46,-1,0,-15,-119,19]],[[-1032463,401624,189,0,-3,144,-185,1]]]},{"geoid":"0801090","name":"Alamosa","full":"Alamosa city","lsad":"city","polygons":[[[-1059136,374832,357,-156,-7,-11,-42,18,7,10,-208,89,2,-143,90,0,0,47,51,0,0,-19,-32,0,0,-9,26,0,0,-11,18,0,0,-24,52,0,8,-38,14,0,0,17,45,0,-1,-8,-33,0,0,-9,36,-2,-14,-27,14,0,-9,-19,-15,0,-13,-26,38,0,0,-49,-17,2,-10,16,-28,0,-20,-37,49,10,0,-10,28,0,0,-28,187,-4,-1,28,38,-2,0,73,-45,0,-1,27,-45,10,0,26,60,14,37,-30,8,7,31,1,5,-11,30,6,-28,63,15,14,-3,19,-36,-8,-30,10,0,-37,-45,-7,0,61,-35,15,-11,18,0,55,43,4,0,36,-14,0,14,11,-2,65,-88,1,0,71,-44,-1,-1,73,-46,0,-1,73,-183,0,1,-39,27,-21,1,-51,-74,-1,-1,24,-24,-3,-2,-15,-24,-7,0,-20,36,-14,0,-13,-31,-6,0,-14,27,6,9,-6,-9,-20,2,-22,-26,0,0,-37,-27,1,-18,16,-46,-6,-16,7]]]},{"geoid":"0801145","name":"Alamosa East","full":"Alamosa East CDP","lsad":"CDP","cdp":true,"polygons":[[[-1058619,374708,11,-18,35,-15,0,-9,106,-1,258,-25,0,241,-369,-2,2,-65,-14,-11,14,0,0,-36,-43,-4,0,-32]]]},{"geoid":"0801420","name":"Allenspark","full":"Allenspark CDP","lsad":"CDP","cdp":true,"polygons":[[[-1055426,402250,100,1,-3,-56,-24,-1,-4,-21,31,-8,1,-51,-102,-8,-1,-201,11,4,-1,-20,40,-2,3,23,48,20,-6,-220,72,24,37,79,286,-9,0,35,95,-4,0,-35,47,-1,-2,-100,60,32,37,-1,-7,-72,94,-1,8,13,4,45,37,25,-16,8,118,-5,0,90,-50,2,-3,38,-139,2,-1,35,-46,-3,-1,35,-142,-7,-188,29,-195,-4,-2,35,80,1,8,10,-50,91,11,11,45,0,-2,248,101,-3,-7,10,2,138,-93,2,0,34,-46,-1,0,72,-247,5]]],"label":[-105.5230877016129,40.21395]},{"geoid":"0801530","name":"Alma","full":"Alma town","lsad":"town","polygons":[[[-1060774,392893,31,-29,5,-28,24,0,21,-9,4,-9,46,0,0,-18,24,0,0,-9,16,10,19,-13,4,19,-16,15,9,13,-18,9,-13,22,-10,43,-91,-13,10,-42,-30,9,-40,35,42,0,1,27,-46,0,-1,-25,-55,9]]]},{"geoid":"0801640","name":"Alpine","full":"Alpine CDP","lsad":"CDP","cdp":true,"polygons":[[[-1066001,376839,43,0,0,-20,17,-12,32,0,65,31,116,2,2,22,40,-2,-5,46,-37,-24,-16,5,6,25,17,0,-23,10,1,7,-217,-1,1,-33,-22,23,-22,-2]]]},{"geoid":"0801740","name":"Altona","full":"Altona CDP","lsad":"CDP","cdp":true,"polygons":[[[-1053079,401147,11,-22,17,0,1,-29,19,-6,10,5,-23,11,0,69,59,-15,76,5,20,-8,25,-29,43,-8,-2,298,-40,0,-24,-9,-2,-11,-23,-12,2,-36,-7,-24,-21,2,-32,21,-57,-76,-18,-6,-16,-31,-7,-67]]]},{"geoid":"0801915","name":"Amherst","full":"Amherst CDP","lsad":"CDP","cdp":true,"polygons":[[[-1021835,406825,122,-49,126,86,-247,0]]]},{"geoid":"0802355","name":"Antonito","full":"Antonito town","lsad":"town","polygons":[[[-1060158,370752,44,0,0,-32,10,-1,-44,-27,21,-25,50,27,-1,23,18,1,2,109,-94,0]]]},{"geoid":"0802575","name":"Applewood","full":"Applewood CDP","lsad":"CDP","cdp":true,"polygons":[[[-1051829,397573,37,-15,-46,-43,-5,-114,177,1,56,11,-23,36,-21,-11,0,34,89,1,-4,-17,13,-10,26,25,19,2,-12,-14,23,-17,-15,-12,2,-43,-23,-3,7,-41,58,13,1,-28,-29,6,-31,-6,-11,-19,1,-18,163,0,0,72,48,5,0,33,-65,-1,0,-18,-21,-8,0,23,-56,-6,22,6,26,20,-2,165,-6,0,6,37,-28,3,0,18,-20,15,-47,-1,0,62,-33,9,-195,-10,0,-103,-50,6,-7,-13,-45,10]]]},{"geoid":"0802850","name":"Arapahoe","full":"Arapahoe CDP","lsad":"CDP","cdp":true,"polygons":[[[-1021839,388500,43,-2,13,-21,14,10,36,-7,-14,27,1,63,-93,0]]]},{"geoid":"0802905","name":"Arboles","full":"Arboles CDP","lsad":"CDP","cdp":true,"polygons":[[[-1074424,370000,316,0,121,111,10,43,-6,119,-11,22,-48,41,-83,-11,-42,97,9,38,-28,-6,-8,9,-10,-11,-51,-17,-27,-40,-143,2]]]},{"geoid":"0803015","name":"Aristocrat Ranchettes","full":"Aristocrat Ranchettes CDP","lsad":"CDP","cdp":true,"polygons":[[[-1047727,401019,282,2,1,71,-98,0,1,73,-188,-1]]]},{"geoid":"0803235","name":"Arriba","full":"Arriba town","lsad":"town","polygons":[[[-1032812,392808,47,0,-1,-20,91,-1,0,106,-137,0]]]},{"geoid":"0803455","name":"ALine truncated
@@ -0,0 +1 @@
{"st":"CT","state":"Connecticut","fips":"09","features":[{"geoid":"0901150","name":"Ansonia","full":"Ansonia city","lsad":"city","polygons":[[[-731024,413529,69,-137,35,-1,18,-10,21,-30,108,-48,9,-13,43,-21,59,-5,12,-12,93,-13,166,28,-9,57,-19,52,-10,12,-8,35,-26,38,-9,144,-251,1,-84,23,-52,0,-53,-24,-36,2,-57,-67]]]},{"geoid":"0902480","name":"Ball Pond","full":"Ball Pond CDP","lsad":"CDP","cdp":true,"polygons":[[[-735362,414496,76,15,0,17,23,-10,28,10,-2,26,33,4,51,-8,-3,29,-11,13,-7,74,-7,1,1,12,-21,12,-10,37,-63,6,-43,-27,-31,0,-13,-194]]]},{"geoid":"0902550","name":"Baltic","full":"Baltic CDP","lsad":"CDP","cdp":true,"polygons":[[[-721076,416144,147,6,0,-33,33,0,6,-7,10,-72,241,45,-59,17,-27,21,-20,31,-42,17,-1,13,-42,19,-22,45,-33,-5,5,-42,-12,-9,-55,7,-29,36,-38,-28,-4,8,-28,-20,-42,-3]]]},{"geoid":"0902690","name":"Bantam","full":"Bantam borough","lsad":"borough","polygons":[[[-732545,417189,248,-1,-7,117,-237,5,-4,-101]]]},{"geoid":"0904790","name":"Bethel","full":"Bethel CDP","lsad":"CDP","cdp":true,"polygons":[[[-734350,413724,107,-371,182,30,31,110,104,152,24,20,50,-32,-5,38,-48,48,3,15,-10,46,16,12,-8,47,-27,18,-22,-7,-60,16,-62,31,-8,21,10,39,-166,-134,-87,-38,-12,-26]]]},{"geoid":"0904945","name":"Bethlehem Village","full":"Bethlehem Village CDP","lsad":"CDP","cdp":true,"polygons":[[[-732276,416389,36,-85,-38,-76,16,-68,19,-12,10,-23,81,-4,42,27,31,6,13,27,9,3,83,-12,62,-49,165,3,-3,14,-20,15,-33,52,-29,22,-17,24,0,104,-19,43,10,33,15,13,13,30,-7,20,60,2,-6,73,-60,115,-404,-21,24,-22,31,-10,1,-38,-14,-35,6,-71,8,-10,-76,7,-20,-6]]]},{"geoid":"0904965","name":"Bigelow Corners","full":"Bigelow Corners CDP","lsad":"CDP","cdp":true,"polygons":[[[-735161,414654,27,-21,23,-5,86,11,70,24,-8,16,11,19,-10,7,-27,2,-27,26,7,25,-40,38,-15,-4,3,-16,-42,-23,8,-15,-2,-30,-58,-29]]]},{"geoid":"0906050","name":"Blue Hills","full":"Blue Hills CDP","lsad":"CDP","cdp":true,"polygons":[[[-727078,418070,164,6,13,-68,41,-6,-10,264,-16,16,-16,-9,12,-21,-17,-18,-54,-9,7,-43,-136,-8]]]},{"geoid":"0906155","name":"Bogus Hill","full":"Bogus Hill CDP","lsad":"CDP","cdp":true,"polygons":[[[-734672,415081,61,-61,19,16,-1,43,-22,45,-12,4,-20,-26]]]},{"geoid":"0906680","name":"Botsford","full":"Botsford CDP","lsad":"CDP","cdp":true,"polygons":[[[-732786,413571,27,-52,29,0,22,19,4,-11,21,-12,121,59,7,71,20,-3,-39,22,-90,-47,-39,40,-18,1,-27,-37,-19,0,-19,-24]]]},{"geoid":"0907100","name":"Branchville","full":"Branchville CDP","lsad":"CDP","cdp":true,"polygons":[[[-734467,412687,15,-33,-3,-33,73,31,-68,101,-14,-46]]]},{"geoid":"0907345","name":"Branford Center","full":"Branford Center CDP","lsad":"CDP","cdp":true,"polygons":[[[-728310,412659,22,-16,12,6,14,-14,14,2,1,-17,34,-4,5,-19,31,17,65,15,2,10,-18,12,-28,5,-27,22,11,28,35,12,16,27,18,11,31,3,27,-11,11,11,4,10,-11,11,13,17,-16,21,16,2,7,14,-14,6,22,17,22,-7,39,49,12,-4,0,13,14,18,-3,6,-26,3,-129,-21,-112,-31,16,-26,-85,-83,-37,-22,5,-25,-20,-38]]]},{"geoid":"0908000","name":"Bridgeport","full":"Bridgeport city","lsad":"city","polygons":[[[-732191,411978,-13,-99,24,-46,-16,-18,3,-21,-38,-30,-8,-24,-16,-1,-21,-45,39,8,14,-13,-51,-48,-7,-20,-91,-70,0,-14,11,-13,24,-3,8,-45,-19,-36,72,-10,49,53,-11,18,25,12,16,20,26,-5,6,-41,10,-3,61,64,67,33,165,12,18,51,66,23,71,-18,-15,-58,74,-22,60,174,32,34,14,53,-9,70,13,25,-9,9,-81,268,17,44,20,13,15,25,-71,-28,-48,-28,-78,-5,-60,-16,-40,-2,-34,37,75,-5,-13,22,31,12,-69,4,-26,-25,-55,27,-47,6,-3,12,-59,-17,-51,7,-318,-19]]]},{"geoid":"0908140","name":"Bridgewater","full":"Bridgewater CDP","lsad":"CDP","cdp":true,"polygons":[[[-733692,415343,-4,-15,7,-12,35,7,1,-22,22,-12,27,8,-13,25,16,10,4,15,19,20,-40,-6,-8,23,5,10,-14,7,-52,-15,-1,-22,-21,-11]]]},{"geoid":"0908420","name":"Bristol","full":"Bristol city","lsad":"city","polygons":[[[-729831,416395,545,60,8,-48,58,4,-19,44,405,40,-149,737,-1002,-107]]]},{"geoid":"0908770","name":"Broad Brook","full":"Broad Brook CDP","lsad":"CDP","cdp":true,"polygons":[[[-725737,418798,16,23,12,-1,26,19,26,3,0,16,45,7,34,15,60,-21,-18,89,9,24,107,47,34,9,106,-24,128,-9,11,331,-42,-13,-15,14,-22,-14,-3,16,-29,11,-14,-25,15,60,-22,1,-18,-24,-116,-91,-79,7,-16,-8,9,-22,-11,-7,-6,-21,-25,0,7,-14,-33,-22,0,-26,-30,6,9,-29,-44,11,3,-14,-42,-22,-12,-16,-4,-24,-34,-18,-10,-16,19,-7,-23,-28,13,-10,-15,-18,3,-21,-13,-76,-38,2,-9,-9,-1,-26,11,-5,-27,-19]]]},{"geoid":"0909050","name":"Brookfield Center","full":"Brookfield Center CDP","lsad":"CDP","cdp":true,"polygons":[[[-733906,414669,9,-26,-25,-7,16,-48,-22,-25,8,-46,79,49,37,-4,-24,33,-9,68,17,22,-45,35,-65,-31]]]},{"geoid":"0909120","name":"Brooklyn","full":"Brooklyn CDP","lsad":"CDP","cdp":true,"polygons":[[[-719714,417866,21,-32,0,-12,62,1,6,-7,13,7,13,-6,92,-2,19,-9,13,35,51,19,-10,16,-16,2,7,32,18,18,-23,-1,0,9,-35,41,-14,33,-82,1,4,-19,-21,-4,-12,-29,12,-40,-6,-45,-17,-17,-58,31,-30,-21]]]},{"geoid":"0910660","name":"Byram","full":"Byram CDP","lsad":"CDP","cdp":true,"polygons":[[[-736593,409972,-3,-29,24,-34,-18,-20,12,-13,10,1,56,75,24,16,26,34,-2,16,41Line truncated
@@ -0,0 +1 @@
{"st":"DC","state":"District of Columbia","fips":"11","features":[{"geoid":"1150000","name":"Washington","full":"Washington city","lsad":"city","polygons":[[[-771179,389324,13,-35,32,-37,71,-61,29,-62,22,-18,110,-69,80,-23,121,-12,19,-9,9,-6,38,-106,52,-85,31,-1,11,-9,30,-39,-2,-20,22,-25,28,6,7,17,-10,19,12,3,48,-40,15,-31,-2,-38,53,-72,23,-66,-28,-100,-40,-12,-57,9,-8,-17,1,-38,14,-15,23,5,20,-15,11,-36,-10,-72,17,-72,-15,-134,-1,-92,1296,1012,-1316,1023,-788,-608]]]}]}
@@ -0,0 +1 @@
{"st":"DE","state":"Delaware","fips":"10","features":[{"geoid":"1001400","name":"Arden","full":"Arden village","lsad":"village","polygons":[[[-754955,398114,40,-51,114,64,-41,23,-19,-15,-10,14,-26,2,-38,-25]]]},{"geoid":"1001530","name":"Ardencroft","full":"Ardencroft village","lsad":"village","polygons":[[[-754860,398022,39,29,-31,27,-9,-6,-17,14,-22,-14]]]},{"geoid":"1001660","name":"Ardentown","full":"Ardentown village","lsad":"village","polygons":[[[-754798,398035,20,12,47,3,1,11,-55,32,-16,34,-67,-35]]]},{"geoid":"1004130","name":"Bear","full":"Bear CDP","lsad":"CDP","cdp":true,"polygons":[[[-757148,396181,-5,-33,241,-150,73,39,172,25,23,22,206,99,-77,97,-16,40,-3,46,-105,-61,-67,-27,-73,60,-28,38,-17,-37,-16,-6,-46,26,-55,-34,-12,2,-19,-11,4,-11,-21,-1,-33,15,-36,-6,16,-54,13,-9,-15,-12,16,-18,-63,9,-63,-27]]]},{"geoid":"1004650","name":"Bellefonte","full":"Bellefonte town","lsad":"town","polygons":[[[-755041,397689,22,-14,-13,-13,61,-40,16,15,-8,5,11,11,13,-7,20,19,-59,33,-12,14,-42,-18]]]},{"geoid":"1005690","name":"Bethany Beach","full":"Bethany Beach town","lsad":"town","polygons":[[[-750753,385331,64,10,2,-9,56,-11,2,8,15,-8,45,1,2,-7,31,2,-12,141,-98,-13,-18,11,-40,3,-15,-7,-98,-8]]]},{"geoid":"1005820","name":"Bethel","full":"Bethel town","lsad":"town","polygons":[[[-756221,385643,11,7,5,28,14,10,54,-30,9,97,-76,19,-59,-58,6,-2,-9,-17,19,-8,-21,-40]]]},{"geoid":"1006730","name":"Blades","full":"Blades town","lsad":"town","polygons":[[[-756134,386369,40,-86,19,2,25,22,3,-20,58,53,13,-13,28,18,14,26,-28,-9,12,26,-73,-30,-25,13,-20,28,-22,2,-34,-18]]]},{"geoid":"1007250","name":"Bowers","full":"Bowers town","lsad":"town","polygons":[[[-754061,390570,-1,-19,54,0,50,41,-45,64,-21,3,-26,-9,-3,-24,-22,-10,16,-36,-20,2]]]},{"geoid":"1008680","name":"Bridgeville","full":"Bridgeville town","lsad":"town","polygons":[[[-756315,387387,47,-55,23,-5,58,32,39,-51,24,28,1,34,-12,10,-16,-3,57,36,21,-54,22,-10,2,-13,-28,0,-47,-223,22,-18,8,-24,-27,4,-24,-59,40,-32,0,-9,46,11,22,-11,4,-15,23,10,10,-15,109,42,2,14,-12,0,2,10,9,0,-10,7,-13,65,28,4,1,-6,66,20,-4,62,9,10,-83,-2,1,-11,-10,60,27,-5,29,-26,-1,45,13,36,-35,-17,-35,9,9,-27,-33,-4,0,10,24,0,-16,168,-30,-3,-15,40,38,1,1,17,16,-4,-2,19,-67,11,-11,18,-15,-2,8,16,-12,2,-2,-14,-21,-14,-44,84,-67,-26,16,-47,14,-10,30,8,8,-17,15,0,-61,-17,10,-24,-67,-8,-13,-12,-63,-19,-22,1,-30,-20]]]},{"geoid":"1009850","name":"Brookside","full":"Brookside CDP","lsad":"CDP","cdp":true,"polygons":[[[-757320,396487,282,59,69,36,8,115,-26,77,27,15,-21,8,-14,28,6,7,-32,26,-271,-83,9,-42,-26,-2,-53,-84,10,-6,-17,-6]]]},{"geoid":"1010760","name":"Camden","full":"Camden town","lsad":"town","polygons":[[[-755817,390758,41,-30,24,-8,8,14,-7,41,31,-2,11,20,31,3,-8,-69,54,10,4,26,57,0,4,25,48,20,51,126,-23,5,-20,15,52,-2,34,74,-63,35,20,30,60,-36,19,43,33,0,7,32,41,12,-8,29,-18,8,-7,-31,-24,6,40,85,-26,13,-39,-9,-33,-18,-24,17,-36,-11,-9,-15,-13,-1,14,-3,5,-22,-13,-1,-7,-17,-21,5,-11,-44,-58,-55,-31,13,14,26,-26,3,-3,-32,-35,1,-1,-29,-42,-1,-11,-28,-61,-2,-10,-13,21,-60,68,30,37,-73,13,2,0,11,40,4,-17,9,1,22,7,-16,17,-3,3,-14,20,2,-2,27,15,2,23,32,-4,-30,44,-7,-24,-8,4,-12,-16,-9,-32,1,-7,-24,26,1,1,-10,-27,-1,19,-14,4,-12,20,-7,-1,-7,-10,3,-4,-9,-27,7,-12,-29,-27,10,-46,-13,7,54,-30,21,-36,-39,-91,-21]]]},{"geoid":"1014660","name":"Cheswold","full":"Cheswold town","lsad":"town","polygons":[[[-756106,392211,-15,-12,9,-20,34,5,-14,39,43,6,12,-35,108,19,18,-8,-14,-17,43,-92,-37,-15,19,-17,-7,-18,24,-38,10,9,72,1,10,-8,13,22,-34,16,20,18,-19,4,35,23,-33,4,-13,41,-14,-7,-11,26,63,-6,16,5,-3,14,29,14,7,-11,40,4,8,-19,74,33,-39,23,-17,-4,4,-13,-34,-11,-23,7,-21,-8,-13,30,-11,-3,-6,10,6,3,-17,35,6,2,-4,9,-46,-13,-5,6,8,15,31,16,-4,7,-7,-7,-42,32,-21,-13,-34,-2,17,-39,-47,-6,-29,32,-42,-11,2,-6,-102,-19,-19,-14]]]},{"geoid":"1015310","name":"Claymont","full":"Claymont CDP","lsad":"CDP","cdp":true,"polygons":[[[-754847,397970,28,-17,14,-20,38,3,26,-12,13,13,67,-35,33,24,6,-13,105,68,144,151,-126,54,-50,-53,-77,-53,-31,-33,-39,-12,-64,9,-65,-32,-42,-41]]]},{"geoid":"1015440","name":"Clayton","full":"Clayton town","lsad":"town","polygons":[[[-756537,392984,53,-27,-2,-7,65,-47,27,-8,-10,-21,26,4,48,-18,-46,-73,36,4,-6,-12,1,-60,-15,-12,-79,53,-59,-38,93,-78,23,17,27,-18,28,17,-8,21,30,-5,40,10,24,76,-39,64,-27,11,56,72,-16,4,14,29,-30,-4,-14,18,15,10,-27,25,-14,-11,-16,36,-71,-3,-6,7,-88,17,1,-13]]],"label":[-75.6317651065449,39.28485]},{"geoid":"1018950","name":"Dagsboro","full":"Dagsboro town","lsad":"town","polygons":[[[-752653,385423,39,-8,7,3,-4,10,18,10,28,-34,36,4,66,44,8,-26,23,-24,21,-42,36,1,0,-7,27,1,-13,6,14,14,-6,51,54,-5,13,-14,11,35,-22,6,10,13,-35,34,-22,-6,-2,16,-29,9,-6,11,-22,7,-22,-6,-43,23,-16,-6,-39,10,-65,-35,-31,-47,10,-13,-43,-5]]]},{"geoid":"1019730","name":"Delaware City","full":"Delaware City city","lsad":"city","polygons":[[[-756157,395818,160,-61,-13,-11,-16,5,-5,-13,27,-14,11,13,21,-2,15,-14,-61,-55,78,-70,-5,-17,110,16,-13,38,-26,34,14,8,31,-31,21,5,66,47,-249,193,-146,Line truncated
@@ -0,0 +1 @@
{"st":"FL","state":"Florida","fips":"12","features":[{"geoid":"1200075","name":"Acacia Villas","full":"Acacia Villas CDP","lsad":"CDP","cdp":true,"polygons":[[[-801126,266434,37,3,6,41,-40,0,-1,-35]]]},{"geoid":"1200375","name":"Alachua","full":"Alachua city","lsad":"city","polygons":[[[-825571,297419,96,0,-2,36,97,-1,27,35,67,1,0,8,-25,0,-1,19,-17,1,45,46,97,0,1,71,84,0,1,-147,43,0,-2,75,125,-2,4,-18,58,0,0,-10,21,0,-1,28,43,1,1,-38,-84,-1,0,-19,-40,-4,12,-12,23,0,-13,-37,21,0,1,-37,-42,0,-27,-65,-2,-21,42,0,-1,-38,-41,0,0,-53,20,0,0,-10,-20,0,0,-65,22,0,0,21,35,0,0,73,58,0,0,32,22,4,1,52,63,0,-2,51,41,0,107,161,126,-49,161,-113,105,0,0,-68,47,0,-6,-37,83,-1,0,37,-70,1,0,31,19,4,8,10,-7,22,86,0,-4,14,13,0,-15,13,104,-5,9,12,25,-10,19,2,21,-13,0,42,-131,145,-68,63,-7,4,-4,-9,-34,11,2,20,41,-14,-1,35,-92,-1,-7,-25,-24,7,0,19,-11,-17,-14,4,-1,14,14,-1,-8,32,-27,8,-16,-24,-25,-3,-44,17,-3,12,9,18,-14,6,13,24,38,-13,41,0,-1,38,-42,-1,-2,37,-43,-1,0,37,-126,-1,-1,75,-19,0,0,35,-68,-12,-3,28,-19,11,-17,-17,0,-10,-13,-1,-94,-72,-69,0,0,13,14,0,-1,101,-32,-2,0,-41,-50,1,10,23,-12,0,0,13,-47,3,0,28,-53,2,8,0,1,52,25,0,7,-9,1,-39,128,-6,13,6,0,12,22,7,3,17,18,12,0,6,-33,8,32,4,-6,115,42,7,-2,37,23,2,0,-9,32,5,29,50,0,19,-76,-4,-21,20,-2,21,-28,0,1,-39,-53,0,1,-34,-98,2,-2,-38,-13,1,0,-37,21,-1,0,-36,-146,11,-6,62,-24,0,0,109,-126,0,1,-37,21,0,0,-36,-20,-1,1,-36,-50,0,-23,34,-31,-1,-1,19,18,0,-6,24,-12,-1,0,15,-22,2,0,17,-43,0,1,-54,41,-1,-1,-54,-55,1,0,-56,44,0,-2,53,42,2,1,-14,41,-1,2,-57,56,-1,31,-54,-8,-37,-39,0,-2,55,-250,1,10,-161,36,-23,-34,0,1,-37,40,1,0,26,26,-26,57,1,42,21,-1,-40,-41,1,0,-19,-28,1,0,14,-15,3,-39,0,1,-18,-41,-1,1,-45,32,0,0,11,15,0,1,-24,-49,-1,1,-12,102,0,0,-27,17,-9,1,72,31,1,0,13,11,0,0,-10,24,-1,7,16,82,-26,7,-24,-75,0,1,7,-32,7,-14,-11,-1,-44,-40,0,1,-36,-120,0,2,-71,-81,4,-1,32,60,0,-1,35,-104,1,-6,-108,15,-110,37,0,0,-18,-76,-2]],[[-824055,297325,162,0,3,68,-164,0]]]},{"geoid":"1200410","name":"Alafaya","full":"Alafaya CDP","lsad":"CDP","cdp":true,"polygons":[[[-812442,285394,9,-43,33,-43,0,-83,20,-93,-20,-79,7,-16,43,-39,63,-165,43,-25,124,1,15,13,1,16,-15,15,0,98,340,7,0,5,82,-7,85,2,0,71,11,12,14,-13,4,-69,66,0,44,11,40,-16,66,-6,34,43,-13,4,0,49,-24,26,9,53,42,45,43,83,11,70,33,47,13,5,-5,31,-68,32,-25,48,-28,26,-23,-5,-29,21,-8,-7,-47,10,-21,27,-2,43,-67,54,-397,1,-301,34,2,-150,-9,-25,-17,1,-39,-21,-142,1]]]},{"geoid":"1200625","name":"Alford","full":"Alford town","lsad":"town","polygons":[[[-854036,306871,41,16,63,10,65,-1,2,148,-168,1]]]},{"geoid":"1200800","name":"Allentown","full":"Allentown CDP","lsad":"CDP","cdp":true,"polygons":[[[-871446,308185,13,-14,-2,-94,9,-5,-11,-285,296,-5,-3,-92,-15,-32,-58,-28,-3,-137,111,3,-3,-151,725,-11,11,7,-37,32,15,-1,42,34,-32,1,1,36,128,-4,4,217,130,-4,4,-14,13,1,15,-35,4,32,28,32,-9,13,-2,-8,-60,29,-52,65,-17,3,-25,35,-16,2,4,19,-37,2,-16,18,9,6,-56,5,-2,8,-23,13,8,18,-20,-8,-133,18,-18,-21,-38,16,-24,-2,-18,22,-18,11,-23,-1,-22,33,-53,24,-23,21,1,16,15,13,-4,7,14,4,-15,31,19,10,-3,8,23,23,16,-1,12,25,-7,-1,7,42,10,10,-26,64,-15,12,-1,28,-12,16,1,33,-31,0,-9,-141,-39,-1,14,-14,-32,-18,-36,-34,-74,-3,-2,-75,-167,3,0,34,-30,6,-34,37,-10,28,-8,-13,-34,-13,-43,20,-34,-4,-14,-14,-36,26,-20,42,-97,-2]]]},{"geoid":"1200950","name":"Altamonte Springs","full":"Altamonte Springs city","lsad":"city","polygons":[[[-814436,286402,471,0,0,36,-115,-2,-8,9,0,28,-63,-4,1,77,41,1,5,44,23,1,5,26,10,-10,121,-5,0,-37,8,-2,-14,-27,12,-27,-6,-23,42,0,0,-49,-18,-9,5,-27,42,0,-6,71,59,0,0,-35,62,0,0,-34,43,0,0,35,8,26,10,0,0,-26,41,1,6,-36,-5,41,51,16,28,-9,8,123,35,1,-3,36,5,0,1,-36,15,0,-1,37,10,1,1,-37,11,0,-1,46,19,10,8,-19,23,0,5,10,-15,14,-80,-1,1,14,-45,5,0,21,22,0,0,8,-20,0,-1,77,-20,0,-11,13,-52,-2,1,-9,-22,0,2,-36,-17,4,0,14,-33,0,28,9,-3,18,-16,5,-17,-5,0,-18,-9,-10,8,-18,-81,1,0,36,-10,0,0,-18,-11,0,0,18,-20,0,-1,-27,-17,-8,-23,17,-61,-1,0,-13,-21,0,7,95,36,20,21,23,-22,-34,21,1,-1,-37,20,0,6,19,5,-5,0,77,-11,6,-48,-27,-42,2,-2,-35,-31,-1,-1,36,-30,3,10,-8,-7,-31,-91,1,13,33,-44,-16,-12,-17,-42,0,-1,-36,-41,0,0,-37,-22,0,1,-36,62,0,0,-54,-6,-20,-35,6,-1,-78,-41,0,0,-18,20,0,0,-18,22,-1,-158,-130]]]},{"geoid":"1201000","name":"Altha","full":"Altha town","lsad":"town","polygons":[[[-851361,305639,211,-1,5,143,-214,3]]]},{"geoid":"1201050","name":"Altoona","full":"Altoona CDP","lsad":"CDP","cdp":true,"polygons":[[[-816542,289676,22,-1,-1,13,34,-10,-31,-75,58,4,17,10,17,41,18,10,-22,16,-10,29,-28,0,10,36,-80,-4,-7,-44]]]},{"geoid":"1201100","name":"Alturas","full":"Alturas CDP","lsad":"CDP","cdp":true,"polygons":[[[-817611,278209,134,-1,11,-14,21,-7,1,-343,363,-3,6,-22,31,-31,11,-55,247,0,0,-29,9,-8,144,1,48,14,82,-8,4,20,138,154,1,210,-24,54,-59,670,5,20,-22,40,12,14,-9,10,7,11,-35,24,-21,0,-84,100,-266,77,-142,28,11,-111,-82,1,0,-19,-41,1,0,44,-81,0,9,37,1,73,82,-7,0,59,-41,1,0,23,41,6,-21,30,-90,0,-130,-64,17,-27,2,-26,-23,-33,-35,-14,-36,-33,-4,-16,22,-61,11,0,10,-28,13,-13,26,0,-6,-31,-47,-48,3,-20,26,-49,2,-20,-161,1,-1,-289,-91,-1]]]},{"geoid":"12Line truncated
@@ -0,0 +1 @@
{"st":"GA","state":"Georgia","fips":"13","features":[{"geoid":"1300184","name":"Abbeville","full":"Abbeville city","lsad":"city","polygons":[[[-833227,319879,28,-43,58,-41,50,-13,55,0,43,11,33,17,40,43,19,76,-6,36,-23,48,-29,4,18,6,-46,31,-83,16,-66,-11,-53,-30,-23,-29,-23,-56]]]},{"geoid":"1300408","name":"Acworth","full":"Acworth city","lsad":"city","polygons":[[[-847229,340563,20,12,39,0,16,-19,-16,-5,0,-22,18,0,21,35,70,-22,27,-17,18,-22,-2,-17,12,4,-5,-48,29,0,-1,-25,23,7,8,-19,13,0,-28,-7,0,-25,45,6,-1,12,29,-18,-27,1,1,-18,16,-15,-1,23,29,-14,-6,-20,32,-11,-1,-10,36,25,5,15,122,-23,0,-20,81,-10,8,14,-1,-35,77,-1,23,22,28,-5,1,7,9,-1,0,-8,36,-7,1,47,14,0,4,7,-9,11,-30,-2,1,19,-18,7,4,92,-26,0,0,-36,-25,0,0,18,-44,-9,-21,12,-25,-31,45,0,-9,11,13,3,18,-24,-23,4,1,-30,-22,0,0,16,-19,-5,2,12,-24,3,39,43,-42,38,37,10,25,28,26,0,0,-35,67,-2,2,64,-7,0,0,12,19,-6,1,8,13,-11,17,-2,1,52,9,7,-22,15,-74,1,65,3,0,6,30,-5,-4,-6,6,-1,0,10,27,-1,10,-17,11,-1,0,18,29,2,0,5,-2,25,-25,11,1,13,19,-9,7,20,89,-3,1,37,-54,1,-2,-30,-23,-2,-13,3,0,11,-12,11,-30,11,15,8,-3,30,-52,0,0,-16,20,-17,-53,9,19,25,-103,-4,-33,6,0,-15,-45,5,-1,10,-69,2,0,-24,-21,-4,-6,27,-195,3,5,-11,-17,-7,-13,19,-18,-16,5,-7,-31,14,5,-9,-4,-33,37,7,11,-17,-22,1,3,-13,-69,3,-8,-19,-17,-12,7,-22,17,-3,15,-21,29,-10,7,-16,14,16,21,-3,2,-25,-204,13,-14,19,3,20,-35,4,-21,-14,-23,-35]]]},{"geoid":"1300436","name":"Adairsville","full":"Adairsville city","lsad":"city","polygons":[[[-849707,343628,47,-1,5,-40,-5,-7,45,0,1,-56,42,7,25,13,17,32,-3,121,29,0,3,-23,31,0,17,35,37,-7,-23,-60,37,-39,5,-54,6,32,-6,16,68,-2,31,-13,13,-26,0,-29,28,1,0,-21,49,0,-2,13,20,12,-30,21,-26,1,-4,14,-26,6,-9,20,47,2,5,-8,38,15,12,-4,7,42,10,-11,19,11,0,11,35,0,0,-37,10,0,2,37,12,0,1,-37,17,-3,19,7,1,-70,82,4,-13,-44,-18,11,-43,-1,0,-64,31,-1,12,-37,211,-2,1,-43,90,7,-15,25,-161,154,-93,65,-76,74,-28,45,-1,24,47,-1,-8,-6,1,-48,13,-7,0,19,14,5,0,74,-33,-26,-17,20,40,31,45,17,3,-8,43,0,-5,17,-24,1,19,7,9,-8,24,0,2,112,-56,-2,-7,-29,-14,4,-29,-62,1,27,-8,7,-35,2,0,73,-141,-3,1,-65,-103,-7,3,74,-40,1,1,-13,-20,1,1,-61,-75,3,29,-56,30,0,1,-11,-13,-7,-45,4,-10,9,14,3,-5,18,-16,3,-22,-25,-59,12,-19,-39,-2,-57,-74,1,-15,12,0,38,21,-5,0,21,-31,7,0,-28,-7,-11,-15,0,-6,-34,41,0,2,-49,-16,-3,-5,-26,-27,-21,-6,-23,-10,-7,-21,4]]]},{"geoid":"1300576","name":"Adel","full":"Adel city","lsad":"city","polygons":[[[-834602,311319,38,1,46,37,26,-22,-3,-62,11,-15,40,-6,-9,-4,-3,-18,30,-3,4,10,36,-5,-33,-30,22,-16,-5,-9,97,-78,2,-25,49,-6,-31,52,9,6,69,-128,10,53,1,-69,40,-77,37,2,-2,-66,46,-91,105,-174,-100,285,11,4,-12,40,163,61,-44,24,13,34,-27,20,-3,-34,-73,3,0,24,-76,0,-8,17,-11,1,14,6,-8,21,44,-2,2,36,-5,15,-50,16,-6,49,-11,23,72,7,9,10,-67,86,43,6,-14,54,20,7,-13,9,9,9,-30,8,18,25,-7,36,-54,0,-1,18,22,16,16,23,11,-17,19,6,25,59,35,-1,3,53,65,52,0,19,-163,1,0,-28,24,-3,-2,-11,-29,5,-18,29,-22,1,-1,-33,9,-5,-9,1,-2,-36,18,2,-15,-14,-48,-9,-17,-29,-20,-9,25,0,-1,-16,-44,-7,100,3,-1,-43,-88,7,-14,-11,7,-18,-48,1,-3,12,-12,-13,-32,9,-32,-2,-20,3,-15,43,-10,3,15,-43,-7,-1,-2,-22,-141,-2,24,-12,-5,-51]]]},{"geoid":"1300660","name":"Adrian","full":"Adrian city","lsad":"city","polygons":[[[-826068,325259,10,-16,54,28,43,-41,33,-11,39,-1,44,14,38,35,10,27,-1,33,-14,29,-33,31,-36,11,-51,-1,-51,-23,-26,-39,-4,-20,-64,2]]]},{"geoid":"1300828","name":"Ailey","full":"Ailey city","lsad":"city","polygons":[[[-825766,321761,58,5,6,-19,14,4,-7,18,9,2,14,-21,80,8,-1,36,30,10,-42,16,23,25,5,37,-10,26,-40,31,8,15,40,20,-43,43,-13,4,-163,-33,-36,-140]]]},{"geoid":"1300996","name":"Alamo","full":"Alamo town","lsad":"town","polygons":[[[-827942,321452,20,1,8,-27,32,-43,43,-22,68,-4,43,12,30,20,22,30,12,34,-3,36,-16,33,90,22,-38,24,-37,-11,-27,-21,-60,30,-55,7,-64,-24,-33,-35,-30,20,-52,-48]]]},{"geoid":"1301024","name":"Alapaha","full":"Alapaha town","lsad":"town","polygons":[[[-832320,313757,48,-6,-3,-17,18,23,103,2,2,148,-169,-1]]]},{"geoid":"1301052","name":"Albany","full":"Albany city","lsad":"city","polygons":[[[-842729,315398,591,3,1,59,125,6,-49,-43,2,-26,30,-73,29,1,-36,-41,-55,0,3,-59,171,-2,114,153,33,21,106,4,-7,-87,117,-3,1,81,57,1,1,-52,39,0,1,20,67,0,-7,18,33,14,-8,-44,45,0,2,-12,92,0,7,168,45,-24,23,8,22,145,34,-19,69,-77,86,4,-3,78,218,-17,-2,14,26,-1,0,17,-28,0,3,165,-56,0,2,75,36,0,0,20,-17,2,-1,111,53,42,21,-16,0,11,32,-26,74,17,-21,5,-22,23,-1,22,-42,71,-40,-8,-4,-20,-25,-17,-95,-40,-20,-1,-56,38,-31,47,-55,32,-13,-2,18,-2,54,-39,-65,38,-3,-14,12,-28,-10,-8,22,-23,-1,-18,-19,-7,-20,11,-28,-6,-53,-36,-54,-13,-16,12,8,18,-3,11,-49,13,-59,2,-61,-49,-27,-34,-35,-15,-44,73,-10,12,-16,2,-5,16,-18,5,-29,26,-12,-11,25,-18,-38,-10,-36,39,-10,-2,8,-18,-15,2,4,16,-35,8,-21,15,-17,0,-15,22,-14,1,7,13,-11,8,-11,-21,-12,-3,9,3,8,20,16,3,12,-16,-5,18,-51,27,-874,-3,1,-274,214,-5,0,32,40,-1,4,-18,-23,-61,-1,-40,183,0,-2,-83,-26,-87,-77,1,1,18,-218,16,5,135,-132,0,3,59,-88,0,-3,-47,16,-11,-11,-1,0,-79,72,0,-8,-39,-62,-52,-8,-165]]]},{"geoid":"1301248","name":"Aldora","full":"Aldora town","lsad":"town","polygons":[[Line truncated
@@ -0,0 +1 @@
{"st":"GU","state":"Guam","fips":"66","features":[{"geoid":"6600100","name":"Adacao","full":"Adacao CDP","lsad":"CDP","cdp":true,"polygons":[[[1448328,134840,167,0,76,76,-2,80,-83,-20,-48,11,-92,-8,-18,-133]]]},{"geoid":"6600850","name":"Afame","full":"Afame CDP","lsad":"CDP","cdp":true,"polygons":[[[1447609,134564,-11,-21,20,-1,27,-22,12,1,9,44,-5,35,-53,56,-27,-20,-18,10]]]},{"geoid":"6604000","name":"Agana Heights","full":"Agana Heights CDP","lsad":"CDP","cdp":true,"polygons":[[[1447417,134685,5,-30,-14,-33,-21,-8,-3,-20,20,-34,116,11,2,102,-8,30,-41,17,-94,0,-56,34]]]},{"geoid":"6607600","name":"Anao","full":"Anao CDP","lsad":"CDP","cdp":true,"polygons":[[[1448709,134861,60,36,45,13,23,-3,30,15,53,61,-7,11,-1,51,6,18,42,7,11,22,45,32,42,8,20,16,29,8,15,-5,31,11,50,27,32,6,32,25,45,109,10,10,1,50,54,91,-212,88,-35,-129,-28,-16,-12,-38,-20,-22,-46,-86,-70,-38,-184,-241,-60,-36,-2,-39,-18,-18]]]},{"geoid":"6607900","name":"Andersen AFB","full":"Andersen AFB CDP","lsad":"CDP","cdp":true,"polygons":[[[1448992,135634,385,-154,25,46,-18,83,19,72,38,21,30,28,-53,35,-40,-17,-14,29,-178,-78,-63,13,-129,-68]]]},{"geoid":"6609500","name":"Apotgan","full":"Apotgan CDP","lsad":"CDP","cdp":true,"polygons":[[[1447705,134811,27,9,70,-2,54,26,-16,31,-23,101,-119,4,3,-28,44,-10,7,-33,-16,-41,-38,-51]]]},{"geoid":"6609850","name":"Apra Harbor","full":"Apra Harbor CDP","lsad":"CDP","cdp":true,"polygons":[[[1446183,134458,17,-19,8,-24,34,-38,87,-33,56,-51,19,-62,-6,-21,24,-21,56,-11,10,-24,34,1,35,-9,28,-21,28,-42,-1,-38,16,-9,6,-25,110,-2,8,-50,29,27,25,7,5,-24,27,26,30,-12,17,59,-6,10,-49,7,-8,11,-20,-2,-44,38,7,28,19,30,-10,31,-17,22,20,25,-14,40,-93,82,-2,94,-428,90,-59,-70]]]},{"geoid":"6612450","name":"Asan","full":"Asan CDP","lsad":"CDP","cdp":true,"polygons":[[[1447105,134690,22,9,13,-11,17,13,21,-3,49,29,10,19,-3,15,-5,8,-58,-35,-39,3,4,-9,-34,-8]]]},{"geoid":"6615800","name":"Astumbo","full":"Astumbo CDP","lsad":"CDP","cdp":true,"polygons":[[[1448342,135417,201,-2,6,16,1,144,-143,0,-45,-79,-29,-74]]]},{"geoid":"6617000","name":"Barrigada","full":"Barrigada CDP","lsad":"CDP","cdp":true,"polygons":[[[1447896,134576,9,-14,25,-8,11,22,50,5,3,-10,82,0,-2,122,-77,11,-77,26,6,-21,-22,-64,9,-14,-12,-27]]]},{"geoid":"6618300","name":"Barrigada Heights","full":"Barrigada Heights CDP","lsad":"CDP","cdp":true,"polygons":[[[1448182,134843,146,-3,18,157,-95,-5,-16,20,-33,-27,19,-38,-72,-34,18,-48,-29,-31]]]},{"geoid":"6624500","name":"Chaguian","full":"Chaguian CDP","lsad":"CDP","cdp":true,"polygons":[[[1448718,135858,16,-87,25,-15,3,-203,144,-1,20,-31,19,-6,49,129,-30,88,-72,93,-108,29,-56,-2]]]},{"geoid":"6625450","name":"Chalan Pago","full":"Chalan Pago CDP","lsad":"CDP","cdp":true,"polygons":[[[1447623,134328,26,-29,8,-34,34,0,-7,-14,11,-9,39,-10,17,5,-4,-10,18,1,2,-11,15,2,-3,-17,28,7,12,21,20,-14,20,26,69,23,-2,114,-30,20,0,8,-39,2,-7,62,-21,-21,-39,-4,-24,12,-69,-2,-18,-21,-1,-19,-27,-5,-26,-23,5,-19]]]},{"geoid":"6627400","name":"Dededo","full":"Dededo CDP","lsad":"CDP","cdp":true,"polygons":[[[1448348,135155,57,-13,32,1,21,94,24,44,-23,2,0,7,-44,13,-105,1,-7,11,-21,-14,-1,-14,-26,-2,-3,-20,-18,-29]]]},{"geoid":"6629350","name":"Finegayan Station","full":"Finegayan Station CDP","lsad":"CDP","cdp":true,"polygons":[[[1448330,135654,-4,-14,61,26,50,-34,61,116,89,109,46,28,39,2,-37,41,-29,55,-238,-2,-8,-69,-15,-38,-35,-53,8,-35,-11,-23,-20,-1,-52,-46]],[[1448113,135512,212,-111,48,115,-179,117,-22,-50,-52,-36,2,-20]]]},{"geoid":"6634650","name":"Hågat","full":"Hågat CDP","lsad":"CDP","cdp":true,"polygons":[[[1446524,133728,25,-35,138,1,0,36,10,17,-7,37,5,28,-35,100,35,12,-18,14,2,34,-18,9,-19,27,-8,3,-40,-118,-35,-31,-40,-80]]]},{"geoid":"6634750","name":"Hagåtña","full":"Hagåtña CDP","lsad":"CDP","cdp":true,"polygons":[[[1447286,134790,32,-10,-7,-19,16,-12,3,10,52,3,12,-10,58,-2,21,-30,41,-17,8,-30,19,2,22,-29,18,-10,33,30,-18,27,3,15,-15,36,6,24,38,3,48,29,-65,-19,-65,-4,-5,32,-10,1,-23,-33,-70,-1,-51,15,-74,-5,-20,23,-7,-9]]]},{"geoid":"6635000","name":"Harmon Industrial Park","full":"Harmon Industrial Park CDP","lsad":"CDP","cdp":true,"polygons":[[[1447942,134947,40,-31,22,1,77,41,78,30,42,-1,-3,35,15,29,-16,43,-190,-69,-53,-60]]]},{"geoid":"6635500","name":"Inalåhan","full":"Inalåhan CDP","lsad":"CDP","cdp":true,"polygons":[[[1447221,132754,55,-60,-1,-15,52,2,-13,-23,61,-25,36,-1,10,10,-2,19,-38,9,-6,12,55,11,72,34,0,11,-28,28,2,19,23,6,35,-35,10,0,46,38,2,12,-10,26,-41,41,-21,8,-122,16,-21,-31,-2,-77,-19,-3,-8,13,-62,12,-24,-2,-20,19,-16,-65]]]},{"geoid":"6635525","name":"Humåtak","full":"Humåtak CDP","lsad":"CDP","cdp":true,"polygons":[[[1446576,132902,16,-34,32,-19,-2,-12,8,20,11,-1,74,82,-30,38,-6,-6,-13,10,8,18,23,16,-18,1,-40,-17,-56,3,44,-12,2,-13,-54,-47]]]},{"geoid":"6636600","name":"Ipan","full":"Ipan CDP","lsad":"CDP","cdp":true,"polygons":[[[1447611,133523,25,-56,40,-54,46,4,-5,207,-14,33,-16,4,-63,-42,-13,-81]]]},{"geoid":"6641300","name":"Liguan","full":"Liguan CDP","lsad":"CDP","cdp":true,"polyLine truncated
@@ -0,0 +1 @@
{"st":"HI","state":"Hawaii","fips":"15","features":[{"geoid":"1500400","name":"Ahuimanu","full":"Ahuimanu CDP","lsad":"CDP","cdp":true,"polygons":[[[-1578627,214343,41,-62,80,-20,43,69,15,-30,88,-50,48,50,-10,20,-2,31,10,10,19,12,61,-45,11,10,7,46,-10,77,-137,99,-2,-14,14,-29,-48,-69,-21,-7,-7,-19,-56,-12,-33,15,-54,-22,-53,-46]]]},{"geoid":"1500550","name":"Aiea","full":"Aiea CDP","lsad":"CDP","cdp":true,"polygons":[[[-1579435,213765,51,-41,37,36,2,8,9,-21,10,-8,4,8,19,18,10,-7,38,23,-23,10,-3,5,8,-6,14,6,13,13,10,3,10,11,19,2,12,6,2,12,20,15,42,6,22,11,20,23,36,17,17,18,36,8,-22,14,-24,25,-36,14,-16,-15,-28,-1,-18,-9,-28,-3,-8,-16,-12,2,-15,-13,-15,-21,-11,1,-43,-10,-34,-16,-10,-25,-16,-15,-7,-4,-6,-9,-14,-5,-6,-13,-26,-14,-23,-1,-31,-20]]]},{"geoid":"1501085","name":"Ainaloa","full":"Ainaloa CDP","lsad":"CDP","cdp":true,"polygons":[[[-1550071,195045,204,142,133,219,-4,11,-11,17,-111,-104,-106,-79,-171,-94]]]},{"geoid":"1502200","name":"Anahola","full":"Anahola CDP","lsad":"CDP","cdp":true,"polygons":[[[-1593387,221441,123,-56,38,10,16,-15,2,-27,-7,-7,-44,6,-21,-7,-6,17,-6,-3,9,-6,-7,-18,173,-15,56,-36,40,-5,-9,40,68,41,2,58,-9,19,22,10,15,-21,-1,17,-31,53,-26,-20,-38,-4,-36,31,14,71,22,26,-37,47,-25,-28,3,-11,-98,14,13,-23,-19,-39,-96,-4,-5,-10,-45,-5,-13,-18,-23,5,0,-42]]]},{"geoid":"1502832","name":"Black Sands","full":"Black Sands CDP","lsad":"CDP","cdp":true,"polygons":[[[-1549683,194120,95,-58,45,63,14,-9,15,56,-11,24,-18,14,-15,1,-7,-21,-20,-9,-20,-24,-22,3,-18,-12,-15,0]]]},{"geoid":"1503850","name":"Captain Cook","full":"Captain Cook CDP","lsad":"CDP","cdp":true,"polygons":[[[-1559499,195023,19,-34,9,-49,23,-33,15,-63,34,-2,20,11,-21,19,41,15,111,-41,20,11,-8,12,21,16,14,-3,34,22,44,-86,46,-12,31,-25,38,-41,19,-38,36,15,0,26,-13,21,6,12,-16,17,26,-2,80,-114,127,-73,18,-23,-20,45,-67,88,-32,58,111,44,-45,110,-68,25,-20,0,41,34,61,27,46,8,23,21,78,3,71,16,89,4,56,-8,32,21,-6,39,47,142,-103,5,-53,-28,-46,-2,-83,-27,-105,-16,-51,-25,-93,-14,-77,-35,-24,-4,-18,12,-27,-6,0,16,-31,-2,-161,-47,-2,29,-33,-11,-24,12,-38,-12,-44,5,-57,-10,-2,-15,14,-26,-97,-14]]]},{"geoid":"1505900","name":"Discovery Harbour","full":"Discovery Harbour CDP","lsad":"CDP","cdp":true,"polygons":[[[-1556402,190337,135,-152,54,107,79,109,59,12,-16,77,-56,123,-47,-19,-61,-41,-44,-5,11,-20,-164,-114]]]},{"geoid":"1506290","name":"East Honolulu","full":"East Honolulu CDP","lsad":"CDP","cdp":true,"polygons":[[[-1577830,212787,21,-8,-6,-17,13,-35,34,-38,13,16,111,47,49,6,8,-18,29,-12,13,33,109,36,90,0,24,23,53,-12,45,6,30,30,67,-35,32,-34,8,-84,-22,-41,-6,-30,22,-23,106,10,72,36,-45,29,0,45,27,13,40,-40,35,68,89,49,31,46,40,1,13,22,105,57,33,47,37,8,21,59,-2,59,-7,1,-17,-21,-25,8,-64,-13,-76,44,-43,47,-60,30,-65,-3,-5,11,-22,6,-35,-14,-40,2,-20,-14,-76,-24,-52,16,-10,-12,-61,1,-21,13,-24,2,-45,55,-64,-14,-48,3,-107,29,-14,13,-35,6,-31,28,-23,7,-107,-165,-9,-55,-26,-33,-49,-19,-34,-31,-32,-178]]]},{"geoid":"1506300","name":"East Kapolei","full":"East Kapolei CDP","lsad":"CDP","cdp":true,"polygons":[[[-1580601,213663,69,-55,23,-27,16,-45,-10,-66,-32,-48,7,-30,89,37,93,85,62,19,-12,51,31,77,4,72,-13,42,-48,61,-88,-49,-93,-39,-70,-60]]]},{"geoid":"1506325","name":"Eden Roc","full":"Eden Roc CDP","lsad":"CDP","cdp":true,"polygons":[[[-1551192,195055,267,-444,219,121,-308,511,-25,23,-55,-102,-63,-71]]]},{"geoid":"1507000","name":"Eleele","full":"Eleele CDP","lsad":"CDP","cdp":true,"polygons":[[[-1595877,218988,42,-6,0,-17,26,-1,-60,42,57,-12,24,64,24,-1,23,51,-17,17,39,30,41,11,15,15,-25,3,-5,19,-21,-26,-55,-2,-58,-23,-23,-38,0,-22,-17,-47,-36,-15]]]},{"geoid":"1507450","name":"Ewa Beach","full":"Ewa Beach CDP","lsad":"CDP","cdp":true,"polygons":[[[-1580213,213071,311,69,-7,14,-124,-27,-13,56,97,104,-45,31,-64,-75,-113,-8,42,-69,-63,-67,-7,6,-25,-27]]]},{"geoid":"1507470","name":"Ewa Gentry","full":"Ewa Gentry CDP","lsad":"CDP","cdp":true,"polygons":[[[-1580482,213319,63,-3,17,-39,26,-15,27,-51,106,41,21,28,32,16,4,-5,67,13,28,-3,29,28,-38,15,-62,49,-41,65,-11,45,-40,-10,-20,4,6,-24,-96,-116,-29,-19,-54,-9]]]},{"geoid":"1507485","name":"Ewa Villages","full":"Ewa Villages CDP","lsad":"CDP","cdp":true,"polygons":[[[-1580487,213318,94,20,29,19,96,116,-16,60,-62,-19,-93,-85,-89,-37]]]},{"geoid":"1507542","name":"Fern Acres","full":"Fern Acres CDP","lsad":"CDP","cdp":true,"polygons":[[[-1551039,195266,25,-23,308,-511,194,96,-387,640,-46,-51,-28,-51,-46,-57]]]},{"geoid":"1507675","name":"Fern Forest","full":"Fern Forest CDP","lsad":"CDP","cdp":true,"polygons":[[[-1551757,194545,117,-195,419,104,271,150,25,7,-267,444,-27,-11,-44,-43,-18,-37,-17,-12,-25,-57,-90,-109,-326,-237]]]},{"geoid":"1508950","name":"Haena","full":"Haena CDP","lsad":"CDP","cdp":true,"polygons":[[[-1595758,222112,16,-11,26,-3,38,17,2,14,19,9,17,29,-1,20,22,-12,20,-42,111,12,39,17,31,-1,-21,56,-51,32,-45,-3,-55,21,-71,-61,-36,28,-76,1]]]},{"geoid":"1509260","name":"Haiku-Pauwela","full":"Haiku-Pauwela CDP","lsad":"CDP","cdp":true,"polygons":[[[-1563392,209358,12,-59,15,1,14,-1Line truncated
@@ -0,0 +1 @@
{"st":"IA","state":"Iowa","fips":"19","features":[{"geoid":"1900190","name":"Ackley","full":"Ackley city","lsad":"city","polygons":[[[-930660,425367,196,-4,-1,72,48,-1,0,73,11,0,-1,61,-10,0,1,13,147,42,0,18,-294,-1,-1,18,-48,0,0,-18,-48,0]]]},{"geoid":"1900235","name":"Ackworth","full":"Ackworth city","lsad":"city","polygons":[[[-934814,413642,48,22,0,-96,97,-1,2,145,-99,2,0,-37,-48,1]]]},{"geoid":"1900370","name":"Adair","full":"Adair city","lsad":"city","polygons":[[[-946578,414896,287,0,1,216,-288,1,0,-72]]]},{"geoid":"1900505","name":"Adel","full":"Adel city","lsad":"city","polygons":[[[-940525,416022,48,0,0,-19,157,-3,0,-15,12,2,1,16,177,0,2,-143,52,0,0,-34,48,0,0,35,40,1,-35,61,-34,39,-6,42,-13,26,27,0,40,-14,12,-12,19,1,0,20,-40,14,-36,0,0,10,-29,0,1,33,48,-1,0,13,-30,3,-20,11,0,14,-37,21,39,-1,0,-14,30,-27,72,-3,30,-9,5,-13,28,0,9,9,-1,23,19,3,0,-35,97,0,0,36,97,1,0,-37,96,0,0,67,95,3,-1,42,-142,-3,0,-17,48,1,0,-22,-99,-1,3,39,-49,0,0,-20,-35,5,-35,-6,-30,-16,-46,1,0,45,-11,24,11,3,-96,0,-1,-33,35,-3,0,-11,-164,-15,-8,-10,5,18,36,0,0,54,-57,0,-52,24,-20,-6,-38,22,-25,42,0,-47,-145,1,-1,-161,-51,-16,0,20,8,0,-12,11,0,-32,-48,-12]]]},{"geoid":"1900595","name":"Afton","full":"Afton city","lsad":"city","polygons":[[[-942047,410202,190,1,0,144,-143,0,-28,26,6,-26,-25,0]]]},{"geoid":"1900640","name":"Agency","full":"Agency city","lsad":"city","polygons":[[[-923144,409915,143,0,0,109,-144,1]]]},{"geoid":"1900730","name":"Ainsworth","full":"Ainsworth city","lsad":"city","polygons":[[[-915616,412865,143,-1,0,72,-142,6]]]},{"geoid":"1900775","name":"Akron","full":"Akron city","lsad":"city","polygons":[[[-965682,428157,169,2,-12,19,21,-1,7,16,-37,0,-24,32,87,2,-1,144,-36,2,-13,-12,-19,-1,-22,7,-24,28,-20,-1,-22,-31,-3,-52,-25,-7,-11,13,0,-93,22,1]]]},{"geoid":"1900865","name":"Albert City","full":"Albert City city","lsad":"city","polygons":[[[-949532,427707,49,0,0,61,36,0,-5,11,18,0,0,113,-28,0,-5,-39,-15,18,-21,53,-30,0]]]},{"geoid":"1900910","name":"Albia","full":"Albia city","lsad":"city","polygons":[[[-928231,410245,25,-12,39,30,-8,-21,8,-19,48,-16,0,-32,-23,0,0,-17,-24,0,0,-37,29,0,3,-14,15,0,1,-23,-48,0,0,-27,68,0,-36,-46,64,1,0,37,-21,24,1,48,115,0,-1,33,70,1,0,75,71,1,-1,73,-48,0,-1,73,-64,0,14,73,-94,-1,1,-36,-94,-1,1,-36,-96,0,3,-105]]]},{"geoid":"1900955","name":"Albion","full":"Albion city","lsad":"city","polygons":[[[-929958,421065,145,3,0,117,-146,-2]]]},{"geoid":"1901000","name":"Alburnett","full":"Alburnett city","lsad":"city","polygons":[[[-916287,421495,47,0,-1,-37,17,0,-61,-65,98,-8,-9,37,58,0,3,145,-150,-4]]]},{"geoid":"1901045","name":"Alden","full":"Alden city","lsad":"city","polygons":[[[-934263,424928,34,1,1,15,32,21,18,37,267,27,1,-31,101,-1,-1,38,112,-2,0,27,15,9,36,0,-5,184,-146,0,0,-14,-13,5,1,-36,-19,6,-7,-39,24,-17,-16,-16,19,0,-1,-101,-454,-42]]]},{"geoid":"1901090","name":"Alexander","full":"Alexander city","lsad":"city","polygons":[[[-934987,427894,401,-3,0,304,-401,2]]]},{"geoid":"1901135","name":"Algona","full":"Algona city","lsad":"city","polygons":[[[-942767,430795,0,-59,91,0,13,-8,0,13,26,-9,0,12,-26,0,0,79,7,6,84,-1,0,-12,49,0,0,-15,-49,-1,0,-21,59,-1,0,50,36,-7,10,7,1,-87,18,0,-22,-5,4,-37,-19,-31,-46,-26,-20,-1,5,-8,80,-1,0,6,19,-6,-8,-15,41,-11,7,10,58,-19,38,0,9,-28,31,-1,0,-13,30,-1,0,18,13,1,-25,24,129,-2,1,72,48,-1,0,8,34,4,2,85,16,8,49,2,1,55,-10,0,0,-17,-14,0,-1,34,-25,0,-1,19,-347,0,4,40,-26,0,1,-40,-74,0,0,-21,-18,0,0,11,-29,0,0,-11,-51,0,0,-6,-20,6,-79,0,0,-14,-156,0]]]},{"geoid":"1901180","name":"Alleman","full":"Alleman city","lsad":"city","polygons":[[[-936392,418131,48,0,0,-9,97,-1,0,-63,145,0,1,-73,98,0,0,49,16,0,0,17,32,1,1,224,-243,-1,1,-55,-14,19,-84,0,0,-36,-97,0]]]},{"geoid":"1901270","name":"Allerton","full":"Allerton city","lsad":"city","polygons":[[[-933795,406994,167,0,1,36,48,0,2,108,-26,0,-1,16,-189,2]]]},{"geoid":"1901315","name":"Allison","full":"Allison city","lsad":"city","polygons":[[[-928101,427375,288,0,-2,291,-285,0]]]},{"geoid":"1901495","name":"Alta","full":"Alta city","lsad":"city","polygons":[[[-953139,426658,48,-1,-1,-36,99,0,0,37,50,0,0,91,15,0,0,18,-63,-1,-1,36,-147,1]]]},{"geoid":"1901540","name":"Alta Vista","full":"Alta Vista city","lsad":"city","polygons":[[[-924269,431912,199,0,-1,109,-197,1]]]},{"geoid":"1901585","name":"Alton","full":"Alton city","lsad":"city","polygons":[[[-960184,429894,10,-13,-11,-9,-34,0,-1,-47,45,1,1,-73,199,0,-3,220,-66,1,8,22,-38,-15,0,-7,-73,0,0,8,24,4,0,46,-49,0,20,-58,-20,0,-1,-35,-99,6,-1,-21,-13,-25]]]},{"geoid":"1901630","name":"Altoona","full":"Altoona city","lsad":"city","polygons":[[[-935224,416604,14,-2,-13,-16,24,0,50,-72,35,-35,82,-147,31,-36,70,0,47,84,47,47,-1,-59,74,0,0,-18,19,0,3,-91,-48,0,-1,-72,48,1,0,-109,48,6,0,-6,150,0,-2,72,-48,0,0,37,144,0,0,109,96,0,0,127,-14,0,0,17,-34,0,-7,36,-42,1,0,235,-121,-21,-41,-34,-32,0,0,12,-142,-47,0,70,-48,0,0,36,-12,0,12,7,-2,101,-97,1,0,119,-96,-1,-1,-124,-55,0,2,22,-43,-28,1,-95,-96,0]]]},{"geoid":"1901675","name":"Alvord","full":"Alvord city"Line truncated
@@ -0,0 +1 @@
{"st":"ID","state":"Idaho","fips":"16","features":[{"geoid":"1600100","name":"Aberdeen","full":"Aberdeen city","lsad":"city","polygons":[[[-1128487,429369,199,1,0,32,29,1,15,17,-14,20,-15,0,13,-16,-28,-12,1,102,-199,0]]]},{"geoid":"1600280","name":"Acequia","full":"Acequia city","lsad":"city","polygons":[[[-1136040,426634,142,-1,4,109,-146,0,0,-98]]]},{"geoid":"1601000","name":"Albion","full":"Albion city","lsad":"city","polygons":[[[-1135888,424119,39,0,0,-73,34,0,8,-17,23,0,-1,-19,38,0,9,22,-46,0,0,14,33,0,0,35,-28,6,5,22,29,4,0,20,24,12,-28,-5,0,16,-138,0]]]},{"geoid":"1601900","name":"American Falls","full":"American Falls city","lsad":"city","polygons":[[[-1128741,427736,12,-6,138,-1,-10,-23,18,-11,38,34,52,1,0,19,49,-2,-2,17,-49,0,93,93,-9,27,17,8,22,-4,1,16,27,12,-8,10,-66,-33,-5,16,-22,0,0,38,47,0,-4,17,-44,0,-12,20,-24,-11,-10,-25,-26,2,-9,-16,-37,11,-7,-34,-37,-80,-15,-13,-110,-36,0,-23,-10,-13]]]},{"geoid":"1601990","name":"Ammon","full":"Ammon city","lsad":"city","polygons":[[[-1119839,434461,257,0,0,-8,-25,-1,-13,-19,-20,-3,0,-8,18,1,-6,-35,38,1,0,31,20,0,0,-17,-15,0,0,-14,22,-1,34,35,-9,16,4,22,14,0,-3,-17,22,-15,47,-1,-5,-30,17,4,2,38,-11,21,-40,0,0,39,46,6,-37,27,1,44,-27,-10,-2,40,21,30,-5,26,-35,1,-15,9,0,55,98,5,0,-10,13,0,1,-15,-14,-4,0,-33,15,-1,23,38,31,0,-10,13,40,-4,0,-10,34,4,22,-6,0,-13,59,7,0,-8,44,-4,-9,18,10,9,-12,1,1,27,-78,-8,0,33,48,19,46,1,0,-8,50,7,0,-37,25,0,5,-72,-45,0,-1,6,-8,-6,14,-13,-9,-24,2,-36,95,1,1,71,44,-13,0,50,-10,14,38,2,3,13,-13,7,-78,0,16,10,10,62,-37,16,-34,-5,-53,-27,-98,-5,5,-14,-31,-1,-21,27,21,2,0,35,-9,7,-94,-3,0,10,-51,0,0,-45,-21,0,1,60,-54,0,25,12,0,60,-7,13,-58,0,0,-13,12,0,0,-60,-97,1,0,6,-49,-6,0,66,-9,6,7,78,53,0,0,72,-58,-6,0,-10,-17,0,10,17,-19,0,0,-8,-29,-9,-32,0,-21,9,0,8,28,0,4,10,-18,6,0,55,-45,1,-1,-70,29,-2,0,-8,-29,-3,1,-62,96,0,-20,-31,-30,-11,-35,-34,-11,0,-1,-96]]]},{"geoid":"1602935","name":"Arbon Valley","full":"Arbon Valley CDP","lsad":"CDP","cdp":true,"polygons":[[[-1126632,428981,24,-20,23,0,32,-41,57,-33,52,-5,26,-38,12,-3,-3,-25,25,-14,29,-2,0,-9,37,-11,-4,-58,-42,-42,-8,-23,-22,5,-37,-11,-22,-12,-15,-20,-2,-20,12,-16,1,-36,-21,-40,26,-15,-12,-14,8,-59,39,-16,22,-20,14,-32,15,-7,121,1,0,128,88,-1,23,-22,-2,-109,171,53,57,1,123,-25,124,-12,104,14,22,15,12,-2,11,14,18,-29,23,5,48,-24,27,-2,45,16,-6,906,-14,7,-85,4,-4,57,-15,12,-1162,3],[-1126317,429027,172,-1,0,196,42,-1,0,12,26,0,0,39,55,0,1,-29,24,0,0,-18,247,0,-1,52,34,1,-1,17,21,0,0,20,95,-1,0,-107,-95,0,-1,-40,44,21,2,-158,-541,-125,0,12,-50,1,0,36,-76,1]]]},{"geoid":"1603160","name":"Arco","full":"Arco city","lsad":"city","polygons":[[[-1133112,436310,39,0,-9,-5,0,-30,14,-1,0,-36,28,0,0,-9,-22,-1,0,-60,58,0,-5,9,44,14,3,119,50,0,0,36,-50,0,0,36,50,0,0,36,-117,0,-29,21,-14,-4,14,-11,-15,-5,0,-29,-31,0,-7,-8]]]},{"geoid":"1603340","name":"Arimo","full":"Arimo city","lsad":"city","polygons":[[[-1121781,425642,16,-26,-1,-73,25,0,49,-36,25,36,73,-1,0,17,-78,0,-56,129,-17,1,-11,24,-14,0,5,-37,-18,-1]]]},{"geoid":"1603610","name":"Ashton","full":"Ashton city","lsad":"city","polygons":[[[-1114571,440700,12,0,4,-22,148,0,0,35,49,0,0,37,-101,9,1,28,-52,0,0,25,-49,0,-6,-93]]]},{"geoid":"1603700","name":"Athol","full":"Athol city","lsad":"city","polygons":[[[-1167202,479475,27,2,-1,-71,80,-1,-1,-72,54,0,2,72,51,1,1,36,23,0,-6,36,78,1,18,25,0,8,-85,0,-23,36,-4,-30,-133,-4,18,55,-13,36,-27,20,-5,-74,-55,0]]]},{"geoid":"1604105","name":"Avimor","full":"Avimor CDP","lsad":"CDP","cdp":true,"polygons":[[[-1162650,437634,117,8,7,14,-13,0,0,23,-26,15,32,13,-43,29,-42,-6,-1,-9,-28,5,-5,11,-10,-56]]]},{"geoid":"1604420","name":"Bancroft","full":"Bancroft city","lsad":"city","polygons":[[[-1118918,427145,92,4,18,12,1,-43,36,0,0,25,49,0,-1,74,-36,0,-1,53,-11,-18,-147,0]]]},{"geoid":"1604600","name":"Banks","full":"Banks CDP","lsad":"CDP","cdp":true,"polygons":[[[-1161415,440791,31,-76,-20,-8,-3,-14,24,-51,2,-22,49,-18,14,-18,47,-7,46,56,18,50,12,14,6,36,-27,50,5,29,15,14,45,16,-5,1,35,47,-24,17,-12,31,-26,20,-31,10,-12,12,-43,10,1,-63,-61,-39,-27,-35,-10,-27,-36,-16]]]},{"geoid":"1605230","name":"Basalt","full":"Basalt city","lsad":"city","polygons":[[[-1121731,433115,99,0,1,-53,-21,-19,34,0,-3,19,-7,0,-2,53,30,0,1,73,-76,9,-22,-25,-3,-29]]]},{"geoid":"1606220","name":"Bellevue","full":"Bellevue city","lsad":"city","polygons":[[[-1142721,434717,50,-36,0,-56,25,-1,48,-106,42,11,9,-22,31,2,-20,24,14,4,-21,14,-28,47,82,20,0,-24,16,0,0,31,34,6,-11,23,-23,6,0,36,21,0,27,53,22,11,29,32,0,12,16,7,0,16,19,16,13,33,47,70,-12,4,-19,-18,-52,-81,-62,-70,0,-13,-14,0,-44,-46,-9,21,-80,-10,-22,24,16,-34,-46,-11,-82,85,-19,-4,11,-24,-13,-22]]]},{"geoid":"1606670","name":"Bennington","full":"Bennington CDP","lsad":"CDP","cdp":true,"polygons":[[[-1113556,424005,13,-17,4,-22,-5,-38,6,-40,30,-54,36,-47,40,-19,6,-30,10,-4,-3,-18,15,-25,17,-11,-6,-15,19,-7,21,7,54,-39,28,4,25,-10,52,5,77,35,16,-4,11,-17,70,14,60,31,28,33,24,-3,26,26,-20,98,-46,26,-64,19,-62,-3,1,129,-470,2]]]},{"geoid":"1607840","name":"Blackfoot","fLine truncated
@@ -0,0 +1 @@
{"st":"IL","state":"Illinois","fips":"17","features":[{"geoid":"1700113","name":"Abingdon","full":"Abingdon city","lsad":"city","polygons":[[[-904141,407981,94,1,0,-37,-95,1,-1,-31,42,16,54,0,0,13,120,1,-8,18,-17,0,1,23,48,1,1,28,42,0,1,37,-48,-1,0,74,-35,8,24,17,-1,12,-30,0,-1,-37,-36,0,0,7,-28,4,0,-10,-104,-1,-2,-33,38,0,0,10,31,2,-5,-18,-42,3,0,-27,-16,0,0,-21,-32,0]]]},{"geoid":"1700178","name":"Adair","full":"Adair CDP","lsad":"CDP","cdp":true,"polygons":[[[-905059,404137,97,-4,-7,37,12,0,16,-27,27,-21,2,120,-106,0,25,-11,-1,-19,-23,-29,-41,0,-1,-10]]]},{"geoid":"1700191","name":"Adams","full":"Adams CDP","lsad":"CDP","cdp":true,"polygons":[[[-912135,398602,283,-3,2,192,-51,-2,-7,11,-25,-1,-12,-11,-17,27,-22,-9,-1,21,-15,-5,-8,9,-29,-14,-93,41]]]},{"geoid":"1700230","name":"Addieville","full":"Addieville village","lsad":"village","polygons":[[[-894951,383842,180,-5,-3,144,-146,4,0,12,-37,-2]]]},{"geoid":"1700243","name":"Addison","full":"Addison village","lsad":"village","polygons":[[[-880518,419283,37,5,4,-6,5,7,0,-14,13,1,-1,-13,-28,0,0,12,-28,-1,-5,-38,97,2,-1,-38,-26,4,0,-18,27,1,9,7,-7,6,99,-26,-1,-40,-12,0,-2,-17,-11,0,-1,-7,26,1,0,-6,-61,-2,-3,-24,63,1,0,-10,30,0,19,12,-1,34,26,2,-1,33,48,-12,-15,16,89,2,-2,37,49,1,4,-95,99,-26,-11,-5,0,-19,61,1,-1,21,15,0,0,20,46,-3,0,6,36,-11,-2,43,49,1,1,-23,14,1,1,-27,35,-5,-2,55,24,0,-23,59,45,0,3,-58,17,-2,-5,-10,13,0,-1,106,-19,3,-14,-32,-45,0,-4,7,-36,-8,1,6,18,1,-6,12,19,-5,23,12,-13,4,0,13,-46,30,9,21,85,-49,0,16,-43,19,41,1,2,-14,26,-9,2,-10,-4,92,-18,0,-4,47,-40,4,-1,32,40,-13,20,24,20,0,8,13,5,-17,25,0,17,31,-70,-3,-42,-11,2,9,-70,-2,11,47,-121,-3,0,-67,-263,123,-75,-31,44,23,0,12,-60,22,-36,-10,-17,-37,18,0,0,-20,-30,-8,-71,30,-29,2,-12,-13,1,14,-91,14,10,-30,-16,-5,17,-5,1,-25,-14,-3,41,2,1,-7,-35,-1,-7,-21,41,1,-1,-82,50,2,-1,-45,-51,7,-14,-19,-34,0,-2,-9]]]},{"geoid":"1700295","name":"Adeline","full":"Adeline village","lsad":"village","polygons":[[[-894969,421375,41,0,-1,-17,31,3,29,14,0,55,-28,16,-72,-3]]]},{"geoid":"1700516","name":"Albany","full":"Albany village","lsad":"village","polygons":[[[-902346,417799,272,-2,0,73,49,1,21,38,-48,-1,0,33,-22,-5,0,14,-46,-6,-128,-54,-50,-39]]]},{"geoid":"1700555","name":"Albers","full":"Albers village","lsad":"village","polygons":[[[-896400,385320,92,1,10,28,3,55,77,25,7,-15,28,0,-1,-19,39,19,-1,-18,39,0,0,-12,-15,0,-1,-68,25,8,5,68,-6,23,23,1,0,11,37,4,12,11,0,26,-23,0,23,16,0,20,-116,2,-20,16,-52,-1,-1,-17,-34,-1,6,-41,40,9,0,-11,-11,-1,-1,-29,-46,1,-12,-9,-11,11,-9,-16,-15,5,0,-22,-22,-7,22,0,1,-36,-92,-2]]]},{"geoid":"1700568","name":"Albion","full":"Albion city","lsad":"city","polygons":[[[-880793,383758,38,4,-1,-51,63,0,0,-37,19,7,-2,41,12,-10,84,0,-60,-21,0,-70,30,18,79,3,0,-37,47,1,6,63,-92,3,7,40,32,-7,0,8,49,1,2,56,22,0,-18,15,10,2,12,-13,0,17,109,6,-1,-80,57,1,1,79,-23,0,-1,26,-45,-10,0,-9,-125,0,0,58,-32,0,0,18,-11,0,1,-18,-24,0,22,36,16,0,-15,9,6,17,-7,-6,-46,0,1,-56,-48,0,0,-15,-44,0,0,-82,-32,5,-4,53,-55,0,0,39,-46,0,-2,-86]]]},{"geoid":"1700594","name":"Alden","full":"Alden CDP","lsad":"CDP","cdp":true,"polygons":[[[-885232,424586,23,-5,0,-30,35,4,8,22,31,1,-2,18,-26,-5,-7,32,-44,-11,1,-25]]]},{"geoid":"1700646","name":"Aledo","full":"Aledo city","lsad":"city","polygons":[[[-907619,411929,74,-1,-2,-19,-15,1,-10,-18,-25,0,-1,-36,170,-4,2,73,96,-2,2,37,48,-1,-1,-37,48,-1,1,52,49,-1,1,21,-49,1,0,18,-70,2,0,22,-24,1,-1,-21,-80,8,-7,6,1,41,-11,0,2,73,-49,1,0,-18,-23,0,0,-18,-21,1,0,25,-11,-23,-42,1,0,-19,-25,0,1,-31,-25,0,0,-60]]]},{"geoid":"1700659","name":"Alexander","full":"Alexander CDP","lsad":"CDP","cdp":true,"polygons":[[[-900488,397241,1,-88,71,-12,49,1,0,36,144,2,-2,71,-123,1,0,58,-70,0,-3,-58,-96,-11]]]},{"geoid":"1700672","name":"Alexis","full":"Alexis village","lsad":"village","polygons":[[[-905633,410611,23,0,-6,-35,109,-5,2,59,32,1,1,32,-33,0,1,8,36,-1,-14,13,-120,-3,-29,-34]]]},{"geoid":"1700685","name":"Algonquin","full":"Algonquin village","lsad":"village","polygons":[[[-883836,421612,48,0,0,-36,78,0,17,-6,-3,-43,24,7,-22,6,101,0,0,18,49,0,-1,-88,41,0,0,-18,73,-6,0,-12,122,0,0,-22,-10,-15,-19,1,2,-32,-20,0,-3,32,-97,2,1,-60,12,-15,14,7,12,-7,111,-1,-1,36,98,-2,-1,13,12,-14,86,0,-1,109,-42,-1,-8,36,175,6,23,11,-1,17,35,-6,1,10,57,-6,51,4,-1,-71,-25,-9,16,-15,21,6,15,-21,23,0,-19,1,-1,20,20,1,0,18,98,1,0,18,99,2,1,160,-44,0,-1,38,-11,0,7,88,-82,-5,-33,18,-31,-22,-10,17,-37,-24,-45,7,-1,56,13,9,59,4,-1,23,25,0,0,-12,43,0,16,16,4,13,-36,13,-2,15,-50,5,-24,12,0,-18,-191,0,15,-45,127,0,0,-27,-75,0,0,-36,-24,0,-1,-36,-15,0,-18,-14,11,-23,-9,-12,-28,14,4,24,-17,-9,-5,7,-14,-23,-12,17,-17,0,-1,-17,-24,0,0,42,-36,3,-145,-9,-1,-18,-12,0,0,18,-415,0,-12,-17,-44,-1,-13,19,-24,-1,0,-72,-25,0]]]},{"geoid":"1700737","name":"Alhambra","full":"Alhambra village","lsad":"village","polygons":[[[-897498,388847,188,-1,-1,-37,46,0,0,37,22,-1,7,73,-261,1]]]},{"geoid":"1700815","name":"Allendale","full":"Allendale village","lsad":"village","polygons":[[[-877159,385239,108,-1,0,34,17,21,-18,19,-108,1]]]Line truncated
@@ -0,0 +1 @@
{"st":"IN","state":"Indiana","fips":"18","features":[{"geoid":"1800140","name":"Aberdeen","full":"Aberdeen CDP","lsad":"CDP","cdp":true,"polygons":[[[-871287,414346,241,0,1,52,23,12,-5,22,-22,7,-28,43,-65,30,0,-21,-144,0]]]},{"geoid":"1800154","name":"Abington","full":"Abington CDP","lsad":"CDP","cdp":true,"polygons":[[[-849720,397320,39,6,-14,-58,77,0,11,18,-8,12,28,57,-14,19,-22,-14,-66,0,-18,8]]]},{"geoid":"1800352","name":"Adams","full":"Adams CDP","lsad":"CDP","cdp":true,"polygons":[[[-855646,393799,0,-24,18,0,-9,7,16,14,22,3,0,17,48,-1,0,22,-48,3,0,29,-46,-13,-16,-57]]]},{"geoid":"1800532","name":"Adams Lake","full":"Adams Lake CDP","lsad":"CDP","cdp":true,"polygons":[[[-853425,415480,93,0,17,-14,0,12,70,2,0,28,49,6,19,30,-26,42,-23,18,-36,3,-6,-11,-39,-2,-53,-24,-17,1,0,-18,-48,-1]]]},{"geoid":"1800640","name":"Advance","full":"Advance town","lsad":"town","polygons":[[[-866293,399939,24,0,0,-36,53,-1,0,-18,17,0,0,17,71,1,5,54,16,37,-20,-7,0,24,-141,1,-1,-54,-24,0]]]},{"geoid":"1800748","name":"Akron","full":"Akron town","lsad":"town","polygons":[[[-860330,410361,192,-38,-1,46,-40,5,0,11,20,0,0,35,-75,-2,-23,13,-1,18,-25,0,0,-24,-47,-1]]]},{"geoid":"1800784","name":"Alamo","full":"Alamo town","lsad":"town","polygons":[[[-870577,399817,49,0,2,34,-52,0]]]},{"geoid":"1800802","name":"Albany","full":"Albany town","lsad":"town","polygons":[[[-852562,403004,17,-8,0,-10,42,0,0,-26,224,4,37,9,0,24,42,0,14,-9,0,14,40,-1,-1,35,-40,8,-2,112,-48,0,-1,55,-94,-1,0,-68,47,0,0,-9,-47,2,0,-34,-47,0,-15,-17,-27,-1,0,-19,-47,0,0,-32,-53,0,0,30,-41,0]]]},{"geoid":"1800820","name":"Albion","full":"Albion town","lsad":"town","polygons":[[[-854379,413956,33,0,1,-37,26,-2,-2,-34,80,2,0,-12,26,1,-4,-28,32,0,-1,-66,24,0,1,101,33,0,0,9,44,-2,-2,14,82,-5,0,21,47,1,3,40,-168,0,-9,16,-19,-1,-2,59,53,0,0,73,-137,-1,1,6,-32,0,0,-21,17,0,-1,-42,-33,0,0,-18,34,0,0,-22,-34,0,0,-37,-48,0,-1,24,-45,11]]]},{"geoid":"1800910","name":"Alexandria","full":"Alexandria city","lsad":"city","polygons":[[[-856912,402666,5,-19,-18,-17,22,-8,0,-18,14,-10,18,0,0,20,16,-3,0,-90,50,1,-2,-146,93,1,4,-37,43,0,-15,37,67,0,-1,37,-48,-1,0,51,-50,4,11,1,0,17,38,0,0,11,-37,0,11,27,25,0,-1,35,-17,1,0,21,-30,7,0,10,4,12,14,-5,11,11,-12,6,10,10,20,0,28,54,-28,0,21,12,-16,5,6,39,-31,1,-8,35,-14,0,5,12,-22,1,0,-8,-21,-1,1,22,-19,0,0,-23,-26,1,0,-6,29,1,0,-19,-47,-1,0,18,-47,-1,0,-27,47,0,0,-15,-21,0,0,-30,-40,-1,0,-8,15,-4,-15,-4,14,-5,-107,-1,0,-13,-29,-1]]]},{"geoid":"1800946","name":"Alford","full":"Alford CDP","lsad":"CDP","cdp":true,"polygons":[[[-872493,384877,94,0,8,83,-31,-9,-81,0]]]},{"geoid":"1800964","name":"Alfordsville","full":"Alfordsville town","lsad":"town","polygons":[[[-869490,385581,16,-8,8,6,6,42,-28,13,-18,-29]]]},{"geoid":"1801270","name":"Alton","full":"Alton town","lsad":"town","polygons":[[[-864265,381231,92,-68,37,42,-61,43,-23,-16,-21,18]]]},{"geoid":"1801288","name":"Altona","full":"Altona town","lsad":"town","polygons":[[[-851596,413513,44,-4,-3,-17,49,0,1,12,39,-7,-2,11,13,0,2,8,0,17,-48,-1,0,19,-96,-10]]]},{"geoid":"1801342","name":"Ambia","full":"Ambia town","lsad":"town","polygons":[[[-875193,404862,62,-1,-1,61,-70,0]]]},{"geoid":"1801360","name":"Amboy","full":"Amboy town","lsad":"town","polygons":[[[-859322,405978,18,0,12,-21,22,3,1,18,52,-1,0,31,19,5,-15,-1,0,25,-58,27,-16,-14,-6,35,-27,0,-1,-37,29,4,0,-10,-34,-30]]]},{"geoid":"1801396","name":"Americus","full":"Americus CDP","lsad":"CDP","cdp":true,"polygons":[[[-867681,405260,41,-40,52,26,10,-15,31,20,-6,8,25,1,0,53,-145,-49]]]},{"geoid":"1801432","name":"Amity","full":"Amity CDP","lsad":"CDP","cdp":true,"polygons":[[[-860099,394316,-12,-31,7,-10,-4,-27,14,-14,29,-4,18,-19,-1,-22,44,2,-1,-32,54,3,-59,101,14,2,1,43,27,40,-118,-4,-10,9,-33,-1]]]},{"geoid":"1801450","name":"Amo","full":"Amo town","lsad":"town","polygons":[[[-866192,396842,57,-5,0,-29,82,1,8,68,-43,3,0,28,-17,6,-11,29,22,7,0,37,-61,0,0,-37,-35,1]]]},{"geoid":"1801468","name":"Anderson","full":"Anderson city","lsad":"city","d":5,"polygons":[[[-8576313,4001956,336,376,290,236,279,173,353,171,345,118,1518,426,-63,-144,590,-262,319,395,561,15,40,244,282,86,23,233,325,87,0,573,474,2,-17,-86,560,49,-1,47,2780,17,-2,-208,-472,2,0,-107,467,-9,9,40,236,0,1,-130,804,3,101,84,-13,-164,46,-150,614,3,-301,563,334,6,-4,-117,203,1,-25,-448,231,-1,0,-108,-230,0,2,-526,444,-8,-1,-97,514,4,-4,313,186,1,-3,426,-187,-1,1,650,247,2,2,-92,358,0,80,-22,63,22,1,98,255,3,0,3659,186,0,145,210,240,146,515,207,277,170,-838,-366,-523,-69,1,33,416,47,357,99,-126,174,194,90,-250,263,-588,-116,-2,937,543,10,0,-190,530,1,218,232,-749,2,-542,-42,0,1124,386,-4,0,-75,206,-64,-21,-157,143,-253,1187,7,0,-581,252,-60,-252,-189,0,-92,248,-1,-3,-175,537,366,-93,159,329,2,438,293,1,67,-384,-3,-99,80,0,659,-2178,15,253,335,-952,-5,1,1459,418,0,3,370,156,-1,0,63,189,0,0,366,-479,0,-180,-142,0,-286,-2314,9,191,215,-21,389,-101,99,111,202,244,0,-1,1415,-130,65,-245,4,16,-229,-518,-10,16,-100,94,-20,-65,-36,-124,-4,1,-79,76,-1,1,-34,136,-1,3,-70,-215,4,8,-348Line truncated
@@ -0,0 +1 @@
{"st":"KS","state":"Kansas","fips":"20","features":[{"geoid":"2000100","name":"Abbyville","full":"Abbyville city","lsad":"city","polygons":[[[-982087,379671,68,-1,46,9,0,75,-16,1,0,-25,-69,2,-14,10,-14,-21]]]},{"geoid":"2000125","name":"Abilene","full":"Abilene city","lsad":"city","polygons":[[[-972631,389328,25,0,-1,-31,47,1,-1,-73,140,0,0,-36,-35,0,0,-37,12,0,0,9,23,1,0,-62,135,36,0,-5,32,0,-1,-8,-23,-1,-5,-33,8,-10,34,16,0,21,55,1,0,-37,21,-7,-3,-27,47,-3,0,37,28,0,0,10,47,0,0,62,25,0,-7,52,30,0,0,13,-22,0,-8,81,-18,0,-1,85,-46,0,-24,-12,0,37,70,15,0,57,-94,0,1,-36,-26,0,0,-53,-67,-13,1,-56,-29,-60,-160,-1,0,-9,-47,0,0,64,47,0,0,-18,11,10,29,-10,7,18,0,55,-117,-2,0,-34,-25,0,0,33,-69,-18,-21,-20,1,10,-26,-4]],[[-972421,389007,42,-7,17,-36,35,-1,0,117,-11,0,11,43,-49,-13,0,-31,-45,0]]]},{"geoid":"2000225","name":"Ada","full":"Ada CDP","lsad":"CDP","cdp":true,"polygons":[[[-979029,391647,25,-18,17,-38,15,-7,14,-27,-9,-25,4,-27,37,-4,0,-12,55,-11,41,0,90,-18,4,298,-227,-4,-52,-76,-1,-26]]]},{"geoid":"2000325","name":"Admire","full":"Admire city","lsad":"city","polygons":[[[-961072,386367,104,1,-1,85,-103,-2]]]},{"geoid":"2000475","name":"Agenda","full":"Agenda city","lsad":"city","polygons":[[[-974344,396988,17,0,-4,45,34,-1,1,85,-48,0]]]},{"geoid":"2000550","name":"Agra","full":"Agra city","lsad":"city","polygons":[[[-991257,397605,46,-9,0,-31,48,0,0,39,27,-1,0,34,-60,15,0,-19,-15,0,0,18,-31,1,0,-19,-15,1]]]},{"geoid":"2000825","name":"Albert","full":"Albert city","lsad":"city","polygons":[[[-990146,384494,67,0,2,90,-69,1]]]},{"geoid":"2000950","name":"Alden","full":"Alden city","lsad":"city","polygons":[[[-983153,382437,24,-8,0,-82,7,0,1,66,41,9,12,22,-21,29,-10,-11,-68,10]]]},{"geoid":"2001050","name":"Alexander","full":"Alexander city","lsad":"city","polygons":[[[-995577,384658,92,0,0,24,12,1,-12,15,-1,33,-91,0]]]},{"geoid":"2001275","name":"Allen","full":"Allen city","lsad":"city","polygons":[[[-961748,386516,103,0,-1,72,-100,0]]]},{"geoid":"2001350","name":"Alma","full":"Alma city","lsad":"city","polygons":[[[-962979,390170,37,-1,2,-23,7,0,-1,-36,32,-4,-1,-26,15,5,28,-10,49,-1,1,15,-24,20,0,14,17,0,-16,96,-54,-7,0,8,-23,0,-1,-16,-23,0,-9,-22]]]},{"geoid":"2001425","name":"Almena","full":"Almena city","lsad":"city","polygons":[[[-997209,398925,39,-5,9,-62,141,-1,1,112,-95,0,-30,-22,-65,1]]]},{"geoid":"2001550","name":"Altamont","full":"Altamont city","lsad":"city","polygons":[[[-953131,371813,59,1,1,-30,100,0,0,27,21,0,0,-27,43,0,1,72,9,10,16,0,-2,27,22,-1,0,37,47,-1,0,19,-34,0,9,119,74,0,1,9,-92,-1,-5,-127,-22,0,0,19,-63,-1,-3,59,-6,0,-1,-23,-52,0,2,-36,-46,3,-1,-14,-21,0,-1,-24,32,0,1,-51,-25,0,0,-48,-65,-2]]]},{"geoid":"2001575","name":"Alta Vista","full":"Alta Vista city","lsad":"city","polygons":[[[-964945,388614,35,-18,-13,-16,0,-29,48,-1,0,73,46,0,1,37,-32,0,12,35,-49,1,-15,-19]]]},{"geoid":"2001600","name":"Alton","full":"Alton city","lsad":"city","polygons":[[[-989526,394622,61,0,0,25,26,0,0,51,-15,0,1,36,-71,-2]]]},{"geoid":"2001625","name":"Altoona","full":"Altoona city","lsad":"city","polygons":[[[-956650,375227,-6,-72,73,-1,2,26,-16,0,0,34,13,0,0,13,19,0,-3,72,-16,0,-1,57,-35,2,10,-11,-3,-13,-35,0,-13,-106]]]},{"geoid":"2001675","name":"Americus","full":"Americus city","lsad":"city","polygons":[[[-962715,384999,120,-3,1,-30,43,-8,0,22,-23,1,0,14,46,-1,0,73,24,-1,2,36,-24,1,2,37,-187,3,-5,-70]]]},{"geoid":"2001725","name":"Ames","full":"Ames CDP","lsad":"CDP","cdp":true,"polygons":[[[-974630,395665,247,1,1,40,12,3,-258,7]]]},{"geoid":"2001775","name":"Andale","full":"Andale city","lsad":"city","polygons":[[[-976406,377974,73,-58,-22,-5,1,-35,126,2,1,-18,55,0,0,74,-75,-1,-17,24,-31,-1,0,10,-38,-1]]]},{"geoid":"2001800","name":"Andover","full":"Andover city","lsad":"city","polygons":[[[-971530,376722,40,0,-1,-73,-38,0,-1,-146,174,0,1,-199,-14,11,-2,22,-9,0,0,23,-66,-2,0,-72,184,1,0,72,92,0,-1,145,-179,0,-2,144,44,0,0,-36,23,4,1,32,68,0,0,-36,45,0,1,37,-137,1,0,72,92,0,0,60,16,11,7,-8,0,-56,22,0,1,84,-24,0,1,54,24,0,0,73,8,-1,-9,7,0,131,92,-29,0,170,-38,-25,-54,-18,-5,-35,-26,0,0,-9,-14,0,-1,27,-45,0,0,37,-45,0,-1,37,-10,0,-1,24,-35,-5,0,17,92,0,-1,112,-91,0,0,-33,19,0,1,-11,-20,0,0,-19,22,0,0,-24,-22,0,0,12,-45,0,-1,36,-46,1,0,37,-85,0,-1,-356,39,18,1,45,49,1,-3,-25,47,19,0,-6,-133,-62,0,-218]]]},{"geoid":"2001975","name":"Anthony","full":"Anthony city","lsad":"city","polygons":[[[-980438,371521,21,0,0,-8,24,3,1,-80,12,0,1,13,192,6,0,36,-23,0,0,23,49,10,1,45,-22,0,-4,-19,-24,0,1,118,-92,-1,-1,-54,-30,-18,-34,0,11,35,-30,0,-9,-10,0,47,-45,-1]],[[-980575,371702,90,0,1,56,45,5,18,48,-24,0,-3,-17,-36,0,0,44,-26,0,1,45,-12,0,1,54,11,0,0,18,-64,0,0,-16,11,0,10,-23,-10,-34,-12,0,-1,-17,22,-7,25,-70,-14,-42,-34,0]],[[-980878,371588,31,-6,0,-21,28,-1,16,-28,-1,-15,46,0,0,199,-49,0,4,-125,-70,11]],[[-980482,371230,14,0,1,22,39,0,0,-21,7,0,28,71,2,74,-91,-1]]]},{"geoid":"2002175","name":"Arcadia","full":"Arcadia city","lsad":"city","polygons":[[[-946305,376366,130,1,0,73,-24,0,-6,7,0,30,-6,-10,-29,1,-2Line truncated
Loaded 100 of 484 files, more files were not shown because too many files have changed in this diff. Show more