diff --git a/.env.example b/.env.example index 55cb367a0..0876262b2 100644 --- a/.env.example +++ b/.env.example @@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live # Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY. MAPILLARY_CLIENT_TOKEN= -# Optional: OpenSky auth mode (oauth | basic | auto | anon) +# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired +# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously. # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) OPENSKY_AUTH_MODE=oauth @@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET= # Optional paid Patreon tokens raise request limits; there is no free API key. LL2_API_TOKEN= -# Optional: OpenSky credentials JSON file path (alternative to above) +# Optional: OpenSky credentials JSON file path, used when the two values above +# are empty: the credentials.json your OpenSky account page downloads when you +# create an API client. Use an absolute path for compatibility with launchers. # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json # Optional: dev server config diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 442b9c1bf..bb2c2de9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,7 @@ The highest-leverage places to jump in: ## Architecture in one minute - **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/). -- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/.js` entries retain compatibility; new code belongs with its focused owner. +- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/.js` entries retain compatibility; new code belongs with its focused owner. - Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers//source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details. - **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6d2ee498b..3557c4137 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -177,7 +177,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. -- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. +- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. - **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. diff --git a/README.md b/README.md index e3833a654..80f052571 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,10 @@ Thirty tools, four jobs — the commands below come straight from the product's **🎥 Direct it** — drone-operator camera verbs: +“Rotate the globe” spins Earth to the right continuously; ask for left, a speed, +or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance; +camera input stops rotation. + > 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_ **🖊️ Annotate it** — a whiteboard over the real world: @@ -488,7 +492,7 @@ How the globe handles live data: ``` src/ ├── main.js # Bootstrap: Google 3D tiles, layer registration -├── ui.js # Runtime UI — panels, HUD, styles, control facade +├── ui/ # Runtime UI — panels, HUD, styles, controls ├── hud.js # Intelligence HUD + AI scene summary ├── keySetup.js # POWER UP panel — in-app provider keys (dev server only) ├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks diff --git a/changelog.d/1225-globe-rotation.md b/changelog.d/1225-globe-rotation.md new file mode 100644 index 000000000..844966030 --- /dev/null +++ b/changelog.d/1225-globe-rotation.md @@ -0,0 +1,2 @@ +- Add continuous globe rotation with direction and speed controls and a globe-fit + check before starting (manjunath22466, #1225). diff --git a/changelog.d/1229-opensky-oauth-only.md b/changelog.d/1229-opensky-oauth-only.md new file mode 100644 index 000000000..25159830b --- /dev/null +++ b/changelog.d/1229-opensky-oauth-only.md @@ -0,0 +1,8 @@ +- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server + now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's + account page downloads) on every platform, not only through the macOS + launcher, and no longer sends retired username/password Basic auth: + `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to + set instead (quanticsoul4772, #1229). +- Credential-file warnings omit parser excerpts and filesystem error details; + the path example also works with the macOS launchers. diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md new file mode 100644 index 000000000..ab68243d6 --- /dev/null +++ b/changelog.d/1268-radio-issue-fix.md @@ -0,0 +1,2 @@ +- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268). +- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). diff --git a/changelog.d/1270-space-issue-fix.md b/changelog.d/1270-space-issue-fix.md new file mode 100644 index 000000000..b483c4c01 --- /dev/null +++ b/changelog.d/1270-space-issue-fix.md @@ -0,0 +1 @@ +- Respect provider cooldowns and bound tle groups (asturksever, #1270). diff --git a/changelog.d/1273-history-issue-fix.md b/changelog.d/1273-history-issue-fix.md new file mode 100644 index 000000000..f672a5c22 --- /dev/null +++ b/changelog.d/1273-history-issue-fix.md @@ -0,0 +1 @@ +- Retire unavailable history and keep local trails (asturksever, #1273). diff --git a/changelog.d/1274-import-issue-fix.md b/changelog.d/1274-import-issue-fix.md new file mode 100644 index 000000000..99bbc1429 --- /dev/null +++ b/changelog.d/1274-import-issue-fix.md @@ -0,0 +1 @@ +- Scan the kml root delimiter in bounded time (asturksever, #1274). diff --git a/changelog.d/1278-overpass-issue-fix.md b/changelog.d/1278-overpass-issue-fix.md new file mode 100644 index 000000000..4b028f046 --- /dev/null +++ b/changelog.d/1278-overpass-issue-fix.md @@ -0,0 +1 @@ +- Reserve singleflight before disk cache reads (asturksever, #1278). diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md new file mode 100644 index 000000000..ec451e7ae --- /dev/null +++ b/changelog.d/1279-launch-issue-fix.md @@ -0,0 +1 @@ +- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md new file mode 100644 index 000000000..3ce673038 --- /dev/null +++ b/changelog.d/1281-traffic-issue-fix.md @@ -0,0 +1,8 @@ +- Hold rendering only for animated content (asturksever, #1281). +- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). +- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). +- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). +- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). +- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). +- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281). diff --git a/changelog.d/1304-antimeridian-distances.md b/changelog.d/1304-antimeridian-distances.md new file mode 100644 index 000000000..fc7744f86 --- /dev/null +++ b/changelog.d/1304-antimeridian-distances.md @@ -0,0 +1,2 @@ +- Point distances wrap longitude differences across the antimeridian, in voice + proximity checks and `places_nearby` (quanticsoul4772, #1304). diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md new file mode 100644 index 000000000..e8cad2a23 --- /dev/null +++ b/changelog.d/1321-dead-code-cleanup.md @@ -0,0 +1,6 @@ +- Remove dead code across the repository: three modules nothing imports + (`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports + and locals nothing reads, a duplicate copy of the bundled dams data and an + unused ship model. The app no longer builds an unused default wind layer + and Bhote Koshi locator layer when it loads. Package exports are unchanged + (samehkhamis, #1321). diff --git a/changelog.d/1324-street-level-provider-exports.md b/changelog.d/1324-street-level-provider-exports.md new file mode 100644 index 000000000..a3dc4cb6b --- /dev/null +++ b/changelog.d/1324-street-level-provider-exports.md @@ -0,0 +1,8 @@ +- Export the KartaView and Panoramax Street Level providers, so a host composing + its own provider list can offer the keyless providers without Mapillary + (samehkhamis, #1324). +- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the + token itself (such as a same-origin proxy). Its sequence lookups go there + through the source's transport, and the photo viewer uses MapillaryJS's + Graph provider at that endpoint, so no client token is needed in the page + (samehkhamis, #1324). diff --git a/changelog.d/1325-phone-layout.md b/changelog.d/1325-phone-layout.md new file mode 100644 index 000000000..c1653df62 --- /dev/null +++ b/changelog.d/1325-phone-layout.md @@ -0,0 +1,4 @@ +- Add a phone layout for viewports 720px and narrower: the globe stays full + screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one + panel sheet at a time, keeping attribution visible and scene STOP reachable. + Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325). diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md new file mode 100644 index 000000000..e240dc82a --- /dev/null +++ b/changelog.d/1338-mobile-contact-context.md @@ -0,0 +1,4 @@ +- Improve touch selection of aircraft and vessels through the shared contact + picker, reveal tracked aircraft details when opening Context, support downward + swipe dismissal of phone drawers while preserving scrolling and controls, and + enlarge the phone logo (samehkhamis, #1338). diff --git a/changelog.d/1341-kartaview-impossible-timestamps.md b/changelog.d/1341-kartaview-impossible-timestamps.md new file mode 100644 index 000000000..302ff38f4 --- /dev/null +++ b/changelog.d/1341-kartaview-impossible-timestamps.md @@ -0,0 +1,3 @@ +- Reject impossible KartaView photo timestamps, so 31 February is unknown + instead of rolling forward into March and passing the imagery date filter + (Rudy Celekli, @rudycelekli, #1341). diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md new file mode 100644 index 000000000..e0003636d --- /dev/null +++ b/changelog.d/1342-firms-acquisition-calendar.md @@ -0,0 +1,3 @@ +- Keep an impossible wildfire acquisition date off the detection clock, so a + fire is not labeled with a later day it did not happen + (Rudy Celekli, @rudycelekli, #1342). diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index a23ba3e0f..9085a7b72 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -79,7 +79,6 @@ moves it. Do not label all ingestion modules platform-independent by filename. | Retained entry | Owner and current reason | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `src/ui.js` | UI facade; direct callers retain standalone StyleManager defaults. Normal assembly uses `src/ui/composition.js` | | `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately | | `src/data/.js` | Legacy default instances; new assembly calls layer factories with supplied sources | | `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly | @@ -87,9 +86,8 @@ moves it. Do not label all ingestion modules platform-independent by filename. | `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances | | `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports | -The two exact standalone-import exceptions are `src/ui.js` to -`src/standalone/catalog.js` and `server/providers/local.js` to -`server/standalone/key-setup.js`. They do not authorize new compatibility +The one exact standalone-import exception is `server/providers/local.js` to +`server/standalone/key-setup.js`. It does not authorize new compatibility back-edges. Voice actions use plain `src/data/feedState.js`, not the manager facade. Settings filesystem hardening belongs in `server/standalone/key-setup-hardening.mjs`; its settings policy is unchanged. @@ -237,7 +235,7 @@ DOM elements and callbacks; importing it creates no browser state. `destroy()` removes owned listeners and cancels pending work without changing saved state or moving focus. Call it before removing or replacing the controls. -`src/ui.js` retains panel layout, persistence, share restoration and application +`src/ui/applicationShell.js` retains panel layout, persistence, share restoration and application reactions to state changes. Map Source selection and Location draft cleanup are provided through callbacks. The component imports no globe, data, application or server modules. Package checks and scoped formatting cover this entry. @@ -470,8 +468,8 @@ release listeners; pending action feedback is limited to its current owner. ## UI assembly and styles `ui/shell` assembles controls from supplied existing layer, navigation, terrain, -rendering, HUD and share operations. `src/standalone/ui.js` provides the running -application's instances; `src/ui.js` remains the compatibility entry. The shell +rendering, HUD and share operations. `src/standalone/controls.js` provides the running +application's instances. The shell imports no standalone bootstrap or concrete live layer implementation. Panel layout, position/drag, notices, recording and deferred UI work have separate owners with synchronous cleanup. Existing scene, share and HUD engines retain diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8be0a867a..da00e585b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,11 +9,21 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. +Overpass reserves each normalized query before its disk-cache lookup. The +initial disk lookup, upstream quota admission, fetching and memory publication +are coalesced under that reservation. A caller that joined a reservation +rejected by another client's quota retries admission with its own quota. +Stale fallback runs per response after the reservation is released, and disk +writes finish asynchronously. Settled and rejected reservations are removed +only while they still own their map entry. + Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP 413 before disk-cache loading or upstream work; malformed coordinates remain 400. The browser continues to split larger workloads into sequential 64-point requests. +Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, @@ -1379,6 +1389,47 @@ The right rail skips panels hidden by other CSS (computed `display: none`), but keeps the collapsed panels its own exclusive presentation hides, so automatic collapse can still be undone. +## Phone layout + +At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and +shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below), +Cameras (CCTV and Street Level), Context (Global Context, plus Weather and +Recent Imagery when shown), Search (Location) and More (Visual Presets and Map +Source above Display). A tab opens one bottom sheet; the same tab, the backdrop +or Escape closes it, and focus moves into the sheet and back to its tab. A +downward swipe of at least 64px on non-control sheet content also closes it, +when every scroll container under the finger starts at the top. Ordinary +vertical scrolling, horizontal gestures, controls and multitouch stay native; +a cancelled gesture or a sheet/viewport change cannot dismiss another sheet. Panels +are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock +as the sheet, and the dock's Location and Presets trays render inline. The voice +control floats above the tab bar while no sheet is open. + +The phone logo is 58px wide (about 39px high), aligned with the top controls. +The shared contact picker preserves exact picks. For a coarse primary pointer +that misses onto the map surface, it searches at most eight hits in a 14px square +and accepts the first registered layer hit, stopping at non-map objects. Civil +and military aircraft and vessels share this rule; fine pointers stay exact. + +Manually opening Context with a tracked civilian or military aircraft enters +the existing Contacts mode when Context is off. Contacts adopts that aircraft +without restarting its tracking; an active or entering Space Missions mode is +left alone. Programmatic sheet changes and closing the tab do not activate a mode. + +Sheets borrow panels. Opening one expands its panels without persisting them or +claiming a user choice, and share links keep reporting each borrowed panel's +state from before the sheet; closing puts back what opening changed, while a +panel the user changed inside the sheet stays as they left it. Escape closes the +sheet before a panel's own Escape handling, and the dock's hover auto-close does +not apply inside a sheet. +Cockpit, recording and clean view close the sheet and hide the tab bar; scene +playback keeps both so its STOP control in the Scenes panel stays reachable. The +right rail neither hides collapsed siblings nor applies Cyber's one-panel +accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the +voice control, and above an open sheet. The page draws under the notch and +system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets. +Wider viewports are unchanged, and crossing to them closes any open sheet. + ## Surface keyboard lifecycle Surface Tab cycling excludes controls with a negative tabindex, native disabled @@ -1865,6 +1916,10 @@ Updated: September 23, 2026 ## Aircraft and vessel server modules +OpenSky credential-file failures emit a fixed warning once per process; neither +JSON parser excerpts nor filesystem error messages are logged. The environment +example uses an absolute path compatible with the macOS launchers. + Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky state vectors and regional fallback, adsb.lol military positions, ADSBDB enrichment and track backfill. Vessel routes and websocket/watchdog setup live @@ -3595,7 +3650,7 @@ Historical planning documents may not match runtime behavior. ## Runtime Stack - Vite + CesiumJS app with Google Photorealistic 3D Tiles -- Scene/HUD/style systems in `src/ui.js` and `src/hud.js` +- Scene/HUD/style systems in `src/ui/` and `src/hud.js` - Layer management in `src/data/manager.js` - Map stack switching in `src/mapStackController.js` - Voice control in `src/voice/` (OpenAI Realtime over WebRTC) @@ -3627,8 +3682,8 @@ its criteria cannot be silently ignored. | 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/localLayers.js` | — | static | -| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| 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) | @@ -4685,7 +4740,7 @@ silently demoting every later lookup for the session. 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. Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. - **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. -- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol). +- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. ### Optional Overpass configuration @@ -4943,7 +4998,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). @@ -5164,7 +5219,7 @@ pins and share links already supply complete boxes. - **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. -- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. +- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. - Civilian and military 2D aircraft use the established distance scale: `3×` near the camera and a `0.5×` floor from 8,000 km outward. A standard 20 px ambient @@ -5249,7 +5304,7 @@ pins and share links already supply complete boxes. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. -- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). +- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. @@ -5373,7 +5428,7 @@ demand with square-root demand scaling and semantic layer weights (`labelArbiter.js:181`). First-run default: ELASTIC. **A default has THREE surfaces, and a PARSE fallback that is not one of them.** -The value literals (engine constant, markup value, markup readout, `ui.js` +The value literals (engine constant, markup value, markup readout, `src/ui/visualPresets.js` `GLOBAL_POST_DEFAULTS`, the share generator's starting state) must all move together, because a fresh boot runs no restore and those literals ARE the startup state. The `scf` / `ko` PARSE fallbacks deliberately do NOT move: they answer @@ -5421,7 +5476,7 @@ easier to meet (detection is now on more often), but does not create it. - **Detection OUTSIDE opacity: `1%`** (moved `5% → 3% → 1%` on 2026-08-24). `KEYHOLE_OUTSIDE_OPACITY_DEFAULT` in `src/celestialRing.js`, mirrored by `#detection-opacity-slider`'s markup value AND readout, - `GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in `ui.js`, and + `GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in `src/ui/visualPresets.js`, and `_detectionOutsideOpacityPct` in `sharelink.js`. The slider's `step` is now `1`, so 1–4 % are reachable at all (at the previous step of 5 the entire sub-default range was one stop wide). `AIRCRAFT_BRACKET_FLOOR_ANCHOR` in @@ -5464,6 +5519,11 @@ easier to meet (detection is now on more often), but does not create it. short-way-round longitude span and wraps the padded edges, so a dateline- crossing box stays its true width. Raw subtraction inflated a 0.41° metro box to 86.7° and a 60° territory to 132°. +- **Point distances take the short way round 180°.** The annotation + resolver's `approximateDistanceM` (voice "point out" proximity checks) and + `places_nearby` distances wrap the longitude difference. Raw subtraction put + Savusavu 38,148 km from a camera over the Lau Islands, so the proximity check + refused it, and sorted a place 2 km across 180° last. - CCTV calibration persists at `godsEyeView.cctv.calibration.v2` (v2 rebuild; the store was wiped clean, no import from the old `v1` key). - CCTV v3 floor QA pins are: zero samples for heading-only edits; zero transient diff --git a/docs/opensky-auth.md b/docs/opensky-auth.md index 67844f912..c5f0a387b 100644 --- a/docs/opensky-auth.md +++ b/docs/opensky-auth.md @@ -3,10 +3,11 @@ God's Eye View uses explicit auth modes for `/api/flights`: - `OPENSKY_AUTH_MODE=oauth` (default, recommended) -- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback) -- `OPENSKY_AUTH_MODE=basic` +- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous) - `OPENSKY_AUTH_MODE=anon` +`OPENSKY_AUTH_MODE=basic` is retired (see below). + Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow. ## Quick Start (OAuth Client JSON) @@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order: 2. `OPENSKY_CREDENTIALS_FILE` 3. Keychain (`opensky-network` / `client_id`, `client_secret`) -## Basic Auth (Legacy) +The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and +Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any +platform. It reads the file on each token refresh. -Basic auth is still supported for now, but OpenSky is moving away from it. +## Basic Auth (Retired) -```bash -OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh -``` +OpenSky stopped accepting username/password (HTTP Basic) sign-in on +2026-03-18; its API now takes only OAuth2 client credentials. With +`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` / +`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and +logs once what to set instead. Create an API client on your OpenSky +account page and use its `credentials.json` as above. ## Troubleshooting @@ -61,6 +67,6 @@ Useful reasons: - `oauth_invalid_or_missing`: OAuth client not found/usable - `oauth_invalid_credentials`: OAuth client rejected by OpenSky -- `basic_invalid_credentials`: username/password rejected -- `missing_basic_creds`: basic mode missing user/pass +- `missing_oauth_creds`: auto mode found no OAuth client +- `basic_retired`: basic mode requested; answered anonymously - `rate_limited`: OpenSky throttling diff --git a/index.html b/index.html index 8a01415b6..a9874d550 100644 --- a/index.html +++ b/index.html @@ -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. --> - + diff --git a/package.json b/package.json index 241c59e59..d6f74bc0f 100644 --- a/package.json +++ b/package.json @@ -272,6 +272,8 @@ "./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js", "./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js", "./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js", + "./layers/street-level/kartaview": "./src/layers/streetLevel/providers/kartaview/index.js", + "./layers/street-level/panoramax": "./src/layers/streetLevel/providers/panoramax/index.js", "./tools": "./src/tools/index.js", "./tools/mcp": "./src/tools/mcp/index.js", "./tools/panel": "./src/tools/panel.js", diff --git a/public/models/README.md b/public/models/README.md index 7e9c089be..aa7f40fbd 100644 --- a/public/models/README.md +++ b/public/models/README.md @@ -8,7 +8,6 @@ under the license listed below. |---|---|---|---|---| | `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. | | `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. | -| `ship.glb` | “Low Poly Cargo Ship” by [Javier_Fernandez](https://sketchfab.com/Javier.Fernandez) | [Sketchfab model](https://sketchfab.com/3d-models/low-poly-cargo-ship-4c22cbaf01c1427f8ab60b3a07b1b32c) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized and repackaged as a glTF binary for the project's vessel visualization. | | `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | | `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | | `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | diff --git a/public/models/ship.glb b/public/models/ship.glb deleted file mode 100644 index e52d13b2c..000000000 Binary files a/public/models/ship.glb and /dev/null differ diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index ac1a3f732..3f4fec617 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -24,11 +24,10 @@ const renderer = (file) => /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( file, ); -// Two compatibility composition entries intentionally select standalone defaults. +// One compatibility composition entry intentionally selects standalone defaults. const compatibilityEdge = (from, to) => - (from === 'server/providers/local.js' && - to === 'server/standalone/key-setup.js') || - (from === 'src/ui.js' && to === 'src/standalone/catalog.js'); + from === 'server/providers/local.js' && + to === 'server/standalone/key-setup.js'; const portableExport = (key) => key.startsWith('./sources/') || /\/source$/.test(key) || diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2c78c0774..7b7171c68 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -285,8 +285,6 @@ "src/ui/sceneControls.test.mjs", "scripts/qa-scene-controls.mjs", "scripts/qa-action-buttons.mjs", - "src/ui.js", - "src/standalone/ui.js", "src/ui/applicationShell.js", "src/ui/panelLayoutController.js", "src/ui/panelPositionControls.js", @@ -1088,5 +1086,7 @@ "src/ui/statusBadge.test.mjs", "src/ui/cctvBadges.test.mjs", "scripts/qa-status-badges.mjs", - "src/dockerTrustedPeers.test.mjs" + "src/dockerTrustedPeers.test.mjs", + "src/cameraGlobeRotation.test.mjs", + "src/voice/globeResetConfirmation.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a7c35c286..3970a1ee2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -103,6 +103,9 @@ "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs", + "server/providers/space/cooldown.js", "server/providers/space/launch-library.js", "server/providers/common/http.js", "src/data/spaceProviderRequests.js", @@ -651,6 +654,8 @@ "exports": ["./ui/shell"], "modules": [ "src/i18n/marker.js", + "src/ui/mobileShell.js", + "src/exclusiveSurfaces.js", "src/ui/placeNames.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -1425,7 +1430,9 @@ "./layers/street-level", "./layers/street-level/mapillary", "./layers/street-level/mapillary/source", - "./layers/street-level/google" + "./layers/street-level/google", + "./layers/street-level/kartaview", + "./layers/street-level/panoramax" ], "modules": [ "src/layers/streetLevel/index.js", @@ -1852,7 +1859,6 @@ "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", - "src/data/localLayers.js", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", "src/data/local_data/neighborhoods/san-francisco.json", @@ -1906,6 +1912,7 @@ "src/data/vesselLabels.js", "src/data/vesselTypes.js", "src/firstRunExperience.js", + "src/exclusiveSurfaces.js", "src/hud.js", "src/hudLocality.js", "src/hudLocalityFormat.js", @@ -2193,6 +2200,8 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/mobileShell.js", + "src/exclusiveSurfaces.js", "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", @@ -3663,6 +3672,7 @@ "src/data/labelArbiter.js", "src/data/meshFloorSampler.js", "src/data/pickRegistry.js", + "src/data/scenePick.js", "src/data/spriteOrder.js", "src/data/terrainHeights.js", "src/data/transit.js", diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index 7eb2aeca7..c2d2b5646 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -370,6 +370,9 @@ try { await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' }); await page.setViewport({ width: 390, height: 844 }); await new Promise((resolve) => setTimeout(resolve, 700)); + // Phones present CCTV in the Cameras tab's sheet. + await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]'); + await new Promise((resolve) => setTimeout(resolve, 400)); await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' }); check( 'narrow camera panel remains within the viewport', diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs index 05c4dbff0..2416a04e3 100644 --- a/scripts/qa-chrome.mjs +++ b/scripts/qa-chrome.mjs @@ -18,3 +18,30 @@ export function chromeExecutablePath({ if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; } + +/** Restore an owned test window before capture: focus emulation alone does not + * unminimize the native compositor, so CDP screenshots can otherwise stall. */ +export async function captureQaScreenshot( + page, + options, + { headful = true } = {}, +) { + if (headful) { + const session = await page.createCDPSession(); + try { + const { windowId, bounds } = await session.send( + 'Browser.getWindowForTarget', + ); + if (bounds.windowState === 'minimized') { + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + } + await page.bringToFront(); + } finally { + await session.detach(); + } + } + return page.screenshot(options); +} diff --git a/scripts/qa-cockpit-plates.mjs b/scripts/qa-cockpit-plates.mjs index 4e725fd8f..3d3aece03 100644 --- a/scripts/qa-cockpit-plates.mjs +++ b/scripts/qa-cockpit-plates.mjs @@ -12,7 +12,7 @@ * passed with the painter stubbed out — the number was right and nothing was * drawn. Every assertion here now comes from the RENDERED path instead: * - * ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the + * ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui/shellFacade.js), the * overlay's own published `visibleCount` / `labeledKeys`, so a contact only * counts once the sweep, the arbiter and the placement solve have all run. * plate scale — tapped off `CanvasRenderingContext2D` while the frame paints. diff --git a/scripts/qa-failstate-b10.mjs b/scripts/qa-failstate-b10.mjs index 95dc2ba4f..2033a2731 100644 --- a/scripts/qa-failstate-b10.mjs +++ b/scripts/qa-failstate-b10.mjs @@ -34,10 +34,6 @@ import puppeteer from 'puppeteer'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(__dirname, '..'); const argv = process.argv.slice(2); const getOpt = (name, dflt) => { diff --git a/scripts/qa-final-polish-credits.mjs b/scripts/qa-final-polish-credits.mjs index 7e7182985..dde1431b9 100644 --- a/scripts/qa-final-polish-credits.mjs +++ b/scripts/qa-final-polish-credits.mjs @@ -52,7 +52,6 @@ try { id, value, ); - const text = () => page.$eval('#cesium-credits', (e) => e.innerText); const shot = async (name, osm, tiles) => { await sleep(300); const state = await page.$eval('#cesium-credits', (e) => { diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index fdac4fd40..8709366de 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -1884,7 +1884,7 @@ async function runBrowserGroup(record) { await step('C15', async () => { // CRT/NVG/FLIR are DISPLAY LABELS; the real style ids are retro / - // surveillance / thermal (src/ui.js STYLE_STATUS_LABELS). Passing the + // surveillance / thermal (src/ui/visualPresets.js STYLE_STATUS_LABELS). Passing the // labels made every call a silent no-op that still "passed" — so assert // the returned visual state actually changed to the requested style. const wanted = [['retro', 'CRT'], ['surveillance', 'NVG'], ['thermal', 'FLIR'], ['normal', 'Normal']]; diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 8d26dcb30..fb04d0025 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -86,14 +86,6 @@ function percentile(values, pct) { return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * pct) - 1)]; } -function parseJson(value, fallback) { - try { - return JSON.parse(value); - } catch { - return fallback; - } -} - function analyzeMembership(samples) { let replacements = 0; let exposure = 0; diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 17b19f4c9..2876e0cac 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -156,14 +156,50 @@ const pressTabs = async (count, backwards = false) => { // The row scrolls smoothly when native Tab brings an edge button into view. await new Promise((resolve) => setTimeout(resolve, 250)); }; +// Phones (≤720px) present the dock's Presets inline in the More tab's sheet. +// It opens from the keyboard, as a keyboard user would, so the focus it moves +// into the sheet shows a ring. +const openPhoneMoreSheet = async () => { + const tab = '#mobile-tab-bar [data-mobile-sheet="more"]'; + if (await page.evaluate(() => document.body.dataset.mobileSheet === 'more')) { + await page.focus(tab); + await page.keyboard.press('Enter'); + } + await page.focus(tab); + await page.keyboard.press('Enter'); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); +}; + +// Media-query changes reach the page on a rendering step; wait until the +// phone shell agrees with the viewport before measuring. +const waitForShellSettled = () => page.waitForFunction(() => ( + document.body.classList.contains('mobile-shell') + === matchMedia('(max-width: 720px)').matches +), { timeout: 10_000, polling: 100 }); + +// The dock slides back into place when the layout leaves the phone shell. +const waitForDockSettled = () => page.waitForFunction(() => ( + !document.getElementById('command-dock').getAnimations({ subtree: true }) + .some((animation) => animation.playState === 'running') +), { timeout: 5_000 }); + const checkResponsiveStyleFocus = async (width) => { // The disclosure is only the starting boundary. Every style target below is // reached by actual Tab events, never by focusing the style under test. const selectedMapBefore = await page.evaluate(() => ( window.__godsEyeView.styleManager.mapStackController.getActiveId() )); - await page.focus('#control-panel-toggle'); - await pressTabs(2); // disclosure -> pin -> Normal + if (width <= 720) { + // Phones show Presets in the More tab's sheet, which focuses its first + // visible control (Normal) when it opens; the tray toggle and pin are + // desktop chrome there. + await openPhoneMoreSheet(); + } else { + await page.focus('#control-panel-toggle'); + await pressTabs(2); // disclosure -> pin -> Normal + } for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) { if (index) await pressTabs(3); const state = await styleFocusMetrics(); @@ -215,7 +251,16 @@ const layerFocusState = async (id) => ({ ...await page.evaluate((layerId) => { const manager = window.__godsEyeView.dataManager; const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`); - const list = document.getElementById('data-toggles'); + // The list scrolls on its own on desktop; in a phone sheet the sheet + // scrolls instead. Measure whichever container actually scrolls. + let list = document.getElementById('data-toggles'); + for (let node = list; node; node = node.parentElement) { + const overflow = getComputedStyle(node).overflowY; + if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) { + list = node; + break; + } + } return { lifecycle: manager.getLayerLifecycleState(layerId), feedState: button?.dataset.feedState, label: button?.textContent.trim(), @@ -1146,6 +1191,8 @@ try { await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 }); await new Promise((resolve) => setTimeout(resolve, 180)); + await waitForShellSettled(); + await openPhoneMoreSheet(); const at620 = await trayMetrics(); check( '620 px tray uses two rows without clipping', @@ -1179,7 +1226,13 @@ try { await page.screenshot({ path: path.join(shotsDir, '480-open.png') }); await checkResponsiveStyleFocus(480); + // Pinning and the compact wing are the narrow desktop dock (721–900 px); + // phones use the tab bar instead. + await page.setViewport({ width: 800, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + await waitForDockSettled(); await page.click('.dock-pin-btn[data-pin-target="control-panel"]'); + await waitForDockSettled(); await page.click('#control-panel-toggle'); const cdp = await page.createCDPSession(); await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); @@ -1374,6 +1427,27 @@ try { for (const width of [1000, 620, 480]) { await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + if (width <= 720) { + // Phones present Location as the Search tab's sheet: the keyboard opens + // it onto the field, and Escape returns focus to the tab. + const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]'; + await page.focus(searchTab); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 250)); + const field = await focusMetrics('#location-search'); + check(`Search: ${width} px keyboard opens the sheet on the location field`, + field.focusedId === 'location-search' && field.visible && field.insideViewport, + JSON.stringify(field)); + await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) }); + await page.keyboard.press('Escape'); + await new Promise((resolve) => setTimeout(resolve, 250)); + const tab = await focusMetrics(searchTab); + const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet); + check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`, + closedSheet && hasVisibleControlFocus(tab), JSON.stringify({ closedSheet, tab })); + continue; + } await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false }); @@ -1489,6 +1563,12 @@ try { if (dataSetup.ready) { for (const width of [1000, 620, 480]) { await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + if (width <= 720 && !(await page.evaluate(() => document.body.dataset.mobileSheet === 'layers'))) { + // Phones present Data Layers in the Layers tab's sheet. + await page.focus('#mobile-tab-bar [data-mobile-sheet="layers"]'); + await page.keyboard.press('Enter'); + } await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); // Resizing schedules rail placement on animation frames. Wait for that // pass and its CSS transitions before comparing a focus rectangle with @@ -1549,6 +1629,7 @@ try { ))); await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); await page.focus('#data-panel .panel-collapse-btn'); const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`); diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 648075bd2..d78c3dbcc 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -1291,8 +1291,6 @@ async function main() { const clusterBadge = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); - const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); - const { radioStationIdFromPick } = await import('/src/data/radio.js'); const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision; const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations'); @@ -2761,13 +2759,16 @@ async function main() { const beforeSpace = radio.getUIState().audioState; // Simulate an already-started hold-Space session; click-started open mic // deliberately ignores Space takeover, and short taps never claim voice. - const priorPushToTalkMode = voice.pushToTalkMode; - voice.pushToTalkMode = true; + // The provider facade forwards reads, not writes. Set the mounted + // controller's input owner; assigning on the facade only shadows it. + const controller = voice.session.adapter.controller; + const priorPushToTalkMode = controller.pushToTalkMode; + controller.pushToTalkMode = true; document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); await new Promise((resolve) => setTimeout(resolve, 700)); const afterSpace = radio.getUIState().audioState; document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); - voice.pushToTalkMode = priorPushToTalkMode; + controller.pushToTalkMode = priorPushToTalkMode; await radio.play(); voice.setVoiceSpeaker('ai'); return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState }; diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index c7068f26c..ab92c128d 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -329,6 +329,9 @@ try { await page.keyboard.press('Escape'); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); + // Phones present Scenes in the Layers tab's sheet. + await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]'); + await new Promise((resolve) => setTimeout(resolve, 400)); await page.screenshot({ path: path.join(shots, 'narrow.png') }); check( 'Scene controls stay inside the narrow viewport', diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 1be1a80fa..0dd841acf 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -30,7 +30,6 @@ import { import { answerKartaViewRequest, describeKartaViewCall, - KV_LINE, KV_SEQUENCE_ID, kartaViewPhotos, resetKartaViewFixture, diff --git a/scripts/qa-traffic-oblique.mjs b/scripts/qa-traffic-oblique.mjs index 596d2f009..6ea1c953d 100644 --- a/scripts/qa-traffic-oblique.mjs +++ b/scripts/qa-traffic-oblique.mjs @@ -80,9 +80,7 @@ try { page.evaluate(() => { const { viewer, dataManager } = window.__godsEyeView; let upper = 0, - lower = 0, - near = 0; - const camera = viewer.camera.positionWC; + lower = 0; dataManager.layers .get('traffic') .module.visitMotionDots((id, key, p) => { diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 424f3486e..9770999df 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -25,12 +25,13 @@ * Visual proof saved to qa-shots/ (gitignored). * * Run: node scripts/qa-traffic.mjs --url http://localhost:4410 - * Add --fixtures for synthetic roads and the recorded flow tile without a key; - * that mode does not qualify live source access or quota accounting. + * Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key; + * that mode does not qualify geographic terrain, live source access or quota accounting. * Exits non-zero on any FAIL. Does not commit anything. */ import puppeteer from 'puppeteer'; +import { captureQaScreenshot } from './qa-chrome.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -44,10 +45,12 @@ const getOpt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; }; -const APP_URL = getOpt('--url', 'http://localhost:4410'); +const appUrl = new URL(getOpt('--url', 'http://localhost:4410')); +appUrl.searchParams.set('welcome', '0'); +const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); -const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; +const { installTrafficRoutes } = await import('./traffic-fixtures.mjs'); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -80,6 +83,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Enable traffic + teleport, then poll the layer until settled. */ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { + // The app deliberately stops rendering hidden pages. Programmatic camera + // changes do not activate a headful window the way a real UI click does. + await page.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'visible'); return page.evaluate(async (v, minC, tS) => { const gev = window.__godsEyeView; const dm = gev.dataManager; @@ -97,6 +104,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { }); let s = null; for (let i = 0; i < tS; i++) { + if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused'); await new Promise((r) => setTimeout(r, 1000)); s = mod.getStats(); // Retained roads from the previous city can satisfy count/loading before @@ -122,8 +130,8 @@ async function main() { return; } - const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); - if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.'); + const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null); + if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; live quota is not qualified.'); if (!statusBefore?.hasKey) { console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m'); process.exitCode = 2; @@ -135,8 +143,10 @@ async function main() { headless: HEADFUL ? false : 'new', ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', - '--disable-dev-shm-usage', '--disable-web-security', + '--no-sandbox', '--disable-setuid-sandbox', + ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), + '--disable-backgrounding-occluded-windows', + '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], @@ -144,24 +154,22 @@ async function main() { let exitCode = 0; try { - const page = await browser.newPage(); - const onFixture = (request) => { - const response = fixtureResponse(request); - if (response) void request.respond(response); else void request.continue(); - }; - if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); } + let page = await browser.newPage(); + // Keep this visible-page regression deterministic even if the OS starts + // Chrome minimized. Use browser focus emulation, not patched DOM getters. + const focusSession = await page.createCDPSession(); + await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); await page.setViewport({ width: 1440, height: 900 }); - // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). + // Track flow-tile requests for the (iii) budget check. const flowRequests = []; page.on('request', (req) => { if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url()); }); - const trafficLogs = []; - page.on('console', (msg) => { - const t = msg.text(); - if (t.includes('[Data:Traffic]')) trafficLogs.push(t); - }); + const pageErrors = []; + if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) }); + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); @@ -194,7 +202,7 @@ async function main() { `coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`); if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1; await sleep(1200); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL }); } // ── (iv) uncovered-roads param — hide vs sim ───────────────────────────── @@ -227,7 +235,7 @@ async function main() { (hid.flowBuckets?.sim || 0) === 0 && hid.count > 0, `count=${hid.count} sim=${hid.flowBuckets?.sim}`); if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL }); } else { record('PARAM: hide mode renders zero sim (white) dots', null, 'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests'); @@ -235,30 +243,26 @@ async function main() { // ── (ii) C4 oblique bounds ─────────────────────────────────────────────── console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...'); - trafficLogs.length = 0; - await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); + const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); { - const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('[')); - let ok = false; let detail = 'no fetch log captured'; - if (fetchLine) { - const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/); - if (m) { - const [s, w, n, e] = m.slice(1).map(Number); - const cLat = (s + n) / 2; const cLon = (w + e) / 2; - const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2); - ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin) - detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`; - } + const bounds = oblique?.roadBounds; + let ok = false; let detail = 'no road bounds published'; + if (bounds) { + const cLat = (bounds.south + bounds.north) / 2; + const cLon = (bounds.west + bounds.east) / 2; + const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180)); + ok = dKm > 1 && dKm <= 13; + detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`; } record('C4: oblique fetch box centers on the look-at point', ok, detail); if (!ok) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL }); } // ── (iii) budget honesty ───────────────────────────────────────────────── console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...'); { - const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()); + const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()); const grew = statusAfter.dailyCount - statusBefore.dailyCount; const ok = grew >= 0 && grew <= flowRequests.length; record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok, @@ -268,25 +272,22 @@ async function main() { // ── (v) KEYLESS fallback (intercepted — server key untouched) ──────────── console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...'); - if (FIXTURES) page.off('request', onFixture); - await page.setRequestInterception(true); + // Keyless is an independent fresh-session contract. Close the keyed page + // so its workers, terrain work and intercepted cache cannot leak into it. + await page.close(); + page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + const keylessFocus = await page.createCDPSession(); + await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); const keylessFlowReqs = []; - page.on('request', (req) => { - const url = req.url(); - if (url.includes('/api/tomtom/status')) { - req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) }); - return; - } - if (url.includes('/api/tomtom/flow/')) { - keylessFlowReqs.push(url); - req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) }); - return; - } - const response = fixtureResponse(req); - if (response) { void req.respond(response); return; } - try { req.continue(); } catch { /* already handled */ } + await installTrafficRoutes(page, { + origin: appUrl.origin, fixtures: FIXTURES, keyless: true, + onFlow: url => keylessFlowReqs.push(url), + onError: error => pageErrors.push(error.message), }); - await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, @@ -316,8 +317,9 @@ async function main() { exitCode = 1; } await sleep(1000); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL }); } + record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); } catch (e) { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 7d83d77a4..fb0fd0bd9 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -47,8 +47,6 @@ import { reduceScenario, reduceSensorContrast, reduceScriptedMotion, - reduceMotion, - reduceFleet, } from '../src/layers/transit/qaMetrics.js'; const BASE = process.env.QA_BASE_URL || 'http://localhost:4173'; diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index b5ad8ac87..278c1c71f 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -295,7 +295,7 @@ async function main() { ); // Wait for the photoreal tileset and at least one vessel refresh. - const settled = await page + await page .waitForFunction(() => { const gev = window.__godsEyeView; const t = gev.tileset; diff --git a/scripts/qa-weather-swap.mjs b/scripts/qa-weather-swap.mjs index e5b26d723..da7f77bb2 100644 --- a/scripts/qa-weather-swap.mjs +++ b/scripts/qa-weather-swap.mjs @@ -8,13 +8,7 @@ * node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google * [--layer weather-satellite] [--product clouds] [--headless] */ -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 6cc6aaa94..3712df28e 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -1,7 +1,7 @@ import { readdirSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; export const ALLOCATION_TEST_FILES = Object.freeze([ 'src/data/focusAllocations.test.mjs', diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 01e048091..7075be2c1 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({ return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT; } +/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */ +export function resolveOpenSkyCredentialsFile({ + authoritativeEnvironment = false, + environment = process.env, + rootDir = ROOT, +} = {}) { + const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE'); + const value = authoritativeEnvironment && environmentDefines + ? String(environment.OPENSKY_CREDENTIALS_FILE || '') + : String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir); + return isConfiguredValue(value.trim()); +} + function hasKeychainItem(service, account) { if (process.platform !== 'darwin') return false; const result = spawnSync('security', [ @@ -153,10 +166,11 @@ export function resolveCredential(spec, { export function buildCapabilitySummary( credentials, - { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {}, + { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {}, ) { const configured = (name) => credentials[name]?.configured === true; - const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true; const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase(); const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode) ? requestedOpenSkyMode @@ -164,11 +178,13 @@ export function buildCapabilitySummary( const flights = openSkyMode === 'anon' ? 'OpenSky keyless anonymous access (rate-limited)' : openSkyMode === 'basic' - ? 'OpenSky Basic mode selected (credential presence and validity not verified)' + ? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)' : openSkyMode === 'auto' ? 'OpenSky auto mode selected (runtime credential choice and validity not verified)' - : hasOAuthCredentials + : hasClientPair ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' + : hasOAuthCredentials + ? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)' : 'OpenSky keyless anonymous access (rate-limited)'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', @@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment credentials, capabilities: buildCapabilitySummary(credentials, { openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }), + openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }), }), }; } diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index 54ad48358..0ec8cdacd 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -1,56 +1,175 @@ -import { readFileSync } from 'node:fs'; -import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js'; -import { tilesForBounds } from '../src/data/tomtomTiles.js'; +import { PbfWriter } from 'pbf'; -const tile = readFileSync( - new URL( - '../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf', - import.meta.url, - ), -); +const corsHeaders = { 'access-control-allow-origin': '*' }; const json = (body) => ({ status: 200, + headers: corsHeaders, contentType: 'application/json', body: JSON.stringify(body), }); -/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */ +// Cesium heightmap-1.0: 65x65 little-endian uint16 heights, scale .2, +// offset -1000, followed by child availability and a uniform land mask. +const flatTerrain = Buffer.alloc(65 * 65 * 2 + 2); +for (let i = 0; i < 65 * 65; i++) flatTerrain.writeUInt16LE(5000, i * 2); + +/** Synthetic, geographically aligned motor roads at both source zooms. */ +export function trafficFixtureTile(zoom, flow = false) { + const writer = new PbfWriter(); + writer.writeMessage( + 3, + (_, layer) => { + layer.writeVarintField(15, 2); + layer.writeStringField(1, flow ? 'Traffic flow' : 'transportation'); + layer.writeVarintField(5, 4096); + layer.writeStringField(3, flow ? 'traffic_level' : 'class'); + layer.writeMessage( + 4, + (_, value) => { + if (flow) value.writeDoubleField(3, 0.6); + else value.writeStringField(1, 'primary'); + }, + null, + ); + if (flow) { + layer.writeStringField(3, 'road_type'); + layer.writeMessage( + 4, + (_, value) => value.writeStringField(1, 'Major road'), + null, + ); + } + // z12 has four times the span of z14. Keep the same world spacing, + // and cover only alternate roads with flow to exercise hide/sim controls. + const step = 256 * 2 ** (zoom - 14); + for (let y = step; y < 4096; y += step) { + if (flow && (y / step) % 2) continue; + layer.writeMessage( + 2, + (_, feature) => { + feature.writeVarintField(3, 2); + feature.writePackedVarint(2, flow ? [0, 0, 1, 1] : [0, 0]); + feature.writePackedVarint(4, [9, 0, y * 2, 10, 8192, 0]); + }, + null, + ); + } + }, + null, + ); + return Buffer.from(writer.finish()); +} + +/** Intercept the current vector sources; never fall through to live roads. */ export function trafficFixtureResponse(request) { const url = new URL(request.url()); + if (url.hostname === 'terrain.reearth.land') { + if (url.pathname.endsWith('/layer.json')) + return json({ + tilejson: '2.1.0', + format: 'heightmap-1.0', + version: 'qa-flat', + scheme: 'tms', + projection: 'EPSG:4326', + minzoom: 0, + maxzoom: 0, + tiles: ['{z}/{x}/{y}.terrain?v={version}'], + available: [[{ startX: 0, startY: 0, endX: 1, endY: 0 }]], + attribution: + 'Synthetic flat terrain for Traffic QA; not live elevation data', + }); + if (/\/0\/[01]\/0\.terrain$/.test(url.pathname)) + return { + status: 200, + contentType: 'application/octet-stream', + headers: corsHeaders, + body: flatTerrain, + }; + throw new Error(`Unexpected terrain fixture request: ${url.pathname}`); + } if (url.pathname === '/api/tomtom/status') return json({ hasKey: true, dailyCount: 0 }); - if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname)) - return { status: 200, contentType: 'application/x-protobuf', body: tile }; - if (url.pathname !== '/api/overpass') return null; - const query = new URLSearchParams(request.postData()).get('data') || ''; - if (!query.includes('highway')) return null; - const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/); - if (!match) throw new Error('Missing road fixture viewport'); - const [south, west, north, east] = match.slice(1).map(Number); - const inside = ([lon, lat]) => - lat >= south && lat <= north && lon >= west && lon <= east; - const segments = tilesForBounds({ south, west, north, east }, 12).flatMap( - ({ z, x, y }) => decodeFlowTile(tile, z, x, y), + const flow = url.pathname.match( + /^\/api\/tomtom\/flow\/(\d+)\/\d+\/\d+\.pbf$/, ); - const elements = segments - .filter((segment) => segment.coords.some(inside)) - .slice(0, 240) - .map((segment, index) => ({ - type: 'way', - id: index + 1, - tags: { highway: 'primary', oneway: 'yes' }, - geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })), - })); - const latitude = (south + north) / 2, - longitude = (west + east) / 2; - elements.push({ - type: 'way', - id: 99999, - tags: { highway: 'residential' }, - geometry: [ - { lat: latitude, lon: longitude }, - { lat: latitude + 0.003, lon: longitude + 0.003 }, - ], + if (flow) + return { + status: 200, + contentType: 'application/x-protobuf', + headers: corsHeaders, + body: trafficFixtureTile(Number(flow[1]), true), + }; + if (url.hostname !== 'tiles.openfreemap.org') return null; + if (url.pathname === '/planet') + return json({ + tilejson: '3.0.0', + minzoom: 0, + maxzoom: 14, + tiles: ['https://tiles.openfreemap.org/qa-traffic/{z}/{x}/{y}.pbf'], + }); + const road = url.pathname.match(/^\/qa-traffic\/(\d+)\/\d+\/\d+\.pbf$/); + if (!road) + throw new Error(`Unrecognized road fixture request: ${url.pathname}`); + return { + status: 200, + contentType: 'application/x-protobuf', + headers: corsHeaders, + body: trafficFixtureTile(Number(road[1])), + }; +} + +/** Intercept provider URLs only; Cesium worker/module loading stays untouched. */ +export async function installTrafficRoutes( + page, + { + origin, + fixtures = false, + keyless = false, + onFlow = () => {}, + onError = () => {}, + }, +) { + const session = await page.createCDPSession(); + const patterns = [ + `${origin}/api/tomtom/status*`, + `${origin}/api/tomtom/flow/*`, + ...(fixtures + ? ['https://tiles.openfreemap.org/*', 'https://terrain.reearth.land/*'] + : []), + ].map((urlPattern) => ({ urlPattern, requestStage: 'Request' })); + session.on('Fetch.requestPaused', async ({ requestId, request }) => { + try { + const url = new URL(request.url); + let response; + if (keyless && url.pathname === '/api/tomtom/status') { + response = json({ hasKey: false }); + } else if (keyless && url.pathname.startsWith('/api/tomtom/flow/')) { + onFlow(request.url); + response = { ...json({ error: 'no_key' }), status: 503 }; + } else if (fixtures) { + response = trafficFixtureResponse({ url: () => request.url }); + } + if (!response) { + await session.send('Fetch.continueRequest', { requestId }); + return; + } + await session.send('Fetch.fulfillRequest', { + requestId, + responseCode: response.status, + responseHeaders: Object.entries({ + 'content-type': response.contentType, + ...response.headers, + }).map(([name, value]) => ({ name, value })), + body: Buffer.from(response.body).toString('base64'), + }); + } catch (error) { + if (page.isClosed()) return; + onError(error); + await session + .send('Fetch.failRequest', { requestId, errorReason: 'Failed' }) + .catch(() => {}); + } }); - return json({ elements }); + await session.send('Fetch.enable', { patterns }); + return session; } diff --git a/scripts/voice-bench/phrases.mjs b/scripts/voice-bench/phrases.mjs index 2f3f73a4c..9d1bcc8ba 100644 --- a/scripts/voice-bench/phrases.mjs +++ b/scripts/voice-bench/phrases.mjs @@ -116,6 +116,9 @@ export const CORE_PHRASES = Object.freeze([ // — camera verbs (tools #23/#24) — { phrase: 'Orbit around this area slowly', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'orbit' && a.speed === 'slow')] }, { phrase: 'Pan left a bit', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'pan' && a.direction === 'left')] }, + { phrase: 'Rotate the globe to the right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && (a.direction === undefined || a.direction === 'right'))] }, + { phrase: 'Keep spinning the Earth slowly to the left', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && a.direction === 'left' && a.speed === 'slow' && (a.mode === undefined || a.mode === 'continuous'))] }, + { phrase: 'Turn the camera to look right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate' && a.direction === 'right')] }, { phrase: 'Stop moving the camera', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'stop')] }, { phrase: 'Fly the route we just drew', expect: 'fly_route' }, diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index a69885e2f..f7019d752 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import os from 'node:os'; import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; import { coalesceProxyRequest, @@ -137,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) { } /** @type {boolean} Guards duplicate auth-failure warnings in logs. */ let _openskyAuthWarned = false; +/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */ +let _openskyCredentialsFileWarned = false; +/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */ +let _openskyBasicRetiredWarned = false; /** @type {boolean} Guards duplicate invalid-auth-mode warnings. */ let _openskyAuthModeWarned = false; /** Default auth mode when OPENSKY_AUTH_MODE env is unset. */ @@ -156,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // current instead of coasting a stale worldwide frame indefinitely. const OPENSKY_SOURCE_STALE_MS = 120_000; +/** + * The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and + * OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account + * page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home + * directory). The file is read on each token refresh, so a replaced file is + * picked up without a restart. + * + * @param {Record} [env] + * @param {(path: string) => string} [readFile] + * @returns {{ clientId: string, clientSecret: string } | null} + */ +export function openSkyClientCredentials( + env = process.env, + readFile = (path) => fs.readFileSync(path, 'utf8'), +) { + const clientId = String(env.OPENSKY_CLIENT_ID || '').trim(); + const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim(); + if (clientId && clientSecret) return { clientId, clientSecret }; + const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim(); + if (!file) return null; + const path = file.replace(/^~(?=$|[\\/])/, os.homedir()); + let raw; + try { + raw = JSON.parse(readFile(path)); + } catch { + if (!_openskyCredentialsFileWarned) { + console.warn( + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ); + _openskyCredentialsFileWarned = true; + } + return null; + } + const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim(); + const fileSecret = String( + raw?.clientSecret ?? raw?.client_secret ?? '', + ).trim(); + return fileId && fileSecret + ? { clientId: fileId, clientSecret: fileSecret } + : null; +} + /** * Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed. * @@ -174,9 +222,9 @@ export async function getOpenSkyToken() { // return the same promise instead of issuing a duplicate token request if (_openskyTokenPromise) return _openskyTokenPromise; - const clientId = process.env.OPENSKY_CLIENT_ID; - const clientSecret = process.env.OPENSKY_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; + const credentials = openSkyClientCredentials(); + if (!credentials) return null; + const { clientId, clientSecret } = credentials; // Wrap the async token fetch in a shared promise stored in _openskyTokenPromise _openskyTokenPromise = (async () => { @@ -417,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { * * Supports four auth modes controlled by OPENSKY_AUTH_MODE env: * - 'oauth' (default) — client_credentials bearer token - * - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD - * - 'auto' — try OAuth first, fall back to Basic, then anon + * - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and + * password) on 2026-03-18, so this requests anonymously and + * warns once + * - 'auto' — try OAuth first, then anon * - 'anon' — no credentials * * Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On @@ -524,21 +574,21 @@ export function openSkyProxy() { _openskySnapshotInFlight, 'global', async () => { - const basicUser = process.env.OPENSKY_USERNAME || ''; - const basicPass = process.env.OPENSKY_PASSWORD || ''; - const hasBasicCreds = Boolean(basicUser && basicPass); const headers = { Accept: 'application/json' }; let usedMode = 'anon'; let reason = 'forced_anonymous'; if (requestedMode === 'basic') { - if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'basic_credentials'; - } else { - reason = 'missing_basic_creds'; + // OpenSky no longer accepts a Basic header, so sending + // OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask + // anonymously and say once what to configure instead. + if (!_openskyBasicRetiredWarned) { + console.warn( + '[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', + ); + _openskyBasicRetiredWarned = true; } + reason = 'basic_retired'; } else if (requestedMode === 'oauth') { const token = await getOpenSkyToken(); if (token) { @@ -554,31 +604,12 @@ export function openSkyProxy() { headers.Authorization = `Bearer ${token}`; usedMode = 'oauth'; reason = 'oauth_token'; - } else if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'oauth_unavailable_fallback_basic'; } else { - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } } - let { upstream, body } = await fetchOpenSkyStates(headers); - // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials - if ( - (upstream.status === 401 || upstream.status === 403) && - requestedMode === 'auto' && - usedMode === 'oauth' && - hasBasicCreds - ) { - const retryHeaders = { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, - }; - ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); - usedMode = 'basic'; - reason = 'oauth_rejected_fallback_basic'; - } + const { upstream, body } = await fetchOpenSkyStates(headers); const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) @@ -586,12 +617,6 @@ export function openSkyProxy() { // Refine the reason string to reflect the actual outcome if (upstream.ok && reason === 'forced_anonymous') { reason = 'anonymous_ok'; - } else if ( - upstream.ok && - usedMode === 'basic' && - reason === 'basic_credentials' - ) { - reason = 'basic_ok'; } else if ( upstream.ok && usedMode === 'oauth' && @@ -627,12 +652,11 @@ export function openSkyProxy() { body, usedMode, reason, - hasBasicCreds, sourceEpochMs, }; }, ); - let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } = + let { upstream, body, usedMode, reason, sourceEpochMs } = await refresh.promise; if ( upstream.ok && @@ -684,35 +708,24 @@ export function openSkyProxy() { } if (upstream.status === 401 || upstream.status === 403) { - if (requestedMode === 'basic' && !hasBasicCreds) { + if (requestedMode === 'oauth' && usedMode !== 'oauth') { body = JSON.stringify({ error: - 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.', - }); - reason = 'missing_basic_creds'; - } else if (requestedMode === 'oauth' && usedMode !== 'oauth') { - body = JSON.stringify({ - error: - 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.', + 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); reason = 'oauth_invalid_or_missing'; - } else if (usedMode === 'basic') { - body = JSON.stringify({ - error: 'OpenSky auth invalid. Username/password were rejected.', - }); - reason = 'basic_invalid_credentials'; } else if (usedMode === 'oauth') { body = JSON.stringify({ error: 'OpenSky auth invalid. OAuth client credentials were rejected.', }); reason = 'oauth_invalid_credentials'; - } else if (requestedMode === 'auto' && !hasBasicCreds) { + } else if (requestedMode === 'auto') { body = JSON.stringify({ error: - 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.', + 'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } else { body = JSON.stringify({ error: 'OpenSky auth required.', diff --git a/server/providers/aircraft/openskyCredentials.test.mjs b/server/providers/aircraft/openskyCredentials.test.mjs new file mode 100644 index 000000000..539579517 --- /dev/null +++ b/server/providers/aircraft/openskyCredentials.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { openSkyClientCredentials } from './opensky.js'; + +const file = (contents) => (filePath) => { + file.read = filePath; + if (contents instanceof Error) throw contents; + return contents; +}; + +test('OpenSky client env vars come first', () => { + const read = () => assert.fail('the file is not read when env vars are set'); + assert.deepEqual( + openSkyClientCredentials( + { + OPENSKY_CLIENT_ID: ' id ', + OPENSKY_CLIENT_SECRET: 'secret', + OPENSKY_CREDENTIALS_FILE: 'credentials.json', + }, + read, + ), + { clientId: 'id', clientSecret: 'secret' }, + ); +}); + +test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => { + const env = { + OPENSKY_CLIENT_ID: '', + OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json', + }; + // The file OpenSky's account page downloads uses camelCase keys. + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"clientId":"cid","clientSecret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); + assert.equal( + path.normalize(file.read), + path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')), + ); + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"client_id":"cid","client_secret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); +}); + +test('a missing, unreadable or incomplete credentials file means no client', () => { + assert.equal(openSkyClientCredentials({}, file('{}')), null); + const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' }; + const missing = Object.assign(new Error('nope'), { code: 'ENOENT' }); + assert.equal(openSkyClientCredentials(env, file(missing)), null); + assert.equal(openSkyClientCredentials(env, file('not json')), null); + assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null); +}); + +test('credential file warnings never expose file contents or read errors', async (t) => { + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + for (const contents of [ + '{"clientId":"id","clientSecret":FAKE_SECRET_123}', + new Error('could not read FAKE_SECRET_123'), + ]) { + const { openSkyClientCredentials: resolve } = await import( + `./opensky.js?warning=${warnings.length}` + ); + const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' }; + assert.equal(resolve(env, file(contents)), null); + assert.equal(resolve(env, file(contents)), null); + } + assert.deepEqual(warnings, [ + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ]); +}); diff --git a/server/providers/aircraft/tracks.js b/server/providers/aircraft/tracks.js index 5f800ac59..3b0acd8d2 100644 --- a/server/providers/aircraft/tracks.js +++ b/server/providers/aircraft/tracks.js @@ -7,10 +7,9 @@ import { readCappedResponseText } from '../common/http.js'; * own credit bucket, 4 credits per call on the free tier). OAuth via the * shared coalesced token. 60s per-icao cache; 404/429 forwarded so the * client can fall back to its accumulated trail silently. - * /api/military/track?hex= — adsb.lol tar1090 readsb trace - * (undocumented but live; no browser CORS, hence this proxy). Up to ~24h - * of real history per aircraft. Treat as best-effort; data is ODbL — - * credit "adsb.lol (ODbL)" in the UI. + * /api/military/track?hex= — compatibility refusal for the retired, + * undocumented ADSB.lol trace route. The standalone military source uses + * locally accumulated trails; provider-supplied history remains optional. */ export function trackBackfillProxies() { const TRACK_CACHE_MS = 60000; @@ -107,10 +106,16 @@ export function trackBackfillProxies() { ); return; } - await proxyJson( - res, - `lol:${hex}`, - `https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`, + res.statusCode = 503; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Retry-After', '1800'); + res.end( + JSON.stringify({ + error: + 'Military history unavailable; locally accumulated trails remain available', + code: 'history_unavailable', + }), ); } catch (error) { res.statusCode = 502; diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 672b9be42..a977f4e75 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -1,9 +1,5 @@ import { createCctvCatalog } from './cctv/catalog.js'; -import { - normalizeFeedType, - isVideoFeedType, - toFiniteNumber, -} from './cctv/normalize.js'; +import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js'; import { buildSyntheticCctvSvg, cctvLiveMediaType, diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 257c292b3..a26fa6c20 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -18,7 +18,7 @@ export const CCTV_MAX_SOURCES_CEILING = 5000; export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 }; /** Caltrans CCTV: one JSON feed per district, identical schema statewide. */ /** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */ -export const TXDOT_ORIGIN = 'https://its.txdot.gov'; +const TXDOT_ORIGIN = 'https://its.txdot.gov'; export const TXDOT_CCTV_STATUS_URL = (district) => `${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`; /** Per-camera frame. Returns JSON `{snippet:}`, not an image body; diff --git a/server/providers/cctv/groundHeights.js b/server/providers/cctv/groundHeights.js index c73b6e840..f20b9758d 100644 --- a/server/providers/cctv/groundHeights.js +++ b/server/providers/cctv/groundHeights.js @@ -3,7 +3,7 @@ import path from 'node:path'; import { poseHash } from '../../../src/data/cctvFootprint.js'; /** Sidecar written by scripts/precompute-cctv-heights.mjs. */ -export const DEFAULT_GROUND_HEIGHTS_FILE = +const DEFAULT_GROUND_HEIGHTS_FILE = 'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json'; /** @type {{path:string, mtimeMs:number, cameras:object}|null} */ diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 1323bc841..206a24888 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -92,7 +92,7 @@ export function buildSyntheticCctvSvg({ cameraId, label, city, status }) { * @param {ReadableStream|NodeJS.ReadableStream|null} body * @returns {import('stream').Readable|null} */ -export function toReadable(body) { +function toReadable(body) { if (!body) return null; if (typeof body.pipe === 'function') return body; if (typeof body.getReader === 'function') { @@ -108,7 +108,7 @@ export function toReadable(body) { * @returns {{essence:string,params:Map}|null} Null when the * value is missing or is not a `type/subtype` media type. */ -export function parseMediaType(value) { +function parseMediaType(value) { const [head, ...rest] = String(value ?? '').split(';'); const essence = head.trim().toLowerCase(); if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(essence)) return null; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 74f693dd8..f190f257e 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -64,7 +64,7 @@ export function toFiniteNumber(value, fallback = NaN) { * @param {string} text * @returns {string} */ -export function normalizeKey(text) { +function normalizeKey(text) { return String(text || '') .trim() .toLowerCase() @@ -80,7 +80,7 @@ export function normalizeKey(text) { * @param {string} value * @returns {{lat:number, lon:number}} */ -export function parsePointString(value) { +function parsePointString(value) { const match = String(value || '').match( /POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i, ); @@ -100,7 +100,7 @@ export function parsePointString(value) { * @param {string|object|null} value * @returns {{lat:number, lon:number}} */ -export function coerceLatLon(value) { +function coerceLatLon(value) { if (!value) return { lat: NaN, lon: NaN }; if (typeof value === 'string') { diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 114a91979..a479a945c 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -133,7 +133,6 @@ import { fallbackHeadingFromId, isLikelyFinlandCoordinate, fintrafficCameraName, - hashSeed, isPlausibleLatLon, isLikelyBcCoordinate, isLikelyTexasCoordinate, @@ -1869,7 +1868,7 @@ export function normalizeCataloniaImageUrl(rawUrl) { * @param {string} imageUrl - A URL normalizeCataloniaImageUrl accepted. * @returns {string} */ -export function cataloniaCameraId(imageUrl) { +function cataloniaCameraId(imageUrl) { const parsed = new URL(imageUrl); const slug = (text) => text diff --git a/server/providers/cctv/stream.js b/server/providers/cctv/stream.js index 14d1743bb..da811f5f9 100644 --- a/server/providers/cctv/stream.js +++ b/server/providers/cctv/stream.js @@ -70,7 +70,7 @@ export async function fetchHlsBytes( } } -export function sameOriginHlsUrl(value, base) { +function sameOriginHlsUrl(value, base) { const url = new URL(value, base); if ( !['https:', 'http:'].includes(url.protocol) || diff --git a/server/providers/cctv/taiwan.js b/server/providers/cctv/taiwan.js index 830ed47fd..a14aabf9a 100644 --- a/server/providers/cctv/taiwan.js +++ b/server/providers/cctv/taiwan.js @@ -12,14 +12,14 @@ export const TAIWAN_FREEWAY_CCTV_URL = 'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml'; /** The whole network is ~1.2 MB. */ export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024; -export const DEFAULT_TAIWAN_MAX_SOURCES = 600; +const DEFAULT_TAIWAN_MAX_SOURCES = 600; /** After a failed download, wait this long before asking again. The catalog * host does not answer from some networks outside Taiwan, and every attempt * there holds the shared catalog refresh until the connect timeout. */ export const TAIWAN_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000; /** The table is republished daily; a good copy stands in for a failed * download for up to this long. */ -export const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000; // The table publishes each camera's MJPEG stream. The same camera servers // answer a single JPEG for the same camera ID on a sibling path, which is what @@ -50,7 +50,7 @@ const ANCHORS = [ /** Taiwan main island with slack for the coast; the freeway network has no * offshore-island cameras. */ -export function isLikelyTaiwanCoordinate(lat, lon) { +function isLikelyTaiwanCoordinate(lat, lon) { return ( Number.isFinite(lat) && Number.isFinite(lon) && diff --git a/server/providers/local-receivers.js b/server/providers/local-receivers.js index 994da7bd8..f3f340b54 100644 --- a/server/providers/local-receivers.js +++ b/server/providers/local-receivers.js @@ -27,13 +27,13 @@ import { normalizeDump1090Aircraft } from '../../src/sources/adsbRecords.js'; */ export const LOCAL_RECEIVERS_ROUTE = '/api/local-receivers/aircraft'; -export const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS'; -export const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']); -export const LOCAL_RECEIVER_TIMEOUT_MS = 2_000; -export const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024; -export const LOCAL_RECEIVER_CACHE_MS = 1_000; +const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS'; +const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']); +const LOCAL_RECEIVER_TIMEOUT_MS = 2_000; +const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024; +const LOCAL_RECEIVER_CACHE_MS = 1_000; /** A feed whose own `now` is older than this is reported stale. */ -export const LOCAL_RECEIVER_STALE_MS = 10_000; +const LOCAL_RECEIVER_STALE_MS = 10_000; const MAX_FEEDS = 8; const LOG_PREFIX = '[local-receivers]'; @@ -139,7 +139,7 @@ class FeedError extends Error { * @param {string} address Resolved IP address. * @returns {boolean} */ -export function isLocalReceiverAddress(address) { +function isLocalReceiverAddress(address) { const value = String(address || '').toLowerCase(); if (value === '::1') return true; const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value); @@ -152,7 +152,7 @@ export function isLocalReceiverAddress(address) { * @param {typeof dnsLookup} lookupImpl `dns.promises.lookup`-compatible. * @returns {Promise>} */ -export async function resolveLocalReceiverAddresses(hostname, lookupImpl) { +async function resolveLocalReceiverAddresses(hostname, lookupImpl) { let resolved; try { resolved = await lookupImpl(hostname, { all: true, verbatim: true }); @@ -274,7 +274,7 @@ function fetchLocalReceiverFeed(url, options) { * @param {object} options * @returns {Promise} Parsed JSON document. */ -export async function fetchLocalReceiverDocument( +async function fetchLocalReceiverDocument( url, { fetchImpl = fetchLocalReceiverFeed, diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 569e7e45f..6e2207848 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -42,7 +42,7 @@ export class TileUpstreamError extends Error { } /** Seconds a Retry-After header asks for (an integer or an HTTP date), else null. */ -export function retryAfterSeconds(header, now = Date.now()) { +function retryAfterSeconds(header, now = Date.now()) { const text = String(header ?? '').trim(); if (!text) return null; const seconds = /^\d+$/.test(text) diff --git a/server/providers/openai/codex-auth.js b/server/providers/openai/codex-auth.js index 5f16ee824..4d3f992da 100644 --- a/server/providers/openai/codex-auth.js +++ b/server/providers/openai/codex-auth.js @@ -145,7 +145,7 @@ const CODEX_LOGIN_ENV = /^(?:PATH|PATHEXT|HOME|USER|USERNAME|LOGNAME|SHELL|LANG|LANGUAGE|LC_[A-Z_]+|TERM|TZ|TMPDIR|TEMP|TMP|XDG_[A-Z_]+|DISPLAY|WAYLAND_DISPLAY|DBUS_SESSION_BUS_ADDRESS|BROWSER|WSL_[A-Z_]+|WSLENV|(?:HTTPS?|ALL|NO)_PROXY|SSL_CERT_(?:FILE|DIR)|NODE_EXTRA_CA_CERTS|SYSTEMROOT|WINDIR|COMSPEC|USERPROFILE|APPDATA|LOCALAPPDATA|PROGRAMDATA|HOMEDRIVE|HOMEPATH|CODEX_[A-Z_]+)$/i; /** The environment `codex login` runs with: the allowlist, then `CODEX_HOME`. */ -export function codexLoginEnvironment(environment, codexHome) { +function codexLoginEnvironment(environment, codexHome) { const env = {}; for (const [name, value] of Object.entries(environment || {})) if (CODEX_LOGIN_ENV.test(name) && value !== undefined) env[name] = value; diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js index 956569642..c8151d2f6 100644 --- a/server/providers/openai/debug-log.js +++ b/server/providers/openai/debug-log.js @@ -157,4 +157,4 @@ function createDebugLogHandler({ }; } -export { createDebugLogHandler, omitVoiceContent }; +export { createDebugLogHandler }; diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 7d0e35636..9735f300c 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -16,6 +16,7 @@ function realtimeInstructions( 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', 'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking stops the tracking.', 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', + 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 18de877db..b8121a201 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -511,18 +511,18 @@ export const ACTION_DESCRIPTIONS = { }, move_camera: { description: - 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").', + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', $position: 1, parameters: { properties: { direction: { description: - 'Required except for orbit (defaults right/clockwise) and stop.', + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', $position: 2, }, mode: { description: - 'once = bounded eased nudge (default); continuous = until stop/manual input.', + 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.', $position: 2, }, }, diff --git a/server/providers/overpass.js b/server/providers/overpass.js index 3b5a2e575..bf6004ac6 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -164,6 +164,30 @@ function overpassProxy({ routing = {} } = {}) { readDisk: () => readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)), allowUpstream: () => _overpassRateLimiter(clientKey(req)), + loadUpstream: async () => { + if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) + return { + status: 503, + contentType: 'application/json', + retryAfterMs: 2000, + body: JSON.stringify({ + error: 'Overpass proxy busy — try again shortly', + }), + }; + _overpassConcurrent++; + try { + const payload = await fetchOverpassPayload(safeBody); + if (overpassPayloadIsData(payload)) { + const entry = { ...payload, cachedAt: Date.now() }; + _overpassCache.set(cacheKey, entry); + trimOverpassCache(); + writeOverpassDisk(cacheKey, entry); + } + return payload; + } finally { + _overpassConcurrent--; + } + }, }); if (preflight.source === 'RATE_LIMITED') { res.writeHead(429, { @@ -173,71 +197,20 @@ function overpassProxy({ routing = {} } = {}) { res.end(JSON.stringify({ error: 'Rate limit exceeded' })); return; } - if (preflight.source !== 'UPSTREAM') { - // A coalesced caller sees the same failure as the original request - // and must get the same last-good fallback, not the raw refusal. - if (!overpassPayloadIsData(preflight.payload)) { - const stale = await readStaleOverpass(cacheKey); - if (stale) { - sendOverpassResponse(res, stale, 'STALE'); - return; - } - } - if (preflight.source === 'DISK') { - _overpassCache.set(cacheKey, preflight.payload); - trimOverpassCache(); - } - sendOverpassResponse(res, preflight.payload, preflight.source); - return; - } - - // From here onward the request is genuinely upstream-bound and has - // consumed one local limiter slot. Cache and dedupe hits above do not. - if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) { - res.writeHead(503, { - 'Content-Type': 'application/json', - 'Retry-After': '2', - }); - res.end( - JSON.stringify({ - error: 'Overpass proxy busy — try again shortly', - }), - ); - return; - } - _overpassConcurrent += 1; - const requestPromise = fetchOverpassPayload(safeBody) - .then((payload) => { - // Only a 2xx is data. `< 500` cached every 4xx, so one mirror's - // refusal was written to memory AND disk — and boundary-class - // queries hold a month-long TTL, so a single 406 outlived the - // outage that caused it. - if (overpassPayloadIsData(payload)) { - const entry = { ...payload, cachedAt: Date.now() }; - _overpassCache.set(cacheKey, entry); - trimOverpassCache(); - writeOverpassDisk(cacheKey, entry); - } - return payload; - }) - .finally(() => { - _overpassConcurrent -= 1; - _overpassInFlight.delete(cacheKey); - }); - - _overpassInFlight.set(cacheKey, requestPromise); - const payload = await requestPromise; - // Degraded upstream (rate-limited on every mirror / 5xx / runtime - // error): last-good roads beat an empty layer — serve stale from - // memory or disk at ANY age before surfacing the failure. - if (!overpassPayloadIsData(payload)) { + if (!overpassPayloadIsData(preflight.payload)) { const stale = await readStaleOverpass(cacheKey); if (stale) { sendOverpassResponse(res, stale, 'STALE'); return; } } - sendOverpassResponse(res, payload, 'MISS'); + // Preflight already published the disk hit to memory; only trim. + if (preflight.source === 'DISK') trimOverpassCache(); + sendOverpassResponse( + res, + preflight.payload, + preflight.source === 'UPSTREAM' ? 'MISS' : preflight.source, + ); } catch (e) { // Every mirror threw (network-level). Same serve-stale rule. const stale = cacheKey ? await readStaleOverpass(cacheKey) : null; diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index 442f599a6..b1a09908a 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -1,3 +1,4 @@ +import { coalesceProxyRequest } from '../common/http.js'; import { isOverpassBoundaryQuery } from './query.js'; import { OVERPASS_BOUNDARY_DISK_TTL_MS, @@ -78,7 +79,9 @@ function writeOverpassDisk(cacheKey, payload) { /** * Resolve every cache/coalescing layer before admitting a request to the local * upstream rate limiter. The injected limiter callback is invoked exactly once - * for a complete cache miss and never for memory, in-flight, or disk hits. + * for a complete cache miss and never for memory, in-flight, or disk hits. A + * caller that joined a reservation rejected by another client's limiter + * retries with its own limiter instead of inheriting that 429. * Exported so the admission ordering can be tested without a Vite server. * * @param {object} options @@ -87,6 +90,7 @@ function writeOverpassDisk(cacheKey, payload) { * @param {Map>} options.inFlight * @param {()=>Promise} options.readDisk * @param {()=>boolean} options.allowUpstream + * @param {()=>Promise} options.loadUpstream Fetch and publish the admitted result. * @param {number} [options.now] * @param {number} [options.cacheMs] * @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>} @@ -97,22 +101,34 @@ async function resolveOverpassPreflight({ inFlight, readDisk, allowUpstream, + loadUpstream = async () => null, now = Date.now(), cacheMs = OVERPASS_CACHE_MS, }) { - const cached = memoryCache.get(cacheKey); - if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) - return { source: 'HIT', payload: cached }; + for (;;) { + const cached = memoryCache.get(cacheKey); + if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) + return { source: 'HIT', payload: cached }; - const pending = inFlight.get(cacheKey); - if (pending) return { source: 'INFLIGHT', payload: await pending }; - - const disk = await readDisk(); - if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk }; - - return allowUpstream() - ? { source: 'UPSTREAM', payload: null } - : { source: 'RATE_LIMITED', payload: null }; + // Reserve synchronously before disk I/O. The owner retains the reservation + // through its own upstream admission, fetch and cache publication. + const request = coalesceProxyRequest(inFlight, cacheKey, async () => { + const disk = await readDisk(); + if (overpassPayloadIsData(disk)) { + memoryCache.set(cacheKey, disk); + return { source: 'DISK', payload: disk }; + } + if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; + return { source: 'UPSTREAM', payload: await loadUpstream() }; + }); + const result = await request.promise; + if (!request.shared) return result; + // The owner's quota rejection belongs to the owner's client. The rejected + // reservation is released before joiners resume, so retry and let this + // caller own the next reservation (or join an admitted one) on its quota. + if (result.source === 'RATE_LIMITED') continue; + return { ...result, source: 'INFLIGHT' }; + } } /** Return only last-good Overpass data, regardless of its age. */ diff --git a/server/providers/radio/catalog.js b/server/providers/radio/catalog.js index 36a3eb730..30e6ed4ec 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -5,7 +5,7 @@ import { readResponseTextCapped } from '../common/http.js'; import { normalizeRadioBrowserStation, publicRadioStation, - cleanRadioText, + normalizeRadioTag, } from './stations.js'; import { radioMirrorOrigin, @@ -25,8 +25,9 @@ import { RADIO_USER_AGENT, RADIO_UUID_RE, RADIO_FALLBACK_MIRRORS, + RADIO_REJECTED_STATION_COUNT_MAX, } from './constants.js'; -export async function mapRadioConcurrent(values, concurrency, mapper) { +async function mapRadioConcurrent(values, concurrency, mapper) { const results = new Array(values.length); let cursor = 0; const workers = Array.from( @@ -177,29 +178,25 @@ export function createRadioProxyMiddleware({ const rows = await fetchPath(`/json/stations/search?${params}`); if (!Array.isArray(rows)) throw new Error('Radio Browser catalog payload was not an array'); - if ( - !rows.every( - (row) => - row && - typeof row === 'object' && - !Array.isArray(row) && - typeof row.stationuuid === 'string' && - typeof row.name === 'string' && - (typeof row.url_resolved === 'string' || - typeof row.url === 'string'), - ) - ) - throw new Error( - 'Radio Browser catalog contained a malformed station row', - ); - const stations = rows + const structuralRows = rows.filter( + (row) => + row && + typeof row === 'object' && + !Array.isArray(row) && + typeof row.stationuuid === 'string' && + typeof row.name === 'string' && + (typeof row.url_resolved === 'string' || + typeof row.url === 'string'), + ); + // Malformed rows are skipped individually, but counted so the + // catalog keeps a diagnostic for upstream schema drift. Policy + // rejections in normalization (offline, non-HTTPS, codec) are normal + // filtering and are not counted. + const rejectedRows = rows.length - structuralRows.length; + const stations = structuralRows .map(normalizeRadioBrowserStation) .filter(Boolean); - const requestedTag = cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); + const requestedTag = normalizeRadioTag(tag); const requestedTagCovered = !requestedTag || stations.some((station) => @@ -215,9 +212,10 @@ export function createRadioProxyMiddleware({ // also contain an accepted station tagged for the requested category. succeeded: stations.length > 0 && requestedTagCovered, stations, + rejectedRows, }; } catch { - return { succeeded: false, stations: [] }; + return { succeeded: false, stations: [], rejectedRows: 0 }; } }, ); @@ -257,6 +255,10 @@ export function createRadioProxyMiddleware({ if (selected.length < RADIO_CATALOG_HEALTHY_MIN_STATIONS) healthReasons.push('station-coverage-below-policy'); const degraded = healthReasons.length > 0; + const rejectedStationCount = Math.min( + RADIO_REJECTED_STATION_COUNT_MAX, + outcomes.reduce((total, outcome) => total + outcome.rejectedRows, 0), + ); const coverage = { successfulQueries, totalQueries: queries.length, @@ -271,6 +273,9 @@ export function createRadioProxyMiddleware({ degraded, degradedReason: degraded ? healthReasons.join(',') : null, coverage, + // Diagnostic only: it never fails health policy, so a usable generation + // with a few skipped rows is still admitted. The client reports it. + rejectedStationCount, }; if (degraded && catalogCache) { const error = new Error( @@ -360,6 +365,7 @@ export function createRadioProxyMiddleware({ coverage: catalog.coverage || null, acceptedGeneration: catalog.acceptedGeneration ?? null, catalogInstance, + rejectedStationCount: catalog.rejectedStationCount ?? 0, }); } catch (error) { sendJson(res, 503, { diff --git a/server/providers/radio/constants.js b/server/providers/radio/constants.js index 0fee93ac6..b0e24babc 100644 --- a/server/providers/radio/constants.js +++ b/server/providers/radio/constants.js @@ -11,6 +11,9 @@ export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil( export const RADIO_USER_AGENT = 'GodsEyeView/1.0 (Radio Browser directory client)'; export { RADIO_UUID_RE } from '../../../src/sources/radioBrowser.js'; +// Upper bound for the structurally malformed upstream rows reported in the +// /stations envelope. The client layer validates against the same bound. +export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000; export const RADIO_FALLBACK_MIRRORS = Object.freeze([ 'https://de1.api.radio-browser.info', 'https://de2.api.radio-browser.info', diff --git a/server/providers/radio/stations.js b/server/providers/radio/stations.js index b97b2d3c6..49279e2d2 100644 --- a/server/providers/radio/stations.js +++ b/server/providers/radio/stations.js @@ -1,5 +1,6 @@ export { cleanRadioText, + normalizeRadioTag, isNonGlobalIpv4, publicRadioHttpsUrl, normalizeRadioBrowserStation, diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index 8baf59ba7..013fce1b1 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -1,6 +1,8 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; +import { providerCooldownMs } from './cooldown.js'; +import { admitSameSiteKeyless } from '../common/same-site.js'; import { readResponseTextCapped } from '../common/http.js'; /** @@ -24,6 +26,18 @@ import { readResponseTextCapped } from '../common/http.js'; export function celestrakProxy() { const TLE_TTL_MS = 6 * 3600_000; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); + const groups = new Set([ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', + // Space Missions (`getActiveTle`) reads the full active catalog. + 'active', + ]); + let cooldownUntil = 0; const mem = new Map(); // group -> { at: epochMs, body: string } const inflight = new Map(); // group -> Promise<{at, body}|null> @@ -64,7 +78,16 @@ export function celestrakProxy() { 'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', }, }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + // Group refreshes run in parallel: a later failure without Retry-After + // must never shorten a longer deadline an earlier one already set. + cooldownUntil = Math.max( + cooldownUntil, + Date.now() + providerCooldownMs(res), + ); + void res.body?.cancel().catch(() => {}); + throw new Error(`HTTP ${res.status}`); + } const body = await readResponseTextCapped( res, CELESTRAK_MAX_RESPONSE_BYTES, @@ -80,12 +103,20 @@ export function celestrakProxy() { const group = String(req.url || '') .replace(/^\//, '') .split('?')[0]; - if (!/^[a-z0-9-]+$/i.test(group)) { + // Keyless and needed to render the satellites layer: refuse cross-site + // browsers but keep the documented reverse-proxy deployment working. + if (admitSameSiteKeyless(req, res)) return; + if (req.method !== 'GET') { + res.writeHead(405); + res.end(); + return; + } + if (!groups.has(group)) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('invalid group'); return; } - const send = (status, body, cacheStatus) => { + const send = (status, body, cacheStatus, entry) => { // Guard against a double-send (e.g. a throw AFTER a response already // went out routing into the catch's send): writeHead after headersSent // throws "Cannot set headers after they are sent". @@ -93,6 +124,17 @@ export function celestrakProxy() { res.writeHead(status, { 'Content-Type': 'text/plain', 'x-tle-cache': cacheStatus, + 'Cache-Control': 'no-store', + ...(entry + ? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() } + : {}), + ...(cooldownUntil > 0 && Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), }); res.end(body); }; @@ -104,7 +146,16 @@ export function celestrakProxy() { if (entry) mem.set(group, entry); } if (entry && now - entry.at < TLE_TTL_MS) { - send(200, entry.body, 'HIT'); + send(200, entry.body, 'HIT', entry); + return; + } + if (Date.now() < cooldownUntil) { + send( + entry ? 200 : 502, + entry?.body || 'celestrak upstream cooling down', + entry ? 'STALE-ERROR' : 'NONE', + entry, + ); return; } // Stale or missing → refresh, single-flight per group. @@ -118,6 +169,8 @@ export function celestrakProxy() { return fresh; }) .catch((err) => { + if (Date.now() >= cooldownUntil) + cooldownUntil = Date.now() + 60000; console.warn( '[celestrak-proxy] refresh failed — serving cache if any', ); @@ -128,9 +181,9 @@ export function celestrakProxy() { } const fresh = await inflight.get(group); if (fresh) { - send(200, fresh.body, 'MISS'); + send(200, fresh.body, 'MISS', fresh); } else if (entry) { - send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty + send(200, entry.body, 'STALE-ERROR', entry); // upstream down — stale beats empty } else { send(502, 'celestrak fetch failed and no cache available', 'NONE'); } diff --git a/server/providers/space/cooldown.js b/server/providers/space/cooldown.js new file mode 100644 index 000000000..acb850124 --- /dev/null +++ b/server/providers/space/cooldown.js @@ -0,0 +1,12 @@ +/** Bound an upstream Retry-After delay (seconds or HTTP date). */ +export function providerCooldownMs(response, now = Date.now()) { + const raw = response?.headers?.get('retry-after')?.trim(); + const seconds = raw && /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) : NaN; + const deadline = raw && !Number.isFinite(seconds) ? Date.parse(raw) : NaN; + const delay = Number.isFinite(seconds) + ? seconds * 1000 + : Number.isFinite(deadline) + ? deadline - now + : 60000; + return Math.min(30 * 60000, Math.max(5000, delay)); +} diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 7b56b6220..f95be2056 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { providerCooldownMs } from './cooldown.js'; import { promises as fsp } from 'node:fs'; import { readResponseTextCapped, @@ -30,6 +31,8 @@ export function rocketLaunchesProxy() { 'launch-library-2-v2.3.json', ); let cache = null; + let cooldownUntil = 0; + let cooldownStatus = 502; let diskLoaded = false; const inFlight = new Map(); @@ -62,7 +65,18 @@ export function rocketLaunchesProxy() { function send(res, status, body, cacheState) { res.writeHead(status, { 'Content-Type': 'application/json', - 'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store', + 'Cache-Control': + status === 200 && cacheState !== 'STALE-ERROR' + ? 'public, max-age=900' + : 'no-store', + ...(cache ? { 'X-GEV-Cached-At': new Date(cache.at).toISOString() } : {}), + ...(Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), 'X-GEV-Cache': cacheState, }); res.end(body); @@ -75,17 +89,25 @@ export function rocketLaunchesProxy() { signal: AbortSignal.timeout(20000), headers: launchLibraryRequestHeaders(), }); - const body = await readResponseTextCapped(upstream, maxResponseBytes); if (!upstream.ok) { + const deadline = Date.now() + providerCooldownMs(upstream); + // Never shorten a longer cooldown that is already in force. + if (deadline >= cooldownUntil) { + cooldownUntil = deadline; + cooldownStatus = upstream.status; + } + void upstream.body?.cancel().catch(() => {}); const error = new Error(`upstream HTTP ${upstream.status}`); error.upstreamStatus = upstream.status; throw error; } + const body = await readResponseTextCapped(upstream, maxResponseBytes); const parsed = JSON.parse(body); if (!Array.isArray(parsed?.results)) throw new Error('malformed upstream response'); const fresh = { at: Date.now(), body }; cache = fresh; + cooldownUntil = 0; void saveDiskCache(fresh); return fresh; } @@ -103,6 +125,16 @@ export function rocketLaunchesProxy() { return; } const stale = cache; + if (now < cooldownUntil) { + send( + res, + stale ? 200 : cooldownStatus, + stale?.body || + JSON.stringify({ error: 'Launch Library 2 unavailable' }), + stale ? 'STALE-ERROR' : 'NONE', + ); + return; + } const request = coalesceProxyRequest( inFlight, 'recent-launches', @@ -112,6 +144,10 @@ export function rocketLaunchesProxy() { const fresh = await request.promise; send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS'); } catch (error) { + if (Date.now() >= cooldownUntil) { + cooldownUntil = Date.now() + 60000; + cooldownStatus = 502; + } // Log only a bounded status, never upstream bodies, URLs, or credentials. const status = Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus diff --git a/server/providers/transitHistory.js b/server/providers/transitHistory.js index f396f3d8f..4da5a8891 100644 --- a/server/providers/transitHistory.js +++ b/server/providers/transitHistory.js @@ -1,5 +1,4 @@ export { createTransitHistory, - TRANSIT_HISTORY_LIMITS, validTransitIdentifier, } from '../../src/sources/transitHistoryStore.js'; diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js index 7767d3905..076baf064 100644 --- a/server/providers/wind/ifs.js +++ b/server/providers/wind/ifs.js @@ -3,7 +3,7 @@ import { decodeWindGribMessage } from './decode.js'; import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js'; /** Keyless ECMWF Open Data root for real-time IFS forecasts. */ -export const IFS_BASE = 'https://data.ecmwf.int/forecasts'; +const IFS_BASE = 'https://data.ecmwf.int/forecasts'; /** * Select the latest IFS cycle that should be available. diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index 02a501010..fcfe08be6 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -31,10 +31,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 }); */ export const MAX_VERTICES = 512; /** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */ -export const MIN_VERTEX_SEPARATION_M = 0.5; +const MIN_VERTEX_SEPARATION_M = 0.5; /** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */ -export const MIN_PATH_LENGTH_M = 1; -export const MIN_AREA_M2 = 1; +const MIN_PATH_LENGTH_M = 1; +const MIN_AREA_M2 = 1; /** @param {string} shape @returns {'area'|'line'|'pin'} */ export function normalizeShape(shape) { @@ -50,7 +50,7 @@ export function createDrawSession(shape = 'area') { } /** Great-circle distance in metres between two {lon, lat} points. */ -export function greatCircleM(a, b) { +function greatCircleM(a, b) { return greatCircleMeters(a, b); } diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 0f4e0fc76..9467406dd 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -52,7 +52,7 @@ import { import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js'; /** The id this tool claims the pointer under. */ -export const DRAW_POINTER_OWNER = 'draw'; +const DRAW_POINTER_OWNER = 'draw'; const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview'; const COLORS = ['primary', 'amber', 'cyan', 'green', 'red']; diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 10c251b60..226caf140 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -29,7 +29,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; /** Ms the analyst region lookup waits on geocode + admin boundary. */ -export const REGION_FALLBACK_BUDGET_MS = 3_000; +const REGION_FALLBACK_BUDGET_MS = 3_000; /** Entity facts that rule out a state/county reading of the ask. */ const NON_ADMIN_ENTITY_KINDS = new Set([ @@ -103,18 +103,6 @@ export function createAnnotationResolver({ cacheWrite(cache, key, null); } - /** Wire an external AbortSignal to a local controller; returns a detach fn. */ - function linkAbort(controller, externalSignal) { - if (!externalSignal) return () => {}; - if (externalSignal.aborted) { - controller.abort(); - return () => {}; - } - const onAbort = () => controller.abort(); - externalSignal.addEventListener('abort', onAbort, { once: true }); - return () => externalSignal.removeEventListener('abort', onAbort); - } - /** * Resolve a single annotation target to a normalized world anchor. * diff --git a/src/app/embed.js b/src/app/embed.js index a1485fe7e..f0d6cad0a 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -15,9 +15,9 @@ import * as Cesium from 'cesium'; import { announceNavigationAuthority } from '../navigationPolicy.js'; import { annotationsFromParams, createView } from '../view/index.js'; -export const EMBED_VIEW_MESSAGE = 'gev:view'; -export const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; -export const EMBED_READY_MESSAGE = 'gev:ready'; +const EMBED_VIEW_MESSAGE = 'gev:view'; +const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; +const EMBED_READY_MESSAGE = 'gev:ready'; const FOLLOW_LAYERS = { aircraft: 'flights', diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index e82e762e5..d7f2c6125 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and // Without the application's Street Level source there are none. assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); }); + +test('every built-in provider is exported, so hosts can choose their own list', async () => { + const google = await import('gods-eye-view/layers/street-level/google'); + const kartaview = await import('gods-eye-view/layers/street-level/kartaview'); + const panoramax = await import('gods-eye-view/layers/street-level/panoramax'); + const layer = createApplicationStreetLevel({ + providers: [ + google.createGoogleProvider({ getApiKey: () => null }), + kartaview.createKartaViewProvider(), + panoramax.createPanoramaxProvider(), + ], + }); + assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']); +}); diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 0fc619142..04794ab13 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -53,7 +53,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ }); /** An omitted provider is unavailable, never an authoritative empty dataset. */ -export class SourceUnavailableError extends Error { +class SourceUnavailableError extends Error { constructor(name) { super(`Data source not configured: ${name}`); this.name = 'SourceUnavailableError'; diff --git a/src/camera.js b/src/camera.js index 3941c9d48..3fd49b58c 100644 --- a/src/camera.js +++ b/src/camera.js @@ -1,51 +1,5 @@ import * as Cesium from 'cesium'; -/** - * Camera presets for notable locations. - * Phase 1 default: fly to Austin, TX on load. - */ -export const CAMERA_PRESETS = { - austin: { - destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800), - orientation: { - heading: Cesium.Math.toRadians(0), - pitch: Cesium.Math.toRadians(-35), - roll: 0.0, - }, - }, - sf: { - destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000), - orientation: { - heading: Cesium.Math.toRadians(30), - pitch: Cesium.Math.toRadians(-30), - roll: 0.0, - }, - }, - nyc: { - destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200), - orientation: { - heading: Cesium.Math.toRadians(-20), - pitch: Cesium.Math.toRadians(-30), - roll: 0.0, - }, - }, -}; - -/** - * Fly the camera to a preset location with a smooth animation. - */ -export function flyToPreset(viewer, presetName, duration = 3.0) { - const preset = CAMERA_PRESETS[presetName]; - if (!preset) return; - - viewer.camera.flyTo({ - destination: preset.destination, - orientation: preset.orientation, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); -} - /** * Set camera to Austin on load with a cinematic fly-in. * @returns {Function} Cancels the pending or active startup flight. diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs new file mode 100644 index 000000000..db9cbd852 --- /dev/null +++ b/src/cameraGlobeRotation.test.mjs @@ -0,0 +1,1322 @@ +import { GLOBE_VIEW } from './locations.js'; +import { projectEarthDiscToViewport } from './celestialRing.js'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { + activeCameraMotionId, + getActiveCameraMotion, + initCameraVerbs, + interruptCameraMotion, + interruptGlobeRotation, + interruptCameraMotionIfActive, + moveCamera, +} from './cameraVerbs.js'; +import { getRenderGovernorDiagnostics } from './renderGovernor.js'; + +const radians = Cesium.Math.toRadians; +const clone = (value) => Cesium.Cartesian3.clone(value); +const longitude = (camera) => + Cesium.Cartographic.fromCartesian(camera.positionWC).longitude; +const wrappedAngle = Cesium.Math.negativePiToPi; +const hasRenderHold = () => + getRenderGovernorDiagnostics().holds.includes('camera-verb'); + +function near(actual, expected, tolerance = 1e-10, message = '') { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${message}: expected ${expected}, received ${actual}`, + ); +} + +function pose(camera) { + return { + position: clone(camera.positionWC), + direction: clone(camera.directionWC), + up: clone(camera.upWC), + }; +} + +function samePose(camera, expected) { + const actual = pose(camera); + for (const key of ['position', 'direction', 'up']) { + near( + Cesium.Cartesian3.distance(actual[key], expected[key]), + 0, + key === 'position' ? 1e-6 : 1e-10, + key, + ); + } +} + +function orthonormalBasis(camera) { + const { directionWC: direction, upWC: up, rightWC: right } = camera; + for (const vector of [direction, up, right]) { + near(Cesium.Cartesian3.magnitude(vector), 1, 1e-12, 'unit camera basis'); + } + near(Cesium.Cartesian3.dot(direction, up), 0, 1e-12); + near(Cesium.Cartesian3.dot(direction, right), 0, 1e-12); + near(Cesium.Cartesian3.dot(up, right), 0, 1e-12); + near( + Cesium.Cartesian3.distance( + Cesium.Cartesian3.cross(direction, up, new Cesium.Cartesian3()), + right, + ), + 0, + 1e-12, + 'right-handed camera basis', + ); +} + +/** Use Cesium itself as an independent oracle for the swept camera path. */ +function arcHeights(camera, axis, angle, ellipsoid = Cesium.Ellipsoid.WGS84) { + const heights = []; + for (let i = 0; i <= 64; i += 1) { + const probe = Cesium.Camera.clone(camera); + probe.rotate(axis, (angle * i) / 64); + heights.push(ellipsoid.cartesianToCartographic(probe.positionWC).height); + } + return heights; +} + +/** Camera-space screen coordinates, before FOV/aspect scaling. */ +function projection(camera, target = Cesium.Cartesian3.ZERO) { + const offset = Cesium.Cartesian3.subtract( + target, + camera.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(offset, camera.directionWC); + return { + x: Cesium.Cartesian3.dot(offset, camera.rightWC) / depth, + y: Cesium.Cartesian3.dot(offset, camera.upWC) / depth, + depth, + }; +} + +/** Real Cesium geometry with no renderer, GPU, provider or wall-clock waits. */ +function harness( + t, + { + latitude = 15, + longitudeDeg = 0, + height = GLOBE_VIEW.heightM, + heading = 0, + pitch = -90, + roll = 0, + ellipsoid = Cesium.Ellipsoid.WGS84, + viewportWidth = 1280, + viewportHeight = 800, + } = {}, +) { + const realPerformance = globalThis.performance; + let nowMs = 0; + globalThis.performance = { now: () => nowMs }; + const canvas = Object.assign(new EventTarget(), { + clientWidth: viewportWidth, + clientHeight: viewportHeight, + width: viewportWidth, + height: viewportHeight, + }); + const scene = { + canvas, + drawingBufferWidth: viewportWidth, + drawingBufferHeight: viewportHeight, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + mapProjection: new Cesium.GeographicProjection(ellipsoid), + globe: { ellipsoid }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + tweens: [], + requestRender() {}, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + height, + ellipsoid, + ), + orientation: { + heading: radians(heading), + pitch: radians(pitch), + roll: radians(roll), + }, + }); + const viewer = { + camera, + scene, + trackedEntity: undefined, + trackedEntityChanged: new Cesium.Event(), + clock: { onTick: new Cesium.Event() }, + }; + const target = Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + 0, + ellipsoid, + ); + initCameraVerbs(viewer, () => target); + t.after(() => { + interruptCameraMotion('test-cleanup'); + globalThis.performance = realPerformance; + }); + const tick = (seconds = 0.1) => { + nowMs += seconds * 1000; + viewer.clock.onTick.raiseEvent(viewer.clock); + }; + const finishOnce = () => { + for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'once motion must finish'); + }; + return { viewer, camera, canvas, target, tick, finishOnce }; +} + +for (const [name, sceneMode] of [ + ['2D', Cesium.SceneMode.SCENE2D], + ['Columbus', Cesium.SceneMode.COLUMBUS_VIEW], + ['morphing', Cesium.SceneMode.MORPHING], +]) { + test(`globe rotation refuses ${name} before changing the prior owner`, (t) => { + const { viewer, camera } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }) + .ok, + true, + ); + const priorId = activeCameraMotionId(); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + viewer.scene.mode = sceneMode; + let handoffs = 0; + let cancellations = 0; + camera.cancelFlight = () => { + cancellations += 1; + }; + for (const mode of ['once', 'continuous']) { + const result = moveCamera( + { motion: 'rotate_globe', direction: 'left', mode }, + () => { + handoffs += 1; + return { ok: true }; + }, + ); + assert.equal(result.ok, false); + assert.match(result.error, /3D/); + } + assert.equal(handoffs, 0); + assert.equal(cancellations, 0); + assert.equal(activeCameraMotionId(), priorId); + assert.equal(hasRenderHold(), true); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); + }); + + test(`active globe rotation yields to ${name} without changing the camera`, (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + viewer.scene.mode = sceneMode; + const before = pose(camera); + let rotations = 0; + camera.rotate = () => { + rotations += 1; + }; + tick(); + assert.equal(rotations, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const before = pose(camera); + const entity = { id: 'cockpit-aircraft', position: {} }; + viewer.trackedEntity = entity; + viewer.trackedEntityChanged.raiseEvent(entity); + assert.equal( + getActiveCameraMotion(), + null, + 'tracking acquisition must synchronously yield', + ); + viewer.trackedEntity = undefined; + viewer.trackedEntityChanged.raiseEvent(undefined); + tick(); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('tracking event keeps legacy yaw and an untracked globe rotation active', (t) => { + const { viewer } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }).ok, + true, + ); + const yawId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent({ id: 'aircraft' }); + assert.equal(activeCameraMotionId(), yawId); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const globeId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent(undefined); + assert.equal(activeCameraMotionId(), globeId); +}); + +test('replaced viewer releases the old tracking listener without duplicate subscriptions', (t) => { + const first = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + initCameraVerbs(first.viewer); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + const next = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 0); + assert.equal(next.viewer.trackedEntityChanged.numberOfListeners, 1); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const id = activeCameraMotionId(); + first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' }); + assert.equal(activeCameraMotionId(), id); + next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' }); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +for (const latitude of [0, 30, -60, 89.5]) { + test(`globe rotation preserves Earth framing and radius from latitude ${latitude} degrees`, (t) => { + const { camera, tick, finishOnce } = harness(t, { latitude }); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const direction = clone(camera.directionWC); + const started = moveCamera({ motion: 'rotate_globe', mode: 'once' }); + assert.equal(started.ok, true); + assert.equal(started.direction, 'right'); + assert.equal(started.mode, 'once'); + for (let i = 0; i < 5; i += 1) { + tick(); + const current = projection(camera); + near(current.x, before.x); + near(current.y, before.y); + near(current.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + } + finishOnce(); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, direction), + radians(15), + ); + assert.equal(hasRenderHold(), false); + }); +} + +for (const [direction, sign] of [ + ['left', -1], + ['right', 1], +]) { + test(`globe ${direction} means visible landmark motion ${direction}`, (t) => { + const { camera, target, finishOnce } = harness(t, { latitude: 0 }); + const originalLongitude = longitude(camera); + near(projection(camera, target).x, 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(sign * projection(camera, target).x > 0.05); + near( + wrappedAngle(longitude(camera) - originalLongitude), + -sign * radians(15), + ); + }); +} + +for (const view of [ + { label: 'north-up', heading: 0 }, + { label: 'east-up', heading: 90 }, + { label: 'south-up', heading: 180 }, + { label: 'tilted east-up', heading: 90, pitch: -86 }, + { label: 'tilted and rolled', heading: 180, pitch: -86, roll: 35 }, + { label: 'near the pole', latitude: 89.5, heading: 180, roll: -25 }, +]) { + for (const [direction, sign] of [ + ['left', -1], + ['right', 1], + ]) { + test(`globe ${direction} stays screen-horizontal in a ${view.label} view`, (t) => { + const { camera, tick, finishOnce } = harness(t, view); + const ray = new Cesium.Ray( + clone(camera.positionWC), + clone(camera.directionWC), + ); + const intersection = Cesium.IntersectionTests.rayEllipsoid( + ray, + Cesium.Ellipsoid.WGS84, + ); + assert.ok(intersection, 'the original screen center must see the globe'); + const landmark = Cesium.Ray.getPoint( + ray, + intersection.start, + new Cesium.Cartesian3(), + ); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const originalLandmark = projection(camera, landmark); + near(originalLandmark.x, 0); + near(originalLandmark.y, 0); + // Exercise both bounded and continuous application of the same direction. + const mode = direction === 'left' ? 'once' : 'continuous'; + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode }).ok, + true, + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(0.25); + } + const after = projection(camera); + const movedLandmark = projection(camera, landmark); + assert.ok( + sign * movedLandmark.x > 0.001, + 'landmark must move in the requested screen direction', + ); + near(movedLandmark.y, originalLandmark.y); + near(after.x, before.x); + near(after.y, before.y); + near(after.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + }); + } +} + +test('equal left and right globe rotations restore the complete camera pose', (t) => { + const { camera, finishOnce } = harness(t, { + latitude: 42, + longitudeDeg: 170, + height: GLOBE_VIEW.heightM, + }); + const before = pose(camera); + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }); + finishOnce(); + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }); + finishOnce(); + samePose(camera, before); +}); + +test('globe rotation converts an inherited target frame without zooming or recentering', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-35), 50_000_000), + ); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + false, + ); + const before = pose(camera); + const screen = projection(camera); + const range = Cesium.Cartesian3.magnitude(camera.positionWC); + moveCamera({ + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', + }); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); + samePose(camera, before); + tick(); + tick(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), range, 1e-6); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); +}); + +for (const [speed, degreesPerSecond] of [ + ['slow', 4], + ['normal', 10], + ['fast', 20], +]) { + test(`continuous globe rotation honors ${speed} speed and explicit stop`, (t) => { + const { camera, tick } = harness(t); + const result = moveCamera({ + motion: 'rotate_globe', + mode: 'continuous', + speed, + }); + assert.equal(result.ok, true); + assert.equal(hasRenderHold(), true); + tick(); // First tick initializes the motion's wall-clock anchor. + const before = clone(camera.directionWC); + tick(0.1); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before), + radians(degreesPerSecond * 0.1), + ); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + const stopped = moveCamera({ motion: 'stop' }); + assert.equal(stopped.stopped, true); + assert.equal(hasRenderHold(), false); + const after = pose(camera); + tick(); + samePose(camera, after); + assert.equal(moveCamera({ motion: 'stop' }).stopped, false); + }); +} + +for (const event of ['pointerdown', 'touchstart', 'wheel']) { + test(`${event} immediately cancels globe rotation and releases the render hold`, (t) => { + const { camera, canvas, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + tick(); + const before = pose(camera); + canvas.dispatchEvent(new Event(event)); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + samePose(camera, before); + }); +} + +test('new navigation can cancel globe rotation before setting a new destination', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + assert.equal(interruptCameraMotion('explicit-navigation').wasActive, true); + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(110, -25, 1_000_000), + }); + const destination = pose(camera); + tick(); + samePose(camera, destination); + assert.equal(hasRenderHold(), false); +}); + +test('replacement motion rejects a stale globe owner cancellation', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + const globeId = activeCameraMotionId(); + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }); + const yawId = activeCameraMotionId(); + assert.notEqual(yawId, globeId); + assert.equal(interruptCameraMotionIfActive(globeId).wasActive, false); + assert.equal(getActiveCameraMotion()?.kind, 'rotate'); + const position = clone(camera.positionWC); + tick(); + tick(); + near(Cesium.Cartesian3.distance(camera.positionWC, position), 0, 1e-6); + assert.equal(interruptCameraMotionIfActive(yawId).wasActive, true); + assert.equal(hasRenderHold(), false); +}); + +test('replacing the viewer retires globe motion and its old clock listener', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + tick(); + const before = pose(camera); + const nextViewer = { + ...viewer, + clock: { onTick: new Cesium.Event() }, + }; + initCameraVerbs(nextViewer); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + nextViewer.clock.onTick.raiseEvent(nextViewer.clock); + samePose(camera, before); +}); + +for (const direction of ['up', 'down', 'diagonal']) { + test(`invalid globe direction ${direction} is refused before camera authority handoff`, (t) => { + const { camera } = harness(t); + const before = pose(camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe', direction }, () => { + handoffs += 1; + }); + assert.equal(result.ok, false); + assert.match(result.error, /left or right/); + assert.equal(handoffs, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +for (const mode of ['once', 'continuous']) { + for (const acquisition of ['tracking', 'reference frame']) { + test(`globe ${mode} yields to new ${acquisition} without moving the new owner`, (t) => { + const { viewer, camera, target, tick } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).ok, true); + tick(); + tick(); + assert.equal(hasRenderHold(), true); + const tracked = { id: 'new-tracked-aircraft' }; + if (acquisition === 'tracking') viewer.trackedEntity = tracked; + else + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-40), 1_000), + ); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + assert.equal( + viewer.trackedEntity, + acquisition === 'tracking' ? tracked : undefined, + ); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + tick(); + samePose(camera, before); + }); + } +} + +test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => { + const { viewer, camera, target } = harness(t); + viewer.trackedEntity = { id: 'tracked-aircraft' }; + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000)); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.match(result.error, /following tracked-aircraft/); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +test('the existing Globe View preset remains unchanged', () => { + assert.deepEqual(GLOBE_VIEW, { + heightM: 18_000_000, + pitchDeg: -90, + durationS: 2.8, + }); +}); +for (const latitude of [0, 90, -90]) + for (const height of [12_500_000, 18_000_000, 30_000_000]) + test(`whole-globe admission depends on framing at latitude ${latitude}, height ${height}m`, (t) => { + const { camera, finishOnce } = harness(t, { latitude, height }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }) + .ok, + true, + ); + finishOnce(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + }); +test('polar preset admission keeps centre-distance although geodetic altitude varies', (t) => { + const { camera, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.ok(Math.min(...arcHeights(camera, camera.upWC, Math.PI * 2)) > 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous', speed: 'fast' }) + .ok, + true, + ); + for (let i = 0; i < 45; i++) tick(0.1); + assert.ok( + Cesium.Ellipsoid.WGS84.cartesianToCartographic(camera.positionWC).height < + GLOBE_VIEW.heightM - 1000, + ); + assert.equal(getActiveCameraMotion().kind, 'rotate_globe'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + moveCamera({ motion: 'stop' }); + assert.equal(hasRenderHold(), false); +}); +test('close-up refusal preserves an inherited target frame', (t) => { + const { camera, target } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, -0.5, 5000)); + const before = pose(camera), + transform = Cesium.Matrix4.clone(camera.transform); + let calls = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(calls, 0); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); +}); +test('navigation callback never releases tracking for globe rotation', (t) => { + const { viewer, camera } = harness(t); + const tracked = (viewer.trackedEntity = { id: 'tracked-aircraft' }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + viewer.trackedEntity = undefined; + }).ok, + false, + ); + assert.equal(calls, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); +}); +test('direct globe request refuses Cockpit before navigation mutation', (t) => { + const { viewer, camera } = harness(t); + initCameraVerbs(viewer, null, { isCockpitActive: () => true }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }).ok, + false, + ); + assert.equal(calls, 0); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('navigation handoff preserves the original world pose even when it levels a rolled camera', (t) => { + const { camera, tick } = harness(t, { + latitude: 50, + heading: 75, + pitch: -86, + roll: 35, + }); + const before = pose(camera); + const result = moveCamera( + { motion: 'rotate_globe', mode: 'continuous' }, + (start) => { + camera.setView({ + orientation: { heading: camera.heading, pitch: camera.pitch, roll: 0 }, + }); + return start(); + }, + ); + assert.equal(result.ok, true); + samePose(camera, before); + const screen = projection(camera); + tick(); + tick(); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); + orthonormalBasis(camera); +}); + +test('direct globe rotation cancels an existing camera flight before acquiring motion', (t) => { + const { camera } = harness(t); + let cancellations = 0; + const original = camera.cancelFlight; + camera.cancelFlight = function () { + cancellations += 1; + return original.call(this); + }; + const before = pose(camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + assert.equal(cancellations, 1); + samePose(camera, before); +}); + +test('legacy rotate remains local yaw at an unchanged camera position', (t) => { + const { camera, finishOnce } = harness(t, { latitude: 0 }); + const before = pose(camera); + moveCamera({ motion: 'rotate', direction: 'right' }); + finishOnce(); + near(Cesium.Cartesian3.distance(camera.positionWC, before.position), 0, 1e-6); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before.direction), + radians(15), + ); + assert.ok( + Math.abs(projection(camera).x) > 0.2, + 'local yaw intentionally moves Earth out of the center', + ); +}); + +test('legacy orbit still revolves around the selected ground target', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-45), 4_000)); + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + const range = Cesium.Cartesian3.distance(camera.positionWC, target); + moveCamera({ motion: 'orbit', direction: 'right', mode: 'continuous' }); + tick(); + tick(); + assert.equal(getActiveCameraMotion()?.kind, 'orbit'); + near(Cesium.Cartesian3.distance(camera.positionWC, target), range, 1e-6); + near(projection(camera, target).x, 0); + near(projection(camera, target).y, 0); + interruptCameraMotion('test-stop'); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); +}); + +// Actual UI/tool authority routes, not a direct call to the interrupt helper. +test('new UI navigation intent stops globe motion before its destination and stale ticks', async (t) => { + const { NavigationController } = await import('./ui/navigationController.js'); + const { viewer, camera, tick } = harness(t); + const navigation = new NavigationController({ + viewer, + tracking: { + flightsLayer: {}, + militaryFlightsLayer: {}, + satellitesLayer: {}, + }, + interruptCameraMotion(reason) { + assert.equal( + getActiveCameraMotion(), + null, + 'the intent stamp must retire rotation before release', + ); + return interruptCameraMotion(reason); + }, + interruptGlobeRotation, + isCockpitActive: () => false, + cancelOrientation() {}, + clearLocation() {}, + cancelShareSelection: () => false, + getDataManager: () => null, + stopOrbit() {}, + showToast() {}, + }); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + navigation._runExplicitNavigation('camera', () => { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + }); + const after = pose(camera); + tick(); + samePose(camera, after); +}); +test('orbit-toggle controller stops globe motion before toggling', async (t) => { + const { LocationNavigation } = await import('./ui/locationNavigation.js'); + const { target } = harness(t); + let toggled = false; + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + LocationNavigation.prototype._toggleOrbit.call({ + services: { interruptGlobeRotation }, + _currentTarget: target, + _currentPoi: {}, + orbitController: { + toggle() { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + toggled = true; + return true; + }, + }, + _orbitIndicator: { classList: { toggle() {} } }, + }); + assert.equal(toggled, true); +}); +for (const owner of ['tracking', 'cockpit', 'close-up']) + test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => { + const { createGevActionRunner } = await import('./voice/gevActions.js'); + const { runExplicitNavigation } = await import('./navigationPolicy.js'); + const { viewer, camera } = harness(t, { + height: owner === 'close-up' ? 5000 : GLOBE_VIEW.heightM, + }); + const before = pose(camera); + if (owner === 'tracking') viewer.trackedEntity = { id: 'tracked-aircraft' }; + const tracked = viewer.trackedEntity; + let releases = 0, + stamps = 0; + const styleManager = { + getCockpitState: () => ({ active: owner === 'cockpit' }), + runImmediateNavigation(noun, navigate) { + return runExplicitNavigation({ + cockpitActive: owner === 'cockpit', + noun, + stamp() { + stamps++; + }, + release() { + releases++; + viewer.trackedEntity = undefined; + }, + navigate, + }); + }, + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { + layers: new Map(), + isEnabled: () => false, + getAll: () => [], + }, + }); + const result = await runner('move_camera', { + motion: 'rotate_globe', + direction: 'left', + }); + assert.equal(result.ok, false); + assert.equal(releases, 0); + assert.equal(stamps, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); + if (owner === 'close-up') { + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + } + }); + +for (const [label, ellipsoid] of [ + ['nonfinite radii', { minimumRadius: 1, maximumRadius: Infinity }], + ['nonpositive radii', { minimumRadius: 0, maximumRadius: 1 }], + ['non-WGS84 radii', new Cesium.Ellipsoid(30_000_000, 30_000_000, 1_000_000)], +]) + test(`globe rotation refuses ${label} before handoff`, (t) => { + const { viewer, camera } = harness(t, { height: 100_000_000 }); + const before = pose(camera); + viewer.scene.globe.ellipsoid = ellipsoid; + let handedOff = false; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handedOff = true; + }); + assert.equal(result.ok, false); + assert.equal( + result.reason, + label === 'non-WGS84 radii' ? 'unsupported-view' : 'unsafe-ellipsoid', + ); + assert.equal(handedOff, false); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); + +test('an external radius change retires globe rotation without another write', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + camera.moveForward(1000); + const changed = pose(camera); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, changed); +}); + +test('a non-WGS84 ellipsoid fails closed rather than borrowing the WGS84 silhouette', (t) => { + const ellipsoid = new Cesium.Ellipsoid(10_000_000, 10_000_000, 8_000_000); + const { camera } = harness(t, { ellipsoid, latitude: 60 }); + const before = pose(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(camera, before); +}); + +test('untouched polar framing permits repeated direction and speed changes below preset altitude', (t) => { + const { camera, finishOnce, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok( + Cesium.Cartographic.fromCartesian(camera.positionWC).height < + GLOBE_VIEW.heightM - 1, + ); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.equal( + moveCamera({ + motion: 'rotate_globe', + direction: 'left', + speed: 'fast', + mode: 'continuous', + }).ok, + true, + ); + tick(); + tick(); + assert.equal(getActiveCameraMotion().speed, 'fast'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); +}); + +function projected(h) { + return projectEarthDiscToViewport( + h.viewer, + h.canvas.clientWidth, + h.canvas.clientHeight, + ); +} + +test('a smaller globe wholly inside the keyhole needs no overlay entry margin or reset', (t) => { + const h = harness(t, { latitude: 0, height: 12_500_000 }); + const disc = projected(h); + const clearance = + disc.keyholeRadius - + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) - + disc.earthRadius; + assert.ok( + clearance > 0 && clearance < 24, + `near-edge inside fit: ${clearance}`, + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +test('keyhole overlap refuses without a camera handoff or pose change', (t) => { + const h = harness(t, { latitude: 0, height: 11_000_000 }); + const before = pose(h.camera); + const disc = projected(h); + assert.ok(disc.earthRadius > disc.keyholeRadius); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(handoffs, 0); + samePose(h.camera, before); +}); + +for (const [angle, fits] of [ + [5, true], + [25, false], +]) + test(`actual off-centre globe at ${angle} degrees has fit ${fits}`, (t) => { + const h = harness(t, { height: 30_000_000 }); + h.camera.lookRight(radians(angle)); + const before = pose(h.camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, fits); + samePose(h.camera, before); + }); + +test('wide viewport uses vertical fovy rather than horizontal fov', (t) => { + const h = harness(t, { + viewportWidth: 2000, + viewportHeight: 500, + latitude: 0, + }); + assert.ok(h.camera.frustum.fovy < h.camera.frustum.fov); + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-reset-unavailable', + ); +}); + +for (const invalid of [ + 'orthographic', + 'behind', + 'zero-css-width', + 'offset-frustum', +]) + test(`unsupported ${invalid} fails without a reset offer`, (t) => { + const h = harness(t); + if (invalid === 'orthographic') + h.camera.frustum = new Cesium.OrthographicFrustum({ + width: 50_000_000, + aspectRatio: 1.6, + }); + if (invalid === 'behind') h.camera.lookRight(Math.PI); + if (invalid === 'zero-css-width') h.canvas.clientWidth = 0; + if (invalid === 'offset-frustum') h.camera.frustum.xOffset = 0.1; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('a new request rechecks close-up framing rather than using previous eligibility', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.dispatchEvent(new Event('wheel')); + h.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000) }); + const before = pose(h.camera); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-view-required', + ); + samePose(h.camera, before); + assert.equal(getActiveCameraMotion(), null); +}); + +// Exact WGS84 equatorial tangent points expose the shared disc approximation. +// Keep the concrete counterexample public; an overlay approximation is not admission proof. +test('off-axis ellipsoid limb overlap must not be admitted as a fully framed globe', (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000 }); + h.camera.lookRight(radians(6)); + const disc = projected(h); + assert.ok( + disc.earthRadius + + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) < + disc.keyholeRadius, + 'the shared approximation reports a fit', + ); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const x = (radius * radius) / h.camera.positionWC.x; + const y = Math.sqrt(radius * radius - x * x); + const limbRadii = [-y, y].map((value) => { + const pixel = Cesium.SceneTransforms.worldToWindowCoordinates( + h.viewer.scene, + new Cesium.Cartesian3(x, value, 0), + ); + return Math.hypot( + pixel.x - disc.keyholeCenterX, + pixel.y - disc.keyholeCenterY, + ); + }); + assert.ok( + Math.max(...limbRadii) > disc.keyholeRadius, + 'an actual ellipsoid tangent point overlaps the keyhole', + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, false); +}); + +for (const roll of [0, 35]) + test(`off-axis boundary overlap is refused with roll ${roll}`, (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000, roll }); + h.camera.lookRight(radians(6)); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + }); + +for (const latitude of [0, 90]) + for (const pitch of [0, 15]) + test(`close-up horizon/upward view offers reset at latitude ${latitude}, pitch ${pitch}`, (t) => { + const h = harness(t, { latitude, pitch, height: 5000 }); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + samePose(h.camera, before); + }); + +for (const [viewportWidth, viewportHeight, fits] of [ + [390, 844, true], + [844, 390, true], + [800, 1200, true], + [1280, 800, true], + [3440, 1440, false], +]) + test(`unchanged reset preset is rechecked at ${viewportWidth}x${viewportHeight}`, (t) => { + const h = harness(t, { + viewportWidth, + viewportHeight, + height: GLOBE_VIEW.heightM, + latitude: 0, + }); + const disc = projected(h); + assert.equal( + disc.earthRadius < disc.keyholeRadius, + fits, + 'reset preset keyhole fit', + ); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, fits); + if (!fits) assert.equal(result.reason, 'globe-reset-unavailable'); + samePose(h.camera, before); + }); + +test('keyhole overlap after resize stops rotation before another pose write', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.clientWidth = 2000; + h.canvas.clientHeight = 500; + h.camera.frustum.aspectRatio = 4; + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + const before = pose(h.camera); + h.tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(h.camera, before); +}); + +for (const clipped of ['near', 'far', 'entirely-beyond-far']) + test(`depth-${clipped} globe is unsupported without a reset offer`, (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + if (clipped === 'near') h.camera.frustum.near = distance - radius + 100; + if (clipped === 'far') h.camera.frustum.far = distance + radius - 100; + if (clipped === 'entirely-beyond-far') + h.camera.frustum.far = distance - 2 * radius; + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); + }); + +test('fully depth-contained globe remains eligible at actual near/far limits', (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + h.camera.frustum.near = distance - radius - 1; + h.camera.frustum.far = distance + radius + 1; + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +for (const [near, far] of [ + [0, 1e9], + [-1, 1e9], + [NaN, 1e9], + [1, Infinity], + [10, 10], + [10, 9], +]) + test(`invalid depth limits ${near}/${far} are unsupported`, (t) => { + const h = harness(t); + h.camera.frustum.near = near; + h.camera.frustum.far = far; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('bare globe rotation defaults right and continues beyond a nudge', (t) => { + const { camera, tick } = harness(t, { latitude: 0 }); + const before = projection(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, true); + assert.equal(result.direction, 'right'); + assert.equal(result.mode, 'continuous'); + assert.equal(result.speed, 'normal'); + for (let i = 0; i < 30; i++) tick(0.1); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + near(projection(camera).x, before.x); + near(projection(camera).y, before.y); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); + assert.equal(hasRenderHold(), false); +}); +for (const [args, direction, mode, speed] of [ + [{ mode: 'once' }, 'right', 'once', 'normal'], + [{ direction: 'left', speed: 'slow' }, 'left', 'continuous', 'slow'], + [{ direction: 'left', mode: 'once', speed: 'fast' }, 'left', 'once', 'fast'], + [ + { direction: 'right', mode: 'continuous', speed: 'fast' }, + 'right', + 'continuous', + 'fast', + ], +]) + test(`explicit globe controls prevail: ${JSON.stringify(args)}`, (t) => { + const { finishOnce, tick } = harness(t); + const result = moveCamera({ motion: 'rotate_globe', ...args }); + assert.deepEqual( + [result.ok, result.direction, result.mode, result.speed], + [true, direction, mode, speed], + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + } + }); +for (const motion of ['rotate', 'pan', 'tilt', 'orbit']) + test(`legacy ${motion} retains its once default`, (t) => { + harness(t); + const result = moveCamera({ + motion, + direction: motion === 'tilt' ? 'up' : 'left', + }); + assert.equal(result.ok, true); + assert.equal(result.mode, 'once'); + }); +for (const mode of [null, '', 'invalid', 'CONTINUOUS']) + test(`invalid globe mode ${JSON.stringify(mode)} retains legacy once fallback`, (t) => { + const { finishOnce } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).mode, 'once'); + finishOnce(); + }); + +test('canvas-only clipping after resize preserves a keyhole-fitting rotation', (t) => { + const h = harness(t, { latitude: 0 }); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + h.canvas.clientWidth = 390; + h.canvas.clientHeight = 844; + h.camera.frustum.aspectRatio = 390 / 844; + const disc = projected(h); + assert.ok(disc.earthRadius < disc.keyholeRadius); + assert.ok(disc.earthRadius > h.canvas.clientWidth / 2); + const before = pose(h.camera); + h.tick(); + h.tick(); + assert.notDeepEqual(pose(h.camera).position, before.position); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + assert.equal(hasRenderHold(), true); +}); + +test('an invalid camera request leaves the active globe rotation untouched', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + const before = pose(h.camera); + const active = getActiveCameraMotion(); + assert.equal(moveCamera({ motion: 'pan' }).ok, false); + assert.deepEqual(getActiveCameraMotion(), active); + samePose(h.camera, before); + assert.equal(hasRenderHold(), true); +}); + +test('close-up ultrawide refusal does not offer an unfit reset or move the camera', (t) => { + const h = harness(t, { + viewportWidth: 3440, + viewportHeight: 1440, + height: 5000, + }); + const before = pose(h.camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-reset-unavailable'); + assert.equal(handoffs, 0); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); +}); diff --git a/src/cameraGroundGuard.js b/src/cameraGroundGuard.js index 5a5fa3775..3f9de0cab 100644 --- a/src/cameraGroundGuard.js +++ b/src/cameraGroundGuard.js @@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js'; /** Minimum eye height above the rendered surface for a usable view. */ export const MIN_EYE_CLEARANCE_M = 120; /** Retries while tiles stream in; sampling fails until the mesh exists. */ -export const GUARD_ATTEMPTS = 8; -export const GUARD_INTERVAL_MS = 600; +const GUARD_ATTEMPTS = 8; +const GUARD_INTERVAL_MS = 600; /** Ignore sub-metre noise rather than nudging the camera forever. */ const GUARD_EPSILON_M = 2; diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 8a09810ea..1fe30ff06 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,7 +1,6 @@ import { LayerBindings } from './ui/layerBindings.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js'; import { CockpitViewController } from './ui/cockpitController.js'; import assert from 'node:assert/strict'; @@ -224,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca 'move camera', ); ordered(move, [ - "if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))", + "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", 'const start = () => {', /const preserveCameraFlight =\s*motion === 'orbit'/, 'runNavigation(start, { preserveCameraFlight })', @@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => { }); test('vessel and fire layers announce valid clicks and never fly cameras', () => { - for (const [label, source] of [['vessels', vessels], ['fires', firms]]) { + for (const source of [vessels, firms]) { assert.match(source, /requestWorldFocus\(\{/); assert.doesNotMatch(source, /camera\.flyTo/); } diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 278a86206..231d9357d 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -4,7 +4,7 @@ * * One motion at a time, driven per clock tick. `once` = bounded eased nudge; * `continuous` runs until move_camera{stop}, ANY manual camera input on the - * canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool + * canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool * starts (the shared UI navigation policy releases this owner before the new * destination mutates the camera). fly_route is a * continuous motion in the same slot: a cinematic dolly along an existing @@ -13,14 +13,24 @@ * Orbit circles the current screen-center ground target holding range and * pitch while advancing heading (per-frame camera.lookAt; the lookAt * transform is cleared on every stop so manual control returns cleanly). + * Globe rotation turns the world-frame camera about its screen-up axis through + * Earth's center, preserving range and framing. Left/right name the visible + * globe motion, opposite to the camera's travel around Earth. */ import * as Cesium from 'cesium'; +import { GLOBE_VIEW } from './locations.js'; +import { + projectEarthDiscToViewport, + earthDiscScreenRadius, + getKeyholeGeometry, +} from './celestialRing.js'; import { holdContinuousRender, releaseContinuousRender, } from './renderGovernor.js'; +let _isCockpitActive = null; /** °/s by speed word — orbit; pan uses fractions of view height/s. */ const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 }; const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 }; @@ -952,6 +962,7 @@ let _viewer = null; let _getTarget = null; let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state } let _tickRemover = null; +let _trackingRemover = null; let _inputRemovers = []; /** Monotonic id for the motion currently running; 0 means "no motion". */ let _motionSeq = 0; @@ -1162,7 +1173,11 @@ function onTick() { else if (m.direction === 'right') cam.moveRight(step); else if (m.direction === 'up') cam.moveUp(step); else cam.moveDown(step); - } else if (m.kind === 'tilt' || m.kind === 'rotate') { + } else if ( + m.kind === 'tilt' || + m.kind === 'rotate' || + m.kind === 'rotate_globe' + ) { const degS = TILT_DEG_S[m.speed]; const stepRad = m.mode === 'once' @@ -1172,7 +1187,38 @@ function onTick() { (scale - (m.lastScale || 0)) : Cesium.Math.toRadians(degS) * dt; m.lastScale = scale; - if (m.kind === 'tilt') { + if (m.kind === 'rotate_globe') { + // Tracking can acquire the camera without going through navigation. + // Yield without resetting its pose/frame: the world-space arc proof + // only describes Cesium's rotation while the frame is identity. + if ( + _viewer.scene.mode !== Cesium.SceneMode.SCENE3D || + _viewer.trackedEntity || + _isCockpitActive?.() || + !Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ) { + interruptCameraMotion('globe-camera-owner-changed'); + return; + } + // Cesium rotates both position and basis about the reference-frame + // origin. The captured screen-up axis keeps left/right horizontal even + // in a tilted or rolled view; a positive angle moves the globe right. + const angle = m.direction === 'left' ? -stepRad : stepRad; + if (globeRotationFitsKeyhole() !== true) { + interruptCameraMotion('globe-framing-changed'); + return; + } + if ( + !globeRotationHasClearance(cam.positionWC) || + Math.abs( + Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius, + ) > 0.01 + ) { + interruptCameraMotion('globe-surface-limit'); + return; + } + cam.rotate(m.rotationAxis, angle); + } else if (m.kind === 'tilt') { const up = m.direction === 'up'; // Clamp pitch to [-89°, -5°]: never through the ground, never at the sky. const next = cam.pitch + (up ? stepRad : -stepRad); @@ -1206,6 +1252,133 @@ function onTick() { } } +/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */ +function globeRotationHasClearance(position) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + const { minimumRadius, maximumRadius } = ellipsoid; + const radius = Cesium.Cartesian3.magnitude(position); + return ( + Number.isFinite(minimumRadius) && + minimumRadius > 0 && + Number.isFinite(maximumRadius) && + maximumRadius >= minimumRadius && + Number.isFinite(radius) && + radius > maximumRadius * (1 + 1e-12) + ); +} + +/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */ +function globeRotationFitsKeyhole(camera = _viewer.camera) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii)) + return null; + const canvas = _viewer.scene.canvas; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + const frustum = camera.frustum; + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 || + !(frustum instanceof Cesium.PerspectiveFrustum) || + !Number.isFinite(frustum.near) || + frustum.near <= 0 || + !Number.isFinite(frustum.far) || + frustum.far <= frustum.near || + frustum.xOffset !== 0 || + frustum.yOffset !== 0 + ) + return null; + try { + const cam = camera; + const distance = Cesium.Cartesian3.magnitude(cam.positionWC); + const sphereRadius = ellipsoid.maximumRadius; + if (!Number.isFinite(distance) || distance <= 0) return null; + if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too. + const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy); + if (!radiusPx) return null; + const keyhole = getKeyholeGeometry(width, height); + // The known nadir reset probe uses the same exact cone below; projecting + // through SceneTransforms would read the live scene.camera instead. + if ( + camera === _viewer.camera && + !projectEarthDiscToViewport(_viewer, width, height) + ) { + // A near-surface horizon view cannot fit even when pointed at Earth; + // an orbital camera looking away is unsupported, not a close-up offer. + return radiusPx > Math.min(keyhole.radius, width / 2, height / 2) + ? false + : null; + } + // The shared disc is an overlay approximation off-axis. Bound the sphere + // against the existing keyhole cone. Canvas-edge clipping does not change + // framing during rigid globe rotation and is not a clearance condition, + // without changing the UI projection or defining another keyhole/mask. + const toCenter = Cesium.Cartesian3.negate( + cam.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC); + const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC)); + const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC)); + const focalY = height / (2 * Math.tan(frustum.fovy / 2)); + const focalX = + width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio); + if ( + ![depth, right, up, focalX, focalY].every(Number.isFinite) || + focalX <= 0 || + focalY <= 0 + ) + return null; + const halfAngle = Math.asin(sphereRadius / distance); + const centerAngle = Math.atan2(Math.hypot(right, up), depth); + const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY)); + // No overlay visibility hysteresis: a fully contained smaller globe needs no reset. + const framed = centerAngle + halfAngle <= keyholeAngle; + if (!framed) return false; + // Projection alone does not prove that a tiny globe is within depth planes. + if ( + depth - sphereRadius < frustum.near || + depth + sphereRadius > frustum.far + ) + return null; + return true; + } catch { + return null; + } +} + +/** Check the unchanged reset destination without moving the live camera. */ +function globeResetFitsKeyhole() { + try { + const camera = Cesium.Camera.clone(_viewer.camera); + const { longitude, latitude } = _viewer.camera.positionCartographic; + camera.setView({ + destination: Cesium.Cartesian3.fromRadians( + longitude, + latitude, + GLOBE_VIEW.heightM, + ), + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg), + roll: 0, + }, + endTransform: Cesium.Matrix4.IDENTITY, + }); + return globeRotationFitsKeyhole(camera) === true; + } catch { + return false; + } +} + +/** Release only globe rotation when a new camera intent takes ownership. */ +export function interruptGlobeRotation(reason = 'camera-intent') { + if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason); + return { wasActive: false, reason, leveled: false }; +} + /** * Wire the module to the viewer once (idempotent). The view-target getter is * optional so a non-voice caller (the Directions FLY chip) can arm route @@ -1218,24 +1391,42 @@ function onTick() { * call brings none, so a callback from the old viewer can never answer for the * new one. */ -export function initCameraVerbs(viewer, getViewTargetCartesian = null) { +export function initCameraVerbs( + viewer, + getViewTargetCartesian = null, + { isCockpitActive } = {}, +) { const nextTarget = typeof getViewTargetCartesian === 'function' ? getViewTargetCartesian : null; if (_viewer === viewer) { if (nextTarget) _getTarget = nextTarget; + if (typeof isCockpitActive === 'function') + _isCockpitActive = isCockpitActive; return; } + // A globe motion belongs to the viewer whose world pose it captured. + if (_active?.kind === 'rotate_globe') + interruptCameraMotion('viewer-replaced'); _getTarget = nextTarget; + _isCockpitActive = + typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); + if (_trackingRemover) _trackingRemover(); + // Cockpit can acquire then clear tracking before the next clock tick. + // Yield on acquisition itself without changing legacy camera motions. + _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { + if (entity && _active?.kind === 'rotate_globe') + interruptCameraMotion('globe-camera-owner-changed'); + }); for (const rm of _inputRemovers) rm(); _inputRemovers = []; const canvas = viewer.scene.canvas; // ANY manual camera input reclaims control — the cancelFlight reflex. - for (const evt of ['pointerdown', 'wheel']) { + for (const evt of ['pointerdown', 'touchstart', 'wheel']) { const h = () => interruptCameraMotion('manual-input'); canvas.addEventListener(evt, h, { passive: true }); _inputRemovers.push(() => canvas.removeEventListener(evt, h)); @@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) { const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()] ? String(args.speed || 'normal').toLowerCase() : 'normal'; - const mode = args.mode === 'continuous' ? 'continuous' : 'once'; + const mode = + args.mode === 'continuous' || + (motion === 'rotate_globe' && args.mode === undefined) + ? 'continuous' + : 'once'; if (motion === 'stop') { const wasActiveBeforeHandoff = Boolean(_active); const stop = () => { @@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) { }; return typeof runNavigation === 'function' ? runNavigation(stop) : stop(); } - if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) { + if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) { return { ok: false, action: 'move_camera', - error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`, + error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, }; } if ( motion !== 'orbit' && + motion !== 'rotate_globe' && !['left', 'right', 'up', 'down'].includes(direction) ) { return { @@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) { error: 'rotate needs left or right.', }; } + if ( + motion === 'rotate_globe' && + direction !== null && + !['left', 'right'].includes(direction) + ) { + return { + ok: false, + action: 'move_camera', + error: 'rotate_globe needs left or right.', + }; + } if (motion === 'tilt') { // Honest limits: at the clamp a tilt is a no-op — say so instead of // reporting success and letting the model gaslight itself (field test). @@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) { } } if ( - motion === 'orbit' && - _viewer?.trackedEntity && - typeof runNavigation !== 'function' + (motion === 'rotate_globe' || + (motion === 'orbit' && typeof runNavigation !== 'function')) && + _viewer?.trackedEntity ) { // The follow camera owns a tracked view; a lookAt orbit fights it frame // by frame and stop rips the camera out (field finding). Honest refusal @@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) { return { ok: false, action: 'move_camera', - error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`, + error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`, }; } + let globePose; + if (motion === 'rotate_globe') { + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) { + return { + ok: false, + action: 'move_camera', + error: 'Globe rotation requires a 3D view.', + }; + } + const cam = _viewer.camera; + globePose = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()), + right: Cesium.Cartesian3.clone(cam.rightWC), + }; + if (_isCockpitActive?.()) + return { + ok: false, + action: 'move_camera', + reason: 'camera-owned', + error: 'Exit Cockpit before rotating the globe.', + }; + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!( + Number.isFinite(ellipsoid.minimumRadius) && + ellipsoid.minimumRadius > 0 && + Number.isFinite(ellipsoid.maximumRadius) && + ellipsoid.maximumRadius >= ellipsoid.minimumRadius && + Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position)) + )) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: + 'Globe rotation requires a finite supported ellipsoid and a clear globe view.', + }; + const fits = globeRotationFitsKeyhole(); + if (fits === null) + return { + ok: false, + action: 'move_camera', + reason: 'unsupported-view', + error: 'Globe rotation requires a supported perspective view.', + }; + if (!fits) { + const canReset = globeResetFitsKeyhole(); + return { + ok: false, + action: 'move_camera', + reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable', + error: canReset + ? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’' + : 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.', + }; + } + if (!globeRotationHasClearance(globePose.position)) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: 'Globe rotation requires a clear ellipsoid bounding sphere.', + }; + } const start = () => { interruptCameraMotion('replaced'); const state = { @@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) { speed, mode, }; + if (motion === 'rotate_globe') { + // Convert an inherited ground-target frame without changing the world + // pose. Rotation must pivot about Earth, never the previous local target. + const cam = _viewer.camera; + cam.cancelFlight(); + cam.lookAtTransform(Cesium.Matrix4.IDENTITY); + // Handoff may level a route's bank. Keep the caller's exact world pose. + // Copy the basis directly: setView's heading/pitch conversion is singular + // at nadir and can change the captured orientation near the poles. + Cesium.Cartesian3.clone(globePose.position, cam.position); + Cesium.Cartesian3.clone(globePose.direction, cam.direction); + Cesium.Cartesian3.clone(globePose.up, cam.up); + Cesium.Cartesian3.clone(globePose.right, cam.right); + state.rotationAxis = globePose.up; + state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position); + } if (motion === 'orbit') { if (_viewer.scene.tweens.length > 0) { // A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm diff --git a/src/cctvFocusPolicy.js b/src/cctvFocusPolicy.js index 7fb2ebe1b..e11c1e1ca 100644 --- a/src/cctvFocusPolicy.js +++ b/src/cctvFocusPolicy.js @@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({ * @param {Object} [after={}] Ownership immediately after the await. * @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership. */ -export function mergeCctvEnableOwnership(before = {}, after = {}) { +function mergeCctvEnableOwnership(before = {}, after = {}) { return { trackedEntity: before.trackedEntity || after.trackedEntity || null, cockpitActive: !!(before.cockpitActive || after.cockpitActive), diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 13fbff552..333926a0f 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -1,7 +1,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { runCctvLayerEnableFocus, runCctvLayerEnableTransition, diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs index be05ef7cb..700a70a74 100644 --- a/src/cctvFocusRequest.test.mjs +++ b/src/cctvFocusRequest.test.mjs @@ -1,7 +1,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { CCTV_WORLD_CLICK_FOCUS_DURATION_SEC, CCTV_FOCUS_REQUEST_EVENT, diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index ccbf31831..c9117b7d3 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -174,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) { * Weather-backed volumetric cloud pass that exists only while cockpit mode is * active. It owns no Cesium fog/post-process stages and cannot affect map mode. */ -export class CockpitCloudEffectsController { +class CockpitCloudEffectsController { constructor(viewer, { weatherService = applicationServices.weather } = {}) { this.weatherService = weatherService; this.viewer = viewer; diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 862d055ce..8fe78e1de 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -281,7 +281,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc uiSource.indexOf('_syncContactsDetection() {'), uiSource.indexOf('/** Apply a temporary cockpit-only'), ); - assert.ok(helper.length > 0, 'the Contacts detection helper is present in ui.js'); + assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source'); assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click'); assert.match(helper, /active: this\._contextMode === 'flights'/); assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/); @@ -302,7 +302,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'), uiSource.indexOf('_syncIrBoost() {'), ); - assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in ui.js'); + assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source'); assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection'); }); diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 6b1c90904..2f9bfeae0 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -8,7 +8,6 @@ import { setContextMode as contextModeAction } from './ui/contextActions.js'; import { ContextControls } from './ui/contextControls.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); // Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped: // - session bookkeeping ran AFTER the exit early-return, so the compensating @@ -21,8 +20,6 @@ const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', imp // - the right-rail entry ignored the activation result entirely. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; const src = readShellSource(); diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 55ff429a2..6994459a7 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -1,4 +1,3 @@ -import { readShellSource } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { _selectContextMode } from './ui/contextTransactions.js'; @@ -8,13 +7,11 @@ import { clearSelectedLayers } from './ui/contextActions.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); -const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); -const ui = readShellSource(); const css = readStylesheet(new URL('../style.css', import.meta.url)); test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { @@ -32,8 +29,6 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence' }); test('Context transition state preserves focus and Tab availability until settle', () => { - const syncSource = _syncContextModeButtons.toString(); - const attributes = () => new Map(); const makeButton = () => { const attrs = attributes(); diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index d5306dbaa..3032a25fe 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -1,4 +1,3 @@ -import { readShellSource } from './testSupport/readShellSource.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const css = readStylesheet(path.join(ROOT, 'style.css')); -const ui = readShellSource(); /* * Required-attribution keep-out pin. @@ -247,6 +245,9 @@ function ownBoxEntries() { const compound = lastCompound(part); if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue; if (compound.includes('::')) continue; // a pseudo-element is its own box + // The phone shell is its own layout; mobileShell.test.mjs pins its + // attribution clearance. + if (part.startsWith('body.mobile-shell')) continue; entries.push({ rule, part }); } } diff --git a/src/data/adminBoundaries.js b/src/data/adminBoundaries.js index e9f97cf8f..9efa967d4 100644 --- a/src/data/adminBoundaries.js +++ b/src/data/adminBoundaries.js @@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) { } /** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */ -export function pointInRing(ring, lat, lon) { +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) { diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index b5a2c8de9..96a5cd105 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', ( } }); +test('vessel touch interaction yields to the first nearby flight behind map tiles', () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: () => ({ matches: true }), + }); + registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3'); + const tile = { primitive: { isCesium3DTileset: true } }; + const harness = installWireHarness(tile); + let drills = 0; + harness.viewer.scene.drillPick = (_position, limit, width, height) => { + drills++; + assert.deepEqual([limit, width, height], [8, 14, 14]); + return [tile, { id: 'a1b2c3' }]; + }; + try { + harness.handler.click({ position: { x: 10, y: 20 } }); + assert.equal(drills, 1); + assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal(harness.trail.clearCalls, 0); + } finally { + harness.cleanup(); + unregisterPickOwner('flights'); + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + } +}); + test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => { registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3'); const harness = installWireHarness({ id: 'atx-cam-3' }); diff --git a/src/data/aisStreamAdapter.js b/src/data/aisStreamAdapter.js index 1f7b0ef9b..05e747308 100644 --- a/src/data/aisStreamAdapter.js +++ b/src/data/aisStreamAdapter.js @@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze( * @param {Object} body * @returns {string|null} */ -export function aisEnvelopeMmsi(envelope, body = {}) { +function aisEnvelopeMmsi(envelope, body = {}) { const metadata = envelope?.MetaData || envelope?.Metadata || {}; const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi; if (raw === null || raw === undefined) return null; diff --git a/src/data/aisWatchdog.js b/src/data/aisWatchdog.js index a83c281d9..f024a6f67 100644 --- a/src/data/aisWatchdog.js +++ b/src/data/aisWatchdog.js @@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({ authProbeMs: 3_600_000, }); -/** - * Statuses reported to the browser. 'missing-key' and 'unsupported' keep their - * pre-watchdog meaning so a keyless install still reads as "feature off". - */ -export const AIS_WATCHDOG_STATUSES = Object.freeze([ - 'idle', - 'missing-key', - 'unsupported', - 'connecting', - 'live', - 'stale', - 'reconnecting', - 'down', - 'auth-failed', -]); - /** Failure classes. Only 'transport' is allowed to walk the fast ladder. */ -export const AIS_FAILURE_KINDS = Object.freeze([ - 'transport', - 'auth', - 'rate-limit', -]); +const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']); /** Statuses that must not flip back to a hopeful 'connecting' on a retry. */ const QUIET_TERMINAL = new Set(['down', 'auth-failed']); diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 59a631421..5175fec48 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -62,7 +62,7 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']); /** Derived per-record field for layers with a time field (hours before now). */ const AGE_FIELD = 'ageHours'; /** Spatial scope kinds `query()` accepts. */ -export const ANALYST_SCOPE_KINDS = Object.freeze([ +const ANALYST_SCOPE_KINDS = Object.freeze([ 'view', 'region', 'radius', @@ -74,7 +74,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([ * @param {string[]} layerKeys Queried layer ids. * @returns {string[]} Field names, common fields first. */ -export function analystFieldsFor(layerKeys) { +function analystFieldsFor(layerKeys) { const fields = new Set(COMMON_FIELDS); for (const key of layerKeys) { const layer = ANALYST_LAYERS[key]; @@ -104,44 +104,6 @@ export function haversineKm(lat1, lon1, lat2, lon2) { return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } -/** - * One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}. - * With `time: true`, both sides compare as epoch ms (ISO strings accepted). - */ -export function applyFilter(records, filter, { time = false } = {}) { - const { field, op } = filter || {}; - if (!field || !op) return records; - const value = time ? toEpochMs(filter.value) : filter.value; - return records.filter((r) => { - const got = time ? toEpochMs(r[field]) : r[field]; - if (got === null || got === undefined || (time && !Number.isFinite(got))) - return false; - if (time && (op === 'eq' || op === 'neq')) - return (got === value) === (op === 'eq'); - switch (op) { - case 'gt': - return Number(got) > Number(value); - case 'gte': - return Number(got) >= Number(value); - case 'lt': - return Number(got) < Number(value); - case 'lte': - return Number(got) <= Number(value); - case 'eq': { - if (typeof got === 'boolean' || typeof value === 'boolean') - return Boolean(got) === Boolean(value); - return String(got).toLowerCase() === String(value).toLowerCase(); - } - case 'neq': - return String(got).toLowerCase() !== String(value).toLowerCase(); - case 'contains': - return String(got).toLowerCase().includes(String(value).toLowerCase()); - default: - return false; - } - }); -} - /** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */ export function applyScope(records, scope, resolved) { if (!scope || scope.kind === 'anywhere') return records; @@ -167,28 +129,6 @@ export function applyScope(records, scope, resolved) { return records; } -/** Numeric summary for the narration layer. */ -function summarize(items, sortField) { - const summary = { count: items.length }; - if (sortField && items.length) { - // A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past - // the engine's argument limit. - let min = Infinity; - let max = -Infinity; - for (const item of items) { - const value = Number(item[sortField]); - if (!Number.isFinite(value)) continue; - if (value < min) min = value; - if (value > max) max = value; - } - if (Number.isFinite(min)) { - summary[`${sortField}Min`] = min; - summary[`${sortField}Max`] = max; - } - } - return summary; -} - const OPS_BY_TYPE = Object.freeze({ number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'], time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'], @@ -213,7 +153,7 @@ const COMMON_UNITS = Object.freeze({ * @param {string} field Field name. * @returns {'number'|'text'|'flag'|'time'|null} Field type. */ -export function analystFieldType(layerKeys, field) { +function analystFieldType(layerKeys, field) { if (!analystFieldsFor(layerKeys).includes(field)) return null; if (COMMON_TYPES[field]) return COMMON_TYPES[field]; for (const key of layerKeys) { diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index f3917c463..369465680 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -14,7 +14,7 @@ import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js'; import { t, msg, getLocale } from '../i18n/index.js'; -export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; +const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID = 'bhote-koshi-locator-callout'; export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context'; @@ -35,7 +35,6 @@ const BORDER_START_DELAY_MS = 700; const INCIDENT_CALLOUT_GAP_MS = 200; const CALLOUT_REVEAL_DURATION_MS = 1200; const CALLOUT_PULSE_DURATION_MS = 1800; -const CALLOUT_DOT_SIZE_PX = 7; const CALLOUT_RING_MIN_SIZE_PX = 16; const CALLOUT_RING_SIZE_RANGE_PX = 14; const CITY_CALLOUT_START_DELAY_MS = 350; @@ -1248,6 +1247,3 @@ export function createBhoteKoshiLocatorLayer({ }, }; } - -const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer(); -export default bhoteKoshiLocatorLayer; diff --git a/src/data/bikeshare.js b/src/data/bikeshare.js index 15c9dc802..6370c790e 100644 --- a/src/data/bikeshare.js +++ b/src/data/bikeshare.js @@ -23,4 +23,3 @@ export { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, } from '../layers/bikeshare/index.js'; -export default layer; diff --git a/src/data/cctvFootprint.js b/src/data/cctvFootprint.js index 42a3ad424..93f2b48ed 100644 --- a/src/data/cctvFootprint.js +++ b/src/data/cctvFootprint.js @@ -17,7 +17,7 @@ */ const EARTH_RADIUS_M = 6371000; -export const PLANE_VERT_ASPECT = 16 / 9; +const PLANE_VERT_ASPECT = 16 / 9; export const SUPPORT_KEYS = Object.freeze([ 'bl', 'bm', @@ -128,7 +128,7 @@ export function planeSupportPoints(pose) { * @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims * @returns {number} */ -export function supportAltitude(capAltM, row, dims) { +function supportAltitude(capAltM, row, dims) { return capAltM + row * dims.upVert; } diff --git a/src/data/cctvGizmo.js b/src/data/cctvGizmo.js index 6e7e18bcb..fa15c5532 100644 --- a/src/data/cctvGizmo.js +++ b/src/data/cctvGizmo.js @@ -316,7 +316,7 @@ export function createCalibrationGizmo({ } const mount = positions.mount; const axes = enuAxes(mount); - const { forwardHoriz, right, view } = viewAxesFor( + const { forwardHoriz } = viewAxesFor( camera.headingDeg, camera.pitchDeg, axes, diff --git a/src/data/cctvLod.js b/src/data/cctvLod.js index 44b2e8ba3..834dd964f 100644 --- a/src/data/cctvLod.js +++ b/src/data/cctvLod.js @@ -94,7 +94,7 @@ export function incumbentRankKm( export const CCTV_CARD_CENTER_WEIGHT = 0.5; /** Robust pool-distance percentile used to scale the screen-space term. */ -export const CCTV_CARD_SPREAD_PERCENTILE = 0.9; +const CCTV_CARD_SPREAD_PERCENTILE = 0.9; /** True only when both viewport dimensions are finite positive pixels. */ export function hasFiniteCctvViewport(viewW, viewH) { @@ -172,8 +172,8 @@ export function blendCenterRankKm( // viewport is bucketed into this grid and every occupied cell gets its best // candidate before global rank fills the rest. Owner-tunable together with // the budgets above. -export const CCTV_CARD_GRID_COLS = 5; -export const CCTV_CARD_GRID_ROWS = 4; +const CCTV_CARD_GRID_COLS = 5; +const CCTV_CARD_GRID_ROWS = 4; /** * Screen-space distribution pass (owner round 2, item C). Buckets the diff --git a/src/data/cockpitDetectionSubjects.test.mjs b/src/data/cockpitDetectionSubjects.test.mjs index dfe6baee9..8421267fc 100644 --- a/src/data/cockpitDetectionSubjects.test.mjs +++ b/src/data/cockpitDetectionSubjects.test.mjs @@ -2,7 +2,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import * as Cesium from 'cesium'; import flightsLayer, { _setCockpitDetectionSubjectForTest as setFlightsCockpitSubject, diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 36f8b24d3..8177b7414 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -1,7 +1,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; /** * Cockpit 3D aircraft policy. diff --git a/src/data/contactPlayback.js b/src/data/contactPlayback.js index 2aa1c9372..14b6b0593 100644 --- a/src/data/contactPlayback.js +++ b/src/data/contactPlayback.js @@ -181,7 +181,7 @@ export function trimTrack(track, nowMs, keep = track.capacity) { for (const id of track.epochs.keys()) if (!used.has(id)) track.epochs.delete(id); } -export function validFix(fix) { +function validFix(fix) { return ( Number.isFinite(fix?.t) && fix.t >= 0 && diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 12dd7682a..aff87aeb7 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -25,11 +25,11 @@ import * as Cesium from 'cesium'; * follows DATA_SOURCES.md (live sources, then bundled snapshots). * @type {{ key: string, html: string }[]} */ -export const OSM_CREDIT = { +const OSM_CREDIT = { key: 'openstreetmap', html: 'Map and place data © OpenStreetMap contributors (ODbL)', }; -export const OPENMAPTILES_CREDIT = { +const OPENMAPTILES_CREDIT = { key: 'openfreemap', html: 'Vector tiles: OpenFreeMap © OpenMapTiles', }; diff --git a/src/data/detection.js b/src/data/detection.js index 5c5e90396..c2e63c3d9 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -43,7 +43,6 @@ import { detectionHorizontalSector, labelBudgetFor, normalizeAllocationStrategy, - normalizeProfile, profileForDensity, viewScaleForAltitude, } from './detectionPolicy.js'; diff --git a/src/data/detectionCohort.js b/src/data/detectionCohort.js index ed9f46c52..95cda50f0 100644 --- a/src/data/detectionCohort.js +++ b/src/data/detectionCohort.js @@ -18,7 +18,7 @@ export function stableIdentityHash(layerId, sourceId) { } /** Negative means a is a better deterministic contender than b. */ -export function compareCohortContenders(a, b) { +function compareCohortContenders(a, b) { const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0); if (priorityDelta) return priorityDelta; diff --git a/src/data/detectionPolicy.js b/src/data/detectionPolicy.js index bf47785fd..0105d3beb 100644 --- a/src/data/detectionPolicy.js +++ b/src/data/detectionPolicy.js @@ -5,7 +5,6 @@ * module so density cannot drift into contradictory mode semantics again. */ -export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]); export const ALLOCATION_ELASTIC = 'ELASTIC'; export const ALLOCATION_WEIGHTED = 'WEIGHTED'; export const ALLOCATION_STRATEGIES = Object.freeze([ @@ -118,7 +117,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) { return 'front'; } -export const VIEW_SCALE_BUDGETS = Object.freeze({ +const VIEW_SCALE_BUDGETS = Object.freeze({ street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }), city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }), metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }), diff --git a/src/data/directions.js b/src/data/directions.js index ef5565488..8a871bfbf 100644 --- a/src/data/directions.js +++ b/src/data/directions.js @@ -24,7 +24,7 @@ import * as input from './inputOwnership.js'; import { createDirectionsLayer } from '../layers/directions/index.js'; /** The shared scene owners every Directions instance runs on. */ -export const directionsServices = Object.freeze({ +const directionsServices = Object.freeze({ credits, render, sprites, @@ -49,15 +49,11 @@ export { DIRECTIONS_MODES, DIRECTIONS_POINTER_OWNER, DIRECTIONS_ROUTE_COLOR, - DIRECTIONS_STEP_OVERLAY_SOURCE_ID, - DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS, - FLIGHT_PROGRESS_MS, POINTER_TOOL_EXITS, STEP_ANCHOR_DEADLINE_MS, STEP_ANCHOR_FAST_ATTEMPTS, STEP_ANCHOR_RETRY_MS, STEP_ANCHOR_SLOW_RETRY_MS, - createDirectionsLayer, createDirectionsStepOverlayEntry, directionsRequestUrl, directionsRowControls, diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index f003bf712..aac775d92 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -8,4 +8,3 @@ export function createEarthquakesLayer({ } = {}) { return createApplicationEarthquakes({ source, ...options }); } -export default createEarthquakesLayer(); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 312838e80..18d9fc110 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) { const cached = cache.get(key); if (cached !== undefined) return cached; + // Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February + // into March, and the fire's age would describe a detection that never happened. + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + cache.set(key, 0); + return 0; + } + const timeText = String(time ?? '').trim(); + if (!/^\d{1,4}$/.test(timeText)) { + cache.set(key, 0); + return 0; + } + const hhmm = timeText.padStart(4, '0'); const year = Number(date.slice(0, 4)); const month = Number(date.slice(5, 7)); const day = Number(date.slice(8, 10)); - const hhmm = String(time ?? '0000').padStart(4, '0'); const hours = Number(hhmm.slice(0, 2)); const minutes = Number(hhmm.slice(2, 4)); - const valid = - Number.isFinite(year) && - Number.isFinite(month) && - Number.isFinite(day) && - Number.isFinite(hours) && - Number.isFinite(minutes); - const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0; - cache.set(key, ms); - return ms; + if ( + month < 1 || + month > 12 || + day < 1 || + day > 31 || + hours > 23 || + minutes > 59 + ) { + cache.set(key, 0); + return 0; + } + const ms = Date.UTC(year, month - 1, day, hours, minutes); + const parsed = new Date(ms); + const result = + parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day + ? ms + : 0; + cache.set(key, result); + return result; } /** diff --git a/src/data/firmsAdapt.test.mjs b/src/data/firmsAdapt.test.mjs index 0e1e56dd6..fa5de9e69 100644 --- a/src/data/firmsAdapt.test.mjs +++ b/src/data/firmsAdapt.test.mjs @@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => { test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => { const cache = new Map(); - assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6)); + assert.equal( + parseAcquisitionMs('2026-07-16', '1006', cache), + Date.UTC(2026, 6, 16, 10, 6), + ); assert.equal(cache.size, 1); - assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6)); + assert.equal( + parseAcquisitionMs('2026-07-16', '1006', cache), + Date.UTC(2026, 6, 16, 10, 6), + ); assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0); }); +test('impossible acquisition dates stay untimed instead of rolling forward', () => { + for (const [date, time] of [ + ['2026-02-30', '1200'], + ['2026-04-31', '1200'], + ['2026-13-01', '1200'], + ['2026-07-16', '2400'], + ['2026-07-16', '1260'], + ]) { + assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`); + const [fire] = adaptFirmsRecords([ + proxyRecord({ acqDate: date, acqTime: time }), + ]); + assert.equal(fire.acqMs, 0, `${date}:${time}`); + assert.equal(fire.lat, 38.99488); + } + const [leap] = adaptFirmsRecords([ + proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }), + ]); + assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45)); +}); + test('daynight "D" → night false', () => { const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]); assert.equal(fire.night, false); @@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () = proxyRecord({ lat: 40.1 }), ]); assert.equal(fires.length, 2); - assert.deepEqual(fires.map((f) => f.index), [0, 1]); + assert.deepEqual( + fires.map((f) => f.index), + [0, 1], + ); }); test('non-finite frp/brightness → 0; empty input → []', () => { - const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]); + const [fire] = adaptFirmsRecords([ + proxyRecord({ frp: 'n/a', brightness: undefined }), + ]); assert.equal(fire.frp, 0); assert.equal(fire.brightness, 0); assert.deepEqual(adaptFirmsRecords([]), []); diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index fc6a5e079..6f5f2e653 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -5,9 +5,9 @@ import { createImportedPins } from './geoImportPins.js'; import { msg } from '../i18n/marker.js'; /** Largest file accepted, in bytes. */ -export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; +const MAX_IMPORT_BYTES = 50 * 1024 * 1024; /** Largest total a KMZ may inflate to — a zip bomb stops here. */ -export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024; +const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024; /** Most files on the globe at once. */ export const MAX_IMPORTED_FILES = 12; /** Most entities one file may create before it is refused. */ @@ -15,9 +15,9 @@ export const MAX_IMPORT_ENTITIES = 20_000; /** Most vertices (points of every line, ring and marker) in one file. */ export const MAX_IMPORT_POSITIONS = 250_000; /** Most normalized style definitions and local image links in one KML/KMZ. */ -export const MAX_KML_LINKS = 2_000; +const MAX_KML_LINKS = 2_000; /** Longest plain-text name kept from a file's features. */ -export const MAX_IMPORT_NAME_LENGTH = 120; +const MAX_IMPORT_NAME_LENGTH = 120; /** What the file picker offers. */ export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx'; @@ -1040,26 +1040,103 @@ export function detachImportedTime(dataSource) { return dataSource; } +/** Repair only the first KML opening tag, scanning its delimiter once. */ +export function repairKmlRootNamespaces(value, what = 'KML') { + const text = String(value); + // Skip XML comments and processing instructions without treating their + // literal '', end + 4); + if (skip < 0) return text; + end = skip + 2; + } else if (char === '<' && text.startsWith('', end + 2); + if (skip < 0) return text; + end = skip + 1; + } else if (char === '[') subsetDepth++; + else if (char === ']') subsetDepth--; + else if (char === '>' && subsetDepth === 0) break; + } + if (end === text.length) return text; + start = end + 1; + continue; + } + const close = text.startsWith('' + : text.startsWith('' + : null; + if (!close) break; + const end = text.indexOf(close, start + 2); + if (end < 0) return text; // The XML parser reports an unterminated preamble. + start = end + close.length; + } + const match = /^') { + end = index + 1; + break; + } + } + if (end < 0) + throw new GeoImportError( + `That ${what} file is not valid XML.`, + msg('That {format} file is not valid XML.'), + { format: what }, + ); + // Walk the root's attributes in order so quoted values are never read as + // declarations. Anything the walk cannot tokenize is kept verbatim for the + // XML parser to judge. + const seen = new Set(); + const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y; + let position = start + match[0].length; + let root = text.slice(start, position); + for (;;) { + attribute.lastIndex = position; + const found = attribute.exec(text); + if (!found || attribute.lastIndex > end) break; + position = attribute.lastIndex; + const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1]; + if (prefix && seen.has(prefix)) continue; + if (prefix) seen.add(prefix); + root += found[0]; + } + root += text.slice(position, end); + if (!seen.has('xsi') && /[<\s]xsi:/.test(text)) + root = root.replace( + /(\/?)>$/, + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>', + ); + return text.slice(0, start) + root + text.slice(end); +} + function parseXml(text, what) { // Match the namespace repairs Cesium applies to ordinary exported KML. - if (what !== 'GPX') - text = String(text).replace(/]*>/i, (root) => { - const seen = new Set(); - root = root.replace( - /\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g, - (declaration, prefix) => { - if (seen.has(prefix)) return ''; - seen.add(prefix); - return declaration; - }, - ); - if (!seen.has('xsi') && /[<\s]xsi:/.test(text)) - root = root.replace( - />$/, - ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">', - ); - return root; - }); + if (what !== 'GPX') text = repairKmlRootNamespaces(text, what); const doc = new DOMParser().parseFromString( String(text).replace(/^/, ''), 'application/xml', diff --git a/src/data/geoFileImport.test.mjs b/src/data/geoFileImport.test.mjs index af02ec222..9b147e1d2 100644 --- a/src/data/geoFileImport.test.mjs +++ b/src/data/geoFileImport.test.mjs @@ -1,9 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { deflateRawSync } from 'node:zlib'; +import { spawnSync } from 'node:child_process'; import * as Cesium from 'cesium'; import { GeoImportError, + repairKmlRootNamespaces, loadGeoFile, MAX_IMPORT_POSITIONS, MAX_IMPORT_ENTITIES, @@ -1171,3 +1173,92 @@ test('a dated import exposes no clock and draws for all time', () => { assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2); assert.ok(track.polyline.clampToGround.getValue()); }); + +test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => { + const text = + ''; + const result = repairKmlRootNamespaces(text); + assert.match(result, /note="a > b"/); + assert.match(result, /xmlns:x="first"/); + assert.doesNotMatch(result, /second/); + assert.match( + result, + /xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/, + ); + assert.ok(result.endsWith('')); + assert.match( + repairKmlRootNamespaces(''), + /XMLSchema-instance"\/>$/, + ); + for (const malformed of ['")}.repeat(100000) + ']>'; + if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`; + const child = spawnSync( + process.execPath, + ['--input-type=module', '-e', source], + { timeout: 5000, encoding: 'utf8' }, + ); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); +}); + +test('KML root repair ignores incomplete tag text inside preamble comments', () => { + const input = + '\n'; + assert.equal( + repairKmlRootNamespaces(input), + input.replace(' xmlns:gx="b"', ''), + ); +}); + +test('KML namespace repair follows a doctype with a quoted root-like entity', () => { + const input = + '">]>'; + assert.equal( + repairKmlRootNamespaces(input), + input.replace(' xmlns:gx="b"', ''), + ); +}); + +test('KML namespace repair skips doctype comments and processing instructions whole', () => { + for (const prolog of [ + "]>", + ' here -->]>', + " fine?>]>", + '">]>', + ]) { + const input = `${prolog}`; + assert.equal( + repairKmlRootNamespaces(input), + input.replace( + ' xmlns:gx="b">', + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">', + ), + prolog, + ); + } + const unterminated = ''; + assert.equal(repairKmlRootNamespaces(unterminated), unterminated); +}); + +test('KML namespace repair ignores declaration text inside quoted attribute values', () => { + const input = + ' b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup">'; + const result = repairKmlRootNamespaces(input); + assert.equal(result, input.replace(' xmlns:gx="dup"', '')); + assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/); + const xsi = ''; + assert.match( + repairKmlRootNamespaces(xsi), + /note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/, + ); +}); diff --git a/src/data/geoImportImages.js b/src/data/geoImportImages.js index 86f8e98e2..71133df26 100644 --- a/src/data/geoImportImages.js +++ b/src/data/geoImportImages.js @@ -1,5 +1,5 @@ // Inspect encoded headers before allowing the browser to decode local images. -export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024; +const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024; const MAX_SIDE = 8192; /** Width and height from a supported raster header; no image is decoded. */ diff --git a/src/data/gtfsRealtime.js b/src/data/gtfsRealtime.js index 96d6b28a5..1fe888d4a 100644 --- a/src/data/gtfsRealtime.js +++ b/src/data/gtfsRealtime.js @@ -35,14 +35,14 @@ import { PbfReader } from 'pbf'; /** VehiclePosition.VehicleStopStatus enum → label. */ -export const VEHICLE_STOP_STATUS = Object.freeze({ +const VEHICLE_STOP_STATUS = Object.freeze({ 0: 'INCOMING_AT', 1: 'STOPPED_AT', 2: 'IN_TRANSIT_TO', }); /** VehiclePosition.OccupancyStatus enum → label. */ -export const OCCUPANCY_STATUS = Object.freeze({ +const OCCUPANCY_STATUS = Object.freeze({ 0: 'EMPTY', 1: 'MANY_SEATS_AVAILABLE', 2: 'FEW_SEATS_AVAILABLE', @@ -64,7 +64,6 @@ export const GTFS_MAX_ENTITIES = 50_000; export const GTFS_MAX_STRING_CHARS = 256; /** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */ export const GTFS_INCREMENTALITY_FULL_DATASET = 0; -export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1; /** * Read a length-delimited string, refusing one that is implausibly long. diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 9b20f3ad4..718ef7068 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => { 'src/layers/submarineCables/interaction.js', 'viewer.scene.pick(click.position)', ], - ['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'], + [ + 'src/layers/vessels/selection.js', + 'pickContact(viewer.scene, click.position)', + ], ]; for (const [file, firstPick] of guarded) { const source = read(file); diff --git a/src/data/layerState.js b/src/data/layerState.js index dd79eb12a..d88235188 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -157,26 +157,6 @@ function trackingIdOption(key, token, defaultValue = null) { }); } -function stringOption(key, token, defaultValue) { - return Object.freeze({ - key, - token, - defaultValue, - normalize: (value) => { - if (typeof value === 'number' && Number.isFinite(value)) - return String(value).trim().toLowerCase(); - if (typeof value !== 'string') return null; - const normalized = value.trim().toLowerCase(); - return normalized ? normalized : null; - }, - encode: (value) => String(value), - decode: (value) => - typeof value === 'string' && value.trim() - ? value.trim().toLowerCase() - : null, - }); -} - function enumOption( key, token, diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js index 8871cfe39..61f6c3abf 100644 --- a/src/data/localGeojson.js +++ b/src/data/localGeojson.js @@ -1,5 +1,4 @@ import { localGeoJsonServices } from '../app/localGeojsonServices.js'; -export { localGeoJsonServices } from '../app/localGeojsonServices.js'; import { createLocalGeoJsonLayer as createLayer, createLocalInfrastructureOverlayPublisher as createPublisher, diff --git a/src/data/localLayers.js b/src/data/localLayers.js deleted file mode 100644 index 63c07b073..000000000 --- a/src/data/localLayers.js +++ /dev/null @@ -1,18 +0,0 @@ -import { localGeoJsonServices } from './localGeojson.js'; -import { createInfrastructureLayers } from './infrastructure.js'; -import { createFirmsHeatmapLayer } from './firmsHeatmap.js'; -import submarineCablesLayer from './telegeographySubmarineCables.js'; - -const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices); - -// Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps -// the historical `local-` prefix for persistence + voice-tool-enum compat, -// but the data is NOT bundled anymore — it needs FIRMS_MAP_KEY server-side. -const fires = createFirmsHeatmapLayer({ - id: 'local-firms', - name: 'FIRMS Active Fires', - icon: '▲', - source: 'NASA FIRMS · LIVE', -}); - -export default [datacenters, dams, submarineCablesLayer, fires]; diff --git a/src/data/local_data/dams/README.md b/src/data/local_data/dams/README.md index 2be11ecc7..8bff208e2 100644 --- a/src/data/local_data/dams/README.md +++ b/src/data/local_data/dams/README.md @@ -14,5 +14,4 @@ this derived database. The public-release snapshot removes contact-oriented fields and note values that contain email or phone identifiers. The runtime layer does not display or -depend on those fields. Both `dams.geojson` and the runtime JSONL form carry the -same privacy transform. +depend on those fields. diff --git a/src/data/local_data/dams/dams.geojson b/src/data/local_data/dams/dams.geojson deleted file mode 100644 index 56d5060d6..000000000 --- a/src/data/local_data/dams/dams.geojson +++ /dev/null @@ -1 +0,0 @@ -{"type":"FeatureCollection","features":[{"id":-19953989,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.292076717,46.16897504],[14.292076711,46.168976415],[14.292080818,46.169315044],[14.292082791,46.169327497],[14.292088326,46.169339424],[14.292097199,46.169350341],[14.292109051,46.169359805],[14.2921234,46.169367433],[14.292139665,46.169372916],[14.292454909,46.16945313],[14.292472725,46.169456276],[14.292491094,46.169456843],[14.292509244,46.169454808],[14.293490178,46.169271915],[14.293505685,46.169267932],[14.293519874,46.169262048],[14.293532266,46.169254461],[14.293542439,46.16924543],[14.293550049,46.169235261],[14.293554837,46.169224298],[14.293556643,46.169212912],[14.293555403,46.16920149],[14.293552972,46.169191516],[14.293548058,46.169179216],[14.293539623,46.169167869],[14.293528021,46.169157953],[14.293513742,46.169149885],[14.293497386,46.169144005],[14.293479642,46.16914056],[14.29260928,46.169036741],[14.292221224,46.168859348],[14.292205414,46.168853594],[14.292188274,46.16885012],[14.29217048,46.168849063],[14.292152735,46.168850464],[14.292135742,46.168854269],[14.292120171,46.168860327],[14.292106638,46.168868398],[14.292095679,46.168878164],[14.292087726,46.168889239],[14.292083093,46.168901184],[14.292078818,46.168919205],[14.292077639,46.168928437],[14.292076717,46.16897504]]]},"properties":{"name":"HE Podlubnik - Centrala","tags":{"name":"HE Podlubnik - Centrala","type":"site","power":"plant","alt_name":"HE Škofja Loka","operator":"Elektro Gorenjska","waterway":"dam","start_date":"1969","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"330KW"},"osm_id":-19953989,"output":"330KW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19754263,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[69.715342348,34.641127032],[69.71550043,34.641135916],[69.715570922,34.64110658],[69.717521056,34.641462978],[69.71812514,34.641577224],[69.718373076,34.641638579],[69.718438371,34.641451327],[69.718438036,34.641377064],[69.718220274,34.641368766],[69.718144586,34.64121412],[69.718014247,34.640948246],[69.717587357,34.640673654],[69.71740899,34.64063493],[69.717223833,34.640595284],[69.716953182,34.640548261],[69.716766517,34.640531246],[69.716362928,34.640542142],[69.716212976,34.640950425],[69.715856829,34.640920669],[69.715476123,34.640943636],[69.715342348,34.641127032]]]},"properties":{"name":"نغلو برېښناکوټ","tags":{"name":"نغلو برېښناکوټ","type":"multipolygon","power":"plant","name:de":"Naghlu-Staudamm","name:en":"Naghlu Dam","name:ja":"ナグルーダム","name:no":"Naghludemningen","name:ru":"ГЭС Наглу","name:uk":"ГЕС Наглу","waterway":"dam","wikidata":"Q4755783","wikipedia":"en:Naghlu Dam","start_date":"1967","plant:source":"hydro","plant:output:electricity":"100 MW"},"osm_id":-19754263,"output":"100 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19685440,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-54.597119846,-25.41383667],[-54.597234845,-25.413703733],[-54.597107524,-25.413586135],[-54.59730056,-25.412625569],[-54.597341379,-25.412122152],[-54.597361664,-25.411634241],[-54.597330064,-25.410920019],[-54.597191679,-25.410193224],[-54.597057568,-25.409737668],[-54.59698146,-25.409498365],[-54.596806446,-25.409085472],[-54.596524311,-25.408510306],[-54.596161124,-25.407928853],[-54.59586826,-25.40753591],[-54.595651001,-25.407280513],[-54.595510101,-25.407146654],[-54.595350007,-25.407013549],[-54.595191002,-25.406910368],[-54.595019173,-25.406815317],[-54.594639221,-25.40665405],[-54.594135721,-25.406544833],[-54.593702209,-25.406530333],[-54.593262159,-25.406579954],[-54.592841471,-25.406631754],[-54.592785815,-25.406430001],[-54.588964338,-25.407187725],[-54.584364349,-25.408041757],[-54.584397877,-25.408225908],[-54.58083188,-25.40887408],[-54.580662817,-25.408794033],[-54.578582764,-25.409343216],[-54.577176783,-25.409921902],[-54.574835215,-25.411356968],[-54.573420266,-25.412660605],[-54.56995393,-25.41756251],[-54.565822741,-25.423818762],[-54.564863516,-25.426166114],[-54.561372454,-25.436599237],[-54.55977612,-25.441166117],[-54.559956834,-25.441255635],[-54.560411552,-25.441039718],[-54.561530369,-25.438307217],[-54.56325352,-25.43362584],[-54.564224731,-25.430147434],[-54.564559924,-25.42936314],[-54.564880364,-25.429084861],[-54.565817042,-25.428049779],[-54.566482565,-25.427226006],[-54.567271218,-25.426135353],[-54.568109324,-25.425411827],[-54.569107525,-25.423653136],[-54.568448121,-25.422704724],[-54.568378384,-25.422578744],[-54.568555074,-25.42190861],[-54.568801502,-25.421307544],[-54.569317157,-25.42054873],[-54.570266659,-25.419124226],[-54.57099622,-25.417694944],[-54.571731145,-25.416653241],[-54.572525079,-25.415601815],[-54.573302919,-25.414637645],[-54.573759146,-25.414053929],[-54.574652741,-25.413023961],[-54.574928338,-25.412760769],[-54.575345505,-25.412317702],[-54.57570995,-25.412033555],[-54.575906506,-25.411902881],[-54.576974612,-25.411197125],[-54.577657653,-25.410793117],[-54.57843826,-25.410462619],[-54.579125325,-25.410282743],[-54.579845665,-25.410085852],[-54.580454862,-25.410050229],[-54.581418948,-25.409637504],[-54.581343679,-25.409208183],[-54.581666801,-25.40912252],[-54.582468027,-25.408903501],[-54.583425325,-25.408732259],[-54.583585754,-25.408733684],[-54.583657671,-25.409021602],[-54.584573729,-25.408797721],[-54.584584961,-25.408839966],[-54.584761903,-25.409508507],[-54.58483097,-25.409769268],[-54.586456472,-25.40951605],[-54.587177148,-25.409355202],[-54.589366669,-25.408905513],[-54.592901821,-25.408175365],[-54.592931157,-25.407262743],[-54.592885224,-25.406935346],[-54.59340222,-25.406856724],[-54.593676979,-25.406826717],[-54.593968418,-25.406820682],[-54.594261701,-25.406872733],[-54.594534112,-25.406940124],[-54.594798142,-25.407044814],[-54.594932253,-25.407127124],[-54.595070722,-25.407215637],[-54.595385546,-25.407493078],[-54.595655024,-25.407795749],[-54.596091302,-25.408433863],[-54.596364552,-25.408895873],[-54.596592708,-25.409375402],[-54.596837543,-25.410112674],[-54.59699747,-25.410878026],[-54.597020017,-25.411535167],[-54.597024879,-25.412155344],[-54.596981628,-25.412662701],[-54.596755903,-25.413699123],[-54.597119846,-25.41383667]]]},"properties":{"name":"Usina Hidrelétrica de Itaipu","tags":{"name":"Usina Hidrelétrica de Itaipu","type":"multipolygon","image":"https://photos.app.goo.gl/Yr2qcbVwyNvDFsFE7","power":"plant","GNS:id":"-911654","name:en":"Itaipu Dam","name:es":"Itaipú","name:gn":"Itaipu","name:hu":"Itaipu-gát","name:pt":"Usina Hidrelétrica de Itaipu","name:zh":"伊泰普水电站","voltage":"500000","operator":"Itaipú Binacional","waterway":"dam","wikidata":"Q244169","architect":"Comisión Técnica Mixta Brasil-Paraguay","frequency":"50;60","wikipedia":"es:Represa de Itaipú","importance":"international","plant:method":"water-storage","plant:source":"hydro","official_name":"Usina Hidrelétrica Itaipu Binacional","operator:wikidata":"Q99520944","wikimedia_commons":"Category:Itaipu Dam","architect:wikidata":"Q57978739","plant:output:electricity":"14000 MW"},"osm_id":-19685440,"output":"14000 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19293321,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[106.267443767,22.654407361],[106.26745919,22.654465364],[106.26749255,22.654591176],[106.267507134,22.654645826],[106.267523898,22.65470802],[106.267539991,22.654769375],[106.267553319,22.654819583],[106.267566394,22.654868449],[106.267576956,22.654908347],[106.26759263,22.654967523],[106.267691536,22.654943048],[106.267683993,22.654911365],[106.267755993,22.654893595],[106.267752976,22.654878172],[106.267679634,22.654894769],[106.267676616,22.654883034],[106.267653985,22.654790498],[106.267650046,22.654774488],[106.267726321,22.654756886],[106.267721795,22.654742972],[106.267646274,22.65476049],[106.267641329,22.654740877],[106.267610064,22.654615986],[106.26760537,22.654596959],[106.26768592,22.654577597],[106.267681478,22.654563348],[106.267600844,22.654583129],[106.267596988,22.654564773],[106.267567149,22.654435524],[106.267563126,22.654418425],[106.267647783,22.654396967],[106.267645184,22.654380287],[106.267558683,22.654401326],[106.2675529,22.654377186],[106.267443767,22.654407361]]]},"properties":{"name":"Đập giữ nước thành phố Cao Bằng","tags":{"name":"Đập giữ nước thành phố Cao Bằng","type":"multipolygon","layer":"3","power":"plant","building":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":-19293321,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19054933,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[44.826032731,41.682772521],[44.826032898,41.682786854],[44.826042789,41.682809653],[44.826066929,41.682830189],[44.826120489,41.682840917],[44.826131972,41.682903279],[44.826143372,41.682964718],[44.826111269,41.682972346],[44.826086542,41.682989696],[44.826075478,41.683008723],[44.82607397,41.683026744],[44.826079166,41.683042837],[44.826092242,41.683058763],[44.826123674,41.683074856],[44.826164075,41.683077371],[44.826187544,41.683204357],[44.826159884,41.683210727],[44.826128368,41.683234867],[44.826121495,41.683250541],[44.826121327,41.68326873],[44.826128955,41.683285829],[44.826140354,41.683297983],[44.826170026,41.683312567],[44.82620808,41.683315082],[44.826232723,41.683464699],[44.826246134,41.683528317],[44.82626625,41.683623368],[44.82662508,41.68359202],[44.82664419,41.683590092],[44.826761621,41.683577854],[44.82686388,41.683567293],[44.826878129,41.683565784],[44.826896234,41.683562348],[44.826863377,41.683388172],[44.826845356,41.683389345],[44.826807721,41.683187341],[44.826760699,41.682934627],[44.826739744,41.682822226],[44.826716359,41.68269633],[44.82668258,41.682515029],[44.826666486,41.68242836],[44.82664528,41.682430539],[44.826549391,41.682440598],[44.826422573,41.682453673],[44.826413018,41.682454679],[44.826395751,41.682443028],[44.826106659,41.682475131],[44.826060475,41.682521818],[44.826075143,41.682595747],[44.826097774,41.682718039],[44.826080759,41.682722565],[44.826052931,41.682738155],[44.826038179,41.682756344],[44.826032731,41.682772521]],[[44.826377394,41.683056751],[44.826467668,41.683046525],[44.826493232,41.683172841],[44.826402959,41.68318315],[44.826377394,41.683056751]],[[44.826342023,41.682813425],[44.82643238,41.682803283],[44.826457945,41.682929682],[44.826367671,41.682939824],[44.826342023,41.682813425]],[[44.826290893,41.682574038],[44.826381166,41.682563895],[44.826406731,41.682690127],[44.826316458,41.682700353],[44.826290893,41.682574038]]]},"properties":{"name":"ორთაჭალის ჰესი","tags":{"name":"ორთაჭალის ჰესი","type":"multipolygon","image":"https://epggen.ge/wp-content/uploads/2023/01/2-9.jpg","power":"plant","name:ar":"محطة أورتاتشالا للطاقة الكهرومائية","name:az":"Ortaçala Su Elektrik Stansiyası","name:de":"Wasserkraftwerk Ortachala","name:en":"Ortachala HPP","name:es":"Central hidroeléctrica de Ortachala","name:fa":"نیروگاه برق آبی اورتاچالا","name:fr":"Centrale hydroélectrique d'Ortachala","name:he":"תחנת הכוח ההידרואלקטרית אורטצ'לה","name:hi":"ओरटाचाला जलविद्युत पावर प्लांट","name:hy":"Օրթաչալայի ՀԷԿ","name:it":"Centrale idroelettrica di Ortachala","name:ja":"オルタチャラ水力発電所","name:ka":"ორთაჭალის ჰესი","name:ko":"오르타찰라 수력 발전소","name:ml":"ഓർട്ടച്ചാല ജലവൈദ്യുതനിലയം","name:nl":"Ortachala Waterkrachtcentrale","name:pl":"Elektrownia wodna Ortachala","name:pt":"Central Hidroelétrica de Ortachala","name:ru":"Ортачальская ГЭС","name:tr":"Ortachala Hidroelektrik Santrali","name:uk":"Ортачальська ГЕС","operator":"სს ,,ეპ ჯორჯია გენერაცია“","waterway":"dam","wikidata":"Q4337097","wikipedia":"ka:ორთაჭალის ჰიდროელექტროსადგური","plant:type":"pier-type","start_date":"1954","operator:en":"JSC “EP Georgia Generation”","plant:method":"run-of-the-river","plant:source":"hydro","wikipedia:ru":"Ортачальская ГЭС","official_name":"ორთაჭალის ჰიდროელექტროსადგური","operator:type":"private","description:ar":"محطة كهرومائية فريدة من نوعها على شكل أ piers، مع محطات توليد الكهرباء مدمجة في أ piers الموسعة لسد الفائض. المثال الوحيد لهذا التصميم في منطقة ما بعد الاتحاد السوفيتي.","description:az":"Daşqın bəndinin genişləndirilmiş dayaqlarına inteqrasiya olunmuş elektrik stansiyaları ilə unikal körpü tipli su elektrik stansiyası. Postsovet məkanında bu dizaynın yeganə nümunəsidir.","description:de":"Einzigartiges Pfeilerkraftwerk mit in die verbreiterten Pfeiler des Überlaufdamms integrierten Kraftwerken. Das einzige Beispiel für diese Bauweise im postsowjetischen Raum.","description:en":"Unique pier power plant with powerhouses integrated into the spillway dam's widened piers. The only example of this design in the post-Soviet space.","description:es":"Central hidroeléctrica única de tipo pilar, con centrales eléctricas integradas en los pilares ensanchados de la presa aliviadero. El único ejemplo de este diseño en el espacio postsoviético.","description:fa":"نیروگاه برق آبی منحصر به فرد از نوع اسکله، با نیروگاه های برق ادغام شده در پایه های عریض سد سرریز. تنها نمونه از این طرح در فضای پس از شوروی.","description:fr":"Unique centrale hydroélectrique de type à piles, avec des centrales électriques intégrées dans les piles élargis du barrage-déversoir. Le seul exemple de cette conception dans l'espace post-soviétique.","description:he":"תחנת כוח הידרואלקטרית ייחודית מסוג מזח, עם תחנות כוח משולבות בעמודים המורחבים של סכר גלישה. הדוגמה היחידה לעיצוב זה במרחב הפוסט-סובייטי.","description:hi":"अनोखे पियर-प्रकार के जलविद्युत स्टेशन, जिसमें स्पिलवे बांध के चौड़े पियर्स में पावरहाउस एकीकृत हैं। सोवियत संघ के बाद के अंतरिक्ष में इस डिजाइन का एकमात्र उदाहरण।","description:hy":"Հիդրոէլեկտրակայան, որը եզակի է իր տեսակի մեջ` կառուցված լցնովի ամբարտակի լայնացված հենասյուների մեջ ներդրված էլեկտրակայաններով: Սա այս դիզայնի միակ օրինակն է հետխորհրդային տարածքում:","description:it":"Centrale idroelettrica unica nel suo genere, di tipo a pilastri, con centrali elettriche integrate nei pilastri allargati della diga di sfioro. L'unico esempio di questo progetto nell'area post-sovietica.","description:ja":"余水吐きダムの拡張された橋脚に発電所が統合された、ユニークな桟橋型水力発電所。旧ソ連圏ではこの設計の唯一の例です。","description:ka":"უნიკალური პირსის ტიპის ჰიდროელექტროსადგური, რომლის ელექტროსადგურები ინტეგრირებულია კაშხლის გაფართოებულ ბურჯებში. პოსტსაბჭოთა სივრცეში ამ დიზაინის ერთადერთი ნიმუშია.","description:ko":"유수로 댐의 넓어진 교각에 발전소가 통합된 독특한 교각형 수력 발전소입니다. 구소련 지역에서 이러한 디자인의 유일한 사례입니다.","description:nl":"Unieke pier-type waterkrachtcentrale met energiecentrales geïntegreerd in de verbrede pijlers van de overlaatdam. Het enige voorbeeld van dit ontwerp in de post-Sovjet ruimte.","description:pl":"Unikatowa elektrownia wodna typu filarowego, z elektrowniami zintegrowanymi z poszerzonymi filarami zapory przelewowej. Jedyny przykład takiej konstrukcji w przestrzeni postsowieckiej.","description:pt":"Central hidroelétrica única do tipo pilar, com as centrais elétricas integradas nos pilares alargados da barragem de vertedouro. O único exemplo deste projeto no espaço pós-soviético.","description:ru":"Уникальная гидроэлектростанция бычкового типа с агрегатными блоками, встроенными в расширенные быки водосливной плотины. Единственный пример такой конструкции на постсоветском пространстве.","description:tr":"Dolgu savak barajının genişletilmiş ayaklarına entegre edilmiş enerji santrallerine sahip, benzersiz iskele tipi hidroelektrik santrali. Sovyet sonrası alanda bu tasarımın tek örneği.","description:uk":"Унікальна бульбова ГЕС з агрегатними блоками, вбудованими в розширені опори водоскидного греблі. Єдиний приклад такої конструкції на пострадянському просторі.","name:zh-Hans-CN":"奥尔塔恰拉水电站","name:zh-Hant-TW":"奧爾塔恰拉水電站","official_name:ar":"محطة أورتاتشالا للطاقة الكهرومائية","official_name:az":"Ortaçala Su Elektrik Stansiyası","official_name:de":"Wasserkraftwerk Ortachala","official_name:en":"Ortachala Hydroelectric Power Plant","official_name:es":"Central hidroeléctrica de Ortachala","official_name:fa":"نیروگاه برق آبی اورتاچالا","official_name:fr":"Centrale hydroélectrique d'Ortachala","official_name:he":"תחנת הכוח ההידרואלקטרית אורטצ'לה","official_name:hi":"ओरटाचाला जलविद्युत पावर प्लांट","official_name:hy":"Օրթաչալայի Ջրէկ","official_name:it":"Centrale idroelettrica di Ortachala","official_name:ja":"オルタチャラ水力発電所","official_name:ka":"ორთაჭალის ჰიდროელექტროსადგური","official_name:ko":"오르타찰라 수력 발전소","official_name:nl":"Ortachala Waterkrachtcentrale","official_name:pl":"Elektrownia wodna Ortachala","official_name:pt":"Central Hidroelétrica de Ortachala","official_name:ru":"Ортачальская гидроэлектростанция","official_name:tr":"Ortachala Hidroelektrik Santrali","official_name:uk":"Ортачальська гідроелектростанція","operator:website":"https://epggen.ge","wikimedia_commons":"Category:Ortachala power plant Tbilisi","operator:wikipedia":"ka:ენერგო-პრო ჯორჯია","description:zh-Hans-CN":"独特的墩式水电站,发电机房集成在溢流坝加宽的墩式桥台上。后苏联时代唯一一座采用这种设计的电站。","description:zh-Hant-TW":"獨特的墩式水電站,發電機房集成在溢流壩加寬的墩式橋台上。後蘇聯時代唯一一座採用這種設計的電站。","official_name:zh-Hans-CN":"奥尔塔恰拉水力发电站","official_name:zh-Hant-TW":"奧爾塔恰拉水力發電站","plant:output:electricity":"18 MW"},"osm_id":-19054933,"output":"18 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-18850085,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-51.959120005,-3.428170863],[-51.959114724,-3.428063742],[-51.958819681,-3.42779066],[-51.951813751,-3.432149417],[-51.951330954,-3.43246005],[-51.946309859,-3.438875056],[-51.945773417,-3.439196335],[-51.939416581,-3.439565809],[-51.939432675,-3.439164232],[-51.939153725,-3.439196335],[-51.939159006,-3.439421221],[-51.938885504,-3.439576538],[-51.937007958,-3.439640827],[-51.930085176,-3.438152955],[-51.922366868,-3.436519238],[-51.91560921,-3.435057183],[-51.911244837,-3.433303102],[-51.909760402,-3.432886522],[-51.90822467,-3.432768169],[-51.906745683,-3.433141415],[-51.90361982,-3.434650158],[-51.903582269,-3.434901866],[-51.903641278,-3.434912511],[-51.90670436,-3.433536371],[-51.906940311,-3.433423969],[-51.907519752,-3.433279381],[-51.908254677,-3.433161532],[-51.908871501,-3.433161532],[-51.909526044,-3.433199083],[-51.910142952,-3.433316849],[-51.914965564,-3.43521778],[-51.915142506,-3.435276621],[-51.915341073,-3.435340911],[-51.9161511,-3.43552833],[-51.922770708,-3.436888461],[-51.929937655,-3.438435928],[-51.936788017,-3.439913825],[-51.936959678,-3.439929919],[-51.937131339,-3.439929919],[-51.939271742,-3.439801424],[-51.940457278,-3.439726406],[-51.945800239,-3.439448043],[-51.945934349,-3.43943195],[-51.946089917,-3.439405128],[-51.947259361,-3.438548413],[-51.947388107,-3.438478844],[-51.948761314,-3.436867004],[-51.948632652,-3.436759967],[-51.951196843,-3.433579202],[-51.95150798,-3.433547016],[-51.951883405,-3.433520361],[-51.952087337,-3.433365044],[-51.952081972,-3.433140158],[-51.952001506,-3.433000851],[-51.951878124,-3.432925917],[-51.951770836,-3.43282416],[-51.951776201,-3.432727768],[-51.951840574,-3.432658115],[-51.954694444,-3.430917864],[-51.959120005,-3.428170863]]]},"properties":{"name":"Barragem Pimental","tags":{"name":"Barragem Pimental","type":"multipolygon","power":"plant","waterway":"dam","wikidata":"Q106602681","plant:source":"hydro","plant:output:electricity":"233.1 MW"},"osm_id":-18850085,"output":"233.1 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-17646284,"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[17.836398138,43.447614253],[17.83644164,43.447629424],[17.837524246,43.448006945],[17.837581243,43.44798532],[17.837783583,43.447645098],[17.837566827,43.447591622],[17.837586356,43.44756086],[17.837521732,43.447538648],[17.837480996,43.447524651],[17.837377731,43.447637387],[17.837037677,43.447517442],[17.836558735,43.447352989],[17.836398138,43.447614253]]],[[[17.836461924,43.446450258],[17.836518753,43.446496023],[17.836579774,43.446543884],[17.836800385,43.446447325],[17.836926952,43.446395776],[17.836905243,43.446376749],[17.836685553,43.446468195],[17.83657223,43.446373564],[17.836963916,43.445988667],[17.836952433,43.445980117],[17.836565273,43.446350597],[17.836461924,43.446450258]]],[[[17.834929796,43.447697569],[17.835048735,43.447758925],[17.835105732,43.447668735],[17.835230455,43.44772305],[17.835320057,43.447762026],[17.835327433,43.447731935],[17.835897822,43.447958917],[17.835973259,43.447988924],[17.836002428,43.448005436],[17.836140143,43.448083472],[17.836471396,43.44821029],[17.836550856,43.448240633],[17.836861322,43.448393519],[17.837121999,43.448460071],[17.837400697,43.448517319],[17.837568587,43.448542716],[17.837594152,43.448573478],[17.837507483,43.448721754],[17.837645952,43.448751426],[17.837853991,43.448382287],[17.837656178,43.44834306],[17.837573867,43.448291427],[17.83752718,43.448173494],[17.83762659,43.448007448],[17.837582752,43.447994289],[17.837524246,43.448017087],[17.83643703,43.44763613],[17.836406687,43.44765633],[17.836369555,43.447643338],[17.836105358,43.447550718],[17.836058755,43.447534457],[17.836020785,43.4475907],[17.83585432,43.447519873],[17.835567324,43.447550886],[17.835482331,43.447528087],[17.835553493,43.447395066],[17.836572817,43.446546063],[17.836514898,43.446499963],[17.836455051,43.446452437],[17.835420221,43.447317785],[17.835336821,43.447465977],[17.835186617,43.44749238],[17.835144037,43.447510234],[17.835054686,43.447503528],[17.834995175,43.447596148],[17.834929796,43.447697569]],[[17.834937926,43.447695474],[17.835000958,43.447597825],[17.835058123,43.447509479],[17.835145881,43.447514928],[17.835188462,43.447497074],[17.835341599,43.447471845],[17.835424328,43.4473203],[17.836455638,43.446459227],[17.836510791,43.446503735],[17.836564686,43.446546063],[17.835547626,43.447392049],[17.835476548,43.447525992],[17.835416868,43.447620539],[17.835321399,43.447726068],[17.835316537,43.447753979],[17.835233975,43.447717937],[17.835103469,43.447662868],[17.835047562,43.447753057],[17.834937926,43.447695474]],[[17.836295879,43.447855149],[17.836435521,43.447643925],[17.837522738,43.448024882],[17.837396422,43.448226467],[17.836295879,43.447855149]]]]},"properties":{"name":"Salakovac Hydroelectric Power Station","tags":{"name":"Salakovac Hydroelectric Power Station","type":"multipolygon","layer":"1","power":"plant","name:bs":"HE Salakovac","operator":"JP Elektroprivreda Bosne i Hercegovine","waterway":"dam","wikidata":"Q11999146","wikipedia":"bs:Hidroelektrana Salakovac","start_date":"1981","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q3048615","plant:output:electricity":"210 MW"},"osm_id":-17646284,"output":"210 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-16854894,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.242529188,40.474778754],[22.243824863,40.476327479],[22.244005242,40.476237792],[22.244182435,40.476149699],[22.244376979,40.476059761],[22.244409333,40.476044757],[22.244436993,40.476016762],[22.24444613,40.475978624],[22.244458032,40.475909557],[22.244476472,40.475843424],[22.24451067,40.475788438],[22.244548892,40.475734375],[22.245318853,40.474793171],[22.24492415,40.473661698],[22.244806049,40.473465729],[22.244703957,40.473148558],[22.244603877,40.473275963],[22.244385277,40.473307479],[22.244306571,40.473368667],[22.244242869,40.473406888],[22.244149075,40.473447624],[22.244045139,40.473484589],[22.243901055,40.473540664],[22.243805585,40.473583998],[22.243713468,40.47364133],[22.243612969,40.473717857],[22.243470476,40.473809555],[22.24333645,40.473905192],[22.243178954,40.474010972],[22.242983488,40.474168217],[22.242901345,40.474223118],[22.242860441,40.47428263],[22.242822471,40.474329987],[22.242803025,40.474367119],[22.242795984,40.474395785],[22.242834122,40.47444658],[22.242879887,40.474487064],[22.242943757,40.474543726],[22.242931268,40.474562585],[22.24283496,40.474617654],[22.242720128,40.474679764],[22.242695066,40.474682363],[22.242529188,40.474778754]]]},"properties":{"name":"Φράγμα Ασωμάτων","tags":{"name":"Φράγμα Ασωμάτων","type":"multipolygon","power":"plant","height":"52","name:de":"Asomata Damm","name:el":"Φράγμα Ασωμάτων","name:en":"Asomata Dam","operator":"ΔΕΗ ΑΕ","waterway":"dam","wikidata":"Q11959418","start_date":"1985","plant:method":"water-storage","plant:source":"hydro","ref:EU:ENTSOE_EIC":"29WYISASOMATON-H","plant:output:electricity":"108 MW"},"osm_id":-16854894,"output":"108 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-14467328,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.184882625,4.306735719],[21.185169537,4.306880978],[21.18531379,4.306886929],[21.185498276,4.306534554],[21.185622496,4.306298519],[21.185539598,4.306257196],[21.185583687,4.306170527],[21.185275485,4.306018564],[21.185210776,4.306097186],[21.185096196,4.306314193],[21.184894527,4.306697665],[21.184882625,4.306735719]]]},"properties":{"name":"Barrage de Mobayi-Mbongo","tags":{"name":"Barrage de Mobayi-Mbongo","type":"multipolygon","power":"plant","name:fr":"Barrage de Mobayi-Mbongo","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"11.37 MW"},"osm_id":-14467328,"output":"11.37 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-14205364,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.637944521,47.848490353],[14.638004954,47.848656566],[14.638177286,47.848638629],[14.638315755,47.848624296],[14.638610128,47.848593954],[14.638621024,47.848634857],[14.63876184,47.84861868],[14.638753877,47.848578363],[14.639125196,47.848540058],[14.639389645,47.848512482],[14.639400625,47.848345095],[14.639401547,47.848331516],[14.638991756,47.848376527],[14.638745076,47.84840293],[14.638715656,47.84829539],[14.638494793,47.848320536],[14.638522118,47.848426986],[14.638279042,47.848453557],[14.637944521,47.848490353]],[[14.638755386,47.848413659],[14.638989325,47.848389938],[14.638997372,47.848425561],[14.639018494,47.848519019],[14.638784387,47.84854274],[14.638763433,47.848449533],[14.638755386,47.848413659]],[[14.638282144,47.848464118],[14.638522285,47.848439224],[14.638530667,47.848475685],[14.638552125,47.848568892],[14.638312067,47.848593786],[14.638290777,47.848501334],[14.638282144,47.848464118]]]},"properties":{"name":"Kraftwerk Weyer","tags":{"name":"Kraftwerk Weyer","type":"multipolygon","power":"plant","website":"https://power.verbund.com/de/Weyer","building":"service","operator":"Ennskraftwerke AG","waterway":"dam","start_date":"1969","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1343462","plant:output:electricity":"36.8 MW"},"osm_id":-14205364,"output":"36.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-13857722,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-79.536441178,37.040036437],[-79.536426426,37.040093266],[-79.536381415,37.040158561],[-79.536272869,37.040150599],[-79.536233223,37.040210948],[-79.53625225,37.04034464],[-79.536272869,37.040490317],[-79.53626214,37.040694333],[-79.536209837,37.040899857],[-79.536282005,37.040923075],[-79.536255435,37.040975965],[-79.536232468,37.041021562],[-79.536160216,37.040998344],[-79.536103806,37.041095742],[-79.536139848,37.041112338],[-79.536108668,37.041155421],[-79.536063405,37.041217866],[-79.536027447,37.041201186],[-79.535947651,37.041287352],[-79.535986711,37.041314761],[-79.535934659,37.041362119],[-79.535881518,37.041410566],[-79.535842375,37.041383157],[-79.535731733,37.041465049],[-79.535749838,37.041485668],[-79.535696948,37.04151534],[-79.535632575,37.041551466],[-79.535614303,37.041530847],[-79.535499052,37.041585497],[-79.535526963,37.041634698],[-79.535471056,37.041654899],[-79.535415233,37.041675099],[-79.535387237,37.041625897],[-79.535299227,37.041657749],[-79.535069228,37.041702173],[-79.534908128,37.041709381],[-79.534767479,37.041710722],[-79.534705705,37.041711728],[-79.534685672,37.041723546],[-79.534709812,37.041764283],[-79.534559524,37.041814574],[-79.534485847,37.041697898],[-79.534554244,37.041634782],[-79.534589112,37.041646433],[-79.53463471,37.041648696],[-79.534693718,37.041642242],[-79.534681649,37.041467731],[-79.534728503,37.041445267],[-79.534732611,37.041119882],[-79.534850712,37.041203366],[-79.534943164,37.041268661],[-79.535073754,37.041328843],[-79.535176265,37.041213927],[-79.535192861,37.041223231],[-79.535412634,37.040976887],[-79.535632575,37.040730459],[-79.53561556,37.040720736],[-79.535716981,37.040607077],[-79.535576836,37.040546895],[-79.535383046,37.040463663],[-79.535320014,37.040473218],[-79.535276931,37.040436421],[-79.535224712,37.04039183],[-79.535379023,37.040335168],[-79.535440714,37.040336258],[-79.535471559,37.040359727],[-79.536124677,37.04023886],[-79.536096514,37.040181025],[-79.536126018,37.040124279],[-79.536320478,37.039995869],[-79.536441178,37.040036437]]]},"properties":{"name":"Smith Mountain Dam","tags":{"name":"Smith Mountain Dam","type":"multipolygon","power":"plant","alt_name":"Smith Mountain Combination Pump Storage","operator":"Appalachian Power","waterway":"dam","wikidata":"Q7545237","ref:US:EIA":"3780","ref:US:NID":"VA019002","start_date":"1965-12","plant:method":"water-pumped-storage","plant:source":"hydro","gnis:feature_id":"1502199","plant:output:electricity":"547.5 MW"},"osm_id":-13857722,"output":"547.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-13757595,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.080986672,48.261644419],[21.081402163,48.261789258],[21.081562593,48.261817757],[21.081551864,48.261847596],[21.081533675,48.26189772],[21.081799717,48.261956561],[21.081999374,48.26187115],[21.082019071,48.261845082],[21.081885883,48.261823121],[21.081780438,48.261816667],[21.081824359,48.261699237],[21.081613135,48.261664368],[21.081638616,48.261595888],[21.0818946,48.261638133],[21.081938521,48.261520367],[21.081471565,48.261426154],[21.08143393,48.261518774],[21.081017182,48.261485079],[21.081006621,48.261513075],[21.081554127,48.261573089],[21.08146553,48.261761263],[21.081034868,48.261620698],[21.080986672,48.261644419]]]},"properties":{"name":"Felsődobszai vízerőmű","tags":{"url":"https://alteo.hu/eromuvek/vizeromuvek/felsodobsza-vizeromu/","name":"Felsődobszai vízerőmű","type":"multipolygon","power":"plant","operator":"Sinergy Kft.","waterway":"dam","wikidata":"Q121863498","start_date":"1912","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"948 kW"},"osm_id":-13757595,"output":"948 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-13217653,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[32.335156169,59.911931931],[32.335445093,59.911944168],[32.339084431,59.912075764],[32.339015783,59.912498296],[32.33919415,59.912507265],[32.339220972,59.912441634],[32.339304289,59.912420763],[32.340065952,59.913022249],[32.341356346,59.914041488],[32.341508059,59.913998154],[32.341531779,59.913826744],[32.341450894,59.913609485],[32.341443686,59.913569838],[32.341418791,59.913434638],[32.340337777,59.912558646],[32.339922873,59.912222448],[32.339997137,59.912197134],[32.340017924,59.912153884],[32.339997137,59.912074842],[32.339940643,59.912027149],[32.339239329,59.911988592],[32.335445847,59.911866552],[32.335164131,59.91185465],[32.335156169,59.911931931]]]},"properties":{"name":"Волховская ГЭС","tags":{"name":"Волховская ГЭС","type":"multipolygon","power":"plant","name:uk":"Волховська ГЕС","operator":"ТГК-1","waterway":"dam","wikidata":"Q800130","wikipedia":"ru:Волховская ГЭС","start_date":"1927","plant:source":"hydro","generator:type":"steam_turbine","generator:method":"run-of-the-river","plant:output:electricity":"86 MW"},"osm_id":-13217653,"output":"86 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-12841971,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-90.540414804,41.515037982],[-90.54025932,41.515040664],[-90.540258482,41.515029935],[-90.538632141,41.515085591],[-90.538297284,41.514490141],[-90.538358723,41.514470359],[-90.538324357,41.514410848],[-90.538454109,41.514363574],[-90.538494175,41.514442196],[-90.538407422,41.514470946],[-90.538547567,41.514724499],[-90.538689305,41.514981236],[-90.538803132,41.514978219],[-90.538804557,41.51501359],[-90.539552306,41.514986349],[-90.540250351,41.514960952],[-90.540246328,41.514876295],[-90.540383959,41.514869254],[-90.540403405,41.514868248],[-90.540414804,41.515037982]]]},"properties":{"name":"Arsenal Power Dam","tags":{"name":"Arsenal Power Dam","type":"multipolygon","power":"plant","building":"yes","waterway":"dam","ref:US:NID":"IL00798","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":-12841971,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-12841969,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-90.53198152,41.510765977],[-90.531954614,41.510842672],[-90.531882027,41.51083144],[-90.530968902,41.510680482],[-90.530979631,41.510644272],[-90.531005112,41.510641255],[-90.53092892,41.510202965],[-90.53110955,41.510183435],[-90.5311901,41.510642177],[-90.53198152,41.510765977]]]},"properties":{"name":"Moline Power Dam","tags":{"name":"Moline Power Dam","type":"multipolygon","power":"plant","operator":"MidAmerican Energy","waterway":"dam","ref:US:EIA":"899","ref:US:NID":"IL01089","start_date":"1942-01","plant:source":"hydro","gnis:feature_id":"1692412","operator:wikidata":"Q6840876","plant:output:electricity":"3.6 MW"},"osm_id":-12841969,"output":"3.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-12795602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-74.887304505,5.561638053],[-74.887188583,5.561717094],[-74.887016922,5.561659678],[-74.887016922,5.56153286],[-74.886849284,5.561414088],[-74.886733949,5.561412663],[-74.886709809,5.56114176],[-74.886551558,5.561165732],[-74.885897016,5.561016283],[-74.885864913,5.560781338],[-74.885543048,5.560824086],[-74.885523015,5.56071378],[-74.88549745,5.560538347],[-74.885784447,5.560458383],[-74.885736167,5.560180691],[-74.885961472,5.559726895],[-74.886063313,5.559291623],[-74.886360619,5.558844029],[-74.886645436,5.559211659],[-74.886897563,5.559531932],[-74.887122869,5.559969719],[-74.887179112,5.560144649],[-74.887220518,5.560421587],[-74.88725832,5.560678576],[-74.887304505,5.561638053]]]},"properties":{"name":"Central hidroeléctrica Miel I","tags":{"name":"Central hidroeléctrica Miel I","type":"multipolygon","power":"plant","height":"188","alt_name":"Patángoras","material":"concrete","operator":"ISAGEN","waterway":"dam","wikidata":"Q1398455","wikipedia":"es:Central hidroeléctrica Miel I","plant:source":"hydro","plant:output:electricity":"396 MW"},"osm_id":-12795602,"output":"396 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-12410460,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[56.549742194,53.003702535],[56.550457841,53.004203522],[56.551847895,53.003578064],[56.553585967,53.00313902],[56.553736171,53.003242369],[56.552899238,53.003745954],[56.552191134,53.004197822],[56.551826354,53.004404436],[56.55180498,53.004533685],[56.551933642,53.00461105],[56.552212676,53.00461105],[56.554615851,53.003500699],[56.554680224,53.003242369],[56.555023547,53.003242369],[56.555195292,53.003565156],[56.555023547,53.004107549],[56.554562794,53.004567799],[56.553671798,53.004972645],[56.552100275,53.005345472],[56.551461657,53.005512523],[56.551298546,53.005673791],[56.551460232,53.005797592],[56.551749324,53.005824079],[56.552403197,53.005856768],[56.552406968,53.005972271],[56.552899238,53.006070171],[56.553684119,53.006141837],[56.553644053,53.006508796],[56.553343478,53.006500582],[56.552323485,53.006531847],[56.552297082,53.006644919],[56.552287526,53.006736868],[56.553800544,53.006689846],[56.557062026,53.006767378],[56.559100589,53.006857735],[56.559456233,53.006887742],[56.55944475,53.007041885],[56.559683047,53.007045574],[56.559662931,53.007194436],[56.559613645,53.007452347],[56.559795784,53.007463747],[56.560023269,53.007324523],[56.560360892,53.007340449],[56.560789542,53.007228634],[56.561111994,53.007201225],[56.561221965,53.007198962],[56.561130434,53.006911882],[56.560597848,53.006943147],[56.56050724,53.006876594],[56.560298782,53.006850275],[56.56033122,53.006727396],[56.560383187,53.006563195],[56.560645876,53.006514161],[56.560880486,53.006225656],[56.560721481,53.005978641],[56.560420571,53.00551705],[56.560234241,53.00540775],[56.558359042,53.002908182],[56.558077661,53.002529153],[56.557918656,53.002279037],[56.55786627,53.002095389],[56.55769025,53.001861618],[56.558237336,53.002083235],[56.558503546,53.002156996],[56.558665233,53.002182896],[56.558827925,53.002348439],[56.559090949,53.002292867],[56.558871092,53.001897576],[56.558863046,53.001790036],[56.558532128,53.001713677],[56.558401957,53.001873772],[56.558070537,53.001740667],[56.557964925,53.001436572],[56.55780483,53.001749468],[56.557324799,53.001817278],[56.556721218,53.002028753],[56.555980928,53.002204689],[56.555980928,53.002259591],[56.555795939,53.002265961],[56.555793257,53.002222459],[56.555581363,53.002286999],[56.555361338,53.00226923],[56.555229993,53.001757598],[56.553918393,53.001476637],[56.553239794,53.001956082],[56.552467234,53.001536484],[56.552070351,53.001726837],[56.552107818,53.001920543],[56.550342925,53.002769546],[56.549742194,53.003702535]],[[56.55337139,53.005347065],[56.553414222,53.005192167],[56.55472314,53.004675674],[56.554959258,53.004339979],[56.555323955,53.003913843],[56.555238124,53.003358542],[56.555881854,53.003216553],[56.555967769,53.003603964],[56.555710276,53.004133365],[56.555796023,53.004623958],[56.556547042,53.005385873],[56.557276687,53.005760292],[56.557619925,53.005902282],[56.557727298,53.006005547],[56.557598552,53.006108896],[56.55742689,53.006160528],[56.555366954,53.005966823],[56.554401358,53.005889374],[56.553800544,53.005786025],[56.553457137,53.005669852],[56.553435763,53.00554077],[56.55337139,53.005347065]]]},"properties":{"name":"Юмагузинская ГЭС","tags":{"name":"Юмагузинская ГЭС","type":"multipolygon","power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"45 MW"},"osm_id":-12410460,"output":"45 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-11949072,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[26.106182544,41.584399069],[26.106356468,41.584443744],[26.106533662,41.584453467],[26.106715968,41.584431591],[26.107225923,41.584322291],[26.108059084,41.584134787],[26.108902555,41.583944518],[26.108828375,41.58380387],[26.108033771,41.583551072],[26.107966632,41.583370945],[26.107769154,41.583413944],[26.106936077,41.583601447],[26.106732145,41.583646793],[26.106795177,41.583822226],[26.106240798,41.584321871],[26.106182544,41.584399069]]]},"properties":{"name":"ВЕЦ \"Ивайловград\"","tags":{"name":"ВЕЦ \"Ивайловград\"","type":"multipolygon","power":"plant","name:en":"Ivailovgrad Hydroelectric Power Station","building":"yes","operator":"НЕК ЕАД","waterway":"dam","wikidata":"Q12274444","plant:method":"water-storage","plant:source":"hydro","operator:wikidata":"Q12287911","ref:EU:ENTSOE_EIC":"32W0011001000854","plant:output:electricity":"120 MW"},"osm_id":-11949072,"output":"120 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-11914625,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[17.775902674,44.165075828],[17.776257061,44.165316389],[17.776404498,44.16519175],[17.776062517,44.164975161],[17.776005184,44.164943897],[17.775958749,44.164984968],[17.775987331,44.165011623],[17.775902674,44.165075828]]]},"properties":{"name":"HE Lašva","tags":{"name":"HE Lašva","type":"multipolygon","power":"plant","building":"yes","waterway":"dam","plant:source":"hydro","generator:method":"run-of-the-river","plant:output:electricity":"yes"},"osm_id":-11914625,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-11748234,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-53.499048453,-25.542819156],[-53.498847455,-25.542653111],[-53.49846348,-25.542437528],[-53.498212945,-25.542478683],[-53.497825868,-25.542839608],[-53.497312812,-25.542446581],[-53.497195214,-25.542465775],[-53.497138971,-25.542550432],[-53.497195214,-25.542615727],[-53.497066217,-25.542762411],[-53.496807132,-25.543020909],[-53.496733455,-25.542958547],[-53.496469341,-25.543206819],[-53.495177606,-25.543081007],[-53.485814601,-25.542232255],[-53.485747546,-25.542672305],[-53.489992309,-25.543088383],[-53.49237805,-25.543341768],[-53.495367456,-25.543599428],[-53.496641757,-25.543696742],[-53.496895477,-25.543708476],[-53.496901931,-25.543913833],[-53.497393111,-25.544348267],[-53.497538956,-25.544222538],[-53.498469179,-25.543350234],[-53.499048453,-25.542819156]]]},"properties":{"name":"Usina Hidrelétrica Governador José Richa","tags":{"name":"Usina Hidrelétrica Governador José Richa","type":"multipolygon","power":"plant","branch":"Copel Geração e Transmissão S.A.","alt_name":"Usina Hidrelétrica de Salto Caxias","loc_name":"Salto Caxias","operator":"Copel","waterway":"dam","wikidata":"Q1630139","short_name":"UHE Gov José Richa","start_date":"1999","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"1240 MW"},"osm_id":-11748234,"output":"1240 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-11662893,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[113.489544843,63.072227269],[113.489920939,63.072323661],[113.490721243,63.072528766],[113.49091059,63.072377221],[113.49103263,63.072279572],[113.49151459,63.071893837],[113.492151195,63.071384469],[113.492247252,63.071307439],[113.491480978,63.071111051],[113.491070852,63.071005942],[113.489544843,63.072227269]]]},"properties":{"name":"Светлинская ГЭС","tags":{"name":"Светлинская ГЭС","type":"multipolygon","power":"plant","building":"yes","operator":"Алроса","waterway":"dam","wikidata":"Q4111426","start_date":"2004","plant:source":"hydro","plant:output:electricity":"277.5 MW"},"osm_id":-11662893,"output":"277.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-11198172,"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-72.484195431,45.886701389],[-72.484187216,45.886844552],[-72.484182271,45.886999785],[-72.484059728,45.886997857],[-72.483463942,45.88698847],[-72.483403927,45.886987464],[-72.483410717,45.886779173],[-72.483473749,45.886780179],[-72.484028463,45.886785041],[-72.484032822,45.886700048],[-72.484195431,45.886701389]]],[[[-72.485676764,45.886602483],[-72.485668969,45.886653277],[-72.485655223,45.88674355],[-72.485593532,45.887147474],[-72.485566123,45.887145379],[-72.485508037,45.88714102],[-72.485456069,45.887137248],[-72.485430169,45.88713532],[-72.485434863,45.887105062],[-72.485513569,45.886590329],[-72.485676764,45.886602483]]],[[[-72.481564854,45.885574862],[-72.481554041,45.885639486],[-72.480511752,45.885555583],[-72.480522481,45.885491126],[-72.481564854,45.885574862]]]]},"properties":{"name":"Centrale de Drummondville - Barrage de Drummondville","tags":{"name":"Centrale de Drummondville - Barrage de Drummondville","type":"multipolygon","layer":"1","power":"plant","building":"industrial","waterway":"dam","wikidata":"Q2944470","wikipedia":"fr:Centrale de Drummondville","plant:source":"hydro","plant:output:electricity":"16 MW;yes"},"osm_id":-11198172,"output":"16 MW;yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-9684503,"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[31.25264712,47.702446838],[31.253279367,47.702546164],[31.253750933,47.702620176],[31.253882529,47.702245673],[31.253432169,47.702173924],[31.25272658,47.702061522],[31.25267696,47.702191526],[31.25268903,47.702260173],[31.25264712,47.702446838]]],[[[31.255805756,47.700846733],[31.256263576,47.700924433],[31.25632116,47.700740367],[31.255867447,47.700668953],[31.255805756,47.700846733]]],[[[31.252395328,47.702635683],[31.252696992,47.70273987],[31.252950461,47.702779265],[31.252900924,47.702943718],[31.253061186,47.702972635],[31.25347894,47.703021837],[31.253531243,47.703016892],[31.253582876,47.70300566],[31.253638531,47.702977664],[31.25369687,47.70294288],[31.253748586,47.702873896],[31.25473807,47.703044887],[31.255659241,47.703199282],[31.255646835,47.703239347],[31.255652535,47.703249573],[31.25566494,47.703258793],[31.25570685,47.703271366],[31.25576787,47.703283855],[31.255836267,47.703297183],[31.255856383,47.703302379],[31.255969371,47.70331688],[31.25600424,47.703237671],[31.25628562,47.703273043],[31.256332978,47.703135328],[31.255724033,47.703054694],[31.253798458,47.702738445],[31.252474118,47.702535184],[31.252395328,47.702635683]]]]},"properties":{"name":"Олександрівська ГЕС","tags":{"name":"Олександрівська ГЕС","type":"multipolygon","power":"plant","name:ru":"Александровская ГЭС","name:uk":"Олександрівська ГЕС","waterway":"dam","start_date":"1999","plant:source":"hydro","plant:output:electricity":"10.5 MW"},"osm_id":-9684503,"output":"10.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-9413892,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-63.955459754,-8.788759538],[-63.954767744,-8.788473463],[-63.954216214,-8.78987584],[-63.953395542,-8.79206536],[-63.953245339,-8.792452353],[-63.95236021,-8.7945516],[-63.952188548,-8.79499165],[-63.951107618,-8.798135367],[-63.950785753,-8.799352],[-63.95064896,-8.799916605],[-63.949720916,-8.803463156],[-63.949227809,-8.80547255],[-63.949302408,-8.807052036],[-63.949935409,-8.809368626],[-63.950080332,-8.809813956],[-63.951036959,-8.812486359],[-63.951453372,-8.812417878],[-63.950766978,-8.809442806],[-63.950675782,-8.80944817],[-63.949737009,-8.805938835],[-63.949732148,-8.805504317],[-63.949946222,-8.804714239],[-63.950117883,-8.80454987],[-63.950907123,-8.801587621],[-63.951566863,-8.799111523],[-63.951838436,-8.798357152],[-63.952929341,-8.795228439],[-63.955078964,-8.789739801],[-63.955459754,-8.788759538]]]},"properties":{"name":"UHE Santo Antonio","tags":{"name":"UHE Santo Antonio","type":"multipolygon","power":"plant","operator":"Santo Antônio Energia S.A.","waterway":"dam","wikidata":"Q1510066","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"3580 MW"},"osm_id":-9413892,"output":"3580 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-7932197,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-121.232521017,44.694396953],[-121.23194166,44.6949515],[-121.231784416,44.694441629],[-121.231240933,44.69451606],[-121.230986877,44.694550761],[-121.230809181,44.694575068],[-121.230740533,44.694479096],[-121.230191938,44.694651847],[-121.230326635,44.694504493],[-121.230555545,44.694337944],[-121.230731229,44.694246498],[-121.230925606,44.694178772],[-121.231102715,44.694140718],[-121.231421312,44.694119763],[-121.231628428,44.694130157],[-121.231877874,44.694172066],[-121.23217233,44.694257059],[-121.232521017,44.694396953]]]},"properties":{"name":"Pelton Dam","tags":{"name":"Pelton Dam","type":"multipolygon","layer":"-1","power":"plant","building":"yes","operator":"Portland General Electric","waterway":"dam","wikidata":"Q1801081","ref:US:EIA":"3048","ref:US:NID":"OR00548","start_date":"1957-12","plant:source":"hydro","gnis:feature_id":"1147573","plant:output:electricity":"109.8 MW"},"osm_id":-7932197,"output":"109.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-6192767,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-116.703053782,45.243656781],[-116.703017572,45.243697937],[-116.70297474,45.243697182],[-116.702776005,45.243693746],[-116.702231601,45.243712689],[-116.7019928,45.243950567],[-116.701682838,45.24381017],[-116.700987643,45.243465339],[-116.701152012,45.243304825],[-116.700929556,45.24319343],[-116.700647421,45.243052195],[-116.700506438,45.243187898],[-116.700370818,45.243131152],[-116.700170323,45.243310525],[-116.700114667,45.243278339],[-116.700083151,45.243264592],[-116.70032455,45.242928478],[-116.70027627,45.242461941],[-116.700190859,45.242323137],[-116.70009782,45.242266894],[-116.699984077,45.241863641],[-116.700078038,45.24182433],[-116.700112237,45.241876298],[-116.700162109,45.242127671],[-116.700173425,45.242181734],[-116.700200833,45.242224817],[-116.700282222,45.242269074],[-116.70084733,45.242543749],[-116.700880857,45.24253478],[-116.700916648,45.242533774],[-116.700951014,45.24254115],[-116.700980183,45.242555986],[-116.701000718,45.242576689],[-116.701010777,45.242600997],[-116.701008933,45.242626143],[-116.701299114,45.242780118],[-116.701521402,45.242891849],[-116.701603377,45.242811299],[-116.702263117,45.243143641],[-116.702181226,45.243224275],[-116.702918246,45.243585284],[-116.703053782,45.243656781]]]},"properties":{"name":"Hells Canyon Dam","tags":{"name":"Hells Canyon Dam","type":"multipolygon","power":"plant","operator":"Idaho Power","waterway":"dam","wikidata":"Q5709011","wikipedia":"en:Hells Canyon Dam","ref:US:EIA":"3013","ref:US:NID":"OR00250;ID00055","start_date":"1967-10","plant:source":"hydro","gnis:feature_id":"1153298","operator:wikidata":"Q3147780","plant:output:electricity":"391.5 MW"},"osm_id":-6192767,"output":"391.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-6191211,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-117.33209751,47.681979153],[-117.332076555,47.682051489],[-117.332042441,47.682168752],[-117.33200841,47.682286098],[-117.331935907,47.682276627],[-117.331932219,47.682306299],[-117.331922077,47.682335132],[-117.331905564,47.682362625],[-117.331883101,47.682388274],[-117.331853513,47.682412916],[-117.331827445,47.682439487],[-117.331804982,47.682467399],[-117.331786206,47.682496484],[-117.331771622,47.682526659],[-117.33176106,47.682557672],[-117.331754774,47.68258902],[-117.331752679,47.682620871],[-117.331949402,47.682621877],[-117.331945714,47.68284559],[-117.331915455,47.682845339],[-117.33191512,47.682860426],[-117.331786458,47.68285942],[-117.331786625,47.682844417],[-117.331738848,47.682844082],[-117.331738262,47.682908035],[-117.331731472,47.682942569],[-117.331715379,47.682975761],[-117.331690652,47.683006271],[-117.331658047,47.683033345],[-117.331618736,47.683055976],[-117.331573976,47.683073327],[-117.33152511,47.683084977],[-117.331525277,47.682951873],[-117.331524775,47.682843327],[-117.331555201,47.682844333],[-117.331554447,47.682641742],[-117.331532821,47.682639647],[-117.331528882,47.682460358],[-117.331760138,47.68225827],[-117.331786374,47.682256845],[-117.331818979,47.68214436],[-117.331842365,47.682063894],[-117.331814369,47.682060122],[-117.331838761,47.681976135],[-117.331847478,47.681946296],[-117.331945965,47.681959288],[-117.33209751,47.681979153]]]},"properties":{"name":"Upriver Dam Hydro Plant","tags":{"name":"Upriver Dam Hydro Plant","type":"multipolygon","power":"plant","alt_name":"Upriver Dam Forebay","operator":"City of Spokane","waterway":"dam","ref:US:EIA":"50380","ref:US:NID":"WA00074S001","start_date":"1936-01","plant:source":"hydro","plant:output:electricity":"17.6 MW"},"osm_id":-6191211,"output":"17.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-6191019,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-117.918955371,47.830516955],[-117.918937769,47.830513686],[-117.918930225,47.830495665],[-117.918708524,47.830396674],[-117.918655383,47.830021249],[-117.918411805,47.82996333],[-117.918137046,47.829898035],[-117.918289513,47.830189641],[-117.918136627,47.831100419],[-117.918076529,47.831158338],[-117.918043336,47.831146771],[-117.918095639,47.831092791],[-117.918245172,47.830192826],[-117.918086503,47.829885797],[-117.918282137,47.829470642],[-117.918536444,47.829531746],[-117.918801563,47.829595364],[-117.918744901,47.830010855],[-117.91876988,47.83039416],[-117.918942882,47.830475548],[-117.918955371,47.830516955]]]},"properties":{"name":"Little Falls Power Plant","tags":{"name":"Little Falls Power Plant","type":"multipolygon","power":"plant","operator":"Avista Corp","waterway":"dam","wikidata":"Q55138813","ref:US:EIA":"3866","ref:US:NID":"WA00069","start_date":"1910-07","plant:source":"hydro","gnis:feature_id":"1513508","plant:output:electricity":"43.2 MW"},"osm_id":-6191019,"output":"43.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-5357608,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[130.293617685,50.265774986],[130.296870199,50.266316457],[130.296778668,50.266557772],[130.299798668,50.26775345],[130.303234242,50.269023225],[130.305036352,50.269908857],[130.307428463,50.27065602],[130.308386431,50.271192126],[130.310064152,50.27176637],[130.30998838,50.271845998],[130.31068106,50.272074238],[130.31087594,50.271935852],[130.311173581,50.272309434],[130.310670248,50.273139578],[130.310866887,50.273221385],[130.311191099,50.273356082],[130.311729217,50.27307906],[130.312981138,50.272067197],[130.314914592,50.270083871],[130.315385236,50.269465957],[130.315576092,50.269141829],[130.31545095,50.268483766],[130.315558741,50.268082356],[130.315655049,50.26777977],[130.316282854,50.267952772],[130.316176991,50.267698381],[130.31614757,50.267627554],[130.31546017,50.267378444],[130.315249198,50.267617161],[130.314839155,50.268046733],[130.313206528,50.267605175],[130.311351026,50.268209845],[130.312953394,50.26873757],[130.312747619,50.269086676],[130.312519631,50.269368978],[130.312045886,50.269948755],[130.310617023,50.269485235],[130.310671589,50.269430334],[130.310431866,50.269364536],[130.310378222,50.269420779],[130.310010843,50.26931064],[130.30991135,50.269428155],[130.31058492,50.26965807],[130.31061937,50.26962865],[130.311411543,50.269890417],[130.310643593,50.270819299],[130.310337738,50.271080731],[130.308948018,50.270632383],[130.308780296,50.270549318],[130.30853412,50.270460554],[130.308484834,50.270109017],[130.30812919,50.269615323],[130.301313865,50.267285656],[130.296525451,50.265531995],[130.295776025,50.265388496],[130.295278559,50.265417749],[130.294857536,50.265779848],[130.293617685,50.265774986]]]},"properties":{"name":"Бурейская ГЭС","tags":{"name":"Бурейская ГЭС","type":"multipolygon","power":"plant","name:de":"Bureja-Talsperre","name:en":"Bureya Dam","name:fr":"Barrage de Boureïa","operator":"РусГидро","waterway":"dam","wikidata":"Q1010573","wikipedia":"ru:Бурейская ГЭС","plant:method":"water-storage","plant:source":"hydro","generator:method":"water-storage","generator:source":"hydro","operator:wikidata":"Q2035424","operator:wikipedia":"ru:Русгидро","plant:output:electricity":"2010 MW"},"osm_id":-5357608,"output":"2010 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-3873625,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-52.115458006,-25.791418442],[-52.115455743,-25.791302436],[-52.115375276,-25.791081405],[-52.11512055,-25.790668429],[-52.114892479,-25.790422169],[-52.114707322,-25.790282023],[-52.114562148,-25.790202479],[-52.114393672,-25.790141962],[-52.114178508,-25.790130059],[-52.113940378,-25.790168532],[-52.113924201,-25.790114217],[-52.113843735,-25.790126371],[-52.113775506,-25.789969546],[-52.113717671,-25.789820851],[-52.113798137,-25.789784557],[-52.113767376,-25.789696463],[-52.11420005,-25.789581883],[-52.114277834,-25.789564951],[-52.114328712,-25.78953595],[-52.114350253,-25.789473169],[-52.114331478,-25.789391027],[-52.114289065,-25.789287007],[-52.113899642,-25.788695413],[-52.113864774,-25.788627855],[-52.113854045,-25.788581922],[-52.113880867,-25.788540934],[-52.114790555,-25.78801673],[-52.11499566,-25.787906508],[-52.114443209,-25.78691166],[-52.114149423,-25.787048704],[-52.113215511,-25.787484479],[-52.112573877,-25.788152768],[-52.111764269,-25.788636572],[-52.111673409,-25.789004537],[-52.111686568,-25.789403851],[-52.111909778,-25.789951525],[-52.112059647,-25.790356957],[-52.112194344,-25.790745375],[-52.111887483,-25.790850819],[-52.111939953,-25.79101326],[-52.112015726,-25.79124787],[-52.112350583,-25.791172433],[-52.11234723,-25.791484239],[-52.110226776,-25.793732517],[-52.110359881,-25.794619826],[-52.110096102,-25.795910471],[-52.110340854,-25.796201239],[-52.110396677,-25.796126473],[-52.110447639,-25.796061262],[-52.112424427,-25.793914321],[-52.113129848,-25.793139162],[-52.113953203,-25.792223859],[-52.114768678,-25.791294054],[-52.114835733,-25.791272345],[-52.114910835,-25.791288019],[-52.115068247,-25.791355577],[-52.115348035,-25.79150905],[-52.115380641,-25.791508882],[-52.115432944,-25.79148952],[-52.115458006,-25.791418442]]]},"properties":{"name":"UHE Ney Braga","tags":{"name":"UHE Ney Braga","type":"multipolygon","power":"plant","old_name":"UHE Segredo","operator":"Copel","waterway":"dam","wikidata":"Q3434883","frequency":"60","long_name":"Usina Hidrelétrica Ney Aminthas de Barros Braga","start_date":"1992","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"1260 MW"},"osm_id":-3873625,"output":"1260 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-3726795,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[15.151424016,46.593916926],[15.151719897,46.594065956],[15.151930199,46.59388189],[15.151946125,46.593853223],[15.151872532,46.593816175],[15.152016617,46.593680975],[15.152090126,46.59371794],[15.152237899,46.593579554],[15.152164222,46.593542422],[15.152308391,46.593407558],[15.152382152,46.593443851],[15.152529589,46.593305382],[15.152456248,46.593268418],[15.152599159,46.593134475],[15.152672417,46.593171356],[15.152819855,46.593033138],[15.152746597,46.592996258],[15.152891017,46.592860638],[15.152964442,46.592897519],[15.152985984,46.592877318],[15.153196286,46.592679925],[15.152910547,46.592536091],[15.152678368,46.592753769],[15.152668896,46.592748992],[15.152525063,46.592883773],[15.152386762,46.592812862],[15.152371087,46.592827362],[15.152336722,46.592825854],[15.152301937,46.592827781],[15.152267822,46.592832978],[15.152235217,46.592841528],[15.152230271,46.592873966],[15.152236474,46.592906236],[15.152253489,46.592936327],[15.152236642,46.592952085],[15.152376955,46.593022661],[15.152234043,46.593156687],[15.152095658,46.59308586],[15.152080068,46.593100277],[15.152045786,46.593098768],[15.152010833,46.593100696],[15.151976887,46.593105977],[15.151944197,46.593114442],[15.151939168,46.59314688],[15.151945371,46.593179235],[15.151962386,46.593209242],[15.151945622,46.593225],[15.152085851,46.593295575],[15.151942018,46.59343044],[15.151803633,46.593359697],[15.151787958,46.59337403],[15.151753676,46.593372521],[15.151718892,46.593374449],[15.151684777,46.59337973],[15.151652172,46.593388279],[15.151647142,46.593420717],[15.151653345,46.593453071],[15.15167036,46.593483079],[15.15165368,46.593498753],[15.151793826,46.593569328],[15.151650076,46.593704193],[15.151424016,46.593916926]]]},"properties":{"name":"HE Vuzenica","tags":{"name":"HE Vuzenica","type":"multipolygon","power":"plant","operator":"Dravske elektrarne Maribor","waterway":"dam","wikidata":"Q12790980","wikipedia":"sl:Hidroelektrarna Vuzenica","plant:type":"pier-type","plant:method":"run-of-the-river","plant:source":"hydro","operator:short":"DEM","generator:source":"hydro","operator:wikidata":"Q20523891","plant:output:electricity":"55.6 MW"},"osm_id":-3726795,"output":"55.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-3711453,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[15.017841625,46.587202183],[15.018135327,46.587321458],[15.018383264,46.587401924],[15.018409667,46.587362781],[15.018476974,46.587266137],[15.018370691,46.587231268],[15.018500694,46.587044268],[15.018735974,46.587122136],[15.018826499,46.58698987],[15.018589123,46.586912421],[15.018722982,46.58671905],[15.01896019,46.586793985],[15.019048871,46.586661383],[15.018812417,46.586584856],[15.018943427,46.586394587],[15.019189519,46.58647103],[15.019272165,46.586341529],[15.019034454,46.586260141],[15.019175689,46.586059562],[15.01928633,46.586097532],[15.019378196,46.585967864],[15.019185831,46.585904329],[15.019161691,46.58589645],[15.019139395,46.585889158],[15.018757935,46.585794024],[15.018810154,46.585881698],[15.018917946,46.585967361],[15.019025737,46.586012875],[15.018891291,46.586206665],[15.018822727,46.586193337],[15.018763551,46.586200043],[15.018734885,46.586205156],[15.018692053,46.586219992],[15.018702279,46.586252262],[15.018714433,46.586270786],[15.018744189,46.58630599],[15.018801018,46.586336752],[15.018666069,46.58653138],[15.018595829,46.586520232],[15.018537239,46.586526267],[15.018508573,46.586531296],[15.018465742,46.586546132],[15.018475968,46.586578234],[15.018488289,46.586596926],[15.018521649,46.586632046],[15.018575545,46.586661886],[15.018440764,46.586855927],[15.018396759,46.586842767],[15.01831294,46.586849724],[15.018284441,46.586854753],[15.018241694,46.586869589],[15.018251836,46.58690186],[15.018263989,46.5869203],[15.018293158,46.586955252],[15.018350575,46.586986182],[15.018215542,46.587180726],[15.018072295,46.587149293],[15.017854533,46.587157424],[15.017841625,46.587202183]]]},"properties":{"name":"HE Dravograd","tags":{"name":"HE Dravograd","type":"multipolygon","power":"plant","operator":"Dravske elektrarne Maribor","waterway":"dam","wikidata":"Q19933913","wikipedia":"sl:Hidroelektrarna Dravograd","plant:method":"run-of-the-river","plant:source":"hydro","generator:type":"kaplan_turbine","operator:short":"DEM","operator:wikidata":"Q20523891","plant:output:electricity":"26.2 MW"},"osm_id":-3711453,"output":"26.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-3655008,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[15.45468538,46.558524088],[15.454748831,46.558923235],[15.454896017,46.558908734],[15.457608988,46.558640597],[15.457736644,46.55862794],[15.45770999,46.558441527],[15.457645449,46.558100551],[15.457511339,46.558116895],[15.457490384,46.558116979],[15.457463059,46.558122679],[15.457506058,46.558315044],[15.45662797,46.558387631],[15.456556472,46.558240696],[15.45648422,46.558140868],[15.456438455,46.558144723],[15.456489669,46.558273637],[15.456250617,46.558292329],[15.456325719,46.5586354],[15.455383257,46.558731289],[15.45534403,46.558458374],[15.455279741,46.558461978],[15.455253422,46.558285456],[15.455331457,46.55817054],[15.455306563,46.558144723],[15.455199275,46.558358797],[15.455180751,46.558476563],[15.454844972,46.558501373],[15.45468538,46.558524088]]]},"properties":{"name":"Hidroelektrikarna Fala","tags":{"name":"Hidroelektrikarna Fala","type":"multipolygon","power":"plant","operator":"Dravske elektrarne Maribor","waterway":"dam","wikidata":"Q12790961","wikipedia":"sl:Hidroelektrarna Fala","start_date":"1918-01-01","plant:method":"run-of-the-river","plant:source":"hydro","generator:source":"hydro","plant:output:electricity":"58 MW"},"osm_id":-3655008,"output":"58 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-3465733,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.39389569,39.139249972],[21.393910191,39.139421634],[21.393968026,39.139799071],[21.394126779,39.139803932],[21.394121247,39.139691196],[21.395003275,39.139780295],[21.395769046,39.139545854],[21.39568816,39.139390453],[21.395627894,39.139415096],[21.395248027,39.138810174],[21.394134658,39.138947134],[21.393944054,39.139129357],[21.39389569,39.139249972]]]},"properties":{"name":null,"tags":{"type":"multipolygon","power":"plant","waterway":"dam","frequency":"50","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"60 MW"},"osm_id":-3465733,"output":"60 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-2576143,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[34.188981442,60.919715977],[34.189029722,60.919739447],[34.189850478,60.91976543],[34.189739669,60.920765894],[34.189705555,60.920921965],[34.189608744,60.921001342],[34.18940959,60.921089436],[34.189251255,60.921150456],[34.191706818,60.922269692],[34.191880994,60.920029628],[34.1916893,60.91993047],[34.191701286,60.919762832],[34.191615371,60.91974984],[34.191728024,60.918962444],[34.191803126,60.918936376],[34.191829948,60.918821628],[34.191738753,60.91879556],[34.191742944,60.918771672],[34.191851489,60.917869947],[34.191899685,60.917869947],[34.192184335,60.917717145],[34.192393296,60.917716055],[34.192480719,60.917701974],[34.192513827,60.91767205],[34.192528244,60.917329482],[34.192529921,60.917272652],[34.191831876,60.917188247],[34.191463575,60.917231246],[34.191087227,60.91726075],[34.190783886,60.917304168],[34.19038189,60.917395783],[34.190474175,60.917489828],[34.190637874,60.917657298],[34.190291869,60.917777997],[34.190719597,60.917807334],[34.190621277,60.918847361],[34.190070419,60.918821628],[34.190006046,60.918855575],[34.190704594,60.918882984],[34.190722866,60.918921038],[34.191012796,60.918932269],[34.190922942,60.919715977],[34.189930944,60.919700387],[34.18986129,60.919705584],[34.188981442,60.919715977]]]},"properties":{"name":"Верхнесвирская ГЭС","tags":{"name":"Верхнесвирская ГЭС","type":"multipolygon","power":"plant","name:cs":"Hornosvirská hydroelektrárna","name:en":"Upper Svir Hydroelectric Station","name:uk":"Верхнесвірська ГЕС","operator":"ТГК-1","waterway":"dam","wikidata":"Q776827","wikipedia":"ru:Верхне-Свирская ГЭС","start_date":"1952","plant:source":"hydro","generator:source":"hydro","plant:output:electricity":"160 MW"},"osm_id":-2576143,"output":"160 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-2090163,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[33.700153291,60.807741881],[33.700216072,60.807808182],[33.700475743,60.807749676],[33.700494183,60.807769039],[33.700514719,60.807790748],[33.700390834,60.80781866],[33.700420925,60.807850427],[33.701134896,60.807689578],[33.701161634,60.807675245],[33.702684626,60.807322032],[33.702736342,60.807362433],[33.702759979,60.807379196],[33.702791244,60.807388752],[33.70282913,60.807389087],[33.702881936,60.807380454],[33.70291077,60.807366372],[33.702922588,60.807350279],[33.702921918,60.80733209],[33.702909177,60.807313231],[33.702872967,60.807278446],[33.703068852,60.807232932],[33.703092489,60.807257156],[33.703111265,60.807266041],[33.703142949,60.807271321],[33.703167591,60.807269729],[33.70319215,60.807258748],[33.703197934,60.80724869],[33.703194832,60.807234692],[33.703170274,60.807209463],[33.703371439,60.807162859],[33.703392478,60.807184317],[33.703412594,60.807195633],[33.70343598,60.807197477],[33.703464227,60.807195884],[33.703483421,60.807185826],[33.703491636,60.807175097],[33.703489373,60.807162859],[33.70346758,60.807140564],[33.70367235,60.807093038],[33.703696154,60.807117429],[33.703709984,60.807129248],[33.70373513,60.807133355],[33.703763461,60.807130589],[33.703783242,60.807119441],[33.703790786,60.807107036],[33.703783996,60.807095972],[33.70376103,60.807072419],[33.70416663,60.806978374],[33.706038226,60.806537569],[33.706481796,60.806433047],[33.706100587,60.80604815],[33.705906043,60.806093999],[33.703997484,60.806519465],[33.70381853,60.806334057],[33.703748373,60.80634948],[33.703956664,60.806568163],[33.703902265,60.806582496],[33.704104269,60.806786596],[33.703646449,60.806880222],[33.703236323,60.806465485],[33.703153342,60.806486775],[33.703579143,60.806952138],[33.703396417,60.806992455],[33.703326428,60.806917018],[33.70330145,60.806891537],[33.703281753,60.806875779],[33.703245878,60.806860776],[33.703204555,60.80686153],[33.70318469,60.806874019],[33.703180583,60.80689095],[33.703185529,60.80690805],[33.703206735,60.806940236],[33.703273539,60.807019697],[33.703095423,60.807058924],[33.703026859,60.806984995],[33.702999953,60.806958676],[33.702976149,60.806941577],[33.702942453,60.806928334],[33.702908423,60.806924562],[33.702892497,60.806935123],[33.702888139,60.806953731],[33.702893838,60.806976446],[33.702915547,60.80701274],[33.702968856,60.807086836],[33.702796441,60.807124973],[33.702434678,60.806735047],[33.702239379,60.80677813],[33.702601226,60.807168056],[33.701067925,60.807506517],[33.700844379,60.8072906],[33.70065126,60.807075185],[33.700607926,60.807083148],[33.700633155,60.807111143],[33.700951751,60.807447257],[33.70085997,60.807468045],[33.700949321,60.807562341],[33.700153291,60.807741881]]]},"properties":{"name":"Нижне-Свирская ГЭС","tags":{"ref":"9","name":"Нижне-Свирская ГЭС","type":"multipolygon","power":"plant","name:en":"Lower Svir Hydroelectric Station","name:uk":"Нижньо-Свірська ГЕС","building":"industrial","waterway":"dam","start_date":"1936","plant:source":"hydro","generator:method":"water-pumped-storage","generator:source":"hydro","plant:output:electricity":"99 MW"},"osm_id":-2090163,"output":"99 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-2007872,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-96.103276153,46.279737379],[-96.10323944,46.279760094],[-96.103199878,46.27972665],[-96.103152352,46.279663199],[-96.103071215,46.279555994],[-96.102976667,46.279481647],[-96.103012039,46.279489861],[-96.103060151,46.279465386],[-96.103088147,46.279495058],[-96.103214127,46.279447868],[-96.103221084,46.279461866],[-96.103194848,46.279473181],[-96.103209768,46.279497656],[-96.103228041,46.279494555],[-96.103264921,46.279550295],[-96.103223263,46.279565717],[-96.10315428,46.27959145],[-96.103195184,46.27964325],[-96.103232735,46.279690859],[-96.103276153,46.279737379]]]},"properties":{"name":"Pisgah Dam","tags":{"name":"Pisgah Dam","type":"multipolygon","power":"plant","waterway":"dam","ref:US:NID":"MN00197","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":-2007872,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-1669782,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[6.026913055,46.192554522],[6.027038951,46.19255712],[6.027634989,46.19277463],[6.027669271,46.192777396],[6.027699697,46.192772954],[6.027724759,46.19276298],[6.027743031,46.19275049],[6.027755185,46.192734816],[6.028545934,46.193018879],[6.028975925,46.193173693],[6.03070704,46.193796552],[6.030821956,46.193833432],[6.030907367,46.193664369],[6.030541665,46.19356496],[6.029721077,46.193270588],[6.029615968,46.193214596],[6.029547236,46.193201688],[6.029504656,46.193195318],[6.029337269,46.193135052],[6.029361074,46.193093059],[6.029404157,46.192988872],[6.029394182,46.192985184],[6.029295276,46.193069338],[6.029264431,46.193108817],[6.029110287,46.193052658],[6.029135852,46.193012173],[6.02918145,46.192908489],[6.029175666,46.192906394],[6.029169883,46.192904298],[6.029070641,46.19298862],[6.029040718,46.193028267],[6.02888297,46.192971437],[6.028912223,46.192931875],[6.028958743,46.192827939],[6.028945667,46.192823245],[6.028845419,46.192907819],[6.028817256,46.192947717],[6.028656743,46.192889965],[6.028799738,46.192704139],[6.028804767,46.192696427],[6.02882262,46.192653344],[6.029014817,46.192395265],[6.029015237,46.192382273],[6.029012722,46.19237364],[6.028998221,46.192375987],[6.02873746,46.192725429],[6.027950232,46.192442539],[6.027909077,46.192467769],[6.027391746,46.192279092],[6.027362241,46.192319577],[6.02725445,46.192279176],[6.027206673,46.192261155],[6.027106426,46.192400295],[6.027056386,46.192451927],[6.026913055,46.192554522]]]},"properties":{"name":"Barrage de Verbois","tags":{"name":"Barrage de Verbois","type":"multipolygon","power":"plant","operator":"SIG","waterway":"dam","wikidata":"Q808805","start_date":"1947","plant:method":"run-of-the-river","plant:source":"hydro","generator:type":"kaplan_turbine","plant:output:electricity":"98 MW"},"osm_id":-1669782,"output":"98 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-1144261,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-118.885722323,46.250364927],[-118.883382347,46.250750075],[-118.88046293,46.251212086],[-118.87753027,46.251663451],[-118.877450642,46.251642832],[-118.87749867,46.251579884],[-118.879611664,46.251234298],[-118.879621806,46.251262461],[-118.879811237,46.251233376],[-118.879791372,46.251155424],[-118.880185238,46.251094823],[-118.880177862,46.25106205],[-118.880088175,46.250666172],[-118.880136455,46.250653767],[-118.88005272,46.250345648],[-118.879698668,46.249061457],[-118.879578639,46.248962048],[-118.879134818,46.24755766],[-118.879038258,46.247174355],[-118.879124927,46.247161447],[-118.878834745,46.246153272],[-118.878927533,46.246138017],[-118.879003725,46.246125528],[-118.87909098,46.246380254],[-118.879199107,46.246368016],[-118.879298348,46.246679739],[-118.880928712,46.246487626],[-118.881184277,46.246459882],[-118.881237753,46.246499445],[-118.881280669,46.24658846],[-118.881194754,46.246714692],[-118.879960938,46.246870428],[-118.880014666,46.247122639],[-118.881055363,46.247003951],[-118.88112443,46.247278962],[-118.880059844,46.247420029],[-118.880464439,46.248956935],[-118.879923806,46.249012926],[-118.880392857,46.250712021],[-118.880501319,46.250700957],[-118.881808477,46.250566847],[-118.883181014,46.250434329],[-118.883348652,46.25040055],[-118.883417551,46.250621832],[-118.885700949,46.250261075],[-118.885722323,46.250364927]]]},"properties":{"name":"Ice Harbor Dam","tags":{"name":"Ice Harbor Dam","type":"multipolygon","power":"plant","operator":"USACE Northwestern Division","waterway":"dam","wikidata":"Q5985283","wikipedia":"en:Ice Harbor Dam","ref:US:EIA":"3925","ref:US:NID":"WA00347","start_date":"1962-01","plant:source":"hydro","gnis:feature_id":"1534587","plant:output:electricity":"603 MW"},"osm_id":-1144261,"output":"603 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-1059409,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[5.657302977,45.235762454],[5.657584022,45.235862282],[5.657516296,45.235955992],[5.657564743,45.235974097],[5.657632134,45.235879549],[5.657910748,45.235980467],[5.657843777,45.236074428],[5.657891554,45.23609136],[5.65795886,45.235998069],[5.658240828,45.236100328],[5.658173856,45.236192948],[5.658220543,45.236210047],[5.658287599,45.236117092],[5.658569734,45.236219267],[5.658502175,45.236311636],[5.658548946,45.236328735],[5.658616253,45.236236199],[5.658899981,45.236338877],[5.658830495,45.236433844],[5.658809791,45.236462259],[5.658915655,45.236629897],[5.65895857,45.236645822],[5.659053034,45.23663216],[5.659138865,45.236663843],[5.659212961,45.236691168],[5.659371295,45.236749423],[5.659395854,45.236743052],[5.659424352,45.236704244],[5.659509177,45.236736179],[5.659520661,45.23671908],[5.659260822,45.236623946],[5.659527534,45.236259249],[5.659490821,45.23624676],[5.659457964,45.236233768],[5.659424185,45.236276767],[5.659044987,45.236140729],[5.658998468,45.236204599],[5.658713232,45.236102843],[5.658742317,45.236063029],[5.658696719,45.236046097],[5.658667299,45.23608633],[5.658383236,45.23598449],[5.658412573,45.235943838],[5.65836664,45.23592699],[5.658336884,45.235967978],[5.658054247,45.235865719],[5.658084757,45.235823222],[5.65803589,45.23580562],[5.658005548,45.235847865],[5.657726933,45.235746779],[5.657757695,45.235704032],[5.657710421,45.235687016],[5.657679827,45.235729597],[5.657397692,45.235626667],[5.657385874,45.23564276],[5.657668679,45.235744935],[5.657594918,45.235847027],[5.657314041,45.235746696],[5.657302977,45.235762454]],[[5.658629497,45.236218178],[5.658680542,45.23614777],[5.658701581,45.236118936],[5.65898623,45.236221195],[5.658964437,45.236250951],[5.658913392,45.236320521],[5.658629497,45.236218178]],[[5.658300172,45.236099658],[5.658372172,45.235999745],[5.658655564,45.236102256],[5.658633855,45.236131844],[5.658582726,45.236201498],[5.658300172,45.236099658]],[[5.657970595,45.235981724],[5.658043434,45.235880638],[5.658325988,45.235983065],[5.65825382,45.236082475],[5.657970595,45.235981724]],[[5.657642947,45.235864461],[5.657716121,45.235762118],[5.657994651,45.235863036],[5.657922399,45.235964374],[5.657642947,45.235864461]]]},"properties":{"name":"Pont-Barrage hydroélectrique de Saint-Égrève Noyarey","tags":{"name":"Pont-Barrage hydroélectrique de Saint-Égrève Noyarey","type":"multipolygon","power":"plant","building":"yes","operator":"EDF","waterway":"dam","wikidata":"Q124714046","plant:method":"run-of-the-river","plant:source":"hydro","generator:type":"kaplan_turbine","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W0000000120023","plant:output:electricity":"46 MW"},"osm_id":-1059409,"output":"46 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-898986,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-118.989963778,47.953732021],[-118.989658341,47.954777328],[-118.989611822,47.954962149],[-118.989208401,47.955077149],[-118.988473811,47.955697074],[-118.988542542,47.956116672],[-118.98529422,47.956559237],[-118.985212328,47.956306355],[-118.982613938,47.956678344],[-118.978833532,47.95720808],[-118.978942665,47.957552744],[-118.976423232,47.957915764],[-118.975307433,47.961031066],[-118.975496278,47.961059732],[-118.975206934,47.961967073],[-118.974040425,47.961767584],[-118.974081915,47.961291576],[-118.974290038,47.960669722],[-118.972748438,47.96043704],[-118.972792527,47.960286334],[-118.972234628,47.960364956],[-118.972043772,47.961026959],[-118.971839002,47.961010027],[-118.971935729,47.960213327],[-118.972406373,47.960237886],[-118.97284592,47.960136633],[-118.973423014,47.958664184],[-118.97402626,47.957246972],[-118.973982506,47.957053182],[-118.982872772,47.955800675],[-118.988723927,47.954976231],[-118.988793413,47.954793589],[-118.988837837,47.954676661],[-118.988890475,47.954538444],[-118.988933726,47.954424534],[-118.988977312,47.954309953],[-118.989018467,47.954201827],[-118.988818475,47.954176932],[-118.988828281,47.954141644],[-118.988874047,47.953980628],[-118.988901036,47.953879626],[-118.989101029,47.95390452],[-118.989125336,47.953810559],[-118.989157104,47.953686926],[-118.989186859,47.953571843],[-118.989218375,47.953449886],[-118.989243605,47.953352069],[-118.989253831,47.953312339],[-118.989108908,47.95316289],[-118.989061466,47.952922245],[-118.989676614,47.952805569],[-118.989774347,47.953002292],[-118.989845928,47.953170517],[-118.989963778,47.953732021]]]},"properties":{"name":"Grand Coulee Dam","tags":{"name":"Grand Coulee Dam","type":"multipolygon","power":"plant","name:ja":"グランドクーリーダム","website":"https://usbr.gov/pn/grandcoulee/","operator":"Bureau of Reclamation","waterway":"dam","wikidata":"Q930391","wikipedia":"en:Grand Coulee Dam","ref:US:EIA":"6163","ref:US:NID":"WA00262","plant:method":"water-storage","plant:source":"hydro","gnis:feature_id":"1513929","plant:output:electricity":"6809 MW"},"osm_id":-898986,"output":"6809 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":5148522,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[5.603469868,45.654011878],[5.603628454,45.654051357],[5.604037574,45.654183623],[5.604539818,45.65433718],[5.604955728,45.653683643],[5.60451375,45.65371474],[5.604351644,45.653662688],[5.603989043,45.653546599],[5.604029193,45.653494128],[5.603912852,45.653382062],[5.603469868,45.654011878]]]},"properties":{"name":"Centrale Hydroélectrique de Brégnier-Cordon","tags":{"name":"Centrale Hydroélectrique de Brégnier-Cordon","power":"plant","operator":"CNR","waterway":"dam","wikidata":"Q2944549","wikipedia":"fr:Centrale hydroélectrique de Brégnier-Cordon","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1121170","ref:EU:ENTSOE_EIC":"17W000001017791S","old_ref:EU:ENTSOE_EIC":"17W0000000128109","plant:output:electricity":"70.4 MW"},"osm_id":5148522,"output":"70.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":6281239,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[20.923031383,52.488366448],[20.923032389,52.488373489],[20.923043453,52.488380949],[20.92306424,52.48838338],[20.92334143,52.488383463],[20.923414017,52.488393522],[20.923424075,52.488576582],[20.923345118,52.488581779],[20.923336652,52.488584378],[20.923330617,52.488591586],[20.923337658,52.488598375],[20.923351823,52.488600555],[20.923425584,52.488603488],[20.923436145,52.488794596],[20.923386273,52.48879929],[20.923356769,52.4888039],[20.923350482,52.488810018],[20.923354673,52.48881773],[20.923373365,52.488820831],[20.923437989,52.488826028],[20.923442432,52.488906494],[20.923461123,52.489232299],[20.923383758,52.489241184],[20.923378561,52.489243363],[20.923374454,52.489247638],[20.923376131,52.489252583],[20.923377975,52.489255265],[20.923387279,52.489258031],[20.923462883,52.489261635],[20.923474031,52.489455676],[20.923490879,52.489455257],[20.923556174,52.489456096],[20.923616021,52.48945685],[20.923620379,52.489482163],[20.92373605,52.489480738],[20.923750299,52.489480571],[20.923750299,52.489535221],[20.923841075,52.489534634],[20.923847277,52.489443439],[20.923841326,52.489440254],[20.92385172,52.489240429],[20.923827831,52.489221486],[20.9238166,52.489039347],[20.923837387,52.48902032],[20.923814253,52.489002299],[20.923808553,52.488909847],[20.923803775,52.488821921],[20.92381442,52.488815718],[20.923822383,52.488808929],[20.923821545,52.488795182],[20.923812744,52.488788226],[20.923801596,52.488782861],[20.923791538,52.48860265],[20.923800842,52.488598878],[20.923808888,52.488589658],[20.923810816,52.48858069],[20.923805536,52.488570631],[20.923788772,52.488563591],[20.923774942,52.488379272],[20.924074092,52.488374998],[20.924073757,52.488361754],[20.924064034,52.488344823],[20.924052299,52.488334262],[20.9240352,52.488322527],[20.924015838,52.488315067],[20.923914081,52.488309451],[20.923910226,52.488237451],[20.923943166,52.48823678],[20.923951045,52.488233176],[20.923950794,52.488228482],[20.923945094,52.488226051],[20.923909639,52.488226722],[20.923906118,52.488158828],[20.923934785,52.488158242],[20.923944675,52.488152961],[20.923947022,52.488144328],[20.923942831,52.488138041],[20.923933276,52.488133934],[20.923904861,52.488134521],[20.923901341,52.488066879],[20.923937299,52.488066125],[20.923942999,52.488063778],[20.923942664,52.488059],[20.92393579,52.488055899],[20.923900754,52.488056485],[20.923897234,52.487988341],[20.923925229,52.487987754],[20.923937299,52.487978869],[20.923937131,52.487971493],[20.923930761,52.487963698],[20.923918356,52.487955986],[20.923895557,52.487956489],[20.923891869,52.487885243],[20.923925397,52.487884656],[20.923928079,52.487879292],[20.923924391,52.487875269],[20.923916177,52.487873928],[20.923891282,52.48787443],[20.923887594,52.487803436],[20.923916344,52.487802849],[20.923915171,52.487779966],[20.923886337,52.487780553],[20.923882817,52.487712744],[20.923917266,52.487712073],[20.923916596,52.487699249],[20.923882146,52.487699835],[20.923878291,52.487625153],[20.923787598,52.487630349],[20.923779133,52.487487522],[20.923445281,52.487494479],[20.923374119,52.487492886],[20.92336272,52.487493557],[20.923370599,52.487682317],[20.923262053,52.487683658],[20.923305471,52.488345493],[20.923065581,52.488355719],[20.92303943,52.488358569],[20.923031383,52.488366448]]]},"properties":{"name":"Elektrownia Wodna Dębe","tags":{"name":"Elektrownia Wodna Dębe","layer":"1","power":"plant","website":"https://pgeeo.pl/Nasze-obiekty/Elektrownie-wodne/Debe","operator":"PGE Energia Odnawialna S.A.","waterway":"dam","wikidata":"Q1272590","wikipedia":"pl:Elektrownia Wodna Dębe","short_name":"EW Dębe","start_date":"1963","plant:method":"run-of-the-river","plant:source":"hydro","generator:type":"kaplan_turbine","plant:output:electricity":"21.18 MW"},"osm_id":6281239,"output":"21.18 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":20573262,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[44.759353434,41.822574871],[44.759597598,41.822771762],[44.759895324,41.823014585],[44.760438471,41.822679728],[44.759919464,41.82221009],[44.759353434,41.822574871]]]},"properties":{"name":"ზაჰესი","tags":{"name":"ზაჰესი","power":"plant","name:en":"Zahesi HPP","name:ml":"സാഹെസി ജലവൈദ്യുതനിലയം","name:ru":"ЗАГЭС","building":"industrial","operator":"ენერგო-პრო ჯორჯია","waterway":"dam","wikidata":"Q56665","start_date":"1927-04-09","operator:en":"Energo-Pro Georgia","plant:method":"run-of-the-river","plant:source":"hydro","official_name":"ზემო ავჭალის ჰიდროელექტროსადგური","official_name:en":"Zemo Avchala Dam","official_name:ru":"Земо-Авчальская ГЭС","operator:wikidata":"Q11878927","wikimedia_commons":"Category:Zemo Avchala Dam","plant:output:electricity":"36.8 MW"},"osm_id":20573262,"output":"36.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":22309103,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-81.008106903,35.022179582],[-81.008067927,35.022182683],[-81.007960974,35.021649091],[-81.007918059,35.021652779],[-81.007687054,35.021672476],[-81.007663249,35.021588071],[-81.00753794,35.020944089],[-81.007764083,35.020923973],[-81.00773483,35.020773098],[-81.007623267,35.020195669],[-81.007587309,35.020122746],[-81.007551267,35.020080753],[-81.007496365,35.020028366],[-81.007410032,35.019984194],[-81.006294568,35.01966149],[-81.00631393,35.019602985],[-81.006272021,35.019592256],[-81.006499506,35.019131838],[-81.006490453,35.018767896],[-81.006524484,35.018766722],[-81.006542756,35.019131838],[-81.006352319,35.019505587],[-81.007493515,35.019814041],[-81.007637014,35.019895597],[-81.007727203,35.019969693],[-81.007787301,35.020056026],[-81.007841448,35.020172786],[-81.00794178,35.020766644],[-81.007967512,35.020919195],[-81.007925351,35.020925397],[-81.00800808,35.021534594],[-81.007960723,35.021545826],[-81.008081925,35.022053769],[-81.008106903,35.022179582]]]},"properties":{"name":"Wylie Dam","tags":{"name":"Wylie Dam","power":"plant","operator":"Duke Energy","waterway":"dam","wikidata":"Q34959655","ref:US:EIA":"3271","ref:US:NID":"SC00687","start_date":"1925-08","plant:method":"water-storage","plant:source":"hydro","gnis:feature_id":"1227719","operator:wikidata":"Q1264404","plant:output:electricity":"60 MW"},"osm_id":22309103,"output":"60 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":22919787,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[26.770251228,60.766671729],[26.770285929,60.766757728],[26.770396151,60.766759488],[26.770621456,60.76687256],[26.770870986,60.766998959],[26.771368116,60.767175482],[26.771607587,60.767265587],[26.771586297,60.767277154],[26.772724895,60.767683593],[26.772798488,60.767711085],[26.773381952,60.767297438],[26.772197254,60.766895191],[26.771696016,60.767210099],[26.771364345,60.767086717],[26.771421258,60.767048328],[26.771336852,60.767015974],[26.77127206,60.767052519],[26.771049688,60.766969873],[26.771099393,60.766944476],[26.771032924,60.766910446],[26.771240544,60.766811288],[26.771534078,60.766680279],[26.771519158,60.766670891],[26.771480937,60.766646751],[26.771391586,60.766651948],[26.771359986,60.766638118],[26.77130542,60.766659995],[26.771192683,60.766600232],[26.77124046,60.766577265],[26.771158653,60.766532757],[26.771108026,60.766555388],[26.770994619,60.766495374],[26.771039378,60.766474754],[26.770998055,60.766444747],[26.770856234,60.766394791],[26.770516347,60.766555891],[26.770471588,60.766545246],[26.770251228,60.766671729]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":22919787,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":24046583,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-47.435724872,-20.020971465],[-47.435636107,-20.020918659],[-47.435359672,-20.021346723],[-47.435006375,-20.02095378],[-47.43493186,-20.021066349],[-47.433798543,-20.022780615],[-47.433315577,-20.023638671],[-47.433439127,-20.023711426],[-47.433695781,-20.023774206],[-47.432598673,-20.025901114],[-47.432785254,-20.026000021],[-47.433876243,-20.023845452],[-47.434130382,-20.023364666],[-47.434323334,-20.022992175],[-47.434759276,-20.022362107],[-47.434875282,-20.022194217],[-47.435007884,-20.022010654],[-47.435653374,-20.021066349],[-47.435724872,-20.020971465]]]},"properties":{"name":"Usina Hidrelétrica de Jaguara","tags":{"name":"Usina Hidrelétrica de Jaguara","power":"plant","operator":"Engie Brasil Energia","waterway":"dam","wikidata":"Q10388418","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"424 MW"},"osm_id":24046583,"output":"424 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":24985119,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-88.079632536,41.569637029],[-88.079627675,41.569683297],[-88.079587945,41.569680028],[-88.079584843,41.569707353],[-88.079564308,41.569706431],[-88.078437528,41.569631664],[-88.07842571,41.569716992],[-88.078358655,41.569712969],[-88.078353206,41.569754962],[-88.078296377,41.569752447],[-88.07831071,41.569619929],[-88.078192022,41.569612637],[-88.078171403,41.569612386],[-88.078181461,41.569517586],[-88.078191352,41.569422787],[-88.078307106,41.569430079],[-88.078342813,41.569432259],[-88.078411461,41.569436198],[-88.078475834,41.569440641],[-88.078491424,41.569441646],[-88.078549008,41.569444999],[-88.078613548,41.569449358],[-88.078629474,41.569450364],[-88.07868974,41.569454052],[-88.078752353,41.569458159],[-88.0787692,41.569459248],[-88.078826532,41.569462853],[-88.078890403,41.569466876],[-88.07890725,41.569467966],[-88.078967013,41.569471821],[-88.079028033,41.569475593],[-88.079044881,41.569476683],[-88.079103303,41.569480622],[-88.07916776,41.569484394],[-88.079182344,41.569485232],[-88.079235569,41.569489172],[-88.079306648,41.569493195],[-88.07932777,41.569494285],[-88.079390215,41.569499062],[-88.079514184,41.569507025],[-88.079576461,41.569509875],[-88.079606133,41.569511468],[-88.079595824,41.56960744],[-88.079593058,41.569633843],[-88.079632536,41.569637029]]]},"properties":{"name":"Lockport Powerhouse","tags":{"name":"Lockport Powerhouse","power":"plant","alt_name":"Lockport Lock and Controlling Works Dam","building":"yes","heritage":"2","historic":"maritime","operator":"Metropolitan Water Reclamation District of Greater Chicago","ref:nrhp":"04000167","waterway":"dam","wikidata":"Q6665748","architect":"Frederick L. Barrett","wikipedia":"en:Lockport Powerhouse","ref:US:EIA":"10903","ref:US:NID":"IL00007","source_ref":"https://catalog.archives.gov/id/28891488","start_date":"1907","description":"The powerhouse contains three 30\" diameter 300-rpm 350-kilowatt D.C. vertical turbine and generator units. They are run by water entering the center bay of the headrace.","plant:method":"run-of-the-river","plant:source":"hydro","nrhp:criteria":"(A), (C)","generator:type":"kaplan_turbine","operator:short":"MWRD","heritage:website":"https://www.nps.gov/subjects/nationalregister/index.htm","protection_title":"U.S. Army Corps of Engineers","heritage:operator":"nrhp","operator:wikidata":"Q6825322","nrhp:inscription_date":"2004-03-10","plant:output:electricity":"16 MW"},"osm_id":24985119,"output":"16 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":25073583,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.834577197,46.281715759],[1.834674679,46.281729925],[1.834714828,46.281745264],[1.83474165,46.281768817],[1.834752463,46.281804021],[1.834739052,46.281831932],[1.834709547,46.281852217],[1.834669985,46.28185909],[1.834665962,46.281910974],[1.834696053,46.281909549],[1.83470016,46.281871579],[1.834741063,46.281851546],[1.834758498,46.281835369],[1.834771154,46.281812822],[1.834771154,46.281783234],[1.834763192,46.2817601],[1.834748356,46.281743671],[1.834812142,46.281746521],[1.834837036,46.281529849],[1.834869642,46.28127923],[1.834881796,46.281188454],[1.83463872,46.281162973],[1.834622292,46.281286019],[1.834596476,46.28153186],[1.834577197,46.281715759]]]},"properties":{"name":"Barrage de Chezelles","tags":{"name":"Barrage de Chezelles","power":"plant","waterway":"dam","wikidata":"Q11983785","plant:method":"run-of-the-river","plant:source":"hydro","ref:EU:ENTSOE_EIC":"17W0000004268565","plant:output:electricity":"6.8 MW"},"osm_id":25073583,"output":"6.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":25073599,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.876523593,46.260418098],[1.876668097,46.260566122],[1.87686348,46.26076645],[1.876984933,46.260890921],[1.877128264,46.261037856],[1.877169922,46.26108052],[1.877419367,46.260964263],[1.87731275,46.260855046],[1.877117284,46.260654635],[1.876843279,46.260373757],[1.876772955,46.260301673],[1.876523593,46.260418098]]]},"properties":{"name":"Barrage de Champsanglard","tags":{"name":"Barrage de Champsanglard","power":"plant","waterway":"dam","wikidata":"Q11963409","plant:method":"run-of-the-river","plant:source":"hydro","ref:EU:ENTSOE_EIC":"17W000000154954I","plant:output:electricity":"6.281 MW"},"osm_id":25073599,"output":"6.281 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":25380234,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-84.183873474,9.972214194],[-84.183826452,9.972280327],[-84.183754368,9.972240932],[-84.183725031,9.972250488],[-84.183704244,9.972170357],[-84.183627382,9.972192066],[-84.183551525,9.972213943],[-84.183574911,9.972294577],[-84.183517076,9.972310921],[-84.183496456,9.972246967],[-84.183410458,9.97232165],[-84.183368549,9.972376468],[-84.183348264,9.97244126],[-84.183316245,9.972450899],[-84.183292525,9.972380742],[-84.183485811,9.972200364],[-84.183477429,9.972177398],[-84.183494109,9.972170357],[-84.183536857,9.972161137],[-84.18360517,9.972145546],[-84.183740621,9.972106738],[-84.183750177,9.972130878],[-84.183794936,9.972139679],[-84.183799127,9.972133895],[-84.183872636,9.97216516],[-84.183873474,9.972214194]]]},"properties":{"name":"Puente Mulas","tags":{"name":"Puente Mulas","power":"plant","waterway":"dam"},"osm_id":25380234,"output":"","source":"","construction":"","source_layer":"power_plant"}},{"id":26135489,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.479572564,36.478395448],[127.480392566,36.47864087],[127.480257869,36.478961562],[127.480316374,36.478977487],[127.480376053,36.478993748],[127.48188949,36.475390284],[127.481771221,36.475358181],[127.480689369,36.477927989],[127.479816897,36.477719028],[127.479572564,36.478395448]]]},"properties":{"name":"대청댐","tags":{"name":"대청댐","power":"plant","name:en":"Daecheon Dam","name:ko":"대청댐","name:tr":"Daecheong Barajı","operator":"한국수자원공사","waterway":"dam","wikidata":"Q5208149","wikipedia":"en:Daecheong Dam","plant:source":"hydro","plant:output:electricity":"90 MW"},"osm_id":26135489,"output":"90 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":26567029,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.074630336,56.235501375],[14.074856647,56.235519564],[14.075309019,56.235837238],[14.075373224,56.235692818],[14.075564918,56.235600449],[14.075145236,56.235323846],[14.074855558,56.235446138],[14.074630336,56.235501375]]]},"properties":{"name":"Nöbbelöv Kraftstationen","tags":{"name":"Nöbbelöv Kraftstationen","power":"plant","operator":"Uniper Sverige AB","waterway":"dam","plant:source":"hydro","plant:output:electricity":"4.4 MW"},"osm_id":26567029,"output":"4.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":28653243,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.866076358,48.14861764],[10.8661756,48.148728784],[10.866609363,48.148565169],[10.867028877,48.148405578],[10.866997781,48.148359058],[10.867050838,48.148335756],[10.867071793,48.148347994],[10.867099034,48.148355873],[10.867117726,48.148348581],[10.866973138,48.148197371],[10.866621265,48.148329554],[10.866583463,48.148291416],[10.866570974,48.14828119],[10.866554881,48.148273647],[10.866536944,48.148269037],[10.866521437,48.148268617],[10.866509283,48.148272473],[10.866503164,48.148279933],[10.86650132,48.148290327],[10.866502829,48.148302061],[10.866509786,48.148314718],[10.866606178,48.148418821],[10.86651733,48.148451594],[10.86644709,48.148477494],[10.86644139,48.148473974],[10.866433846,48.148472968],[10.866423955,48.14847498],[10.866418591,48.148478165],[10.866415825,48.14848244],[10.866416831,48.148488978],[10.866257072,48.14854807],[10.866251288,48.148544969],[10.866241146,48.148543628],[10.866233183,48.14854698],[10.866229076,48.14855268],[10.86623092,48.148558883],[10.866076358,48.14861764]]]},"properties":{"name":"Lechstaustufe 19 Schwabstadl","tags":{"ref":"BNA1245","name":"Lechstaustufe 19 Schwabstadl","layer":"1","power":"plant","building":"industrial","operator":"Uniper Kraftwerke","waterway":"dam","wikidata":"Q60692923","wikipedia":"de:Lechstaustufe 19 – Schwabstadl","start_date":"1981","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1273399","operator:wikipedia":"de:Uniper Kraftwerke","plant:output:electricity":"12 MW"},"osm_id":28653243,"output":"12 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":28653294,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.926126659,48.237421976],[10.926147195,48.237512668],[10.926148787,48.237548291],[10.926698054,48.237478135],[10.927110276,48.237421473],[10.927114047,48.23739046],[10.927197783,48.23737554],[10.927311441,48.237328686],[10.927327032,48.237282753],[10.927276153,48.237206394],[10.92677743,48.237270934],[10.926759241,48.237241765],[10.926737532,48.23721838],[10.926724876,48.237214356],[10.926710123,48.237214273],[10.926687157,48.237221649],[10.926680535,48.237231791],[10.926680619,48.237253165],[10.926706268,48.237349473],[10.926668549,48.237353328],[10.926535864,48.237372942],[10.926527398,48.237367075],[10.92651734,48.237365482],[10.926504934,48.237368164],[10.926497056,48.237374032],[10.926495631,48.237378642],[10.926326735,48.237399596],[10.926319862,48.237395322],[10.926310223,48.237392891],[10.926300584,48.237392472],[10.926292621,48.237394986],[10.926286418,48.237398674],[10.926285496,48.237403536],[10.926126659,48.237421976]]]},"properties":{"name":"Laufwasserkraftwerk Unterbergen","tags":{"ref":"UNT","name":"Laufwasserkraftwerk Unterbergen","power":"plant","building":"industrial","operator":"Uniper Kraftwerke","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"12.3 MW"},"osm_id":28653294,"output":"12.3 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":30734802,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.936386193,48.266117086],[10.936454254,48.266166121],[10.936959682,48.266037877],[10.937374167,48.265944252],[10.937382633,48.265906784],[10.937449437,48.265802765],[10.937403169,48.265716599],[10.936992958,48.265822463],[10.936970327,48.265793293],[10.936955659,48.265781056],[10.93694493,48.265775691],[10.936927244,48.265771752],[10.936910145,48.265772423],[10.936894052,48.265777033],[10.936885754,48.265782732],[10.936883239,48.265792539],[10.936888269,48.265804441],[10.936949373,48.265908461],[10.936890532,48.265925979],[10.93678844,48.265950287],[10.936782824,48.265947353],[10.936772766,48.265946934],[10.936763546,48.265948191],[10.936754158,48.265951292],[10.936749297,48.265955567],[10.936748458,48.265960848],[10.936580485,48.266003679],[10.936575624,48.265999488],[10.936566487,48.265996974],[10.936555088,48.26599798],[10.936548131,48.266001919],[10.936544694,48.266006361],[10.936545197,48.266012564],[10.936391557,48.266047936],[10.936386193,48.266117086]]]},"properties":{"name":"Laufwasserkraftwerk Merching","tags":{"name":"Laufwasserkraftwerk Merching","power":"plant","website":"http://wasserpfad.augsburg-tourismus.de/11_1_eon_merching.html","alt_name":"Laufkraftwerk Lech 23","building":"industrial","operator":"Uniper Kraftwerke","waterway":"dam","start_date":"1978","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1273399","plant:output:electricity":"12 MW"},"osm_id":30734802,"output":"12 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":30752840,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[125.971925172,40.901749382],[125.971925172,40.901758099],[125.971930118,40.901764385],[125.971937242,40.901771426],[125.971949228,40.901774276],[125.971985187,40.901773438],[125.972234297,40.901754746],[125.972407299,40.901745023],[125.972526658,40.901738988],[125.972920943,40.901724823],[125.973033595,40.902156658],[125.97303896,40.902515488],[125.972985148,40.902839281],[125.972936952,40.903129797],[125.972717095,40.903697336],[125.972628582,40.904015681],[125.972236979,40.903987266],[125.972405288,40.904438129],[125.972491789,40.904410971],[125.972664792,40.904390687],[125.972889846,40.904381467],[125.972940389,40.904127998],[125.973165778,40.903564483],[125.973295027,40.903208755],[125.973387647,40.902880352],[125.973433244,40.902509369],[125.973429137,40.902097817],[125.97336116,40.90168459],[125.973345151,40.901584342],[125.973068464,40.900573233],[125.972947765,40.900145588],[125.972915578,40.900086747],[125.972849864,40.900090771],[125.972806949,40.899943752],[125.972827065,40.899829255],[125.972881548,40.899528177],[125.972746599,40.899480568],[125.972700918,40.899778545],[125.972573596,40.899775527],[125.972530681,40.900038049],[125.972177971,40.90005322],[125.972244942,40.900233766],[125.972126925,40.900241896],[125.972184928,40.900563929],[125.972519952,40.901645278],[125.97251442,40.901667658],[125.972511319,40.901672939],[125.972503859,40.901678471],[125.972495645,40.901682326],[125.972487766,40.90168459],[125.97226179,40.901698923],[125.97203179,40.901728343],[125.971930118,40.901742592],[125.971925172,40.901749382]]]},"properties":{"name":"위원댐","tags":{"name":"위원댐","power":"plant","name:en":"Wiwon Dam","name:ko":"위원댐","waterway":"dam","wikidata":"Q16903060","plant:source":"hydro","plant:output:electricity":"390 MW"},"osm_id":30752840,"output":"390 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":30903917,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[110.274428756,28.720342072],[110.274615589,28.721184286],[110.275297122,28.720923693],[110.275189246,28.720408373],[110.275140548,28.720175776],[110.275253284,28.719519892],[110.27533199,28.719061653],[110.27587145,28.718298564],[110.275891818,28.718200999],[110.27597614,28.717797997],[110.275249931,28.718249949],[110.274629671,28.719244965],[110.274428756,28.720342072]]]},"properties":{"name":"凤滩水电站","tags":{"name":"凤滩水电站","power":"plant","name:en":"Fengtan Dam","waterway":"dam","wikidata":"Q3376645","plant:source":"hydro","plant:output:electricity":"800 MW"},"osm_id":30903917,"output":"800 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":31232648,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3.092088146,43.764900636],[3.092318481,43.764930728],[3.09239769,43.765066682],[3.092489891,43.765180676],[3.092587373,43.765269189],[3.09267027,43.765332388],[3.092775965,43.765398186],[3.092856348,43.765443449],[3.092819887,43.765483849],[3.092813097,43.765500026],[3.092819887,43.765510085],[3.092838997,43.765512935],[3.092860036,43.765503463],[3.092891468,43.765456524],[3.092991799,43.765505056],[3.092955841,43.765545037],[3.092950728,43.765558867],[3.09295626,43.765570937],[3.092978137,43.765570351],[3.092990961,43.765562555],[3.093033625,43.765517461],[3.09308333,43.765530285],[3.093138567,43.765550653],[3.093107721,43.765593736],[3.093102692,43.765611673],[3.093117025,43.765624749],[3.093137142,43.765620726],[3.093151056,43.765603459],[3.093174693,43.765561047],[3.093285753,43.765589126],[3.093269995,43.765634556],[3.093268654,43.765647883],[3.093279634,43.765656517],[3.093299583,43.765654086],[3.093308468,43.765638998],[3.09332213,43.765592395],[3.093580377,43.765615278],[3.093692359,43.765616451],[3.093815573,43.765607399],[3.093945157,43.765593568],[3.094039286,43.765572278],[3.094116735,43.765552749],[3.094208852,43.765524585],[3.094305327,43.765483346],[3.094368946,43.765382093],[3.094325276,43.76540749],[3.094074238,43.765483263],[3.093734939,43.765495752],[3.093740136,43.765543528],[3.093674589,43.765555095],[3.093678026,43.765487454],[3.093725635,43.765365162],[3.093632428,43.765342782],[3.093535617,43.765479491],[3.093432939,43.765476054],[3.093715325,43.765022174],[3.093653634,43.764995017],[3.093320957,43.765533219],[3.093117444,43.765472953],[3.093046952,43.765449819],[3.092955422,43.765410089],[3.092881745,43.765363318],[3.093076457,43.76503986],[3.093223475,43.76471305],[3.093193971,43.764700393],[3.093034547,43.765035753],[3.09284059,43.765347224],[3.092740678,43.765288551],[3.092601622,43.765177323],[3.09248503,43.765067939],[3.092367431,43.764908348],[3.092396517,43.76490818],[3.092347566,43.764756384],[3.092224855,43.764756049],[3.092250336,43.764875156],[3.092088146,43.764900636]]]},"properties":{"name":"Barrage des Monts d'Orb","tags":{"name":"Barrage des Monts d'Orb","power":"plant","building":"yes","waterway":"dam","wikidata":"Q17621785","start_date":"2004-01-09","plant:source":"hydro","ref:EU:ENTSOE_EIC":"17W000000433018H","plant:output:electricity":"1.3 MW"},"osm_id":31232648,"output":"1.3 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":37339258,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[47.755085274,52.051492518],[47.755085945,52.051499559],[47.755099356,52.051511462],[47.755676031,52.051765517],[47.755678629,52.051783622],[47.755658596,52.051809271],[47.755494982,52.051890072],[47.755401021,52.051919744],[47.755890188,52.052245549],[47.756557136,52.052687526],[47.756781771,52.05253288],[47.756798535,52.052525839],[47.756820663,52.05253003],[47.756836086,52.052536652],[47.757190724,52.052746451],[47.757207572,52.052753073],[47.757220983,52.052753995],[47.757234394,52.052750642],[47.757375881,52.052651233],[47.757383927,52.0526369],[47.757378563,52.052620723],[47.757366493,52.052609156],[47.757001042,52.052412097],[47.756996935,52.052394747],[47.757011771,52.052376642],[47.757196843,52.052244627],[47.757279992,52.052184445],[47.757302791,52.052179583],[47.757324248,52.052188552],[47.757720042,52.052415953],[47.75774393,52.052421988],[47.757766729,52.052422826],[47.758017599,52.052266168],[47.758026987,52.05224052],[47.758025646,52.052221576],[47.757989436,52.052201795],[47.757591128,52.051998953],[47.757579058,52.05198814],[47.757584422,52.051970035],[47.7576783,52.051909853],[47.757686933,52.051906584],[47.757699757,52.051907758],[47.757760778,52.051936676],[47.757772177,52.051937095],[47.757781565,52.05193458],[47.757861361,52.051879762],[47.757864629,52.051872386],[47.757859936,52.051865765],[47.757794976,52.051836847],[47.757794976,52.051832321],[47.757797993,52.051829052],[47.757935792,52.051729223],[47.758197223,52.051516491],[47.758355558,52.051405179],[47.759173631,52.050828672],[47.762943476,52.048186947],[47.763003323,52.048144954],[47.765494257,52.046398752],[47.767130321,52.04524733],[47.767806237,52.044760677],[47.768168335,52.044486086],[47.768195241,52.04447368],[47.766600668,52.043868256],[47.766556328,52.043978729],[47.766497319,52.04406456],[47.766465133,52.044109068],[47.76638475,52.044170172],[47.766355246,52.044172519],[47.766337728,52.044172519],[47.766221136,52.04412558],[47.765812099,52.043906142],[47.765694081,52.043835231],[47.76568067,52.04378737],[47.765691399,52.043738755],[47.765790641,52.043665246],[47.765837831,52.043630461],[47.765839927,52.043623085],[47.76583012,52.043618223],[47.76581344,52.043618223],[47.765800029,52.043623252],[47.76563633,52.043737833],[47.765612274,52.04377748],[47.765608251,52.043826933],[47.765773123,52.043939166],[47.766159445,52.044170172],[47.76676026,52.044490947],[47.766768222,52.044507459],[47.766282826,52.044859667],[47.756762325,52.051527974],[47.756709939,52.051535434],[47.756640285,52.05151976],[47.755634457,52.051015672],[47.755622387,52.051012571],[47.755610233,52.051015421],[47.755429938,52.051146178],[47.755429268,52.051153554],[47.755439996,52.051162271],[47.756502151,52.051689661],[47.756503492,52.051705335],[47.756496787,52.051719333],[47.756418919,52.051775492],[47.756215155,52.05191723],[47.756192356,52.051924689],[47.756162852,52.051920582],[47.755312591,52.051537026],[47.755148222,52.051461673],[47.755125424,52.051460751],[47.755110085,52.05146578],[47.755085274,52.051492518]]]},"properties":{"name":"Саратовская ГЭС","tags":{"name":"Саратовская ГЭС","power":"plant","operator":"РусГидро","waterway":"dam","wikidata":"Q1542516","wikipedia":"ru:Саратовская ГЭС","start_date":"1967","plant:source":"hydro","plant:output:electricity":"1391 MW"},"osm_id":37339258,"output":"1391 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":39373286,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0.68313764,46.24388538],[0.683174688,46.243894768],[0.683459757,46.24354298],[0.683503594,46.243556977],[0.68351843,46.243537364],[0.683479789,46.243522025],[0.683562519,46.243416664],[0.683601243,46.24343368],[0.683617085,46.24341482],[0.683580456,46.24340099],[0.68366394,46.243293953],[0.68369093,46.24330552],[0.683711381,46.24329169],[0.683742562,46.243306778],[0.68375195,46.243305185],[0.68375656,46.243296636],[0.683729067,46.243280291],[0.683691516,46.243265455],[0.683714734,46.243240728],[0.683736862,46.2432502],[0.683760332,46.243253972],[0.683796458,46.243253133],[0.683773659,46.243226563],[0.683731749,46.243213319],[0.683755638,46.243182642],[0.683849264,46.243214241],[0.683886479,46.243221031],[0.684035845,46.243279117],[0.684118323,46.243288924],[0.684222175,46.243271071],[0.684248745,46.24325766],[0.684272131,46.243225138],[0.684288056,46.243171494],[0.684246566,46.243124136],[0.684219828,46.243102678],[0.684099799,46.243041742],[0.684231562,46.242921797],[0.684204573,46.242908721],[0.684459299,46.242685846],[0.684465166,46.242680649],[0.684646131,46.242523321],[0.684945868,46.242683583],[0.685080984,46.242567075],[0.684568347,46.242303296],[0.684425687,46.242435563],[0.684416551,46.242445118],[0.684388974,46.242474119],[0.684423843,46.242495242],[0.684292499,46.242614265],[0.684050178,46.242833535],[0.684011286,46.242814089],[0.683988487,46.24281367],[0.684003155,46.242830769],[0.683976417,46.242858849],[0.68393543,46.242860358],[0.68393325,46.242881396],[0.68394817,46.242890029],[0.683913888,46.242920037],[0.683877595,46.242915343],[0.683879774,46.242933532],[0.683890586,46.242939064],[0.683884049,46.242946523],[0.683894861,46.242951553],[0.683895448,46.24295742],[0.683886647,46.242963874],[0.683894274,46.242968819],[0.683786399,46.24306496],[0.683720937,46.243143833],[0.683685649,46.243129249],[0.683660671,46.243131596],[0.683616247,46.243186581],[0.683635777,46.243210051],[0.683610799,46.24323989],[0.683578947,46.243236789],[0.68358624,46.24325196],[0.683610296,46.243255564],[0.68360996,46.243264617],[0.68361918,46.243270233],[0.68313764,46.24388538]]]},"properties":{"name":"Barrage de Chardes","tags":{"name":"Barrage de Chardes","layer":"1","power":"plant","building":"yes","operator":"EDF","waterway":"dam","wikidata":"Q11963438","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000012749N","plant:output:electricity":"4.83 MW"},"osm_id":39373286,"output":"4.83 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":39373532,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0.677387738,46.225678797],[0.678638234,46.225743673],[0.678702021,46.225746942],[0.678748876,46.225747696],[0.678727921,46.225922627],[0.67874326,46.22596286],[0.678789444,46.225971577],[0.678829929,46.225972164],[0.678837724,46.226026898],[0.678773435,46.226027568],[0.678774189,46.22608205],[0.678808639,46.226123876],[0.678914418,46.226166792],[0.678995387,46.226169558],[0.679105442,46.226149776],[0.679157242,46.226092695],[0.679050456,46.226079452],[0.679061437,46.226031759],[0.679218597,46.226034022],[0.679280624,46.22594903],[0.679298645,46.225825397],[0.679855538,46.225844088],[0.680083526,46.225851632],[0.6801106,46.22572649],[0.680023763,46.225723724],[0.678692549,46.225677624],[0.678644018,46.225673936],[0.678550141,46.225666643],[0.678549051,46.225701764],[0.677387822,46.225646527],[0.677387738,46.225678797]]]},"properties":{"name":"Barrage de la Roche","tags":{"name":"Barrage de la Roche","power":"plant","building":"yes","operator":"EDF","waterway":"dam","wikidata":"Q11982887","plant:method":"water-storage","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000012254D","plant:output:electricity":"8.7 MW"},"osm_id":39373532,"output":"8.7 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":39465710,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0.671457961,46.169529685],[0.67182425,46.169574277],[0.671820562,46.169605457],[0.672901157,46.169736383],[0.672891099,46.169780974],[0.673118081,46.169803857],[0.673521837,46.169844928],[0.673554023,46.169752057],[0.673608171,46.169758846],[0.673631053,46.169735628],[0.671506157,46.169464641],[0.671457961,46.169529685]]]},"properties":{"name":"Barrage de Jousseau","tags":{"name":"Barrage de Jousseau","power":"plant","building":"service","operator":"EDF","waterway":"dam","wikidata":"Q19376499","plant:method":"water-storage","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000012780T","plant:output:electricity":"5.4 MW"},"osm_id":39465710,"output":"5.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":39994852,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-86.446594052,32.805117493],[-86.445868431,32.805403484],[-86.445576238,32.805519657],[-86.44558923,32.805542791],[-86.445476661,32.80558755],[-86.445405666,32.805653432],[-86.445494263,32.805805061],[-86.445388567,32.805848647],[-86.445381778,32.80583708],[-86.444175873,32.806334378],[-86.444104376,32.806211918],[-86.444067244,32.806227257],[-86.44406431,32.806222228],[-86.444047714,32.806229018],[-86.444042601,32.806220133],[-86.444029525,32.806225581],[-86.443995076,32.806165399],[-86.443048759,32.806548955],[-86.442982123,32.806226671],[-86.443002826,32.806223653],[-86.44333366,32.806191299],[-86.44340742,32.806316525],[-86.445736081,32.805347493],[-86.445805064,32.805284042],[-86.445725855,32.805223189],[-86.44559384,32.804974833],[-86.445576405,32.804973744],[-86.445544303,32.804917836],[-86.445538603,32.804920183],[-86.445520833,32.804889086],[-86.445507255,32.80489026],[-86.445487473,32.804854972],[-86.446201863,32.804571999],[-86.446220471,32.804605191],[-86.446259279,32.804589853],[-86.446203372,32.804489856],[-86.446236648,32.804476697],[-86.446594052,32.805117493]]]},"properties":{"name":"Mitchell Dam","tags":{"name":"Mitchell Dam","power":"plant","height":"32.3088","operator":"Alabama Power","waterway":"dam","wikidata":"Q11989635","ref:US:EIA":"17","ref:US:NID":"AL01424","start_date":"1949-11","plant:source":"hydro","operator:wikidata":"Q4705296","plant:output:electricity":"170 MW"},"osm_id":39994852,"output":"170 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":41380670,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[38.298271834,57.526153028],[38.298882372,57.526370371],[38.299879148,57.525563026],[38.301419658,57.524313284],[38.300580378,57.524016481],[38.299794155,57.524653254],[38.300022981,57.524734726],[38.299258552,57.525353897],[38.298271834,57.526153028]]]},"properties":{"name":"Угличская ГЭС","tags":{"name":"Угличская ГЭС","power":"plant","name:en":"Uglich Hydroelectric Station","name:ru":"Угличская ГЭС","building":"industrial","operator":"РусГидро","waterway":"dam","wikidata":"Q2268920","wikipedia":"ru:Угличская ГЭС","start_date":"1941","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q2035424","plant:output:electricity":"120 MW"},"osm_id":41380670,"output":"120 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":43931599,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-74.028112742,45.310229377],[-74.028053818,45.310242536],[-74.027941751,45.310264497],[-74.027818035,45.310288637],[-74.027676464,45.310316297],[-74.027629274,45.310325434],[-74.027616617,45.310295175],[-74.026978168,45.308663134],[-74.026453628,45.307322449],[-74.026435775,45.307278276],[-74.026570304,45.307252795],[-74.026610621,45.307245168],[-74.026578854,45.307170653],[-74.026871634,45.307112734],[-74.026899965,45.307190434],[-74.026920836,45.307186578],[-74.027470102,45.308588955],[-74.028112742,45.310229377]]]},"properties":{"name":"Centrale des Cèdres","tags":{"name":"Centrale des Cèdres","power":"plant","height":"9","building":"yes","operator":"Hydro-Québec","waterway":"dam","wikidata":"Q2944510","wikipedia":"fr:Centrale des Cèdres","plant:method":"run-of-the-river","plant:source":"hydro","building:levels":"1","plant:output:electricity":"122 MW"},"osm_id":43931599,"output":"122 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":44172216,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[82.716661977,49.90169864],[82.716800949,49.901768964],[82.71795958,49.901670979],[82.721781057,49.901451625],[82.721698747,49.90131785],[82.721687766,49.901236126],[82.720970778,49.901264541],[82.720895844,49.901032781],[82.720620666,49.901046444],[82.720634748,49.901269151],[82.719766718,49.901291866],[82.719590363,49.900996488],[82.717939631,49.901091287],[82.717546268,49.901515579],[82.716789969,49.901591351],[82.716661977,49.90169864]]]},"properties":{"name":"Усть-Каменогорская ГЭС","tags":{"name":"Усть-Каменогорская ГЭС","power":"plant","waterway":"dam","wikidata":"Q3433599","plant:source":"hydro","plant:output:electricity":"367.8 MW"},"osm_id":44172216,"output":"367.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":53176328,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-6.561461134,42.577795243],[-6.561388966,42.577793232],[-6.56138335,42.577882164],[-6.561241277,42.577893982],[-6.561068023,42.577816785],[-6.56092201,42.577858443],[-6.56073392,42.578035971],[-6.560463352,42.578318693],[-6.560354304,42.578386251],[-6.560318513,42.578350293],[-6.560189767,42.577700444],[-6.560080215,42.577044057],[-6.560256822,42.576938026],[-6.560492857,42.577068448],[-6.560860319,42.577003237],[-6.560911281,42.577119746],[-6.561307158,42.577126786],[-6.561418219,42.577204654],[-6.561432552,42.577396684],[-6.561449064,42.577640178],[-6.561461134,42.577795243]]]},"properties":{"name":"Presa de Bárcena","tags":{"name":"Presa de Bárcena","power":"plant","alt_name":"Central hidroeléctrica de Bárcena","operator":"Enel Green Power","waterway":"dam","wikidata":"Q110931453","plant:source":"hydro","plant:output:electricity":"61.2 MW"},"osm_id":53176328,"output":"61.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":54440016,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.841561072,45.701809849],[4.841913364,45.701971955],[4.842510323,45.702246546],[4.842784579,45.701935996],[4.841872628,45.701516398],[4.841849577,45.701538191],[4.841561072,45.701809849]]]},"properties":{"name":"Chute de Pierre Bénite","tags":{"name":"Chute de Pierre Bénite","layer":"1","power":"plant","building":"yes","operator":"CNR","waterway":"dam","wikidata":"Q16507094","wikipedia":"fr:Barrage de Pierre-Bénite","start_date":"1966","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1121170","ref:EU:ENTSOE_EIC":"17W000001017793O","old_ref:EU:ENTSOE_EIC":"17W000000012916U","plant:output:electricity":"80.8 MW"},"osm_id":54440016,"output":"80.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":57871388,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-119.867544933,47.947068324],[-119.867309737,47.947111994],[-119.867226672,47.946910744],[-119.865203867,47.947285751],[-119.86327318,47.947643658],[-119.863355574,47.947842896],[-119.863155749,47.947879944],[-119.863090371,47.947892097],[-119.863004959,47.947685484],[-119.862805973,47.947194891],[-119.862716035,47.946977129],[-119.862964977,47.946931028],[-119.863085761,47.947223473],[-119.865029524,47.946863135],[-119.867009581,47.946496091],[-119.866885529,47.946195936],[-119.867166658,47.946143884],[-119.867261541,47.946395844],[-119.867455414,47.946851736],[-119.867544933,47.947068324]]]},"properties":{"name":"Wells Dam","tags":{"name":"Wells Dam","power":"plant","operator":"PUD No 1 of Douglas County","waterway":"dam","wikidata":"Q7981641","wikipedia":"en:Wells Dam","ref:US:EIA":"3886","ref:US:NID":"WA00098","start_date":"1967-09","plant:source":"hydro","gnis:feature_id":"1527892","plant:output:electricity":"774 MW"},"osm_id":57871388,"output":"774 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":59914639,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[5.687656446,44.960800323],[5.687708833,44.961050942],[5.687746636,44.961225956],[5.687827856,44.96122956],[5.687921817,44.961252778],[5.68799826,44.961285635],[5.688074787,44.961338189],[5.688136478,44.961395019],[5.688190122,44.961459643],[5.688208898,44.96155637],[5.688190876,44.961618732],[5.688164473,44.961666173],[5.687988956,44.961743371],[5.688032123,44.961801457],[5.688076966,44.961786118],[5.688089539,44.961781843],[5.688178052,44.961891395],[5.688336386,44.961919474],[5.688530763,44.961874296],[5.688651462,44.961909332],[5.688731928,44.961992984],[5.688753637,44.962106139],[5.688704436,44.962199933],[5.688676692,44.962227677],[5.688585161,44.962152743],[5.688510646,44.962229521],[5.688757996,44.962408391],[5.688829578,44.962339994],[5.688733186,44.962270928],[5.688849862,44.962149641],[5.688885652,44.96210044],[5.688926137,44.962017459],[5.688970058,44.961899693],[5.688987576,44.9618002],[5.688993863,44.961716465],[5.688990762,44.9616334],[5.688952121,44.961445478],[5.68903024,44.961440029],[5.688958324,44.961273565],[5.689077849,44.961232829],[5.689067959,44.961222938],[5.689047758,44.961216987],[5.688967963,44.961243139],[5.688933932,44.961198379],[5.688902332,44.961156637],[5.688974417,44.961126295],[5.688983972,44.961116823],[5.688983721,44.96110467],[5.688899566,44.961012385],[5.688883892,44.961002662],[5.688855394,44.961009954],[5.688791356,44.961034597],[5.688717092,44.960961674],[5.688749195,44.960941977],[5.68879035,44.96090912],[5.688786075,44.960898642],[5.688670154,44.960950275],[5.688495223,44.960830665],[5.688448285,44.960866875],[5.688291878,44.960789845],[5.688093227,44.960712732],[5.687853756,44.960652466],[5.687723334,44.960615083],[5.687656446,44.960800323]]]},"properties":{"name":"Barrage de Monteynard","tags":{"name":"Barrage de Monteynard","layer":"2","power":"plant","height":"135","website":"https://www.hydrelect.info/articles.php?lng=fr&pg=59","operator":"EDF","waterway":"dam","wikidata":"Q1664867","wikipedia":"fr:Barrage de Monteynard-Avignonet","start_date":"1962","plant:method":"water-storage","plant:source":"hydro","ref:FR:RTE_nom":"MONTEYNARD","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W100P100P02861","old_ref:EU:ENTSOE_EIC":"17W000000012610H","plant:output:electricity":"366 MW","building:foundation_height":"135"},"osm_id":59914639,"output":"366 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":64684464,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[72.15951688,41.187941838],[72.160396728,41.188204946],[72.161327455,41.188483225],[72.161602549,41.188027836],[72.161186555,41.187894983],[72.159899598,41.187499693],[72.159776719,41.187459543],[72.15951688,41.187941838]]]},"properties":{"name":"Үч-Коргон ГЭСи","tags":{"name":"Үч-Коргон ГЭСи","power":"plant","name:en":"Üç-Korgon Hydroelectric Power Station","name:fa":"نیروگاه برق آبی اوچ-کورگانسک","name:ky":"Үч-Коргон ГЭСи","name:ru":"Уч-Курганская ГЭС","name:uk":"Уч-Курганська ГЕС","name:uz":"Uchqoʻrgʻon gidrouzeli","int_name":"Üç-Korgon GESi","waterway":"dam","wikidata":"Q4479261","wikipedia":"ky:Үч-Коргон ГЭСи","plant:source":"hydro","plant:output:electricity":"180 MW"},"osm_id":64684464,"output":"180 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":68609752,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-75.764051409,45.498884895],[-75.76393423,45.498904341],[-75.763845801,45.498918925],[-75.763271641,45.499014228],[-75.762863861,45.499081953],[-75.762781551,45.4990957],[-75.762747856,45.498995871],[-75.762742826,45.498981035],[-75.762555323,45.499012132],[-75.762517772,45.498900737],[-75.762675771,45.498874585],[-75.76265775,45.498821192],[-75.762649452,45.498796717],[-75.762644758,45.498782552],[-75.763189582,45.498692195],[-75.763754857,45.498598318],[-75.763944037,45.498566969],[-75.764051409,45.498884895]]]},"properties":{"name":"Centrale des Rapides-Farmer","tags":{"name":"Centrale des Rapides-Farmer","power":"plant","name:en":"Rapides-Farmer Generating Station","name:fr":"Centrale des Rapides-Farmer","website":"http://www.hydroquebec.com/visit/outaouais/farmers.html","building":"industrial","historic":"yes","operator":"Hydro-Québec","waterway":"dam","wikidata":"Q2944516","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q167840","plant:output:electricity":"yes"},"osm_id":68609752,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":69762775,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.69480344,61.278872985],[22.694889271,61.278869632],[22.694974263,61.278826968],[22.695048276,61.278823447],[22.695118432,61.278794949],[22.695229492,61.278834092],[22.695295961,61.278798553],[22.695399477,61.278826968],[22.695388329,61.278883881],[22.695462342,61.278906931],[22.695521518,61.278890922],[22.69561397,61.278903327],[22.695687899,61.278880277],[22.695713799,61.27893895],[22.695761995,61.278972562],[22.69554264,61.279244722],[22.695311719,61.279531467],[22.695001924,61.279468938],[22.694932438,61.279557367],[22.695539036,61.279672283],[22.695807592,61.280271924],[22.695870037,61.28041165],[22.696139683,61.280416596],[22.69604874,61.280238983],[22.695735089,61.279625596],[22.695992832,61.279323177],[22.696388291,61.27885949],[22.697067979,61.278364454],[22.69727342,61.277998249],[22.697343995,61.277849386],[22.696732535,61.277810243],[22.695960059,61.277611424],[22.695761827,61.277810075],[22.696451825,61.277948461],[22.696279242,61.278191033],[22.69528783,61.278123391],[22.69514802,61.278224644],[22.69518138,61.278366718],[22.69503344,61.27841659],[22.694996476,61.278489345],[22.695051964,61.278567548],[22.695044588,61.278624461],[22.694970575,61.27865648],[22.694933527,61.278739964],[22.694848535,61.278780784],[22.69480344,61.278872985]]]},"properties":{"name":"Äetsän voimalaitos","tags":{"name":"Äetsän voimalaitos","power":"plant","operator":"UPM Energy Oy","waterway":"dam","wikidata":"Q11903209","wikipedia":"fi:Äetsän voimalaitos","start_date":"1921","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"16 MW"},"osm_id":69762775,"output":"16 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":71038021,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.53160065,45.807400874],[1.531706095,45.807442364],[1.531841127,45.807277828],[1.532013375,45.80706828],[1.531909943,45.807023185],[1.531734258,45.807237762],[1.53160065,45.807400874]]]},"properties":{"name":"Barrage de l'Artige","tags":{"name":"Barrage de l'Artige","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q11982848","start_date":"2006-01-01","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000053389Z","plant:output:electricity":"2.4 MW"},"osm_id":71038021,"output":"2.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":74727575,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[124.959426724,40.463244837],[124.959809777,40.463517332],[124.959959981,40.463770382],[124.959702489,40.46500906],[124.960067018,40.464659953],[124.96238738,40.462329365],[124.965295482,40.459364434],[124.965850028,40.458686339],[124.964586037,40.459310874],[124.964380262,40.459403159],[124.964058397,40.459435848],[124.963685569,40.459760228],[124.963228085,40.459591081],[124.963224146,40.459603654],[124.963215093,40.459614634],[124.962340945,40.460510324],[124.962318146,40.460523568],[124.962280679,40.460519461],[124.962256455,40.46051133],[124.962155956,40.460478641],[124.96205135,40.460450059],[124.961970884,40.460429691],[124.961880946,40.460408233],[124.961859572,40.460407227],[124.961847418,40.460424578],[124.961855549,40.460440922],[124.961870217,40.460450059],[124.961982954,40.460480736],[124.962084877,40.460509235],[124.962107676,40.460521556],[124.962118321,40.460547037],[124.9621117,40.460572518],[124.962103653,40.460596071],[124.961019286,40.461672224],[124.959450948,40.463228576],[124.959426724,40.463244837]]]},"properties":{"name":"水丰水库 / 수풍언제","tags":{"name":"水丰水库 / 수풍언제","power":"plant","name:cs":"vodní nádrž Supchun","name:de":"Supung-Talsperre","name:en":"Shuifeng Dam","name:ko":"수풍언제","name:ru":"Супхунская плотина","name:zh":"水丰水库","waterway":"dam","wikidata":"Q708579","wikipedia":"de:Supung-Talsperre","alt_name:ko":"수풍댐","name:ko-Hani":"水豊堰堤","plant:method":"water-storage","plant:source":"hydro","sorting_name:cs":"Supchun","plant:output:electricity":"yes"},"osm_id":74727575,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":76455894,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-119.451986602,37.144900248],[-119.451984842,37.144934446],[-119.451878057,37.145098732],[-119.451848971,37.145100073],[-119.451781162,37.14523192],[-119.451717375,37.145306687],[-119.451708574,37.145356643],[-119.451612937,37.1453693],[-119.451593323,37.145475247],[-119.451592569,37.145557222],[-119.451554347,37.145770122],[-119.451542361,37.145962487],[-119.451506822,37.146157618],[-119.451512941,37.146187122],[-119.451456112,37.146192654],[-119.451437168,37.14603893],[-119.451368018,37.145477594],[-119.451350667,37.145336443],[-119.451284115,37.144795391],[-119.451225525,37.144047725],[-119.451218401,37.143951836],[-119.451290234,37.143947394],[-119.45145477,37.144058538],[-119.451572117,37.144217458],[-119.4516411,37.144261128],[-119.451782251,37.144343019],[-119.451851318,37.14441963],[-119.451874033,37.144510071],[-119.45189943,37.144588274],[-119.451909237,37.144768736],[-119.451919631,37.144904355],[-119.451986602,37.144900248]]]},"properties":{"name":"Big Creek Number 7 104-022 Dam","tags":{"name":"Big Creek Number 7 104-022 Dam","power":"plant","alt_name":"Big Creek Dam Number 7","waterway":"dam","nhd:fdate":"Thu Mar 27 00:00:00 PST 2003","nhd:com_id":"77258663","ref:US:NID":"CA00440","plant:source":"hydro","gnis:feature_id":"1663705"},"osm_id":76455894,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":80373951,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[32.526459278,67.38119337],[32.526961271,67.381375844],[32.527805328,67.381057583],[32.527308868,67.380868739],[32.526459278,67.38119337]]]},"properties":{"name":"Нива ГЭС-1","tags":{"name":"Нива ГЭС-1","layer":"1","power":"plant","building":"yes","operator":"ТГК-1","waterway":"dam","wikidata":"Q4318430","start_date":"1953","plant:source":"hydro","plant:output:electricity":"24.9 MW"},"osm_id":80373951,"output":"24.9 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":80667335,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[5.811237047,46.053085269],[5.811306533,46.053164059],[5.811469142,46.053082838],[5.811506442,46.05306423],[5.811845238,46.052913272],[5.811868037,46.052951493],[5.811898883,46.052983261],[5.811918664,46.052999689],[5.811930482,46.053009329],[5.811988317,46.053036235],[5.812042465,46.053043359],[5.812059731,46.053042437],[5.812091331,46.053040845],[5.812131564,46.053028775],[5.81214573,46.053009832],[5.812171294,46.052846887],[5.812214796,46.052838338],[5.812227453,46.052860382],[5.812254359,46.052878152],[5.812290234,46.052885276],[5.812321833,46.052882678],[5.812349577,46.052871362],[5.81235863,46.052864322],[5.812369191,46.052856191],[5.812381345,46.052834817],[5.812377657,46.052813276],[5.812439096,46.052804559],[5.812507995,46.052794752],[5.812523837,46.052814533],[5.812552922,46.052829788],[5.812589719,46.052834985],[5.812623163,46.052829285],[5.812647889,46.05281688],[5.812657864,46.052805062],[5.812666832,46.052794417],[5.812670437,46.052773127],[5.812665575,46.052757285],[5.812723829,46.05274555],[5.812789879,46.052732223],[5.812803877,46.052751417],[5.812828352,46.052765918],[5.812858527,46.05277254],[5.812891467,46.052770025],[5.812918625,46.05275938],[5.812931868,46.05274664],[5.812940083,46.052738761],[5.812946034,46.052714537],[5.812937652,46.052692828],[5.812982579,46.052681261],[5.813028679,46.052669359],[5.813040246,46.052688134],[5.813064051,46.052705485],[5.81309892,46.052715962],[5.813139739,46.052714956],[5.81317025,46.05270473],[5.813195563,46.05268453],[5.813205956,46.052662485],[5.813205286,46.052643291],[5.813198329,46.052628036],[5.813241747,46.052616636],[5.813290027,46.052603896],[5.813298828,46.052618732],[5.813308048,46.05262527],[5.813330763,46.052641363],[5.813368482,46.052649745],[5.813406619,46.052646392],[5.813437632,46.052633149],[5.813454899,46.052617558],[5.813463951,46.05260004],[5.813464035,46.052576571],[5.813455486,46.052559388],[5.813494965,46.052547821],[5.813549279,46.052532063],[5.813565456,46.052551677],[5.813596134,46.052567435],[5.813628153,46.05257238],[5.813660339,46.052568441],[5.81368406,46.052559053],[5.813690598,46.052553856],[5.813702836,46.052544049],[5.813709457,46.052532482],[5.8137139,46.052524771],[5.813714151,46.052502391],[5.813702165,46.052481269],[5.813769053,46.052461068],[5.813798222,46.052456458],[5.813841472,46.052449585],[5.81389738,46.052446149],[5.813968626,46.05244355],[5.814082536,46.052437599],[5.814138946,46.052419746],[5.814060659,46.052266273],[5.813989497,46.052227632],[5.813933338,46.05219444],[5.813885058,46.052238696],[5.813809118,46.052291754],[5.813743656,46.052305249],[5.81365003,46.052279349],[5.813612479,46.052255125],[5.813580544,46.052220424],[5.813578951,46.052174575],[5.813577526,46.05213694],[5.813575682,46.05210752],[5.813572246,46.052053121],[5.813540059,46.051961088],[5.813477027,46.051902331],[5.813380468,46.051785152],[5.813322716,46.051697645],[5.813271419,46.051619022],[5.813236467,46.051632936],[5.813149882,46.051660345],[5.813056926,46.05169052],[5.812864059,46.051744919],[5.812695666,46.051788253],[5.812500619,46.051832593],[5.812215718,46.05188783],[5.81192428,46.051932422],[5.811731999,46.051956226],[5.811725377,46.051857571],[5.811719258,46.051842232],[5.811706518,46.05182966],[5.81166838,46.051821781],[5.811585567,46.05182924],[5.811498647,46.051837119],[5.811481296,46.05193435],[5.81139622,46.052019929],[5.811445338,46.05208866],[5.811455983,46.052112465],[5.811551285,46.052070723],[5.811581376,46.052106179],[5.811595122,46.052125122],[5.811587579,46.05213518],[5.811567797,46.052181197],[5.8115481,46.052227129],[5.811700483,46.052357384],[5.811811795,46.052339698],[5.811873485,46.052453273],[5.811889579,46.052517479],[5.811929896,46.052550922],[5.811951269,46.05258445],[5.811978091,46.052684027],[5.811956634,46.052714789],[5.811860074,46.052791064],[5.811780027,46.052848731],[5.811421282,46.053009999],[5.811237047,46.053085269]]]},"properties":{"name":"Barrage de Génissiat","tags":{"name":"Barrage de Génissiat","layer":"2","power":"plant","height":"5 m","building":"yes","operator":"CNR","waterway":"dam","wikidata":"Q681294","wikipedia":"fr:Barrage de Génissiat","start_date":"1947","plant:method":"water-storage","plant:source":"hydro","ref:FR:RTE_nom":"GENISSIAT","operator:wikidata":"Q1121170","ref:EU:ENTSOE_EIC":"17W100P100P0319C","plant:output:electricity":"420 MW"},"osm_id":80667335,"output":"420 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":81265501,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.466819938,45.287405957],[1.467097547,45.287411825],[1.467096373,45.287427667],[1.466987995,45.287447867],[1.466976261,45.287522969],[1.467059577,45.287553563],[1.467189245,45.287569069],[1.467212044,45.287394055],[1.467386639,45.287366898],[1.467458723,45.287355834],[1.467603059,45.28732239],[1.467723843,45.287284588],[1.467823839,45.287244354],[1.467862563,45.287280145],[1.467921069,45.28733446],[1.467962392,45.287334795],[1.468074122,45.287335466],[1.468179651,45.287333789],[1.468173699,45.287308727],[1.468074374,45.287310823],[1.467981921,45.287311829],[1.46791495,45.28725743],[1.46787874,45.287227926],[1.467933558,45.287204037],[1.467905059,45.28714],[1.467796178,45.287216359],[1.467391584,45.287122062],[1.467298964,45.287130947],[1.46725563,45.287137066],[1.467134762,45.287232871],[1.466857992,45.287349044],[1.466819938,45.287405957]]]},"properties":{"name":"Barrage du Saillant","tags":{"name":"Barrage du Saillant","layer":"1","power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","ref:EU:ENTSOE_EIC":"17W000000044353K","plant:output:electricity":"0.42 MW"},"osm_id":81265501,"output":"0.42 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":82852602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.827923464,44.013255673],[4.827946682,44.013309066],[4.827974175,44.013302863],[4.828081799,44.013550465],[4.828054222,44.013556667],[4.828074087,44.013602349],[4.828176765,44.013579298],[4.82828858,44.013837461],[4.828504079,44.014332664],[4.82856359,44.01446954],[4.828869949,44.014400725],[4.828822926,44.014292598],[4.828894424,44.014276421],[4.828878331,44.014239373],[4.828564429,44.014309865],[4.828493182,44.014150274],[4.828732318,44.014096546],[4.828810521,44.014071735],[4.828803145,44.01404835],[4.828716476,44.014057989],[4.828473652,44.014112471],[4.828405256,44.013949695],[4.828633998,44.013897559],[4.828717482,44.013866295],[4.828710441,44.013846346],[4.828619079,44.013853973],[4.828390253,44.013909545],[4.828355551,44.013823128],[4.828319845,44.013738722],[4.828547246,44.013687676],[4.828632322,44.013659178],[4.828624778,44.013642833],[4.82852956,44.013645851],[4.828303919,44.013700752],[4.828245832,44.013566893],[4.828514472,44.01350646],[4.828498966,44.013470837],[4.828535762,44.013462539],[4.828521262,44.013429262],[4.828619581,44.013407553],[4.828603321,44.013368661],[4.828544228,44.013381821],[4.828490165,44.013393891],[4.828445573,44.013290961],[4.828460828,44.013287524],[4.82844155,44.013243184],[4.828423277,44.013247291],[4.82837835,44.013143942],[4.828396958,44.013139835],[4.828386565,44.013115947],[4.82793545,44.013217871],[4.827950538,44.013249554],[4.827923464,44.013255673]]]},"properties":{"name":"Usine-Barrage de Sauveterre","tags":{"name":"Usine-Barrage de Sauveterre","power":"plant","operator":"CNR","waterway":"dam","wikidata":"Q56357342","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1121170","ref:EU:ENTSOE_EIC":"17W000001017800G","operator:wikipedia":"fr:Compagnie nationale du Rhône","old_ref:EU:ENTSOE_EIC":"17W000000012744X;17W000001657048S","plant:output:electricity":"52 MW"},"osm_id":82852602,"output":"52 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":83348196,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[8.1324846,47.586108621],[8.132628684,47.586252454],[8.132731028,47.586205683],[8.133763594,47.585735039],[8.133619425,47.58559129],[8.133526722,47.585633535],[8.132715186,47.586003596],[8.1324846,47.586108621]]]},"properties":{"name":"Rheinkraftwerk Albbruck-Dogern","tags":{"name":"Rheinkraftwerk Albbruck-Dogern","layer":"1","power":"plant","building":"industrial","operator":"RADAG","waterway":"dam","roof:shape":"hipped","roof:colour":"green","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"79.5 MW"},"osm_id":83348196,"output":"79.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":83913812,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[72.634043073,41.657404943],[72.634098058,41.657631422],[72.634300565,41.657673499],[72.635402953,41.657114342],[72.635453831,41.657216517],[72.635682322,41.657146445],[72.635845434,41.657096321],[72.635936629,41.657330846],[72.636454379,41.657393543],[72.636829888,41.657439057],[72.636832571,41.657168489],[72.637629187,41.657397986],[72.637599682,41.657130435],[72.637149071,41.657010155],[72.63700155,41.656863891],[72.636829888,41.656751657],[72.636743974,41.656669431],[72.636634087,41.656597346],[72.636416744,41.656485196],[72.636328231,41.656272799],[72.63621298,41.655763682],[72.635955488,41.655733675],[72.635952722,41.655649521],[72.635928247,41.65561834],[72.635711323,41.655619346],[72.635711323,41.655669469],[72.63553304,41.655672571],[72.63520447,41.655685479],[72.635102546,41.655691598],[72.635040855,41.655811794],[72.63494966,41.655823864],[72.634952342,41.655921932],[72.634922838,41.656052271],[72.634764587,41.656350918],[72.634453451,41.656962043],[72.634043073,41.657404943]]]},"properties":{"name":"Токтогульская ГЭС","tags":{"name":"Токтогульская ГЭС","power":"plant","name:en":"Toktogul Hydroelectric Station","waterway":"dam","wikidata":"Q278196","plant:source":"hydro","plant:output:electricity":"1200 MW"},"osm_id":83913812,"output":"1200 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":89754799,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-6.847840842,43.237431661],[-6.847796083,43.237444653],[-6.847704301,43.237471223],[-6.846970465,43.236930255],[-6.846962335,43.23690972],[-6.846904667,43.23676245],[-6.846385408,43.236834785],[-6.846240821,43.236855573],[-6.846141914,43.236677625],[-6.846047115,43.236507137],[-6.846636614,43.236281831],[-6.846830404,43.235576075],[-6.846966442,43.235506924],[-6.847062415,43.235633072],[-6.847150341,43.235748575],[-6.847308675,43.23595653],[-6.847330719,43.23593767],[-6.847366259,43.235977987],[-6.847344633,43.235991817],[-6.847353267,43.235997433],[-6.847418143,43.235965917],[-6.847449323,43.235971366],[-6.847463908,43.235989219],[-6.847473463,43.236014449],[-6.847453514,43.236034062],[-6.847386124,43.236057364],[-6.847438846,43.236151157],[-6.847505482,43.236122156],[-6.847533981,43.236121569],[-6.847556444,43.236142356],[-6.847549571,43.236162557],[-6.847513277,43.236177644],[-6.847469105,43.23618896],[-6.847519228,43.236282837],[-6.847583266,43.236252578],[-6.847610004,43.236254422],[-6.847627271,43.236269342],[-6.84763163,43.236284765],[-6.847620482,43.236300439],[-6.847599527,43.236308821],[-6.847568514,43.236321226],[-6.847540016,43.236331368],[-6.847581506,43.236414768],[-6.847652752,43.236394735],[-6.847686112,43.236392472],[-6.847704217,43.236411918],[-6.847701619,43.236431448],[-6.847653255,43.236449721],[-6.847604053,43.236466736],[-6.847646382,43.236553321],[-6.847686112,43.23654167],[-6.847725926,43.236538569],[-6.84776046,43.236562457],[-6.847763058,43.236589531],[-6.847723328,43.236601517],[-6.847669767,43.236616688],[-6.847763058,43.236941068],[-6.847813266,43.237184143],[-6.847840842,43.237431661]]]},"properties":{"name":"Presa de Salime","tags":{"name":"Presa de Salime","power":"plant","name:gl":"Encoro de Salime","alt_name":"Central hidroelèctrica de Salime","man_made":"dam","operator":"Enel Green Power España","waterway":"dam","wikidata":"Q46481097","plant:source":"hydro","operator:wikidata":"Q3725218","plant:output:electricity":"78.6 MW"},"osm_id":89754799,"output":"78.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":91572432,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[25.887089485,62.244769727],[25.887318479,62.244909118],[25.887371034,62.244947843],[25.888065642,62.244711557],[25.887783172,62.244540482],[25.887089485,62.244769727]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":91572432,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":93688711,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-122.351378273,45.29886754],[-122.351198565,45.299086391],[-122.350753318,45.299646721],[-122.350670169,45.299786364],[-122.350337576,45.300129686],[-122.349935328,45.300137146],[-122.350601941,45.299357294],[-122.351289257,45.298554895],[-122.351378273,45.29886754]]]},"properties":{"name":"River Mill Dam","tags":{"name":"River Mill Dam","power":"plant","waterway":"dam","wikidata":"Q7337635","ref:US:NID":"OR00552","plant:source":"hydro","gnis:feature_id":"1158851"},"osm_id":93688711,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":94449902,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-5.613499652,39.777278766],[-5.613499652,39.777314137],[-5.613428574,39.777391335],[-5.613353221,39.777442883],[-5.613236042,39.777462162],[-5.613127245,39.777465347],[-5.612930521,39.777449254],[-5.612796662,39.777455624],[-5.612591641,39.77749745],[-5.612612512,39.777730131],[-5.612643525,39.778076388],[-5.613457659,39.777898524],[-5.613466041,39.778011176],[-5.612373879,39.778387356],[-5.612591473,39.778622133],[-5.612483347,39.778721962],[-5.612415202,39.778784742],[-5.612232644,39.778639568],[-5.612067437,39.778495147],[-5.611950844,39.778355756],[-5.611880269,39.778257437],[-5.611814806,39.778134223],[-5.611782452,39.778014781],[-5.611761665,39.777942864],[-5.611753451,39.777874132],[-5.611614143,39.777881425],[-5.611630656,39.778042022],[-5.611546082,39.77804856],[-5.611528145,39.777855189],[-5.611513225,39.77764472],[-5.611596541,39.777640529],[-5.611610539,39.777812777],[-5.611746913,39.777801461],[-5.611740375,39.777631392],[-5.611775914,39.777467023],[-5.611850429,39.777300223],[-5.611976744,39.777116073],[-5.612080429,39.777006521],[-5.612197021,39.776894539],[-5.611983282,39.776869645],[-5.611523367,39.777016328],[-5.611494115,39.777053879],[-5.61146176,39.777056394],[-5.61143586,39.77701658],[-5.611465029,39.776971737],[-5.611341564,39.77670075],[-5.611736938,39.776637718],[-5.612037262,39.776629336],[-5.612550737,39.77667267],[-5.612704042,39.776590444],[-5.612777132,39.776577033],[-5.612805044,39.776601173],[-5.612837734,39.776627827],[-5.612895736,39.776666216],[-5.612420063,39.77693142],[-5.612382429,39.777050443],[-5.612378238,39.777156558],[-5.612461973,39.777256218],[-5.612600023,39.777259571],[-5.612830106,39.777227385],[-5.613043509,39.777188744],[-5.61321098,39.777124455],[-5.613499652,39.777278766]]]},"properties":{"name":"Presa de Valdecañas","tags":{"name":"Presa de Valdecañas","power":"plant","building":"yes","operator":"Ibedrola","waterway":"dam","wikidata":"Q106171110","plant:method":"water-pumped-storage","plant:source":"hydro","plant:output:electricity":"249 MW"},"osm_id":94449902,"output":"249 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":98375825,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0.805550977,44.853720336],[0.805570842,44.854015044],[0.806262098,44.853991658],[0.806410961,44.853986545],[0.806478267,44.853984282],[0.806524535,44.853982774],[0.806553034,44.854406479],[0.806845059,44.854396588],[0.806819997,44.854023174],[0.806886717,44.854020995],[0.80688194,44.853950419],[0.806872133,44.853805077],[0.80666862,44.85381195],[0.806465778,44.85381874],[0.805667318,44.853845897],[0.805658685,44.853716732],[0.805550977,44.853720336]]]},"properties":{"name":"Centrale Hydroélectrique de Mauzac","tags":{"name":"Centrale Hydroélectrique de Mauzac","power":"plant","building":"yes","operator":"Électricité de France","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","operator:short":"EDF","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W0000000126262","plant:output:electricity":"13.2 MW"},"osm_id":98375825,"output":"13.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":98648435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[8.235573878,47.557918939],[8.235674461,47.557919778],[8.236247867,47.557924136],[8.236247196,47.557965878],[8.236451044,47.557967471],[8.23645138,47.557946935],[8.238043019,47.557959256],[8.238262541,47.557624651],[8.23809155,47.557580059],[8.23791662,47.557824391],[8.237646555,47.557822296],[8.237167194,47.557818524],[8.236447859,47.557812992],[8.236448362,47.557783991],[8.236260021,47.557782482],[8.236259434,47.557816932],[8.235575638,47.557811735],[8.235573878,47.557918939]]]},"properties":{"name":"Hydraulisches Kraftwerk Beznau","tags":{"name":"Hydraulisches Kraftwerk Beznau","power":"plant","building":"electricity","operator":"Axpo","waterway":"dam","wikidata":"Q301150","start_date":"1902","plant:source":"hydro","building:roof":"tile","plant:output:electricity":"19.25 MW"},"osm_id":98648435,"output":"19.25 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":102573645,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.92231918,47.875614527],[10.922390929,47.875654928],[10.922358323,47.875785099],[10.9223859,47.875799097],[10.922535936,47.875731036],[10.922642218,47.87567077],[10.922772557,47.87556432],[10.922908344,47.875620562],[10.922915971,47.875613941],[10.922793428,47.875547221],[10.922822597,47.8755235],[10.922783369,47.875500533],[10.922852855,47.87545284],[10.922917145,47.875399448],[10.923120657,47.875235665],[10.9229583,47.87519342],[10.922790075,47.875331219],[10.92273224,47.875381427],[10.922674321,47.875431718],[10.922605422,47.875393664],[10.922489751,47.875329626],[10.922441472,47.875364076],[10.922544569,47.875428617],[10.922411045,47.875537079],[10.92231918,47.875614527]]]},"properties":{"name":"Lechstaustufe 8a - Kinsau","tags":{"name":"Lechstaustufe 8a - Kinsau","power":"plant","alt_name":"Laufwasserwerk Klein Kinsau","operator":"Uniper Kraftwerke","waterway":"dam","wikidata":"Q60680045","wikipedia":"de:Lechstaustufe 8a – Kinsau","start_date":"1991","description":"Hauptkraftwerk","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1273399","plant:output:electricity":"8.1 MW"},"osm_id":102573645,"output":"8.1 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":104296520,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-51.385517369,-29.005973024],[-51.385046139,-29.005707402],[-51.384467368,-29.005447814],[-51.384033521,-29.005289983],[-51.383445614,-29.005120333],[-51.38300414,-29.005035676],[-51.382251864,-29.004943978],[-51.382259072,-29.005052523],[-51.382662745,-29.005210103],[-51.382788557,-29.005438426],[-51.383127856,-29.005425518],[-51.383497666,-29.005495423],[-51.383921707,-29.005612435],[-51.384226137,-29.005719974],[-51.384650262,-29.005889205],[-51.385041529,-29.006085845],[-51.385138675,-29.006038235],[-51.385497504,-29.006053239],[-51.385517369,-29.005973024]]]},"properties":{"name":"Usina Hidroeletrica Castro Alves","tags":{"name":"Usina Hidroeletrica Castro Alves","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":104296520,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":110183925,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-68.614205608,-34.606090256],[-68.613903859,-34.605766212],[-68.613129875,-34.606954011],[-68.612828126,-34.610689826],[-68.613352917,-34.612115001],[-68.613812078,-34.611542768],[-68.614205608,-34.606090256]]]},"properties":{"name":"Dique El Tigre","tags":{"name":"Dique El Tigre","power":"plant","height":"48.5 m","operator":"Hidroeléctrica Diamante S.A.","waterway":"dam","wikidata":"Q16561586","wikipedia":"es:Embalse El Tigre","plant:source":"hydro","plant:output:electricity":"14 MW"},"osm_id":110183925,"output":"14 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":110561134,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[12.598362359,50.528666254],[12.59840452,50.528764239],[12.599005838,50.53031992],[12.598861417,50.530345569],[12.598893017,50.530417485],[12.598921264,50.530481942],[12.599059817,50.530457383],[12.599282357,50.531064736],[12.599401296,50.531048643],[12.599534484,50.530461155],[12.599574634,50.530274574],[12.599725173,50.530248842],[12.599701787,50.530185391],[12.599566922,50.529818682],[12.599364164,50.529852545],[12.599108097,50.529467397],[12.59857593,50.528814195],[12.59850175,50.528648736],[12.598362359,50.528666254]]]},"properties":{"name":"Wasserkraftanlage Talsperre Eibenstock","tags":{"name":"Wasserkraftanlage Talsperre Eibenstock","image":"https://photos.app.goo.gl/EfvC77QyLi8vsfQE9","power":"plant","operator":"Landestalsperrenverwaltung Sachsen","waterway":"dam","wikidata":"Q607636","start_date":"1982","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"1.7 MW"},"osm_id":110561134,"output":"1.7 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":111646274,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-79.022516268,43.143976625],[-79.022415266,43.143977631],[-79.02223011,43.143978972],[-79.022060628,43.143980397],[-79.021984352,43.143980984],[-79.021112131,43.14398836],[-79.021124034,43.143336667],[-79.021107438,43.142250792],[-79.021099978,43.141769838],[-79.021093104,43.14129165],[-79.021267364,43.14129207],[-79.02193272,43.141284861],[-79.02196784,43.141284693],[-79.02197354,43.141658023],[-79.022017042,43.141658694],[-79.022083007,43.141643523],[-79.022215777,43.141661376],[-79.022386851,43.141665232],[-79.022480812,43.141653916],[-79.022516268,43.143976625]]]},"properties":{"name":"Lewiston Pump-Generating Plant","tags":{"name":"Lewiston Pump-Generating Plant","power":"plant","alt_name":"Lewiston Pump-Generating Plant Dam;Lewiston Dam","operator":"New York Power Authority","waterway":"dam","wikidata":"Q11983871","ref:US:EIA":"2692","ref:US:NID":"NY00689","start_date":"1961-10","plant:source":"hydro","plant:output:electricity":"240 MW"},"osm_id":111646274,"output":"240 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":111646277,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-79.041062151,43.141430371],[-79.041059971,43.141479321],[-79.041056283,43.141574121],[-79.041053601,43.141605888],[-79.041012111,43.141604044],[-79.040990234,43.141601446],[-79.040873474,43.142865185],[-79.040745315,43.144239985],[-79.04081983,43.144241661],[-79.040824607,43.144376526],[-79.04074104,43.144569142],[-79.040267714,43.144567717],[-79.039396666,43.144549529],[-79.03874556,43.144538129],[-79.038771041,43.144206793],[-79.038673057,43.144199668],[-79.038622681,43.14419598],[-79.038636009,43.144088021],[-79.038749667,43.144095397],[-79.038878665,43.142711377],[-79.038981846,43.141474124],[-79.038876318,43.14146876],[-79.038887131,43.141342193],[-79.038918311,43.141344121],[-79.038968435,43.141346384],[-79.038995509,43.141042792],[-79.039200362,43.141053018],[-79.039252749,43.14038037],[-79.039754406,43.140409958],[-79.039979712,43.140423034],[-79.039976443,43.140455639],[-79.040331919,43.14047475],[-79.040293027,43.140888397],[-79.040245669,43.141390557],[-79.041062151,43.141430371]]]},"properties":{"name":"Robert Moses Power Plant","tags":{"name":"Robert Moses Power Plant","power":"plant","alt_name":"Robert Moses - Niagara Dam","operator":"New York Power Authority","waterway":"dam","wikidata":"Q2944554","wikipedia":"en:Robert Moses Niagara Power Plant","ref:US:EIA":"2693","ref:US:NID":"NY16253","start_date":"1961-01","plant:source":"hydro","operator:short":"NYPA","operator:wikidata":"Q269610","plant:output:electricity":"2429.1 MW"},"osm_id":111646277,"output":"2429.1 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":113308943,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[35.079879681,47.869268337],[35.08026835,47.869293986],[35.082998839,47.869584586],[35.083032366,47.86963756],[35.083080646,47.869748285],[35.083136972,47.869844593],[35.083249625,47.869902093],[35.083397147,47.869919192],[35.083570149,47.86991827],[35.08421522,47.869871499],[35.084447232,47.869877785],[35.084860292,47.869893962],[35.085094985,47.869901171],[35.085207638,47.86989304],[35.085343089,47.869868733],[35.085835275,47.869768988],[35.086369034,47.869688857],[35.086495098,47.869670836],[35.086571541,47.869643846],[35.086913523,47.869545862],[35.087368157,47.869414434],[35.087751629,47.869293064],[35.088168797,47.869140094],[35.088518825,47.868997099],[35.088927862,47.86879828],[35.089208153,47.868650674],[35.089461621,47.868507679],[35.089694973,47.868355548],[35.090112057,47.868040723],[35.090413722,47.867790524],[35.090667274,47.867532361],[35.090970364,47.867204964],[35.091321063,47.866744797],[35.091424328,47.866597192],[35.091481995,47.866526952],[35.09157252,47.866447324],[35.091638821,47.866331318],[35.09167503,47.86622646],[35.091763627,47.86609696],[35.091519043,47.865984894],[35.091407145,47.866191256],[35.090967598,47.866091679],[35.09033577,47.865939632],[35.090290089,47.866001742],[35.090248682,47.866058571],[35.089968559,47.866437433],[35.089941737,47.866477918],[35.089619872,47.866865748],[35.089336899,47.867156349],[35.08902308,47.867440663],[35.088758883,47.867650294],[35.088266697,47.867946259],[35.088812527,47.868368121],[35.088835325,47.868403241],[35.08880842,47.868432996],[35.088465181,47.868601137],[35.088034686,47.868797358],[35.087690022,47.868937671],[35.087364134,47.869059125],[35.086905476,47.869196755],[35.086413291,47.869331788],[35.086004254,47.869422564],[35.085736033,47.869465731],[35.085415425,47.869530523],[35.085211661,47.869562961],[35.084911254,47.869589951],[35.084531721,47.869592633],[35.084146824,47.869593555],[35.083951023,47.869594477],[35.083694872,47.869618617],[35.083430674,47.869648372],[35.083370325,47.86963932],[35.083348196,47.869603865],[35.083305952,47.869461288],[35.083287176,47.869415858],[35.083252224,47.869387025],[35.08319464,47.869364561],[35.083065223,47.869327681],[35.083106127,47.869192732],[35.083142924,47.869011851],[35.083169829,47.868913866],[35.083174859,47.868885955],[35.083170835,47.868867431],[35.083155748,47.868858797],[35.083135631,47.868862569],[35.08312155,47.868874052],[35.083083328,47.868992572],[35.083069247,47.869021741],[35.083016273,47.869024088],[35.080250832,47.868635336],[35.080059892,47.868600635],[35.079879681,47.869268337]]]},"properties":{"name":"Дніпровська ГЕС","tags":{"name":"Дніпровська ГЕС","image":"https://upload.wikimedia.org/wikipedia/commons/3/3e/Дніпрогес_з_висоти.jpg","power":"plant","name:de":"DniproHES","name:en":"Dnieper Hydroelectric Station","name:hr":"DniproGES","name:ru":"Днепровская ГЭС","name:uk":"Дніпровська ГЕС","name:zh":"第聂伯河水电站","alt_name":"Дніпрогес","waterway":"dam","wikidata":"Q738619","wikipedia":"uk:Дніпровська ГЕС","start_date":"1932-10-10","alt_name:ru":"Днепрогэс","plant:method":"water-storage","plant:source":"hydro","generator:source":"hydro","plant:output:electricity":"1578,6 MW"},"osm_id":113308943,"output":"1578,6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":113557354,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.776328433,56.290556146],[101.77670763,56.290639881],[101.777197049,56.290800143],[101.77933586,56.289509497],[101.781065465,56.28960195],[101.781621688,56.289714183],[101.782203728,56.289373962],[101.781943721,56.289232559],[101.784376485,56.287806965],[101.786731129,56.286380617],[101.787380056,56.28673668],[101.787466389,56.286712624],[101.787583317,56.286719581],[101.788513876,56.287238085],[101.788686795,56.287272535],[101.788791317,56.287268093],[101.788858456,56.287238253],[101.788869185,56.287187626],[101.788853091,56.287134066],[101.788771452,56.287048068],[101.788359565,56.286787139],[101.787848772,56.286461418],[101.786854427,56.285908799],[101.789441082,56.284375917],[101.789215273,56.284066373],[101.789821285,56.283737467],[101.789925221,56.283246791],[101.79096684,56.282641869],[101.792181126,56.281961845],[101.791970321,56.281788591],[101.791947522,56.281747771],[101.791966885,56.281717345],[101.793963622,56.280588973],[101.793718535,56.280382527],[101.791898572,56.281430097],[101.791685923,56.281556245],[101.791589028,56.281643668],[101.791570169,56.281709466],[101.791589028,56.28178264],[101.791685923,56.281902166],[101.789680721,56.283070436],[101.78959598,56.283027185],[101.78428529,56.286188252],[101.781076194,56.288037719],[101.779284981,56.289039692],[101.77936176,56.289085625],[101.776814667,56.290561594],[101.776604868,56.290519433],[101.776494227,56.290515326],[101.776328433,56.290556146]]]},"properties":{"name":"Братская ГЭС","tags":{"name":"Братская ГЭС","power":"plant","name:en":"Bratsk Hydroelectric Power Station","operator":"Иркутскэнерго","waterway":"dam","wikidata":"Q1965517","wikipedia":"ru:Братская ГЭС","start_date":"1967","plant:method":"water-storage","plant:source":"hydro","official_name":"Братская гидроэлектростанция","plant:output:electricity":"4500 MW"},"osm_id":113557354,"output":"4500 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":114014331,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[83.967503278,28.195326228],[83.967542087,28.195661924],[83.967782144,28.195641891],[83.968038882,28.19560962],[83.968099483,28.195547594],[83.968091772,28.195485484],[83.96788323,28.195505433],[83.967856995,28.195292365],[83.967673263,28.195311057],[83.967503278,28.195326228]]]},"properties":{"name":"Phewa dam","tags":{"name":"Phewa dam","power":"plant","name:ne":"फेवा ड्याम","waterway":"dam","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":114014331,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":116390669,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.824295756,47.739750331],[10.824391226,47.739841694],[10.824754162,47.739733064],[10.82530083,47.739583028],[10.825382386,47.73955629],[10.825447597,47.739534832],[10.825414153,47.739476746],[10.825367466,47.739395693],[10.825347182,47.739360321],[10.824826246,47.739528127],[10.82485508,47.739584956],[10.824687442,47.73963047],[10.824295756,47.739750331]]]},"properties":{"name":"Lechstaustufe 4 - Dessau","tags":{"name":"Lechstaustufe 4 - Dessau","layer":"1","power":"plant","building":"yes","operator":"Uniper Kraftwerke","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1273399","plant:output:electricity":"10.3 MW"},"osm_id":116390669,"output":"10.3 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":116935484,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[5.416890128,45.846012782],[5.416901276,45.84601538],[5.416968667,45.845753194],[5.4170721,45.845769036],[5.417117027,45.845598967],[5.417350295,45.845627633],[5.417317186,45.845773227],[5.417374519,45.845779429],[5.417413494,45.845635931],[5.417620695,45.845661328],[5.417592364,45.845804659],[5.417645757,45.845811029],[5.417679536,45.845666357],[5.417895454,45.845693263],[5.417863686,45.845839779],[5.417911547,45.845844221],[5.417948428,45.845698879],[5.418151186,45.845722097],[5.418066696,45.84615519],[5.418077676,45.846157034],[5.418148084,45.845894764],[5.418168369,45.845900632],[5.418235424,45.845648755],[5.418174236,45.84564029],[5.417148543,45.84551892],[5.417162792,45.845429652],[5.417029687,45.845417247],[5.416987945,45.845624448],[5.416890128,45.846012782]]]},"properties":{"name":"Barrage de Villebois","tags":{"name":"Barrage de Villebois","layer":"1","power":"plant","alt_name":"Barrage de Sault-Brénaz","building":"yes","operator":"CNR","waterway":"dam","wikidata":"Q2885280","wikipedia":"fr:Barrage de Sault-Brénaz","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1121170","operator:wikipedia":"fr:Compagnie nationale du Rhône","plant:output:electricity":"1.3 MW"},"osm_id":116935484,"output":"1.3 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":119073566,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[25.236922271,56.582030542],[25.237983337,56.582446284],[25.237867247,56.58253639],[25.237704052,56.58266304],[25.237789296,56.58279648],[25.239050185,56.583273159],[25.240270842,56.583742462],[25.241097549,56.58391647],[25.241653688,56.583435852],[25.239736998,56.582724145],[25.237255871,56.58180247],[25.237226199,56.581822755],[25.237155959,56.581870783],[25.236922271,56.582030542]]]},"properties":{"name":"Pļaviņu HES","tags":{"name":"Pļaviņu HES","power":"plant","name:de":"Kraftwerk Plavinu","name:en":"Pļaviņas Hydro Power Plant","name:lt":"Pliavinių hidroelektrinė","name:lv":"Pļaviņu HES","name:ru":"Плявиньская ГЭС","operator":"AS „Latvenergo“","waterway":"dam","wikidata":"Q2984983","wikipedia":"lv:Pļaviņu HES","start_date":"1965","plant:method":"water-storage","plant:source":"hydro","ref:EU:ENTSOE_EIC":"43W-PLAV-HPP---J","plant:output:electricity":"908 MW"},"osm_id":119073566,"output":"908 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":120329100,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-85.002132045,32.514546918],[-84.996223139,32.51682713],[-84.996572413,32.516339639],[-84.99719787,32.516080135],[-84.997321671,32.516257832],[-84.99946551,32.515418887],[-85.001171814,32.514751017],[-85.002132045,32.514546918]]]},"properties":{"name":"Oliver Dam","tags":{"name":"Oliver Dam","layer":"1","power":"plant","operator":"Georgia Power","waterway":"dam","wikidata":"Q65208526","ref:US:EIA":"720","ref:US:NID":"GA00837","start_date":"1959-07","plant:method":"water-storage","plant:source":"hydro","gnis:feature_id":"124283","operator:wikidata":"Q5547666","plant:output:electricity":"60 MW"},"osm_id":120329100,"output":"60 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":120358831,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-84.995917534,32.498803524],[-84.995275229,32.498790448],[-84.99522762,32.498789694],[-84.995161151,32.49878852],[-84.995138101,32.498735044],[-84.995037518,32.498528933],[-84.995170455,32.498499848],[-84.995250503,32.498581152],[-84.995362904,32.498583164],[-84.995362652,32.498545781],[-84.99591326,32.49855894],[-84.995917534,32.498803524]]]},"properties":{"name":"North Highlands Dam","tags":{"name":"North Highlands Dam","power":"plant","operator":"Georgia Power","waterway":"dam","wikidata":"Q7055615","ref:US:EIA":"719","ref:US:NID":"GA00853","start_date":"1899","plant:method":"run-of-the-river","plant:source":"hydro","generator:method":"run-of-the-river","operator:wikidata":"Q5547666","plant:output:electricity":"26.6 MW"},"osm_id":120358831,"output":"26.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":122070337,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[5.317256625,45.121552736],[5.317705057,45.121664048],[5.318032873,45.121745436],[5.318067741,45.121660947],[5.31820906,45.121673855],[5.318976172,45.121284767],[5.318612733,45.120983857],[5.318526567,45.120961728],[5.319138027,45.119762949],[5.318650032,45.119635879],[5.318088277,45.120891656],[5.317676893,45.120743464],[5.317587878,45.120923758],[5.317424682,45.121229279],[5.317256625,45.121552736]]]},"properties":{"name":"Barrage de Beauvoir","tags":{"name":"Barrage de Beauvoir","layer":"1","power":"plant","operator":"Électricité de France","waterway":"dam","wikidata":"Q125158000","plant:method":"water-storage","plant:source":"hydro","operator:short":"EDF","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W0000000124294","plant:output:electricity":"39 MW"},"osm_id":122070337,"output":"39 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":123113736,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-54.408357877,-27.730412904],[-54.408347903,-27.730332018],[-54.4082122,-27.729227367],[-54.408111701,-27.729203144],[-54.408014639,-27.728925703],[-54.407955965,-27.728924445],[-54.408117904,-27.730465123],[-54.408149839,-27.730648603],[-54.408222677,-27.730648603],[-54.408191413,-27.730430254],[-54.408357877,-27.730412904]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":123113736,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":123384238,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-85.412862651,44.900953618],[-85.412747484,44.90103618],[-85.412630808,44.901118993],[-85.412528046,44.901049758],[-85.412650254,44.900963089],[-85.412763661,44.900882455],[-85.412862651,44.900953618]]]},"properties":{"name":"Elk Rapids Hydroelectric Power","tags":{"name":"Elk Rapids Hydroelectric Power","power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","gnis:feature_id":"2463255","plant:output:electricity":"0.8 MW"},"osm_id":123384238,"output":"0.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":127868956,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3.886127878,44.530804667],[3.886322339,44.530834255],[3.886502047,44.530825621],[3.886477823,44.53072814],[3.886495341,44.530595287],[3.886582597,44.530466121],[3.886649568,44.530351457],[3.886751492,44.530238637],[3.886865486,44.530088517],[3.886936564,44.530129672],[3.887069334,44.530033196],[3.887235631,44.529957591],[3.887337555,44.529898248],[3.887471665,44.529839993],[3.887619187,44.529783583],[3.887770731,44.529742428],[3.88791423,44.529739578],[3.888025541,44.529752067],[3.888165016,44.529707056],[3.888056387,44.529659279],[3.887949098,44.529594236],[3.887892772,44.529645952],[3.887690265,44.529651652],[3.887502427,44.529667829],[3.887291957,44.529718539],[3.887196739,44.529743434],[3.887086768,44.529721473],[3.88685878,44.52983136],[3.886649568,44.529953736],[3.886653591,44.530060856],[3.886551667,44.530167977],[3.886472542,44.530281636],[3.886388053,44.530476766],[3.886357124,44.530610542],[3.886362572,44.53068799],[3.886181523,44.530771139],[3.886127878,44.530804667]]]},"properties":{"name":"Barrage de Puylaurent","tags":{"area":"yes","name":"Barrage de Puylaurent","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q2885269","start_date":"2006-11-02","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000413649V","plant:output:electricity":"3.69 MW"},"osm_id":127868956,"output":"3.69 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":130697711,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[30.510348792,50.588611195],[30.510449291,50.588660984],[30.510474856,50.588732146],[30.510602261,50.589115618],[30.511221097,50.588967761],[30.51354322,50.588434086],[30.514342518,50.588258652],[30.514183094,50.587885741],[30.514130037,50.587816088],[30.514063652,50.587767473],[30.513179864,50.587969225],[30.512384589,50.588151448],[30.510348792,50.588611195]]]},"properties":{"name":"Київська ГЕС","tags":{"name":"Київська ГЕС","power":"plant","name:de":"Wasserkraftwerk Kiew","name:en":"Kiev Hydroelectric Power Plant","name:ru":"Киевская ГЭС","name:uk":"Київська ГЕС","waterway":"dam","wikidata":"Q3443779","wikipedia":"uk:Київська ГЕС","start_date":"1964","plant:source":"hydro","plant:output:electricity":"440 MW"},"osm_id":130697711,"output":"440 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":131306887,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[5.40554363,45.853206047],[5.406110833,45.853457085],[5.406462622,45.853072439],[5.405913272,45.852851073],[5.405818724,45.85276935],[5.40563625,45.85306104],[5.405580175,45.853148966],[5.405552263,45.853192636],[5.40554363,45.853206047]]]},"properties":{"name":"Usine Hydroélectrique de Porcieu Amblagnieu","tags":{"name":"Usine Hydroélectrique de Porcieu Amblagnieu","power":"plant","hauteur":"5 m","operator":"CNR","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1121170","ref:EU:ENTSOE_EIC":"17W000001017792Q","old_ref:EU:ENTSOE_EIC":"17W000000012798A","plant:output:electricity":"40 MW"},"osm_id":131306887,"output":"40 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":131505885,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[52.277032099,55.702163558],[52.278206236,55.702285599],[52.279289177,55.699785277],[52.28029576,55.697460891],[52.279163533,55.697329379],[52.278152591,55.699622417],[52.277032099,55.702163558]]]},"properties":{"name":"Нижнекамская ГЭС","tags":{"name":"Нижнекамская ГЭС","power":"plant","name:en":"Nizhnekamsk Dam","name:ru":"Нижнекамская ГЭС","operator":"Татэнерго","waterway":"dam","wikidata":"Q12133686","wikipedia":"ru:Нижнекамская ГЭС","start_date":"1979","plant:source":"hydro","plant:output:electricity":"1205 MW"},"osm_id":131505885,"output":"1205 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":133117561,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-96.539337876,56.040769564],[-96.538928755,56.040796134],[-96.538642849,56.040814742],[-96.537680355,56.040877271],[-96.53639147,56.04096109],[-96.536362468,56.040821951],[-96.536309578,56.040567727],[-96.536286109,56.040455242],[-96.537736346,56.04036103],[-96.538967312,56.040281066],[-96.539232516,56.040263884],[-96.53925741,56.040383326],[-96.539337876,56.040769564]]]},"properties":{"name":"Kelsey Generating Station","tags":{"name":"Kelsey Generating Station","fixme":"offset","power":"plant","building":"yes","operator":"Manitoba Hydro","waterway":"dam","wikidata":"Q1323872","start_date":"1968","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q3285979","plant:output:electricity":"250 MW"},"osm_id":133117561,"output":"250 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":133667228,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-114.282814863,51.100890067],[-114.282810002,51.100895683],[-114.282799944,51.100901131],[-114.28278427,51.100905071],[-114.282763902,51.100906998],[-114.28275443,51.100947651],[-114.282727943,51.101009174],[-114.282712101,51.101044126],[-114.282727776,51.101049575],[-114.282742863,51.101057034],[-114.282752167,51.101065165],[-114.282756442,51.101070781],[-114.282755603,51.101075475],[-114.28274873,51.101079582],[-114.282737163,51.101082767],[-114.282721321,51.101083437],[-114.282695254,51.101080839],[-114.282681256,51.101110092],[-114.282640017,51.101176477],[-114.282612105,51.101218386],[-114.282537255,51.101357526],[-114.28246274,51.101620466],[-114.282453436,51.101667153],[-114.282390236,51.101664555],[-114.282435247,51.101321064],[-114.282035346,51.101170944],[-114.282002406,51.101158539],[-114.28212101,51.101033984],[-114.282430721,51.101150241],[-114.282512277,51.101038343],[-114.282419657,51.101018561],[-114.282434744,51.10099065],[-114.282525855,51.10101018],[-114.282560473,51.100890821],[-114.28246735,51.100888391],[-114.282469445,51.100857377],[-114.282562401,51.100859892],[-114.282559215,51.10073475],[-114.282463075,51.100739025],[-114.282463913,51.100707844],[-114.282492663,51.100656128],[-114.282531974,51.1006081],[-114.282599868,51.100584044],[-114.282669437,51.100574069],[-114.282710676,51.100573818],[-114.282741857,51.10058748],[-114.282703217,51.100651099],[-114.282741606,51.100685632],[-114.282742611,51.100687979],[-114.282765829,51.100688817],[-114.282785108,51.100691667],[-114.282799357,51.100696529],[-114.282807236,51.100700552],[-114.282809751,51.100705162],[-114.28280623,51.100710862],[-114.282797764,51.100717064],[-114.282783515,51.100721926],[-114.282758872,51.100726452],[-114.282760549,51.100730475],[-114.282771948,51.100783533],[-114.28277396,51.100839021],[-114.282770104,51.100869028],[-114.282773373,51.100869447],[-114.282792568,51.100873974],[-114.282806062,51.100880344],[-114.282813271,51.100885038],[-114.282814863,51.100890067]]]},"properties":{"name":"Bearspaw Dam","tags":{"area":"yes","name":"Bearspaw Dam","power":"plant","operator":"TransAlta","waterway":"dam","plant:source":"hydro"},"osm_id":133667228,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":137168521,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[50.7362435,32.732886862],[50.73626303,32.733113844],[50.736286583,32.733324481],[50.736326648,32.733498573],[50.73639605,32.733683143],[50.736454724,32.733808117],[50.73639999,32.733827898],[50.736573244,32.734078349],[50.736680029,32.734190164],[50.73676762,32.734281946],[50.736825372,32.734254788],[50.73688899,32.734308349],[50.737018993,32.734407926],[50.737107925,32.734476155],[50.737319149,32.734594591],[50.737737742,32.734762396],[50.738093721,32.734884186],[50.738569478,32.734962556],[50.738622787,32.735009663],[50.738601497,32.735063558],[50.738516085,32.735072527],[50.738569478,32.735225161],[50.738697553,32.735157771],[50.738972396,32.735038832],[50.738813475,32.734890723],[50.738735272,32.734874295],[50.738633599,32.734824925],[50.738496639,32.734792068],[50.73815625,32.734670279],[50.737890292,32.734541952],[50.737745537,32.73445302],[50.73765166,32.734357634],[50.73776909,32.734239282],[50.737499193,32.733982544],[50.73745217,32.733952956],[50.737370028,32.733854217],[50.737197947,32.733646933],[50.737135334,32.733663361],[50.73710013,32.73362053],[50.73694364,32.73365674],[50.73692805,32.733627152],[50.737017988,32.733597563],[50.736955375,32.733488934],[50.736869377,32.733521875],[50.736744151,32.733156675],[50.73661113,32.732804552],[50.736536866,32.73242284],[50.736557654,32.732244557],[50.736357577,32.732206419],[50.736322374,32.732325274],[50.736302173,32.732393252],[50.736270825,32.732501881],[50.736247355,32.732755182],[50.7362435,32.732886862]]]},"properties":{"name":"تاج سد زاینده رود","tags":{"name":"تاج سد زاینده رود","power":"plant","name:en":"Zayanderud Dam","waterway":"dam","wikidata":"Q8067540","start_date":"1971","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"55.5 MW"},"osm_id":137168521,"output":"55.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":138744725,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.953054477,45.105099061],[1.953085322,45.105111382],[1.953094626,45.105099061],[1.953388747,45.10520593],[1.953406265,45.105212384],[1.953486647,45.105081794],[1.95367809,45.104790439],[1.953756126,45.104816339],[1.954085451,45.104316945],[1.954481579,45.103721159],[1.954777796,45.103820317],[1.954853652,45.104000193],[1.95499254,45.103975299],[1.954986421,45.103839847],[1.955010394,45.103712358],[1.954595992,45.103524855],[1.954028789,45.103344644],[1.953825695,45.103131157],[1.953588991,45.103214641],[1.953530569,45.103238027],[1.953442475,45.103267196],[1.953426465,45.103436175],[1.953586727,45.103439025],[1.953579268,45.103559975],[1.953709522,45.10358026],[1.953871964,45.103631725],[1.953992244,45.103669862],[1.953664931,45.104168669],[1.95347902,45.104451978],[1.953392351,45.104583993],[1.953334348,45.104676026],[1.953586392,45.10476303],[1.953409785,45.105029323],[1.953158831,45.104945336],[1.95311424,45.10501214],[1.953054477,45.105099061]]]},"properties":{"name":"Barrage du Sablier","tags":{"name":"Barrage du Sablier","layer":"1","power":"plant","alt_name":"Barrage d'Argentat","operator":"EDF","waterway":"dam","wikidata":"Q2367090","plant:method":"water-storage","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000012506C","plant:output:electricity":"46 MW"},"osm_id":138744725,"output":"46 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":143581084,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-51.633497316,-20.781341176],[-51.633447611,-20.781301865],[-51.633241081,-20.781139843],[-51.633087441,-20.781017216],[-51.632835146,-20.780809847],[-51.632890131,-20.780748324],[-51.632686702,-20.780588062],[-51.632773036,-20.780488401],[-51.629726046,-20.778093524],[-51.629677766,-20.778146246],[-51.629610711,-20.778089752],[-51.629666451,-20.778020937],[-51.629686065,-20.777970813],[-51.629671648,-20.777945248],[-51.629636863,-20.777942063],[-51.629560671,-20.777996545],[-51.629531502,-20.778015824],[-51.629503423,-20.778003251],[-51.629352716,-20.77789043],[-51.629280716,-20.777836535],[-51.629408624,-20.777696976],[-51.629410635,-20.777679877],[-51.629399152,-20.777669148],[-51.629383143,-20.777673423],[-51.629251211,-20.777810132],[-51.629009813,-20.777625814],[-51.62905013,-20.777579462],[-51.628666574,-20.777243431],[-51.628277653,-20.776917459],[-51.627301329,-20.776145067],[-51.625391513,-20.774586452],[-51.625373408,-20.774571364],[-51.625367457,-20.774550661],[-51.625380113,-20.774530628],[-51.625424454,-20.774481762],[-51.625490838,-20.774402721],[-51.625551104,-20.774301132],[-51.625580022,-20.774249751],[-51.625604162,-20.774196442],[-51.625622937,-20.774126872],[-51.625597456,-20.77412243],[-51.625571975,-20.774202058],[-51.625543812,-20.774261653],[-51.625510955,-20.77432435],[-51.625411881,-20.774463741],[-51.625348765,-20.774533814],[-51.625298474,-20.774493413],[-51.625139804,-20.774653926],[-51.62492682,-20.774483438],[-51.624827159,-20.774576142],[-51.625133182,-20.774772949],[-51.624955821,-20.774883339],[-51.62467595,-20.775172012],[-51.62462373,-20.775205875],[-51.624517448,-20.775153823],[-51.624501103,-20.775185758],[-51.624625071,-20.775243677],[-51.624680141,-20.77520512],[-51.62473521,-20.775160445],[-51.624781981,-20.775101939],[-51.624995719,-20.774900103],[-51.625063277,-20.774865318],[-51.628748046,-20.777847431],[-51.628259129,-20.77835462],[-51.628297518,-20.778389657],[-51.628692725,-20.777984643],[-51.628999671,-20.778242135],[-51.629185916,-20.778396781],[-51.628523159,-20.77907362],[-51.628558363,-20.779105639],[-51.628912415,-20.778747983],[-51.62904904,-20.778709929],[-51.629271412,-20.778893577],[-51.629236459,-20.778931128],[-51.629557319,-20.779199684],[-51.629597049,-20.779157774],[-51.629928553,-20.779424235],[-51.629898965,-20.779457511],[-51.630226278,-20.779731264],[-51.630263829,-20.779693462],[-51.631384909,-20.780603736],[-51.631355404,-20.780633827],[-51.631663942,-20.780892828],[-51.631706103,-20.780848907],[-51.632330471,-20.78136364],[-51.63266843,-20.781640745],[-51.632983002,-20.781894549],[-51.633077131,-20.781800169],[-51.633346777,-20.781497583],[-51.633417604,-20.781428348],[-51.633497316,-20.781341176]]]},"properties":{"name":"Usina Hidrelétrica Engenheiro Souza Dias","tags":{"name":"Usina Hidrelétrica Engenheiro Souza Dias","note":"14 Kaplan turbines mapped separately as nodes","power":"plant","branch":"Rio Paraná Energia S.A.","website":"http://ctgbr.com.br/","operator":"China Three Gorges Brasil Energia Ltda.","waterway":"dam","wikidata":"Q1713762","wikipedia":"pt:Usina Hidrelétrica Engenheiro Souza Dias","short_name":"UHE Jupiá","start_date":"1969-04-14","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"1551.2 MW"},"osm_id":143581084,"output":"1551.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":146581549,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-74.983758836,42.502532793],[-74.983631432,42.502607979],[-74.983506038,42.50249256],[-74.983394056,42.502389379],[-74.983440911,42.502361635],[-74.983392547,42.502317043],[-74.98347293,42.502269518],[-74.983530765,42.502322743],[-74.98363688,42.502420476],[-74.983758836,42.502532793]]]},"properties":{"name":"Goodyear Lake Plant","tags":{"area":"yes","name":"Goodyear Lake Plant","power":"plant","alt_name":"Colliersville Dam","operator":"Hydroland Omega LLC","waterway":"dam","ref:US:EIA":"50079","start_date":"1980-08","plant:source":"hydro","plant:output:electricity":"1.4 MW"},"osm_id":146581549,"output":"1.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":150158208,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[34.539962322,48.546612782],[34.540014792,48.54671655],[34.540089391,48.546864072],[34.540499099,48.547674769],[34.54131449,48.549288034],[34.541731322,48.550112981],[34.541929554,48.55050517],[34.542107754,48.550857881],[34.54212326,48.550888475],[34.54213533,48.550912363],[34.542220239,48.550893588],[34.542277571,48.550880847],[34.542668419,48.550794178],[34.541839449,48.549155516],[34.542513019,48.549006235],[34.541195132,48.546399547],[34.541167891,48.546345735],[34.540569591,48.546478253],[34.539962322,48.546612782]]]},"properties":{"name":"Середньодніпровська ГЕС","tags":{"name":"Середньодніпровська ГЕС","power":"plant","name:ru":"Среднеднепровская ГЭС","name:uk":"Середньодніпровська ГЕС","old_name":"Дніпродзержинська ГЕС","waterway":"dam","wikidata":"Q3379468","wikipedia":"uk:Середньодніпровська ГЕС","plant:source":"hydro","plant:output:electricity":"352 MW"},"osm_id":150158208,"output":"352 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":150284674,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.045502978,45.987011396],[4.045529297,45.987071159],[4.045508426,45.987142489],[4.046315184,45.98687452],[4.046635038,45.986846022],[4.046881717,45.986857756],[4.047037034,45.986865048],[4.047163852,45.986812829],[4.047633155,45.986631361],[4.048077982,45.986583584],[4.04857922,45.986652232],[4.048726071,45.986680982],[4.048940229,45.986722556],[4.049539032,45.986633289],[4.049546492,45.986595989],[4.049549342,45.986581489],[4.049544564,45.986538238],[4.049374495,45.986478224],[4.049131671,45.986403373],[4.048918352,45.986340677],[4.048477632,45.986228694],[4.048076055,45.986177481],[4.047956277,45.986176475],[4.04761396,45.986173458],[4.047009877,45.986312765],[4.046677283,45.98644193],[4.046072361,45.986714426],[4.046019722,45.986756754],[4.045502978,45.987011396]]]},"properties":{"name":"Barrage de Villerest","tags":{"name":"Barrage de Villerest","power":"plant","website":"https://www.hydrelect.info/articles.php?lng=fr&pg=281","operator":"EDF","waterway":"dam","wikidata":"Q2885323","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W0000000126173","plant:output:electricity":"65.76 MW"},"osm_id":150284674,"output":"65.76 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":157755082,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[32.479469491,15.2386632],[32.484261425,15.238404869],[32.484459992,15.238404869],[32.484592762,15.238393219],[32.485823896,15.238323397],[32.485933866,15.238314261],[32.486551948,15.238323816],[32.486626128,15.238334881],[32.486667953,15.239374739],[32.48651775,15.240040598],[32.48651775,15.240069851],[32.486532921,15.240088123],[32.486559575,15.240090051],[32.486580446,15.240069851],[32.48673635,15.239369291],[32.486926451,15.239358227],[32.486924523,15.239268373],[32.486935923,15.239253789],[32.486935923,15.239191343],[32.486913124,15.239171143],[32.48690558,15.239105177],[32.486926451,15.239072153],[32.486903652,15.23848894],[32.486871382,15.238446779],[32.486873226,15.238371593],[32.486880854,15.238351393],[32.486884625,15.238300096],[32.487042457,15.238290876],[32.487050084,15.238338569],[32.487070955,15.238371593],[32.487070955,15.238419286],[32.487061484,15.238485252],[32.487084282,15.239051952],[32.487105153,15.239073997],[32.487110853,15.239145578],[32.487086126,15.239173071],[32.487091826,15.23924088],[32.487110853,15.239264769],[32.48712418,15.239464677],[32.487150751,15.239547155],[32.487141279,15.239563668],[32.487145051,15.239642541],[32.48716785,15.239659054],[32.487203976,15.239659054],[32.487221075,15.239637093],[32.487221075,15.239061172],[32.487184949,15.2390227],[32.487160222,15.238433955],[32.487179249,15.238404534],[32.487182937,15.238296491],[32.490386333,15.238149976],[32.490377867,15.238087698],[32.487178327,15.238237734],[32.487168856,15.238205967],[32.487160641,15.238131871],[32.487127533,15.237834649],[32.487099286,15.237813023],[32.487075649,15.237813023],[32.487054443,15.237836996],[32.487054443,15.238171769],[32.487043798,15.238202447],[32.487042624,15.238242344],[32.486882027,15.238250391],[32.486871466,15.238212756],[32.486867526,15.238145952],[32.486683124,15.238151485],[32.48663124,15.23737356],[32.486599054,15.23736719],[32.486572232,15.237382696],[32.486607184,15.238189957],[32.484240973,15.238317027],[32.479473263,15.238573262],[32.479469491,15.2386632]]]},"properties":{"name":"Jebel Aulia Dam","tags":{"name":"Jebel Aulia Dam","power":"plant","name:ar":"خزان جبل أولياء","waterway":"dam","wikidata":"Q2885151","plant:source":"hydro","plant:output:electricity":"30.4 MW"},"osm_id":157755082,"output":"30.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":158460599,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-79.061711472,43.143924658],[-79.061609716,43.144175193],[-79.061465631,43.144299496],[-79.061124068,43.144395134],[-79.060981576,43.144569058],[-79.060111367,43.144814732],[-79.059273512,43.145051269],[-79.058932955,43.144991925],[-79.058803454,43.144674084],[-79.05986234,43.144369569],[-79.060622411,43.144154322],[-79.060588465,43.14408777],[-79.060523253,43.143926166],[-79.0609685,43.144037143],[-79.061604854,43.143855758],[-79.061711472,43.143924658]]]},"properties":{"name":"Sir Adam Beck Pump Generating Station","tags":{"name":"Sir Adam Beck Pump Generating Station","power":"plant","waterway":"dam","plant:method":"water-pumped-storage","plant:source":"hydro","plant:output:electricity":"174 MW"},"osm_id":158460599,"output":"174 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":162950421,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[26.612908824,60.873880797],[26.613412744,60.873879875],[26.613417689,60.873892448],[26.613465298,60.873892783],[26.613472674,60.873879791],[26.613754139,60.873880378],[26.613761766,60.873893454],[26.613806442,60.873893621],[26.613814656,60.873880462],[26.614042895,60.873880378],[26.614044152,60.873911642],[26.614106262,60.873911642],[26.614105424,60.873880294],[26.614105424,60.873859088],[26.615121227,60.873868392],[26.615121898,60.873837379],[26.615250141,60.873838887],[26.615258439,60.873707292],[26.615122317,60.873707627],[26.615120724,60.873636884],[26.614224531,60.873635626],[26.614221346,60.873775185],[26.614102826,60.873774263],[26.614099641,60.873729252],[26.614055384,60.873729587],[26.614052534,60.873793625],[26.613820188,60.87379329],[26.613814572,60.873727995],[26.613762437,60.873727995],[26.613756569,60.873793122],[26.613465885,60.873792536],[26.613460437,60.873729336],[26.613410397,60.873728749],[26.613410229,60.873832936],[26.612909578,60.873828829],[26.612908824,60.873880797]]]},"properties":{"name":"Keltin voimalaitos","tags":{"name":"Keltin voimalaitos","layer":"2","power":"plant","building":"power_plant","operator":"UPM","waterway":"dam","wikidata":"Q20736983","wikipedia":"fi:Keltin voimalaitos","start_date":"1939-11","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"17 MW"},"osm_id":162950421,"output":"17 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":166148049,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[77.979819249,31.563394194],[77.979831486,31.563501482],[77.979873396,31.563496872],[77.979960065,31.564161222],[77.980160476,31.564552322],[77.98035393,31.56491794],[77.980465074,31.564899584],[77.980421656,31.564819118],[77.980279918,31.564538575],[77.980128792,31.564259542],[77.980092918,31.564164072],[77.980073137,31.564111182],[77.980011949,31.563739193],[77.979985127,31.563465189],[77.979966854,31.563386064],[77.979926118,31.563383717],[77.97993165,31.563348094],[77.979853028,31.56336184],[77.979858476,31.563397631],[77.979819249,31.563394194]]]},"properties":{"name":"NATHPA JHAKRI","tags":{"name":"NATHPA JHAKRI","power":"plant","height":"57","material":"concrete","waterway":"dam","wikidata":"Q11990941","plant:source":"hydro"},"osm_id":166148049,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":167537527,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-47.03193423,-23.380890545],[-47.031783859,-23.380607069],[-47.031637175,-23.380672615],[-47.03166844,-23.380731456],[-47.031359651,-23.380869422],[-47.031460317,-23.381059356],[-47.031744631,-23.380932203],[-47.031763155,-23.380967155],[-47.03193423,-23.380890545]]]},"properties":{"name":"Pequena Central Hidroelétrica Rasgão","tags":{"name":"Pequena Central Hidroelétrica Rasgão","power":"plant","alt_name":"Barragem PCH Rasgão","operator":"Empresa Metropolitana de Águas e Energia S/A.","waterway":"dam","wikidata":"Q10386154","wikipedia":"pt:Usina Hidrelétrica Rasgão","plant:source":"hydro","plant:output:electricity":"22 MW"},"osm_id":167537527,"output":"22 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":167755036,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[45.135086074,41.846935783],[45.135124882,41.846978027],[45.135217586,41.84688239],[45.135351193,41.846704694],[45.135833236,41.846722379],[45.136213439,41.846732522],[45.136197179,41.84664359],[45.136179074,41.846553736],[45.135702982,41.846527835],[45.135228817,41.84651719],[45.135219681,41.846615762],[45.135212724,41.846768983],[45.135086074,41.846935783]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":167755036,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":171254362,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[33.185219955,0.44998243],[33.185290195,0.450073626],[33.1861568,0.451180791],[33.186488556,0.450921371],[33.185551794,0.449723178],[33.185219955,0.44998243]]]},"properties":{"name":"Kiira Power Station","tags":{"name":"Kiira Power Station","power":"plant","operator":"UEGCL","waterway":"dam","wikidata":"Q6406092","wikipedia":"en:Kiira Hydroelectric Power Station","source_ref":"http://en.wikipedia.org/wiki/Kiira_Power_Station","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"200 MW"},"osm_id":171254362,"output":"200 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":172135658,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[77.184110428,34.21466879],[77.184253843,34.214851935],[77.18430514,34.215038851],[77.184377476,34.215096183],[77.184525165,34.215385359],[77.184692132,34.215693142],[77.184915259,34.215954825],[77.185096056,34.215880143],[77.185216672,34.216007296],[77.185325134,34.216149286],[77.185524036,34.21613688],[77.18549998,34.216002267],[77.185334186,34.215770424],[77.185186497,34.215605971],[77.18526185,34.215503795],[77.185026738,34.215189725],[77.184876031,34.214992751],[77.184812832,34.214844726],[77.184767569,34.214738527],[77.184698167,34.214566531],[77.184643936,34.21444449],[77.184617366,34.214444071],[77.184475125,34.214441976],[77.18430338,34.214484388],[77.184300362,34.21455664],[77.184110428,34.21466879]]]},"properties":{"name":"Nimoo Bazgo Hydroelectric Plant","tags":{"name":"Nimoo Bazgo Hydroelectric Plant","power":"plant","waterway":"dam","wikidata":"Q15262355","wikipedia":"en:Nimoo Bazgo Hydroelectric Plant","plant:source":"hydro","plant:output:electricity":"45 MW"},"osm_id":172135658,"output":"45 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":172496518,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.307222118,48.026751054],[21.307288418,48.02675667],[21.307498217,48.026729345],[21.30763677,48.026704367],[21.308063577,48.026614094],[21.308776625,48.026467662],[21.309000255,48.026419969],[21.309188764,48.026369929],[21.310124519,48.026163064],[21.309170826,48.026343777],[21.309000171,48.026367834],[21.308320398,48.026496328],[21.307865093,48.025508688],[21.307971963,48.025483794],[21.307960982,48.025438029],[21.308300617,48.025362257],[21.308262647,48.025306685],[21.308049076,48.025314564],[21.307842462,48.024866719],[21.307417835,48.02496529],[21.30765345,48.025451272],[21.307600896,48.025468372],[21.30765278,48.025597788],[21.307733749,48.025586556],[21.307827962,48.025799289],[21.307766355,48.025813454],[21.307832488,48.025962736],[21.307903566,48.025951672],[21.308001048,48.026164321],[21.307922342,48.026180247],[21.307996941,48.02633154],[21.308051088,48.026322739],[21.308143792,48.026524491],[21.308092075,48.026539746],[21.308005742,48.026558689],[21.30768899,48.026628595],[21.307458068,48.02667503],[21.307283054,48.026721634],[21.307222118,48.026751054]]]},"properties":{"name":"Tiszalöki vízerőmű","tags":{"name":"Tiszalöki vízerőmű","power":"plant","operator":"Tiszavíz Vízerőmű Kft.","waterway":"dam","wikidata":"Q28830084","wikipedia":"hu:Tiszalöki vízerőmű","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"12.9 MW"},"osm_id":172496518,"output":"12.9 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":172497270,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[20.513485643,47.493224226],[20.513551944,47.493491022],[20.513834498,47.493458919],[20.513903984,47.493738874],[20.514152591,47.493710544],[20.514095594,47.493481382],[20.514773271,47.493405275],[20.514751059,47.493315924],[20.516492818,47.493118362],[20.516604549,47.493307039],[20.517047616,47.495125996],[20.517092124,47.495166983],[20.51712012,47.495115267],[20.516648554,47.493193967],[20.516639585,47.493102185],[20.516712256,47.493094222],[20.516789873,47.49308584],[20.51699305,47.493938867],[20.517054238,47.493932161],[20.516852989,47.493086762],[20.517319358,47.493036052],[20.517256745,47.492773447],[20.516735223,47.492830276],[20.516571776,47.492847962],[20.516510336,47.492697339],[20.516272877,47.491786897],[20.516229291,47.491792177],[20.516455267,47.49270547],[20.51645116,47.492887776],[20.514105065,47.493153985],[20.513485643,47.493224226]]]},"properties":{"name":"Kiskörei vízerőmű","tags":{"name":"Kiskörei vízerőmű","power":"plant","operator":"Tiszavíz Vízerőmű Kft.","waterway":"dam","wikidata":"Q17025650","wikipedia":"hu:Kiskörei vízerőmű","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"28 MW"},"osm_id":172497270,"output":"28 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":172827257,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.394619258,36.589438231],[9.394973226,36.589621543],[9.395127285,36.589719527],[9.395099373,36.589748193],[9.395125357,36.589763951],[9.395095853,36.589790438],[9.39508722,36.589779877],[9.395077664,36.58977577],[9.395063583,36.589778536],[9.39505562,36.589787672],[9.395050926,36.589798317],[9.395041874,36.589815835],[9.395043299,36.58983042],[9.395060062,36.589839556],[9.395084621,36.589838969],[9.395107169,36.58983109],[9.395138768,36.589814159],[9.395191071,36.589760179],[9.395320069,36.589842238],[9.395278327,36.589888758],[9.39527137,36.589905354],[9.395274891,36.589923124],[9.3952966,36.589921447],[9.395314872,36.589911892],[9.395355692,36.589864953],[9.395493574,36.589952628],[9.395448396,36.589998645],[9.395443451,36.590014319],[9.395444121,36.590031334],[9.395466585,36.590031334],[9.395488378,36.590020689],[9.395530371,36.589976181],[9.395666744,36.590059581],[9.395620141,36.590086068],[9.395565072,36.590123786],[9.395456275,36.590189249],[9.395291319,36.590410028],[9.395084789,36.591050741],[9.395287296,36.590923671],[9.395469686,36.590806241],[9.395524671,36.590785873],[9.395571526,36.590780425],[9.395622572,36.590796518],[9.396024819,36.590989386],[9.39757522,36.591828163],[9.398828231,36.591980462],[9.399094691,36.591558852],[9.398698898,36.591124921],[9.398500917,36.590642123],[9.398447776,36.590153291],[9.398409638,36.589389364],[9.397833383,36.589086274],[9.397801867,36.589050065],[9.397811254,36.588999438],[9.397847464,36.588954176],[9.397910496,36.588925174],[9.397973528,36.588920732],[9.398041924,36.588932634],[9.398083499,36.588921821],[9.398095569,36.588900364],[9.398074027,36.588854096],[9.398053994,36.588817467],[9.398033878,36.588775473],[9.398004373,36.588760386],[9.397965481,36.588767846],[9.397891721,36.588815287],[9.397862216,36.588840098],[9.397823324,36.588842193],[9.39780589,36.588822831],[9.397803124,36.588778659],[9.397922566,36.588490992],[9.397851068,36.588437431],[9.397758365,36.588448747],[9.397180684,36.588079189],[9.396887736,36.587891769],[9.396636195,36.587947844],[9.395481169,36.58936908],[9.394885635,36.589126591],[9.394656222,36.58939498],[9.394619258,36.589438231]]]},"properties":{"name":"Barrage Sidi Salem","tags":{"name":"Barrage Sidi Salem","power":"plant","name:ar":"سد سيدي سالم","name:fr":"Barrage Sidi Salem","operator":"الشركة التونسية للكهرباء والغاز","waterway":"dam","wikidata":"Q2885301","operator:ar":"الشركة التونسية للكهرباء والغاز","plant:source":"hydro","operator:website":"https://www.steg.com.tn/ar/","operator:wikidata":"Q3488699","operator:wikipedia":"ar:الشركة التونسية للكهرباء والغاز","operator:wikipedia:ar":"الشركة التونسية للكهرباء والغاز","plant:output:electricity":"33 MW"},"osm_id":172827257,"output":"33 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":180382670,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-75.703387636,-10.757952237],[-75.703032076,-10.758044773],[-75.702956974,-10.758237138],[-75.702916741,-10.758353059],[-75.703203654,-10.758617173],[-75.70331287,-10.758222218],[-75.703387636,-10.757952237]]]},"properties":{"name":"Presa y Bocatoma Huallamayo","tags":{"name":"Presa y Bocatoma Huallamayo","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":180382670,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":181216989,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-115.734631655,47.960144093],[-115.734113067,47.961126871],[-115.733585678,47.962130772],[-115.733098354,47.962399998],[-115.733008248,47.962374182],[-115.732903307,47.962326573],[-115.733510157,47.96097935],[-115.734066464,47.95973405],[-115.734143326,47.959561886],[-115.734254554,47.959581919],[-115.734631655,47.960144093]]]},"properties":{"name":"Noxon Rapids Dam","tags":{"name":"Noxon Rapids Dam","power":"plant","operator":"Avista Corp","waterway":"dam","wikidata":"Q7067316","wikipedia":"en:Noxon Rapids Dam","ref:US:EIA":"2199","ref:US:NID":"MT00223","start_date":"1959-09","plant:source":"hydro","gnis:feature_id":"795206","plant:output:electricity":"487.8 MW"},"osm_id":181216989,"output":"487.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":181482774,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[108.299542985,14.980491027],[108.299816654,14.980834433],[108.30204448,14.979178085],[108.303242673,14.978287173],[108.302859872,14.977807476],[108.302699191,14.977865395],[108.302540856,14.977929266],[108.302385372,14.977999254],[108.302232821,14.978075027],[108.302125365,14.97813303],[108.302019586,14.978193966],[108.301810625,14.97832003],[108.301606526,14.978453386],[108.301470152,14.978548185],[108.301336964,14.978647176],[108.30120696,14.978749938],[108.301044938,14.978887736],[108.300888867,14.979031653],[108.300738831,14.979181438],[108.300448566,14.979477738],[108.300164838,14.97977999],[108.299953698,14.980013761],[108.299746246,14.980250717],[108.299542985,14.980491027]]]},"properties":{"name":"Đập thủy điện Đăk Đrinh","tags":{"name":"Đập thủy điện Đăk Đrinh","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":181482774,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":182490294,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[83.343995629,49.658834169],[83.345005984,49.659182269],[83.345321395,49.659593066],[83.346088758,49.659395756],[83.347035243,49.65915243],[83.347339841,49.659074226],[83.347507898,49.65903106],[83.348239555,49.65884297],[83.348492856,49.659060732],[83.348781277,49.658976158],[83.348836179,49.658960065],[83.348697542,49.658719672],[83.348389255,49.65818499],[83.348638785,49.657609824],[83.34659008,49.658150038],[83.346410791,49.658197647],[83.343995629,49.658834169]]]},"properties":{"name":"Бухтарминская-ГЭС","tags":{"name":"Бухтарминская-ГЭС","power":"plant","name:en":"Bukhtarma Hydroelectric Power Plant","name:ru":"Бухтарминская ГЭС","waterway":"dam","wikidata":"Q1046946","start_date":"1960","destination":"Усть-Каменогорское водохранилище","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"675 MW"},"osm_id":182490294,"output":"675 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":183223020,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[24.625706432,43.916596488],[24.62583325,43.916597745],[24.627657069,43.916617359],[24.627970217,43.916620795],[24.627984131,43.915945382],[24.627660924,43.915941861],[24.627658997,43.916036158],[24.627564784,43.916032972],[24.627566544,43.915941861],[24.627502925,43.915941777],[24.627500998,43.916030793],[24.627378538,43.916026518],[24.627380382,43.915941693],[24.627300586,43.915941693],[24.627298742,43.916023752],[24.627178462,43.916019645],[24.627180055,43.915941526],[24.627115179,43.915941442],[24.627113418,43.916017466],[24.62701074,43.916013946],[24.627030521,43.915060755],[24.626976039,43.915060169],[24.626953576,43.916149649],[24.626949636,43.916338744],[24.626767581,43.916336733],[24.626771437,43.916149481],[24.626704298,43.916149146],[24.626700442,43.916333547],[24.62652702,43.91633162],[24.626530876,43.916148559],[24.626466922,43.916148391],[24.626463234,43.916328686],[24.626298865,43.916326842],[24.626302553,43.916147721],[24.626235498,43.916147469],[24.626231726,43.916323741],[24.626059897,43.916321897],[24.626063417,43.916146882],[24.62599259,43.916146547],[24.625988986,43.916318041],[24.625827132,43.916316365],[24.6257123,43.916315023],[24.625706935,43.916571761],[24.625706432,43.916596488]]]},"properties":{"name":"Centrala Hidroelectrică Rusănești","tags":{"name":"Centrala Hidroelectrică Rusănești","power":"plant","alt_name":"Barajul Rusănești","waterway":"dam","plant:source":"hydro"},"osm_id":183223020,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":183855151,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[24.402694834,44.263691517],[24.404105341,44.264575138],[24.404621582,44.264153695],[24.404079525,44.263806349],[24.403741147,44.264083371],[24.402877224,44.263542152],[24.402694834,44.263691517]]]},"properties":{"name":"Ipotesti","tags":{"name":"Ipotesti","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":183855151,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":184879657,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[119.211665621,29.483344623],[119.211803335,29.483714432],[119.212530382,29.483742428],[119.212562484,29.48385265],[119.214998852,29.484054319],[119.215016454,29.483943426],[119.215891273,29.484194045],[119.216117082,29.484222124],[119.216141222,29.484095893],[119.215310659,29.48359038],[119.215180739,29.483553751],[119.214843284,29.483458533],[119.214833142,29.483096435],[119.21471085,29.483071792],[119.213666465,29.48299669],[119.212632138,29.482922259],[119.212457292,29.482919577],[119.212254366,29.48327455],[119.211818842,29.483267426],[119.211665621,29.483344623]]]},"properties":{"name":"新安江水电站","tags":{"name":"新安江水电站","layer":"-1","power":"plant","name:en":"Xin'anjiang Hydropower Station","name:zh":"新安江水电站","building":"industrial","waterway":"dam","wikidata":"Q19398785","start_date":"1959","alt_name:en":"Xin'anjiang Hydroelectric Dam","plant:source":"hydro","plant:output:electricity":"845 MW"},"osm_id":184879657,"output":"845 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":186922631,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[24.245896918,44.916918377],[24.247609425,44.916942852],[24.247616298,44.916740429],[24.246385835,44.916719894],[24.246394468,44.916460893],[24.245928434,44.916453182],[24.245896918,44.916918377]]]},"properties":{"name":"Hidrocentrala Babeni","tags":{"name":"Hidrocentrala Babeni","power":"plant","operator":"Hidroelectrica","waterway":"dam","plant:source":"hydro"},"osm_id":186922631,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":187021432,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[24.296605924,45.009378564],[24.29695503,45.009737226],[24.29829932,45.00908327],[24.298008049,45.008752017],[24.297499686,45.008988974],[24.297249989,45.009111266],[24.297015128,45.0091862],[24.296605924,45.009378564]]]},"properties":{"name":"Hidrocentrala Govora","tags":{"name":"Hidrocentrala Govora","power":"plant","operator":"Hidroelectrica","waterway":"dam","plant:source":"hydro"},"osm_id":187021432,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":188687095,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[146.799643461,-37.903502382],[146.800201445,-37.903629117],[146.800458937,-37.903849226],[146.800968556,-37.9038535],[146.801365523,-37.904230267],[146.80130115,-37.904556155],[146.800700335,-37.905216482],[146.800866632,-37.905453438],[146.800930838,-37.905720318],[146.800591035,-37.905435249],[146.80021913,-37.905026967],[146.799944958,-37.904534278],[146.799771118,-37.904113172],[146.799659052,-37.903635655],[146.799643461,-37.903502382]]]},"properties":{"name":"Lake Glenmaggie","tags":{"name":"Lake Glenmaggie","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":188687095,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":189660115,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-104.804975755,21.839919557],[-104.803226954,21.842381992],[-104.802726052,21.842227263],[-104.802732757,21.842212343],[-104.802736361,21.842189292],[-104.802725381,21.841981086],[-104.802685148,21.841602643],[-104.802639551,21.841201737],[-104.802591271,21.840845757],[-104.802542907,21.840467398],[-104.802403516,21.839431646],[-104.802269322,21.838525395],[-104.802093637,21.83734648],[-104.802009231,21.836907017],[-104.801963634,21.836553552],[-104.801944104,21.836425393],[-104.801944858,21.83636672],[-104.801960952,21.83632196],[-104.802011914,21.836269657],[-104.802084333,21.83621484],[-104.80219422,21.83615516],[-104.802325732,21.836112832],[-104.802652962,21.836087938],[-104.802650279,21.835644786],[-104.802650279,21.835313533],[-104.802795202,21.835316886],[-104.802824623,21.836361774],[-104.802697218,21.836384154],[-104.802765531,21.836564784],[-104.804770566,21.837975123],[-104.804975755,21.839919557]]]},"properties":{"name":"Presa Aguamilpa","tags":{"name":"Presa Aguamilpa","power":"plant","waterway":"dam","wikidata":"Q397881","wikipedia":"en:Aguamilpa Dam","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"960 MW"},"osm_id":189660115,"output":"960 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":191534597,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[35.334245967,36.979576177],[35.334265162,36.979822353],[35.334548973,36.979813385],[35.334542854,36.979564861],[35.334245967,36.979576177]]]},"properties":{"name":"HES","tags":{"name":"HES","power":"plant","building":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":191534597,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":192403227,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-75.96075565,-12.714934934],[-75.960671747,-12.714739719],[-75.960414255,-12.714211156],[-75.960315013,-12.714300004],[-75.960258603,-12.714336717],[-75.960348708,-12.714521035],[-75.960451722,-12.714731756],[-75.960497319,-12.714799817],[-75.960597483,-12.714872153],[-75.960704772,-12.714963767],[-75.96075565,-12.714934934]]]},"properties":{"name":"Presa El Platanal","tags":{"name":"Presa El Platanal","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":192403227,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":200227270,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.277540786,37.524616723],[127.27764447,37.524854685],[127.277676154,37.524927356],[127.278968978,37.526668529],[127.280208075,37.528295624],[127.280314358,37.528429064],[127.280894721,37.52820862],[127.279550934,37.526447331],[127.278174123,37.524657543],[127.277803223,37.52475997],[127.277627874,37.524541789],[127.277540786,37.524616723]]]},"properties":{"name":"팔당댐","tags":{"name":"팔당댐","power":"plant","name:en":"Paldang Dam","name:ja":"八堂ダム","name:ko":"팔당댐","operator":"한국수력원자력","waterway":"dam","wikidata":"Q12621569","wikipedia":"ko:팔당댐","start_date":"1974-05-24","operator:ko":"한국수력원자력","plant:method":"water-storage","plant:source":"hydro","official_name":"팔당댐 관리교","operator:wikidata":"Q6431646","operator:wikipedia":"ko:한국수력원자력","plant:output:electricity":"120 MW"},"osm_id":200227270,"output":"120 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":200364985,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-53.238243356,-29.261609839],[-53.236746851,-29.26019841],[-53.236326583,-29.260440228],[-53.23623413,-29.260313159],[-53.236174619,-29.260325648],[-53.236167327,-29.260478534],[-53.235951325,-29.260519856],[-53.235665753,-29.26056252],[-53.23581646,-29.26157606],[-53.235436676,-29.261729449],[-53.235211538,-29.261781668],[-53.234891433,-29.261956515],[-53.234972319,-29.262103869],[-53.235397952,-29.262162123],[-53.235626778,-29.26214955],[-53.236341922,-29.262041675],[-53.236756574,-29.261979062],[-53.237381948,-29.261695335],[-53.237819651,-29.261712769],[-53.238030875,-29.261779237],[-53.238243356,-29.261609839]]]},"properties":{"name":"Usina Hidrelétrica de Itaúba","tags":{"name":"Usina Hidrelétrica de Itaúba","power":"plant","waterway":"dam","wikidata":"Q10388365","wikipedia":"pt:Usina Hidrelétrica Itaúba","plant:source":"hydro","plant:output:electricity":"500 MW"},"osm_id":200364985,"output":"500 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":201659414,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[23.057818721,49.178362859],[23.058524981,49.178660836],[23.058664623,49.178519433],[23.058565968,49.178477859],[23.058621875,49.178421281],[23.058014271,49.178164879],[23.057818721,49.178362859]]]},"properties":{"name":"Яворська МГЕС","tags":{"name":"Яворська МГЕС","power":"plant","name:uk":"Яворська МГЕС","waterway":"dam","plant:source":"hydro","plant:output:electricity":"450 kW"},"osm_id":201659414,"output":"450 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":202362210,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-91.389491872,44.931796423],[-91.389201607,44.93214444],[-91.388980073,44.932418193],[-91.388945875,44.932454067],[-91.388832133,44.932573258],[-91.388699028,44.932527241],[-91.388748565,44.932461443],[-91.38868042,44.93242867],[-91.388984432,44.932055843],[-91.389275703,44.931698774],[-91.389491872,44.931796423]]]},"properties":{"name":"Chippewa Falls WP304 Dam","tags":{"name":"Chippewa Falls WP304 Dam","power":"plant","loc_name":"NSP Dam","operator":"Xcel Energy","waterway":"dam","ref:US:EIA":"4000","ref:US:NID":"WI00731","start_date":"1928","plant:method":"water-storage","plant:source":"hydro","generator:type":"kaplan_turbine","gnis:feature_id":"1792941","operator:wikidata":"Q1486956","plant:output:electricity":"21.6 MW"},"osm_id":202362210,"output":"21.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":203098545,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.470999037,24.258849892],[99.471046394,24.258985176],[99.471069445,24.259130686],[99.471124346,24.25921878],[99.471185869,24.259388346],[99.471231718,24.259575598],[99.471230796,24.259813476],[99.471446044,24.259662518],[99.471436321,24.259596552],[99.471325596,24.259164633],[99.471318387,24.258924072],[99.471314699,24.258836062],[99.471242196,24.258833548],[99.470999037,24.258849892]]]},"properties":{"name":"Yunnan Yongde Damengtong","tags":{"name":"Yunnan Yongde Damengtong","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":203098545,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":205599612,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[42.725391925,42.200579593],[42.725710437,42.200741448],[42.725960134,42.200868434],[42.726151325,42.200662071],[42.726047138,42.200609097],[42.72590649,42.2005376],[42.725792496,42.200479681],[42.7255832,42.200373147],[42.725391925,42.200579593]]]},"properties":{"name":"რიონის ჰიდროელექტროსადგური","tags":{"name":"რიონის ჰიდროელექტროსადგური","power":"plant","name:cs":"Rionská vodní elektrárna","name:de":"Wasserkraftwerk Rioni","name:en":"Rioni Hydroelectric Power Plant","name:ka":"რიონის ჰიდროელექტროსადგური","name:ru":"Рионская ГЭС","alt_name":"რიონჰესი","int_name":"Rionhesi","operator":"Energo-Pro","waterway":"dam","wikidata":"Q4394751","wikipedia":"ru:Рионская ГЭС","alt_name:en":"Rionhesi","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"51 MW"},"osm_id":205599612,"output":"51 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":205719566,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-89.856496264,44.337731969],[-89.856206083,44.337784021],[-89.855884302,44.337841772],[-89.855816576,44.337639182],[-89.856420073,44.33752217],[-89.856496264,44.337731969]]]},"properties":{"name":"Port Edwards Mill","tags":{"name":"Port Edwards Mill","power":"plant","operator":"Domtar Industries Inc","waterway":"dam","ref:US:EIA":"54103","start_date":"1913-01","plant:source":"hydro","plant:output:electricity":"6.7 MW"},"osm_id":205719566,"output":"6.7 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":206380579,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[42.717561467,42.30740829],[42.717666241,42.307640888],[42.717750563,42.307620017],[42.717997494,42.307558326],[42.718242999,42.307497055],[42.718220285,42.30744735],[42.718399657,42.307402591],[42.718317766,42.307223218],[42.718156666,42.307263451],[42.717907556,42.307324052],[42.717676383,42.307379959],[42.717561467,42.30740829]]]},"properties":{"name":"გუმათჰესი II","tags":{"name":"გუმათჰესი II","power":"plant","name:en":"Gumati 2 hydro power plant","building":"yes","int_name":"Gumathesi II","operator":"Energo-Pro","waterway":"dam","wikidata":"Q4152037","plant:source":"hydro","plant:output:electricity":"22.8 MW"},"osm_id":206380579,"output":"22.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":208625392,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.651934018,45.81530987],[1.651999649,45.81540995],[1.652691994,45.815076099],[1.65262167,45.815003344],[1.651934018,45.81530987]]]},"properties":{"name":"Barrage de Fleix","tags":{"name":"Barrage de Fleix","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q11969209","start_date":"2006-01-12","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W0000000527813","plant:output:electricity":"3.2 MW"},"osm_id":208625392,"output":"3.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":211058310,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[46.991501774,34.961838428],[46.99201483,34.962213351],[46.99215288,34.962314269],[46.992357734,34.962463886],[46.992450522,34.962844173],[46.992295876,34.964171028],[46.992409283,34.96465282],[46.992567868,34.964846693],[46.992788648,34.965057163],[46.992907084,34.965213821],[46.993017725,34.965396546],[46.99322392,34.965895186],[46.993405472,34.965891581],[46.993780897,34.965852941],[46.993742173,34.965658984],[46.994948497,34.960638475],[46.994862498,34.960505119],[46.994532754,34.960458264],[46.993884079,34.960714331],[46.993450818,34.960993281],[46.992982689,34.961037454],[46.992807675,34.961010129],[46.991990858,34.960756241],[46.991882061,34.96080209],[46.991501774,34.961838428]]]},"properties":{"name":"سد گاوشان","tags":{"name":"سد گاوشان","power":"plant","name:en":"Gavoshan Dam","waterway":"dam","wikidata":"Q5528365","wikipedia":"en:Gavoshan Dam","plant:source":"hydro","plant:output:electricity":"11 MW"},"osm_id":211058310,"output":"11 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":213794983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.568911351,45.79578825],[1.568967761,45.795875673],[1.569006989,45.79594122],[1.569072368,45.795985057],[1.56911126,45.795986566],[1.569096424,45.795762266],[1.569061136,45.795767128],[1.569080163,45.795347278],[1.569119474,45.795346356],[1.569104219,45.795113842],[1.569103464,45.794988533],[1.569058957,45.79503413],[1.569022328,45.795139994],[1.568964828,45.795183663],[1.568962229,45.795335041],[1.568918979,45.795337555],[1.568911351,45.79578825]]]},"properties":{"name":"Barrage de Villejoubert","tags":{"name":"Barrage de Villejoubert","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q12009833","start_date":"2006-01-12","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000402623N","plant:output:electricity":"3.2 MW"},"osm_id":213794983,"output":"3.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":213809695,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.61389778,45.802825193],[1.613918483,45.802956286],[1.61400993,45.802933236],[1.614753321,45.802254972],[1.61478777,45.802222199],[1.614734294,45.802193868],[1.614598758,45.802309035],[1.614522064,45.802374247],[1.614430031,45.80233108],[1.613954861,45.802787726],[1.61389778,45.802825193]]]},"properties":{"name":"Barrage de Bujaleuf","tags":{"name":"Barrage de Bujaleuf","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q11962549","start_date":"2006-02-12","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000052946Y","plant:output:electricity":"3.2 MW"},"osm_id":213809695,"output":"3.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":215429821,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-49.653744341,-3.832655107],[-49.650506328,-3.831885313],[-49.649723207,-3.831720273],[-49.647780868,-3.831263879],[-49.645890162,-3.830811172],[-49.645980435,-3.830577736],[-49.645816402,-3.830511352],[-49.645627641,-3.830929022],[-49.645594868,-3.83119104],[-49.64300201,-3.831246612],[-49.640440333,-3.831301681],[-49.640442847,-3.831572417],[-49.64044905,-3.832228049],[-49.643025228,-3.832189325],[-49.645468301,-3.832106176],[-49.649485412,-3.833052158],[-49.649704683,-3.833102449],[-49.653450974,-3.833972826],[-49.653744341,-3.832655107]]]},"properties":{"name":"Usina Hidrelétrica de Tucuruí","tags":{"name":"Usina Hidrelétrica de Tucuruí","power":"plant","name:de":"Tucuruí-Staudamm","waterway":"dam","wikidata":"Q1475210","wikipedia":"pt:Usina Hidrelétrica de Tucuruí","short_name":"UHE de Tucuruí","plant:method":"water-storage","plant:source":"hydro","generator:type":"francis_turbine","plant:output:electricity":"8370 MW"},"osm_id":215429821,"output":"8370 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":216537489,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.357149619,48.662723431],[22.357381043,48.662865253],[22.357546083,48.662747739],[22.357538791,48.66274338],[22.357556225,48.662730723],[22.357560835,48.662727455],[22.357535438,48.662712032],[22.357651946,48.662628129],[22.357676086,48.662610862],[22.357444159,48.662468538],[22.357411721,48.662491588],[22.357445919,48.662512543],[22.357296386,48.662618993],[22.357149619,48.662723431]]]},"properties":{"name":"Оноківська ГЕС","tags":{"name":"Оноківська ГЕС","power":"plant","name:uk":"Оноківська ГЕС","alt_name":"Оноківська гідроелектростанція","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"2650 kW"},"osm_id":216537489,"output":"2650 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":219443743,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.835880287,29.207495687],[102.836552599,29.208039756],[102.837201275,29.208726318],[102.837949778,29.209966085],[102.838568363,29.211502069],[102.838813785,29.211444653],[102.838881092,29.211693092],[102.838999025,29.211480359],[102.839302534,29.210933524],[102.839375037,29.210228858],[102.84019529,29.209351775],[102.841558355,29.209422938],[102.842394618,29.20889295],[102.842264866,29.208340331],[102.842040566,29.207385129],[102.841215033,29.207372053],[102.839219469,29.207109868],[102.837192054,29.206879198],[102.836043147,29.206748356],[102.835990425,29.206990258],[102.835880287,29.207495687]]]},"properties":{"name":"瀑布沟大坝","tags":{"name":"瀑布沟大坝","power":"plant","name:de":"Pubugou-Talsperre","name:en":"Pubugou Dam","name:zh":"瀑布沟大坝","waterway":"dam","wikidata":"Q1549749","wikipedia":"en:Pubugou Dam","name:zh-Hant":"瀑布溝大壩","plant:method":"water-storage","plant:source":"hydro","wikipedia:en":"Pubugou Dam","name:zh-Latn-pinyin":"pùbùgōu dàbà","plant:output:electricity":"3300 MW"},"osm_id":219443743,"output":"3300 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":220340687,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-64.308633675,-27.692034181],[-64.308398478,-27.691492626],[-64.308086336,-27.691598908],[-64.308321533,-27.692140379],[-64.308633675,-27.692034181]]]},"properties":{"name":"Central Hidroeléctrica Los Quiroga","tags":{"name":"Central Hidroeléctrica Los Quiroga","power":"plant","name:en":"Los Quiroga Dam","operator":"Hidroeléctrica Río Hondo S.A.","waterway":"dam","wikidata":"Q3443730","wikipedia":"es:Dique de Los Quiroga","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":220340687,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":220396006,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-47.300113557,-23.209091453],[-47.299837457,-23.208793141],[-47.299773336,-23.208722817],[-47.29968826,-23.208782832],[-47.299774258,-23.208880313],[-47.299220801,-23.209263953],[-47.299473431,-23.209528234],[-47.300113557,-23.209091453]]]},"properties":{"name":"Pequena Central Hidroelétrica Porto Góes","tags":{"name":"Pequena Central Hidroelétrica Porto Góes","layer":"1","power":"plant","operator":"Empresa Metropolitana de Águas e Energia S/A.","waterway":"dam","plant:source":"hydro","plant:output:electricity":"24.3 MW"},"osm_id":220396006,"output":"24.3 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":221204058,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.825150542,47.716443024],[10.825230841,47.71647454],[10.825306697,47.71650438],[10.825678518,47.716193327],[10.825798547,47.716083524],[10.825929975,47.715978164],[10.826027038,47.715899961],[10.825957552,47.715856542],[10.825896448,47.715816728],[10.82585655,47.715790661],[10.825812629,47.715775657],[10.825502247,47.716038765],[10.82544634,47.716021917],[10.825388672,47.716018062],[10.825405939,47.716050332],[10.825460589,47.716084446],[10.825537199,47.716125434],[10.825150542,47.716443024]]]},"properties":{"name":"Lechstaustufe 3 - Urspring","tags":{"name":"Lechstaustufe 3 - Urspring","power":"plant","building":"industrial","operator":"Uniper Kraftwerke","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1273399","plant:output:electricity":"10.1 MW"},"osm_id":221204058,"output":"10.1 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":221372346,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-64.566703756,-32.223546265],[-64.56666017,-32.223516677],[-64.566609627,-32.223569902],[-64.566653381,-32.223599574],[-64.566703756,-32.223546265]]]},"properties":{"name":null,"tags":{"power":"plant","building":"yes","operator":"Empresa Provincial de energía de Córdoba (EPEC)","waterway":"dam","plant:source":"hydro"},"osm_id":221372346,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":221656476,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-104.454542464,21.430374739],[-104.454288241,21.430726946],[-104.453554238,21.430286226],[-104.453481818,21.430368117],[-104.453363717,21.430428048],[-104.453235055,21.430428048],[-104.453137406,21.430370129],[-104.453062304,21.430280275],[-104.45306331,21.430098471],[-104.453062304,21.429900742],[-104.452488311,21.429191633],[-104.451297914,21.427528999],[-104.449642572,21.425216683],[-104.453233546,21.42567576],[-104.453630513,21.426304989],[-104.453373021,21.428172561],[-104.452799029,21.428946546],[-104.453657335,21.429960254],[-104.454542464,21.430374739]]]},"properties":{"name":"Presa El Cajón","tags":{"name":"Presa El Cajón","power":"plant","waterway":"dam","wikidata":"Q1323767","plant:method":"water-storage","plant:source":"hydro","official_name":"Presa Leonardo Rodríguez Alcaine","plant:output:electricity":"750 MW"},"osm_id":221656476,"output":"750 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":222066432,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-104.108816451,21.196110771],[-104.108598522,21.197024482],[-104.108324014,21.197232437],[-104.108129722,21.197281052],[-104.108180768,21.19773619],[-104.10825587,21.197923777],[-104.108301467,21.198241367],[-104.108068115,21.198513946],[-104.107947415,21.198596424],[-104.10795278,21.198906555],[-104.107783717,21.199309054],[-104.10753578,21.199194389],[-104.106697422,21.19793878],[-104.106118233,21.196858856],[-104.105125732,21.194955326],[-104.104846782,21.195080384],[-104.10477168,21.195075354],[-104.104691214,21.194980387],[-104.104878968,21.194665228],[-104.104278154,21.194145047],[-104.104020661,21.193925022],[-104.103312558,21.193724946],[-104.103441221,21.192264567],[-104.1043639,21.191954353],[-104.105351037,21.19193432],[-104.106810159,21.190693966],[-104.107217855,21.190954056],[-104.107657737,21.191674397],[-104.106423921,21.193174758],[-104.105908937,21.19442517],[-104.105914217,21.195125478],[-104.106300539,21.194895395],[-104.106279081,21.194565232],[-104.106418556,21.194460123],[-104.10662232,21.194455177],[-104.106869167,21.194655253],[-104.107410974,21.194700264],[-104.107711381,21.194590294],[-104.108215636,21.19475525],[-104.108558959,21.195235449],[-104.108650154,21.195855626],[-104.108816451,21.196110771]]]},"properties":{"name":"Presa La Yesca","tags":{"name":"Presa La Yesca","power":"plant","waterway":"dam","wikidata":"Q2547037","wikipedia":"es:Presa La Yesca","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"750 MW"},"osm_id":222066432,"output":"750 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":222454790,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[106.121635532,30.816668378],[106.12165875,30.816762674],[106.12172723,30.817040953],[106.127276134,30.816034622],[106.127071699,30.815203137],[106.125847187,30.815425174],[106.125959924,30.815884083],[106.121854719,30.816628564],[106.121635532,30.816668378]]]},"properties":{"name":"Sichuan Xiaolongmen","tags":{"name":"Sichuan Xiaolongmen","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":222454790,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":226607275,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[17.149865283,50.458622797],[17.150017079,50.458890766],[17.149977768,50.458899986],[17.149878442,50.458924042],[17.149959495,50.459068463],[17.150025293,50.459185977],[17.15012663,50.459162591],[17.150166361,50.459153455],[17.150318492,50.459421341],[17.150483867,50.459383874],[17.150560478,50.459366439],[17.150917631,50.459399045],[17.150954176,50.459392758],[17.150987619,50.45938555],[17.151490031,50.459272143],[17.151522133,50.459263342],[17.15154577,50.459216152],[17.151522888,50.459176757],[17.151056267,50.458340327],[17.151022237,50.458278468],[17.150997762,50.458265895],[17.15095669,50.458246533],[17.150925593,50.458254747],[17.150387391,50.458379051],[17.150351265,50.458389025],[17.150111291,50.458565297],[17.150030155,50.45858424],[17.149865283,50.458622797]]]},"properties":{"name":"Elektrownia wodna Otmuchów","tags":{"name":"Elektrownia wodna Otmuchów","power":"plant","branch":"Elektrownie Wodne Opole","website":"https://www.tauron-ekoenergia.pl/elektrownie/energia-wodna/ew-opole/otmuchow","operator":"Tauron Ekoenergia","waterway":"dam","start_date":"1933","plant:method":"water-storage","plant:source":"hydro","operator:wikidata":"Q9355892","plant:output:electricity":"4.8 MW"},"osm_id":226607275,"output":"4.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":226985822,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[75.984492278,32.596414223],[75.98549006,32.597932773],[75.986711555,32.598129077],[75.986475018,32.597286779],[75.986045026,32.596435429],[75.985550578,32.595795471],[75.984492278,32.596414223]]]},"properties":{"name":"Chamera Dam","tags":{"name":"Chamera Dam","power":"plant","waterway":"dam","wikidata":"Q5069772","plant:source":"hydro"},"osm_id":226985822,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":227646615,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.896102344,29.060162784],[99.896916562,29.060209387],[99.897294083,29.06027376],[99.897852569,29.060368978],[99.897790794,29.060183152],[99.896986048,29.060065889],[99.89615406,29.060056082],[99.896102344,29.060162784]]]},"properties":{"name":"Sichuan Niangyong","tags":{"name":"Sichuan Niangyong","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":227646615,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":229601886,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.833378068,38.668323136],[21.833553753,38.668800067],[21.834080975,38.669662481],[21.834424046,38.670139327],[21.834850769,38.670662022],[21.835570439,38.671223945],[21.835896746,38.671524437],[21.836022223,38.671857617],[21.835930274,38.672118965],[21.836105959,38.672380229],[21.837185464,38.672602349],[21.837863225,38.672700334],[21.838264885,38.672478297],[21.839243976,38.671583277],[21.840448958,38.670564038],[21.840465638,38.670296152],[21.840214181,38.670025836],[21.839893322,38.670163635],[21.839548993,38.670322723],[21.839020095,38.670530175],[21.838782384,38.670562781],[21.838533442,38.670487847],[21.838377873,38.670319035],[21.838110742,38.669441198],[21.837794242,38.667661469],[21.838179641,38.667218569],[21.838581889,38.666827972],[21.839502138,38.665830274],[21.840195238,38.664667537],[21.840547194,38.663978041],[21.840409731,38.66365056],[21.839796259,38.66378224],[21.838047375,38.664794942],[21.836348699,38.665291569],[21.83543197,38.66579792],[21.835414033,38.666525889],[21.835213956,38.666998544],[21.834886056,38.667382687],[21.834283062,38.667740343],[21.833449985,38.668023986],[21.833378068,38.668323136]]]},"properties":{"name":"Φράγμα Ευήνου","tags":{"area":"yes","name":"Φράγμα Ευήνου","power":"plant","height":"127","length":"640","name:el":"Φράγμα Ευήνου","material":"soil","operator":"ΕΥΔΑΠ ΠΑΓΙΩΝ","waterway":"dam","wikidata":"Q134968440","start_date":"2001","plant:method":"water-storage","plant:source":"hydro","water_supply":"yes","plant:output:electricity":"0.82 MW"},"osm_id":229601886,"output":"0.82 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":231680649,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.722257756,44.095979274],[4.723758788,44.096456791],[4.723986272,44.096087736],[4.722730077,44.09568817],[4.722697555,44.095740893],[4.722581968,44.095704096],[4.722591943,44.095687751],[4.722462862,44.095646764],[4.722257756,44.095979274]]]},"properties":{"name":"Barrage de Caderousse","tags":{"name":"Barrage de Caderousse","power":"plant","operator":"CNR","waterway":"dam","wikidata":"Q17761526","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1121170","ref:EU:ENTSOE_EIC":"17W100P100P0325H","operator:wikipedia":"fr:Compagnie nationale du Rhône","old_ref:EU:ENTSOE_EIC":"17W000000012933U","plant:output:electricity":"156 MW"},"osm_id":231680649,"output":"156 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":231762818,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0.454549981,44.846579122],[0.454571522,44.846685489],[0.454856842,44.846661935],[0.454862626,44.846693619],[0.454657353,44.84671449],[0.454687779,44.846861257],[0.454700939,44.846925043],[0.455107042,44.846882799],[0.455088434,44.846792945],[0.455010064,44.846801159],[0.454992629,44.846716837],[0.45501065,44.84671558],[0.45501065,44.846705354],[0.455005621,44.846698481],[0.454994138,44.846695379],[0.454975027,44.846695128],[0.454972177,44.846685656],[0.454961616,44.846682639],[0.454911995,44.84668465],[0.454909481,44.846655565],[0.454948373,44.84665238],[0.454958263,44.846649027],[0.454962371,44.846643579],[0.454962035,44.846627234],[0.455421783,44.846584235],[0.455423375,44.846592869],[0.455444581,44.846588929],[0.455461848,44.846579541],[0.455474086,44.846564957],[0.455477858,44.846545762],[0.455472996,44.846527993],[0.455458244,44.846514498],[0.455436954,44.846506284],[0.45541206,44.846506284],[0.455414071,44.846517348],[0.454923143,44.846563867],[0.454915935,44.846542493],[0.454659616,44.846566466],[0.454549981,44.846579122]]]},"properties":{"name":"Barrage de Bergerac","tags":{"name":"Barrage de Bergerac","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q19950893","start_date":"2001-01-01","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000417493G","plant:output:electricity":"1.54 MW"},"osm_id":231762818,"output":"1.54 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":231807833,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[74.030476189,34.091867874],[74.030827558,34.092232151],[74.031380093,34.092036769],[74.031492327,34.092194349],[74.031739844,34.09254203],[74.03197546,34.092452092],[74.032055926,34.092556447],[74.032175536,34.092492744],[74.03189273,34.092048671],[74.031732217,34.091796544],[74.031774378,34.091482558],[74.031744873,34.091466967],[74.031785023,34.091229256],[74.031731379,34.091042675],[74.031201642,34.091430338],[74.03120709,34.091512481],[74.031570446,34.091505859],[74.031522166,34.09177123],[74.03090266,34.091776846],[74.030476189,34.091867874]]]},"properties":{"name":"URI II","tags":{"name":"URI II","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":231807833,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":237985251,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.845938375,61.328600469],[22.846132667,61.328769951],[22.847025843,61.328534336],[22.84683155,61.32836477],[22.846382029,61.328483374],[22.845938375,61.328600469]]]},"properties":{"name":"Tyrvään voimalaitos","tags":{"name":"Tyrvään voimalaitos","power":"plant","building":"yes","waterway":"dam","wikidata":"Q18662517","wikipedia":"fi:Tyrvään voimalaitos","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"16.8 MW"},"osm_id":237985251,"output":"16.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":239999918,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[113.002995983,35.453946334],[113.003128249,35.454410775],[113.003199998,35.454650833],[113.003746666,35.454989881],[113.003869042,35.454963226],[113.003844567,35.454790475],[113.003736273,35.454255375],[113.003297899,35.453521036],[113.003142834,35.453594126],[113.002995983,35.453946334]]]},"properties":{"name":"Shanxi Zezhou Dongjiaohe","tags":{"name":"Shanxi Zezhou Dongjiaohe","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":239999918,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":243555051,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-19.033328629,64.197087979],[-19.03268096,64.197104575],[-19.03265858,64.196752367],[-19.032417181,64.196546843],[-19.032350713,64.195971258],[-19.03303476,64.195975365],[-19.033277835,64.19597679],[-19.033289821,64.196239646],[-19.033322678,64.196957975],[-19.033328629,64.197087979]]]},"properties":{"name":"Vatnsfellsvirkjun","tags":{"name":"Vatnsfellsvirkjun","power":"plant","name:en":"Vatnsfell Power Station","building":"yes","operator":"Landsvirkjun","waterway":"dam","wikidata":"Q953982","wikipedia":"is:Vatnsfellsvirkjun","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"90 MW"},"osm_id":243555051,"output":"90 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":245072101,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[49.47862082,53.429707148],[49.479966283,53.429739586],[49.480364256,53.423089887],[49.479102193,53.423055857],[49.47862082,53.429707148]]]},"properties":{"name":"Жигулёвская ГЭС","tags":{"name":"Жигулёвская ГЭС","power":"plant","name:en":"Zhigulyovskaya Dam","operator":"ОАО РусГидро","waterway":"dam","wikidata":"Q1965688","wikipedia":"ru:Жигулёвская ГЭС","start_date":"1955-12-29","plant:source":"hydro","contact:website":"http://www.zhiges.rushydro.ru/","plant:output:electricity":"2488 MW"},"osm_id":245072101,"output":"2488 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":246381798,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-38.209001882,-9.416734154],[-38.208989561,-9.416701213],[-38.208927116,-9.416691574],[-38.207972165,-9.416732645],[-38.207973423,-9.416678331],[-38.207819363,-9.416676906],[-38.207874349,-9.414804808],[-38.207828416,-9.414803215],[-38.207701514,-9.41479894],[-38.207562542,-9.414799276],[-38.207118971,-9.414800281],[-38.207070775,-9.414800114],[-38.206924679,-9.414799611],[-38.206923757,-9.414560224],[-38.206000155,-9.414890135],[-38.206012812,-9.415021731],[-38.206012225,-9.4151084],[-38.206014907,-9.415320127],[-38.206005519,-9.415359103],[-38.20599412,-9.415379639],[-38.205976015,-9.415392211],[-38.205811059,-9.41539481],[-38.205760768,-9.415402102],[-38.205735957,-9.415412747],[-38.205718523,-9.415435881],[-38.205710476,-9.415488771],[-38.205711147,-9.415586755],[-38.205733946,-9.415638975],[-38.205809048,-9.415679375],[-38.205874007,-9.415707036],[-38.205981379,-9.415718351],[-38.20600686,-9.415734864],[-38.206008202,-9.415769229],[-38.206000826,-9.415794459],[-38.205983307,-9.415830836],[-38.20598205,-9.415879032],[-38.205993449,-9.415917422],[-38.206015578,-9.41595975],[-38.206010213,-9.416026638],[-38.205998143,-9.416064943],[-38.205972662,-9.41608288],[-38.205864033,-9.416119845],[-38.205805024,-9.416137782],[-38.205785578,-9.416154965],[-38.205790272,-9.416182709],[-38.205823716,-9.416209866],[-38.205898231,-9.416242975],[-38.205953803,-9.416249513],[-38.205986073,-9.416263427],[-38.206008872,-9.416283962],[-38.206020942,-9.416326207],[-38.206028318,-9.416458557],[-38.206023624,-9.416555871],[-38.206019601,-9.416606749],[-38.206009543,-9.416657627],[-38.205973333,-9.416723174],[-38.205907451,-9.416842281],[-38.206707336,-9.417180407],[-38.20731251,-9.417108322],[-38.207542174,-9.416943618],[-38.20778307,-9.416918472],[-38.20778835,-9.416874132],[-38.207948612,-9.416873881],[-38.207970237,-9.416817806],[-38.208735002,-9.416797605],[-38.208955446,-9.416792576],[-38.208994674,-9.416763407],[-38.209001882,-9.416734154]]]},"properties":{"name":"Usina Paulo Afonso IV","tags":{"name":"Usina Paulo Afonso IV","power":"plant","operator":"Companhia Hidro-Elétrica do São Francisco","waterway":"dam","wikidata":"Q10388387","start_date":"1972","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"2462.4 MW"},"osm_id":246381798,"output":"2462.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":249573944,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-92.628200453,38.201572835],[-92.625450853,38.203319037],[-92.625489661,38.203352564],[-92.622705277,38.205066244],[-92.62240948,38.204787881],[-92.623740442,38.203962264],[-92.623846725,38.204044071],[-92.625388827,38.203109992],[-92.625786213,38.202882339],[-92.625998862,38.202737165],[-92.62614982,38.202637839],[-92.62662432,38.20234003],[-92.626960182,38.202139032],[-92.627181968,38.202001066],[-92.627437783,38.201884893],[-92.627678009,38.201802583],[-92.628200453,38.201572835]]]},"properties":{"name":"Bagnell Dam","tags":{"name":"Bagnell Dam","power":"plant","alt_name":"Osage Dam","operator":"Ameren Missouri","waterway":"dam","wikidata":"Q14693496","ref:US:EIA":"2106","ref:US:NID":"MO30014","start_date":"1931-10","plant:source":"hydro","operator:wikidata":"Q7885672","plant:output:electricity":"208 MW"},"osm_id":249573944,"output":"208 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":254327683,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-92.781186027,16.402849117],[-92.781177478,16.402866719],[-92.781135652,16.402947185],[-92.780253122,16.402608892],[-92.779766301,16.402445445],[-92.779567817,16.402535466],[-92.777931753,16.40337944],[-92.777566805,16.403164277],[-92.777272852,16.402990771],[-92.77713782,16.402911143],[-92.777363125,16.401331322],[-92.777569907,16.401127726],[-92.777717931,16.401002751],[-92.77791566,16.40080125],[-92.777981374,16.400850117],[-92.77821875,16.401025131],[-92.77879006,16.401310786],[-92.779609224,16.401815964],[-92.779703353,16.401818897],[-92.779752973,16.401815042],[-92.779734282,16.402268083],[-92.78017802,16.402443098],[-92.780326882,16.402500597],[-92.780454958,16.402552398],[-92.780573981,16.402600426],[-92.780837424,16.402700673],[-92.780968433,16.402756832],[-92.781097934,16.40281232],[-92.781186027,16.402849117]]]},"properties":{"name":"Presa La Angostura","tags":{"area":"yes","name":"Presa La Angostura","power":"plant","alt_name":"Presa Belisario Domínguez","operator":"Comisión Federal de Electricidad","waterway":"dam","wikidata":"Q624981","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"900 MW"},"osm_id":254327683,"output":"900 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":255045610,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-41.024096836,-19.498105915],[-41.023735911,-19.497795952],[-41.022980115,-19.498577732],[-41.02316611,-19.498737407],[-41.02325018,-19.498650571],[-41.02342511,-19.498800858],[-41.02351421,-19.498708657],[-41.024053083,-19.498151261],[-41.024096836,-19.498105915]]]},"properties":{"name":"Usina Hidrelétrica de Aimorés","tags":{"name":"Usina Hidrelétrica de Aimorés","power":"plant","operator":"Aliança Geração de Energia S.A.","waterway":"dam","wikidata":"Q10388396","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"330 MW"},"osm_id":255045610,"output":"330 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":255478124,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-76.507468469,45.407879639],[-76.506650228,45.408046941],[-76.506377229,45.407389465],[-76.506091742,45.406701897],[-76.50594598,45.406350696],[-76.506733963,45.406188841],[-76.507390182,45.407699344],[-76.507468469,45.407879639]]]},"properties":{"name":"Stewartville Generating Station","tags":{"name":"Stewartville Generating Station","note":"http://wikimapia.org/11665878/Stewartville-generating-station","layer":"-1","power":"plant","website":"https://www.opg.com/power/hydro/ottawa_st_lawrence/stewartville.asp","building":"industrial","man_made":"dam","operator":"Ontario Power Generation","wikidata":"Q65211734","wikipedia":"en:List of electrical generating stations in Ontario","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1129706","plant:output:electricity":"182 MW"},"osm_id":255478124,"output":"182 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":255891174,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.367306453,24.025056569],[100.368267271,24.024954477],[100.368942517,24.024882644],[100.369398576,24.024828748],[100.369538051,24.024867976],[100.369768637,24.025024717],[100.370433825,24.025411794],[100.37083624,24.025656797],[100.371109742,24.025808677],[100.371194064,24.02584606],[100.371306968,24.025708764],[100.371176043,24.02566719],[100.370962891,24.024887925],[100.370451343,24.02462532],[100.370154959,24.02443966],[100.369906855,24.024276884],[100.369586247,24.024049651],[100.369564622,24.024085022],[100.369372425,24.024097176],[100.36930956,24.024203794],[100.369142677,24.024241931],[100.368904463,24.02429633],[100.36885107,24.024339161],[100.367728985,24.024634372],[100.367745832,24.024707965],[100.367639718,24.024734536],[100.367658828,24.024806033],[100.367455986,24.024868898],[100.367306453,24.025056569]]]},"properties":{"name":"大朝山大坝","tags":{"name":"大朝山大坝","power":"plant","name:de":"Dachaoshan-Talsperre","name:en":"Dachaoshan Dam","name:zh":"大朝山大坝","end_date":"2003","waterway":"dam","wikidata":"Q750635","start_date":"1997","plant:source":"hydro","plant:output:electricity":"1350 MW"},"osm_id":255891174,"output":"1350 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":255891175,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.087509892,24.703137532],[100.087987996,24.703708591],[100.088211122,24.703942278],[100.088460483,24.70415652],[100.08871479,24.70434352],[100.089056521,24.704558264],[100.08937344,24.704748198],[100.089683152,24.704905946],[100.090003676,24.705056065],[100.090319422,24.705177184],[100.090273238,24.705339374],[100.090204255,24.705319425],[100.090161088,24.70546988],[100.090489155,24.705581192],[100.090521845,24.705592256],[100.09078554,24.70566258],[100.091058454,24.705682529],[100.091203042,24.70568739],[100.091387696,24.705681272],[100.091624233,24.705661323],[100.091915839,24.705623185],[100.092283134,24.705535259],[100.092224377,24.705341721],[100.092482288,24.705254465],[100.092852349,24.705079451],[100.093446794,24.704755993],[100.09403009,24.704407641],[100.094608274,24.704006986],[100.09522887,24.703592082],[100.09522074,24.703485045],[100.095211268,24.703385133],[100.094613471,24.703428635],[100.092001586,24.704249475],[100.09197116,24.704453239],[100.091779717,24.704509649],[100.091792458,24.704608723],[100.091537061,24.704672593],[100.091338494,24.704691956],[100.091166162,24.704685669],[100.091005984,24.704665888],[100.090832143,24.704627164],[100.090851086,24.704556756],[100.090609017,24.704484755],[100.090595689,24.704531526],[100.089373189,24.703763995],[100.087937872,24.702994872],[100.087787584,24.702993195],[100.087603853,24.703094533],[100.087509892,24.703137532]]]},"properties":{"name":"小湾坝","tags":{"area":"yes","name":"小湾坝","power":"plant","height":"292","name:de":"Xiaowan-Talsperre","name:en":"Xiaowan Dam","name:fr":"Barrage de Xiaowan","name:it":"Diga di Xiaowan","material":"concrete","operator":"中国华能集团","waterway":"dam","wikidata":"Q83532","start_date":"2010","operator:en":"China Huaneng Group","operator:zh":"中国华能集团","plant:source":"hydro","operator:wikidata":"Q842509","plant:output:electricity":"4200 MW"},"osm_id":255891175,"output":"4200 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":255891176,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.446463135,24.621971372],[100.447362597,24.622223248],[100.447356981,24.62225795],[100.448404384,24.622493565],[100.450047237,24.622877791],[100.450261562,24.622904949],[100.450437834,24.622613929],[100.449957634,24.622222746],[100.449758732,24.622187961],[100.449743477,24.622022753],[100.449511968,24.621982772],[100.449571731,24.62138774],[100.448814259,24.621218174],[100.448224089,24.621057661],[100.447827374,24.620949786],[100.447676583,24.620976273],[100.44756963,24.62110242],[100.447454127,24.621062355],[100.447437699,24.621084232],[100.447374499,24.621062774],[100.447197725,24.621332755],[100.447120695,24.621626541],[100.447008462,24.621588487],[100.446934282,24.621749671],[100.446551648,24.62181572],[100.446463135,24.621971372]]]},"properties":{"name":"漫灣大壩","tags":{"name":"漫灣大壩","power":"plant","name:de":"Manwan-Talsperre","name:en":"Manwan Dam","end_date":"1995","waterway":"dam","wikidata":"Q584245","start_date":"1986","plant:source":"hydro","plant:output:electricity":"1750 MW"},"osm_id":255891176,"output":"1750 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":262048370,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-71.010627181,18.923438862],[-71.010320403,18.923172988],[-71.009872726,18.922783733],[-71.009852106,18.922765795],[-71.009646414,18.922589943],[-71.009516746,18.922486175],[-71.008909058,18.922000192],[-71.010105072,18.922373439],[-71.01058787,18.922977355],[-71.010627181,18.923438862]]]},"properties":{"name":"Presa Palomino","tags":{"name":"Presa Palomino","power":"plant","waterway":"dam","wikipedia":"es:Proyecto Hidroeléctrico Palomino","plant:source":"hydro"},"osm_id":262048370,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":262072687,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-70.377573801,18.545944765],[-70.377456874,18.546291022],[-70.377320249,18.546471149],[-70.377238525,18.546584556],[-70.377014812,18.546809275],[-70.376633939,18.547084453],[-70.376300842,18.547309255],[-70.376384745,18.547401289],[-70.376303524,18.547502961],[-70.376139323,18.547309675],[-70.376051983,18.547089063],[-70.376465462,18.546444494],[-70.376671909,18.546167976],[-70.376895454,18.545818869],[-70.377071725,18.545172625],[-70.377154371,18.544655964],[-70.377439775,18.544730144],[-70.377499705,18.544742381],[-70.377381772,18.545040945],[-70.377390908,18.545160555],[-70.37752988,18.545593228],[-70.377573801,18.545944765]]]},"properties":{"name":"Presa de Jigüey","tags":{"name":"Presa de Jigüey","power":"plant","waterway":"dam","wikidata":"Q23770784","plant:source":"hydro"},"osm_id":262072687,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":262420198,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-54.966673933,-25.374917566],[-54.966530938,-25.374729643],[-54.966391463,-25.374797537],[-54.9661908,-25.374797537],[-54.965596104,-25.375043965],[-54.964827568,-25.375399777],[-54.964599748,-25.375505137],[-54.964494555,-25.375651569],[-54.964594802,-25.375820213],[-54.965800204,-25.375305983],[-54.966673933,-25.374917566]]]},"properties":{"name":"Represa Yguazú","tags":{"name":"Represa Yguazú","power":"plant","waterway":"dam","wikidata":"Q5760708","plant:method":"water-storage","plant:source":"hydro"},"osm_id":262420198,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":262626314,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-93.455931077,50.583884221],[-93.45577685,50.584010788],[-93.455763271,50.584064767],[-93.454953663,50.583600326],[-93.454413617,50.58326966],[-93.45438998,50.583255662],[-93.453973232,50.583010575],[-93.453944817,50.582992722],[-93.453802744,50.582903371],[-93.453675507,50.582894821],[-93.452993807,50.582510762],[-93.453063712,50.582429542],[-93.452907305,50.582331222],[-93.452977546,50.582286043],[-93.45314795,50.582375227],[-93.453218525,50.582375059],[-93.453871979,50.582759118],[-93.453887066,50.582858024],[-93.454299372,50.583135298],[-93.454397608,50.583071847],[-93.454516966,50.583146865],[-93.45455393,50.583170167],[-93.45465334,50.583223727],[-93.454710169,50.583259518],[-93.455077464,50.583258093],[-93.455276366,50.583374266],[-93.45588724,50.583740555],[-93.455902243,50.583830493],[-93.455931077,50.583884221]]]},"properties":{"name":"Manitou Station","tags":{"area":"yes","name":"Manitou Station","power":"plant","operator":"Ontario Power Generation","waterway":"dam","wikidata":"Q65211717","start_date":"1956","plant:source":"hydro","operator:wikidata":"Q1129706","plant:output:electricity":"73 MW"},"osm_id":262626314,"output":"73 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":263918556,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-93.221430153,50.630764373],[-93.221289169,50.630854562],[-93.220668908,50.631260498],[-93.219992237,50.631702392],[-93.219850751,50.631675653],[-93.22014856,50.631485636],[-93.220583832,50.631206686],[-93.220668405,50.631152539],[-93.220569164,50.631098727],[-93.220101118,50.630793877],[-93.220580312,50.630469246],[-93.2211191,50.630782981],[-93.221330827,50.630710645],[-93.221430153,50.630764373]]]},"properties":{"name":"Ear Falls Generating Station","tags":{"area":"yes","name":"Ear Falls Generating Station","power":"plant","alt_name":"Lac Seul Generating Station","operator":"Ontario Power Generation","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"22 MW"},"osm_id":263918556,"output":"22 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":268061755,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0.429582555,41.242757641],[0.429933672,41.242865935],[0.431085765,41.243221495],[0.431044275,41.243297519],[0.431360691,41.243395168],[0.431372678,41.24337304],[0.432653768,41.243768331],[0.432643123,41.243787777],[0.432797517,41.243835386],[0.432617558,41.244165298],[0.432668185,41.244180888],[0.432850239,41.243847121],[0.433061799,41.243916858],[0.433030199,41.243976453],[0.433131369,41.244006796],[0.433162968,41.243947284],[0.433340246,41.244000342],[0.433308646,41.244059853],[0.433345442,41.244070917],[0.433376958,41.244011322],[0.433554822,41.244064631],[0.43352339,41.244124226],[0.433563456,41.244136296],[0.433595055,41.244076617],[0.433766381,41.244128082],[0.433734866,41.244187593],[0.433775182,41.244199663],[0.433806698,41.244140068],[0.433985987,41.24419388],[0.433954388,41.244253391],[0.433996381,41.244265964],[0.434027897,41.244206369],[0.434201989,41.244258588],[0.434170473,41.244318183],[0.434209868,41.244329918],[0.434241384,41.244270323],[0.434414135,41.244322123],[0.434382535,41.244381634],[0.434427965,41.244395297],[0.434459565,41.244335785],[0.434632651,41.244387669],[0.434601135,41.244447181],[0.434644134,41.244460089],[0.434675734,41.244400578],[0.434704736,41.244336456],[0.434841277,41.244378449],[0.43504546,41.244419688],[0.435080329,41.244389346],[0.435247631,41.244370738],[0.435305467,41.244255822],[0.435216618,41.244212488],[0.435129949,41.244237298],[0.435111342,41.244271329],[0.434781598,41.244144678],[0.433256175,41.24368996],[0.433755317,41.242743392],[0.433700584,41.242726963],[0.433169758,41.24373363],[0.432949649,41.243668083],[0.433479553,41.242663093],[0.433415934,41.24264415],[0.432888545,41.243644278],[0.432757033,41.243605051],[0.433283835,41.242605928],[0.433219295,41.242586734],[0.432863567,41.243261393],[0.431855475,41.242994849],[0.431874167,41.242959058],[0.431836029,41.242947826],[0.432186225,41.242274843],[0.432119421,41.242255229],[0.431748774,41.242967691],[0.431682054,41.242948078],[0.431669397,41.242972385],[0.431599324,41.242949754],[0.431625141,41.242917148],[0.431721197,41.242871383],[0.431704685,41.242849674],[0.43161064,41.242900133],[0.431578621,41.242942043],[0.431518858,41.242921088],[0.43154568,41.242879178],[0.431615837,41.242838023],[0.431573592,41.242800724],[0.431495053,41.242855877],[0.431464124,41.24290248],[0.431399081,41.242885381],[0.431434117,41.242831821],[0.43148994,41.242790665],[0.431459933,41.242775159],[0.431399081,41.242836431],[0.431371253,41.24287373],[0.431321967,41.242855541],[0.430964227,41.242745487],[0.430844534,41.242942043],[0.430309852,41.242781445],[0.42997642,41.24276443],[0.429638797,41.242654543],[0.429582555,41.242757641]]]},"properties":{"name":"Central hidroelèctrica de Riba-roja","tags":{"name":"Central hidroelèctrica de Riba-roja","power":"plant","name:ca":"Central hidroelèctrica de Riba-roja","building":"yes","operator":"Enel Green Power","waterway":"dam","wikidata":"Q56702079","plant:source":"hydro","plant:output:electricity":"261.3 MW"},"osm_id":268061755,"output":"261.3 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":270131139,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.675734495,48.283917483],[7.675751762,48.283982862],[7.675760395,48.284015635],[7.675873886,48.28444504],[7.676559526,48.284364825],[7.676593557,48.284493571],[7.677537694,48.284383181],[7.678287791,48.284295423],[7.678252922,48.284163659],[7.678521981,48.2841246],[7.678583672,48.284113871],[7.678641339,48.284087971],[7.678682997,48.284048659],[7.678716441,48.284003146],[7.678724488,48.283960398],[7.678677549,48.283800555],[7.678656091,48.28370869],[7.678432127,48.28373635],[7.678146639,48.283760993],[7.678140018,48.283736182],[7.677369888,48.283826288],[7.676443772,48.283934666],[7.676418123,48.283837436],[7.676309075,48.28385026],[7.675856033,48.283903233],[7.675734495,48.283917483]]]},"properties":{"name":"Centrale Hydroélectrique de Rhinau","tags":{"name":"Centrale Hydroélectrique de Rhinau","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q1786190","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000012043Q;17W0000000126084","plant:output:electricity":"150 MW"},"osm_id":270131139,"output":"150 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":272518535,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[91.899908035,25.658626381],[91.900191259,25.659357199],[91.900330231,25.659677472],[91.90064757,25.660026746],[91.900674644,25.660006378],[91.900744968,25.659974527],[91.900711943,25.659876375],[91.900643631,25.659673197],[91.900659975,25.65957362],[91.900681265,25.659334233],[91.900787045,25.659120662],[91.900702639,25.658952689],[91.900621922,25.658726126],[91.90053542,25.658520518],[91.900205676,25.658484057],[91.899908035,25.658626381]]]},"properties":{"name":"UMIAM I II &IV","tags":{"name":"UMIAM I II &IV","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":272518535,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":273649691,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.873377485,32.120850518],[102.873451748,32.120853452],[102.873567251,32.120476518],[102.873447138,32.120434859],[102.873377485,32.120850518]]]},"properties":{"name":"Sichuan Heishui Zumu","tags":{"name":"Sichuan Heishui Zumu","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":273649691,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":281513993,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[106.682269017,28.923967083],[106.683129587,28.924242847],[106.683188595,28.924102031],[106.684134996,28.924405373],[106.684205823,28.924235723],[106.682398936,28.923656533],[106.682269017,28.923967083]]]},"properties":{"name":"Chongqing Qijiang Zhutan","tags":{"name":"Chongqing Qijiang Zhutan","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":281513993,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":284957774,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[108.74794815,29.175043221],[108.748581235,29.175037269],[108.749149864,29.175210356],[108.749356729,29.175279758],[108.749436273,29.17504364],[108.749070152,29.174852365],[108.748623564,29.174814143],[108.748078237,29.174766953],[108.74794815,29.175043221]]]},"properties":{"name":"Yutan Hydroelectric","tags":{"name":"Yutan Hydroelectric","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":284957774,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":285660506,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.956556928,9.962087765],[76.957063531,9.962816488],[76.957534258,9.963023018],[76.957096136,9.962329499],[76.95656816,9.96156683],[76.956556928,9.962087765]]]},"properties":{"name":"Lower Periyar Dam","tags":{"name":"Lower Periyar Dam","power":"plant","operator":"Kerala State Electricity Board","waterway":"dam","plant:source":"hydro"},"osm_id":285660506,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":288142971,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-72.442273761,43.13340143],[-72.441785012,43.133599326],[-72.44158393,43.133325322],[-72.442063878,43.133122731],[-72.442273761,43.13340143]]]},"properties":{"name":"Bellows Falls Power Plant","tags":{"area":"yes","name":"Bellows Falls Power Plant","power":"plant","operator":"Great River Hydro, LLC","waterway":"dam","wikidata":"Q116696619","ref:US:EIA":"3745","start_date":"1928-03","plant:source":"hydro","plant:output:electricity":"40.8 MW"},"osm_id":288142971,"output":"40.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":289491012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-111.408735367,46.119505984],[-111.408270172,46.120060363],[-111.407662484,46.120889166],[-111.407608839,46.120870558],[-111.40779651,46.12060284],[-111.407522925,46.120521032],[-111.407571205,46.120435537],[-111.407640942,46.120446685],[-111.40769467,46.120387173],[-111.407893154,46.120424389],[-111.408203954,46.120030356],[-111.408560772,46.119536159],[-111.408585163,46.119502296],[-111.408451053,46.119453849],[-111.408483239,46.119409257],[-111.408735367,46.119505984]]]},"properties":{"name":"BROADWATER POWER PROJECT","tags":{"name":"BROADWATER POWER PROJECT","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":289491012,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":289864149,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-5.841358002,34.942544628],[-5.840812424,34.943644418],[-5.840427024,34.943544757],[-5.839654883,34.942944026],[-5.839641137,34.942904463],[-5.839423878,34.942465252],[-5.839312566,34.942221171],[-5.839155657,34.941880446],[-5.838946445,34.941275776],[-5.83883111,34.940884341],[-5.838733209,34.940296267],[-5.838836726,34.9392378],[-5.840799851,34.939982951],[-5.840389725,34.940440519],[-5.840869673,34.941792436],[-5.841358002,34.942544628]]]},"properties":{"name":"Oued El Makhazine وادي المخازن","tags":{"name":"Oued El Makhazine وادي المخازن","power":"plant","height":"67.0","name:ar":"وادي المخازن","name:fr":"Oued El Makhazine","website":"https://www.masen.ma/ar/%D8%A7%D9%84%D9%85%D8%B4%D8%A7%D8%B1%D9%8A%D8%B9/%D9%88%D8%A7%D8%AF%D9%8A-%D8%A7%D9%84%D9%85%D8%AE%D8%A7%D8%B2%D9%86","capacity":"807.0","operator":"مازن","waterway":"dam","start_date":"1979","operator:ar":"مازن","operator:en":"Mazen","operator:fr":"Mazen","plant:method":"water-storage","plant:source":"hydro","operator:website":"http://www.masen.ma/ar","operator:wikidata":"Q48988857","plant:output:electricity":"36 MW"},"osm_id":289864149,"output":"36 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":290884578,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.501435264,16.316543678],[107.501568285,16.316632358],[107.50170843,16.316737551],[107.501745981,16.31682774],[107.501807253,16.316919354],[107.501860981,16.316999318],[107.502033145,16.317171817],[107.501986458,16.317278854],[107.501918145,16.31738916],[107.502173458,16.317570377],[107.502348975,16.317694261],[107.502303545,16.317784786],[107.502420054,16.317843795],[107.502540921,16.317663751],[107.502567156,16.31762159],[107.502725322,16.317659896],[107.502988179,16.317709097],[107.503293364,16.317783864],[107.503473575,16.317873466],[107.50382243,16.318107489],[107.503856377,16.318078236],[107.503887976,16.318051079],[107.503470222,16.317700129],[107.503283725,16.317588733],[107.503028161,16.317452779],[107.502724736,16.317294025],[107.50274963,16.317257648],[107.502497586,16.317122532],[107.502429274,16.317081544],[107.502203884,16.31694626],[107.502217882,16.316925222],[107.501979668,16.316770995],[107.501964162,16.316788429],[107.501854024,16.316703856],[107.501477676,16.316479724],[107.501455297,16.316513335],[107.501435264,16.316543678]]]},"properties":{"name":"Đập Bình Điền","tags":{"name":"Đập Bình Điền","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":290884578,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":292125638,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[114.029883141,57.80886448],[114.029898899,57.808904546],[114.030119595,57.809124235],[114.030500217,57.809138065],[114.03048295,57.809273182],[114.030477921,57.80930914],[114.03045529,57.809473341],[114.030588478,57.809478119],[114.031838723,57.809523968],[114.031859929,57.809361862],[114.03303524,57.809425984],[114.034127318,57.809489519],[114.034479525,57.809512653],[114.03453032,57.809235128],[114.034557393,57.809124151],[114.032991486,57.809030358],[114.029883141,57.80886448]]]},"properties":{"name":"Мамаканская ГЭС","tags":{"name":"Мамаканская ГЭС","power":"plant","name:en":"Mamakansk Dam","waterway":"dam","wikidata":"Q4279777","wikipedia":"ru:Мамаканская ГЭС","start_date":"1962","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"86 MW"},"osm_id":292125638,"output":"86 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":294005378,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-68.528688822,-34.599725207],[-68.528608104,-34.599661923],[-68.528516741,-34.599741048],[-68.528385145,-34.599637951],[-68.528228907,-34.599772983],[-68.528510036,-34.59999326],[-68.528606679,-34.599909608],[-68.528651941,-34.599870549],[-68.528583126,-34.599816653],[-68.528688822,-34.599725207]]]},"properties":{"name":"Central Hidroeléctrica Los Coroneles","tags":{"name":"Central Hidroeléctrica Los Coroneles","layer":"1","power":"plant","building":"yes","waterway":"dam","wikidata":"Q17624801","wikipedia":"es:Central hidroeléctrica Los Coroneles","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":294005378,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":294269153,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-47.795105173,-10.698446172],[-47.79489135,-10.698218017],[-47.794247537,-10.698481125],[-47.794053831,-10.698044428],[-47.793523927,-10.698251628],[-47.793273224,-10.698363527],[-47.793016822,-10.698251628],[-47.79273762,-10.697949377],[-47.792401506,-10.698257244],[-47.792361608,-10.6985315],[-47.79194595,-10.69857408],[-47.79203681,-10.698861831],[-47.792435704,-10.698800224],[-47.792697806,-10.698929054],[-47.792720605,-10.699124939],[-47.792395806,-10.699124939],[-47.792412906,-10.699253769],[-47.792600912,-10.699578484],[-47.792988323,-10.69985274],[-47.792828816,-10.699326524],[-47.793837326,-10.699024272],[-47.794093729,-10.698917822],[-47.795105173,-10.698446172]]]},"properties":{"name":"Usina Hidrelétrica Isamu Ikeda","tags":{"name":"Usina Hidrelétrica Isamu Ikeda","power":"plant","waterway":"dam","wikidata":"Q10388366","start_date":"1982","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"30 MW"},"osm_id":294269153,"output":"30 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":294585305,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.032887037,47.99612073],[21.03314939,47.996176973],[21.033266653,47.995882181],[21.033027266,47.995825184],[21.032887037,47.99612073]]]},"properties":{"name":"Kesznyéteni vízerőmű","tags":{"name":"Kesznyéteni vízerőmű","power":"plant","operator":"Hernádvíz Kft.","waterway":"dam","wikidata":"Q110664364","wikipedia":"hu:Kesznyéteni vízerőmű","start_date":"1944","plant:method":"run-of-the-river","plant:source":"hydro","contact:website":"https://www.hernadviz.hu/","plant:output:electricity":"4.4 MW"},"osm_id":294585305,"output":"4.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":295161337,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-4.416967216,58.027447164],[-4.416442426,58.027999951],[-4.416360451,58.028073376],[-4.416140426,58.028190639],[-4.415988043,58.028266831],[-4.415888549,58.028289294],[-4.415779501,58.028284935],[-4.415674978,58.02824487],[-4.414990261,58.028656086],[-4.414403947,58.029008126],[-4.414180234,58.029143075],[-4.414264053,58.02918423],[-4.414293976,58.029249273],[-4.414246032,58.029328482],[-4.414164308,58.029388078],[-4.413737166,58.029661328],[-4.412863856,58.029936841],[-4.412829909,58.029925442],[-4.412798058,58.029914629],[-4.412998553,58.029607181],[-4.413706321,58.029100243],[-4.413780417,58.029026482],[-4.41376248,58.028989351],[-4.413714535,58.028925983],[-4.413799779,58.028898994],[-4.413853339,58.028895222],[-4.413854345,58.028898658],[-4.413889968,58.028894551],[-4.413887202,58.02888575],[-4.413965238,58.028874519],[-4.413970854,58.028880889],[-4.41403464,58.028865382],[-4.414374945,58.028688021],[-4.414408138,58.028706797],[-4.414419872,58.028699421],[-4.41453747,58.028757088],[-4.414562532,58.028743426],[-4.414593378,58.02875734],[-4.414850702,58.028602526],[-4.415382031,58.028283594],[-4.415310282,58.028250151],[-4.415258817,58.028171193],[-4.41520467,58.028058624],[-4.41660252,58.027445236],[-4.416920613,58.027424785],[-4.416945256,58.027436519],[-4.416967216,58.027447164]]]},"properties":{"name":"Lairg Power Station","tags":{"name":"Lairg Power Station","power":"plant","height":"12","repd:id":"470","operator":"SSE","waterway":"dam","plant:source":"hydro","operator:wikidata":"Q493854","plant:output:electricity":"3.5 MW"},"osm_id":295161337,"output":"3.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":295161340,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-4.402879417,58.012626283],[-4.402819654,58.012681017],[-4.402793083,58.012674479],[-4.40277942,58.01269074],[-4.402751173,58.01270918],[-4.402724854,58.012714461],[-4.402691327,58.012712198],[-4.402670707,58.01270457],[-4.402646148,58.012733991],[-4.402359571,58.012653524],[-4.402249349,58.012617482],[-4.401058448,58.013154846],[-4.400930708,58.013212597],[-4.400785869,58.01327873],[-4.400686878,58.013323993],[-4.400660056,58.013307983],[-4.40075737,58.013260123],[-4.400978569,58.012947645],[-4.40100296,58.012937838],[-4.401116283,58.012999026],[-4.402086153,58.012562497],[-4.40200158,58.012509272],[-4.402035778,58.012463758],[-4.402098307,58.01238983],[-4.402450012,58.012480354],[-4.402572304,58.012452778],[-4.402646148,58.012469961],[-4.402697781,58.012481947],[-4.402780342,58.012600299],[-4.402879417,58.012626283]]]},"properties":{"name":"Shin Diversion Weir","tags":{"name":"Shin Diversion Weir","type":"water","power":"plant","operator":"SSE Renewables","waterway":"dam","wikidata":"Q128121348","ref:canmore":"5068","plant:source":"hydro","operator:wikidata":"Q7392992","plant:output:electricity":"18.6 MW"},"osm_id":295161340,"output":"18.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":295772121,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.290779583,27.028477548],[103.291118463,27.02903419],[103.290949819,27.029589659],[103.29115987,27.029415232],[103.291250646,27.028982977],[103.29090397,27.028398758],[103.290779583,27.028477548]]]},"properties":{"name":"Yunnan Tianhuaban","tags":{"name":"Yunnan Tianhuaban","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":295772121,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":298773684,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.626570083,34.543377947],[102.62669556,34.543503173],[102.627019353,34.54353033],[102.627376254,34.543459671],[102.627356473,34.543345342],[102.627894088,34.543187762],[102.628061056,34.543141829],[102.628041274,34.54307117],[102.627868356,34.543129759],[102.627356473,34.543285495],[102.62727718,34.543192875],[102.626570083,34.543377947]]]},"properties":{"name":"Alashan","tags":{"name":"Alashan","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":298773684,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":304788297,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[74.18537267,34.144823983],[74.185469229,34.144886093],[74.186117569,34.144196263],[74.186020758,34.144133985],[74.18537267,34.144823983]]]},"properties":{"name":"URI I","tags":{"name":"URI I","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":304788297,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":305929959,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[119.513010379,29.921566866],[119.513139041,29.922301456],[119.51338044,29.92226424],[119.513251778,29.921538954],[119.513010379,29.921566866]]]},"properties":{"name":"Zhejiang Tonglu Bipu","tags":{"name":"Zhejiang Tonglu Bipu","power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":305929959,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":306654372,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[73.41555295,19.516995037],[73.41564733,19.517076845],[73.416019319,19.516371927],[73.416709317,19.515064266],[73.417349275,19.513851572],[73.418208169,19.512223639],[73.419095896,19.510541475],[73.419680869,19.509504885],[73.419606354,19.509464568],[73.418672359,19.510780946],[73.41835854,19.510861831],[73.417366039,19.51241416],[73.41593374,19.515437764],[73.416035747,19.51577656],[73.41555295,19.516995037]]]},"properties":{"name":"Bhatsa Dam","tags":{"name":"Bhatsa Dam","power":"plant","waterway":"dam","wikidata":"Q4901429","plant:source":"hydro"},"osm_id":306654372,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":309201830,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.805289235,46.301986722],[1.805326451,46.302002396],[1.80579064,46.302257876],[1.805843949,46.302287297],[1.805959452,46.302350748],[1.806091551,46.302251254],[1.805756442,46.302075151],[1.805645466,46.302016477],[1.805328881,46.301849929],[1.805331899,46.301935676],[1.805289235,46.301986722]]]},"properties":{"name":"Barrage de l'Age","tags":{"name":"Barrage de l'Age","power":"plant","waterway":"dam","wikidata":"Q19378661","start_date":"2006-01-01","plant:method":"run-of-the-river","plant:source":"hydro","ref:EU:ENTSOE_EIC":"17W0000000576877","plant:output:electricity":"6.9 MW"},"osm_id":309201830,"output":"6.9 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":309945666,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.917400459,44.941194883],[1.917417223,44.94128633],[1.917618472,44.94129488],[1.918377537,44.941272081],[1.918390948,44.941359336],[1.918888666,44.941344333],[1.918956894,44.941342321],[1.918948848,44.941215168],[1.919522841,44.941192369],[1.919515548,44.941131768],[1.919099974,44.94114367],[1.918299167,44.941166553],[1.917564325,44.941187591],[1.917400459,44.941194883]]]},"properties":{"name":"Barrage de Brugale","tags":{"name":"Barrage de Brugale","power":"plant","waterway":"dam","wikidata":"Q11962375","plant:method":"water-storage","plant:source":"hydro","ref:EU:ENTSOE_EIC":"17W000000012319B","plant:output:electricity":"5.1 MW"},"osm_id":309945666,"output":"5.1 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":311171311,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-91.890141524,44.93518003],[-91.889114406,44.935601472],[-91.889173917,44.935674059],[-91.889006279,44.935742875],[-91.888563966,44.935924259],[-91.888488278,44.935831639],[-91.888670668,44.935756872],[-91.888585675,44.935652937],[-91.888851046,44.935548582],[-91.889024719,44.935472726],[-91.889056654,44.935511618],[-91.8898728,44.935176761],[-91.890071954,44.935095037],[-91.890141524,44.93518003]]]},"properties":{"name":"Cedar Falls 1883C3 Dam","tags":{"name":"Cedar Falls 1883C3 Dam","power":"plant","waterway":"dam","ref:US:NID":"WI00734","plant:source":"hydro","gnis:feature_id":"1792947"},"osm_id":311171311,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":312422841,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.871632767,36.11027542],[101.872584029,36.111599342],[101.872755355,36.111837723],[101.873245864,36.112502073],[101.873591869,36.112944386],[101.87391474,36.113431626],[101.873693626,36.113598761],[101.873769566,36.113678557],[101.874037619,36.113960524],[101.874226966,36.113855247],[101.874098639,36.113700769],[101.874497618,36.113511673],[101.874277593,36.113241524],[101.874421594,36.113152089],[101.874288741,36.112968526],[101.874439196,36.11282025],[101.873798148,36.112103681],[101.873726986,36.111676036],[101.873584493,36.11145576],[101.873507128,36.111472188],[101.873378969,36.111544524],[101.873217366,36.111327684],[101.873089877,36.111156442],[101.873185599,36.111107156],[101.872664496,36.110401735],[101.873183587,36.11014022],[101.87311343,36.110042319],[101.873000945,36.109885326],[101.872135095,36.110297129],[101.871986484,36.110103172],[101.871632767,36.11027542]]]},"properties":{"name":"直岗拉卡坝","tags":{"name":"直岗拉卡坝","power":"plant","name:en":"Zhiganglaka Dam","name:zh":"直岗拉卡坝","waterway":"dam","wikidata":"Q12011218","wikipedia":"no:Zhiganglaka-demningen","plant:source":"hydro","plant:output:electricity":"152 MW"},"osm_id":312422841,"output":"152 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":312422888,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.807670548,36.118817585],[101.807671638,36.11882756],[101.807675745,36.11883217],[101.80768354,36.118834768],[101.807754283,36.118843234],[101.807765934,36.118855471],[101.807771047,36.118868799],[101.807759228,36.119012548],[101.807764677,36.119139031],[101.807787475,36.119220755],[101.807884622,36.119547481],[101.807940361,36.119758538],[101.80794757,36.119763986],[101.807955449,36.119765495],[101.807964837,36.119764237],[101.808040274,36.119744791],[101.808050835,36.119746887],[101.808056367,36.11975024],[101.808060558,36.11975837],[101.808147143,36.119997841],[101.80819316,36.120065148],[101.808290473,36.120027681],[101.808148987,36.11956106],[101.808109341,36.119319326],[101.808118644,36.119304741],[101.808162985,36.119287978],[101.808301035,36.119351848],[101.808506978,36.119396859],[101.808540422,36.119350255],[101.809137297,36.11944078],[101.809518171,36.119018248],[101.80960266,36.118922359],[101.809649599,36.118837869],[101.809700561,36.118646091],[101.809736016,36.118485746],[101.809767532,36.118151559],[101.809459162,36.118017281],[101.809114498,36.117844949],[101.809015257,36.117722573],[101.809001846,36.117645628],[101.809012574,36.117610926],[101.809539628,36.117297695],[101.809778848,36.117184539],[101.809735765,36.117099295],[101.809614647,36.117129973],[101.809420689,36.1172267],[101.809362854,36.117181773],[101.809300325,36.117207757],[101.809364279,36.117271795],[101.809123635,36.117397858],[101.8089767,36.117480336],[101.808905286,36.117411689],[101.808710742,36.117545464],[101.80858275,36.11765753],[101.808559952,36.117651579],[101.808462051,36.117775882],[101.808326264,36.11793187],[101.808272285,36.11789566],[101.808125182,36.118106465],[101.8079671,36.118329088],[101.807801222,36.118570654],[101.807729137,36.118685067],[101.807670548,36.118817585]]]},"properties":{"name":"李家峡水坝","tags":{"name":"李家峡水坝","power":"plant","name:de":"Lijiaxia-Talsperre","name:en":"Lijiaxia Dam","name:zh":"李家峡水坝","waterway":"dam","wikidata":"Q782413","wikipedia":"en:Lijiaxia Dam","plant:source":"hydro","plant:output:electricity":"2000 MW"},"osm_id":312422888,"output":"2000 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":312950310,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.007545184,35.804622029],[103.008037034,35.805049422],[103.008252198,35.805266262],[103.008399887,35.805424931],[103.008494518,35.805558455],[103.00857356,35.805681836],[103.008644387,35.805791891],[103.008717142,35.805889791],[103.008913362,35.805809241],[103.008983938,35.805702121],[103.009098351,35.805547055],[103.009252997,35.805268525],[103.00920413,35.805169618],[103.009121652,35.805062498],[103.008929623,35.804834175],[103.008066455,35.804081396],[103.007564462,35.804599146],[103.007545184,35.804622029]]]},"properties":{"name":"炳灵大坝","tags":{"name":"炳灵大坝","power":"plant","name:en":"Bingling Dam","waterway":"dam","wikidata":"Q84813734","plant:source":"hydro","plant:output:electricity":"240 MW"},"osm_id":312950310,"output":"240 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":312950311,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.473457044,36.169532877],[103.473646056,36.170263192],[103.473734569,36.170604923],[103.474155675,36.17188727],[103.474353069,36.172485822],[103.474523557,36.172551955],[103.474740145,36.172332768],[103.474843327,36.172200669],[103.475029153,36.172003024],[103.475055221,36.171971173],[103.475066034,36.17195005],[103.474995542,36.17169457],[103.474756239,36.170892338],[103.474754898,36.170880436],[103.474764285,36.170863169],[103.474871574,36.170772477],[103.474871574,36.170764346],[103.474868556,36.170757222],[103.474859839,36.17075345],[103.474856738,36.170755378],[103.474105049,36.170905833],[103.474089626,36.170902648],[103.474076299,36.170896697],[103.474054171,36.170826289],[103.474058864,36.170818158],[103.474068839,36.170810698],[103.47422315,36.170777087],[103.474244524,36.170757557],[103.474258689,36.170728891],[103.474257935,36.170666027],[103.474276039,36.170591344],[103.474330689,36.17052563],[103.474159196,36.170535437],[103.474128267,36.170534682],[103.474042184,36.170308036],[103.473940596,36.170019949],[103.473866332,36.169818951],[103.47392006,36.169799422],[103.473800199,36.169440173],[103.473457044,36.169532877]]]},"properties":{"name":null,"tags":{"layer":"1","power":"plant","name:en":"Hekou Power Plant","waterway":"dam","wikidata":"Q84813815","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"74 MW"},"osm_id":312950311,"output":"74 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":313068636,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.485465293,31.226136082],[103.485505191,31.226206155],[103.48726866,31.225472655],[103.487093897,31.22516529],[103.485522877,31.22581866],[103.485657826,31.226055951],[103.485465293,31.226136082]]]},"properties":{"name":"Taipingyi Dam","tags":{"name":"Taipingyi Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":313068636,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":313302689,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.274283135,30.363884925],[102.274328648,30.363952567],[102.274709774,30.36376146],[102.274806668,30.363712845],[102.274731567,30.363601365],[102.27431792,30.363808817],[102.274347592,30.363852739],[102.274283135,30.363884925]]]},"properties":{"name":"Lajiaogou First Stage","tags":{"name":"Lajiaogou First Stage","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":313302689,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":315136737,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.271399198,36.059423419],[103.271916194,36.059720055],[103.2720208,36.059652748],[103.272318441,36.059467341],[103.272472752,36.059358962],[103.272755641,36.059128041],[103.273136599,36.058809361],[103.27339057,36.058600819],[103.27370573,36.058838865],[103.273738419,36.058863424],[103.273848222,36.058766194],[103.273831123,36.058747],[103.273790387,36.05870333],[103.274397153,36.058211061],[103.274474099,36.05827074],[103.274814153,36.058526472],[103.274979695,36.058389763],[103.273750741,36.057431292],[103.273642195,36.057573869],[103.273542283,36.05769155],[103.273443628,36.057805377],[103.273318235,36.057919119],[103.27312654,36.058089439],[103.272902576,36.058256994],[103.272722868,36.058406527],[103.272535784,36.058566956],[103.272325147,36.058733421],[103.272048292,36.058941544],[103.271786023,36.059143799],[103.271565495,36.059312359],[103.271399198,36.059423419]]]},"properties":{"name":"Yanguoxia Dam","tags":{"name":"Yanguoxia Dam","power":"plant","name:en":"Yanguoxia Dam","waterway":"dam","wikidata":"Q11428972","plant:source":"hydro","plant:output:electricity":"471 MW"},"osm_id":315136737,"output":"471 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":316835848,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.252677976,32.472874526],[76.253077625,32.473389761],[76.253052144,32.473430665],[76.253317767,32.473773233],[76.253482974,32.47378019],[76.253218693,32.473446758],[76.253304439,32.473270235],[76.253751782,32.472990196],[76.253958647,32.472898163],[76.254009944,32.472878465],[76.253470653,32.472256277],[76.253273594,32.472077658],[76.253157086,32.471903231],[76.253083995,32.471762247],[76.253017527,32.471727798],[76.252953741,32.47175353],[76.253795451,32.472812835],[76.25366352,32.472878381],[76.253520944,32.472697081],[76.253473419,32.472691716],[76.253455649,32.472728345],[76.253590933,32.472920123],[76.253488841,32.472984748],[76.253348361,32.472812332],[76.253291028,32.472806884],[76.253279461,32.472855499],[76.25342757,32.473032189],[76.253315168,32.473103268],[76.253174771,32.472952477],[76.253132946,32.472948119],[76.253121043,32.472982568],[76.253256495,32.473154984],[76.253161947,32.473258333],[76.252686274,32.472679227],[76.252677976,32.472874526]]]},"properties":{"name":"CHAMERA II","tags":{"area":"yes","name":"CHAMERA II","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":316835848,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":320994578,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.33555644,35.873228493],[102.335655681,35.873327064],[102.335736483,35.873407446],[102.335900098,35.873248358],[102.336276361,35.873573157],[102.336653798,35.873366627],[102.337190575,35.873022382],[102.337265593,35.87297427],[102.337657364,35.872719879],[102.337294427,35.872357026],[102.337777895,35.872033652],[102.337871773,35.872124848],[102.339327625,35.871034278],[102.339663069,35.870716185],[102.339900361,35.87091492],[102.340113094,35.870855325],[102.339663824,35.870409659],[102.339586794,35.870344531],[102.338510977,35.871210885],[102.337760545,35.871773981],[102.337444044,35.871988223],[102.337102062,35.872233896],[102.336644494,35.872551654],[102.335886687,35.873040151],[102.33555644,35.873228493]]]},"properties":{"name":"苏只水库","tags":{"name":"苏只水库","power":"plant","name:en":"Suzhi Dam","name:zh":"苏只坝","waterway":"dam","wikidata":"Q19391189","wikipedia":"no:Suzhi-demningen","name:zh-Hant":"蘇只壩","plant:source":"hydro","name:zh-Latn-pinyin":"sūzhǐ shuǐkù","plant:output:electricity":"225 MW"},"osm_id":320994578,"output":"225 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":321565322,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-40.201892233,-17.896111402],[-40.201832722,-17.89579708],[-40.201294017,-17.894464274],[-40.201238026,-17.894325553],[-40.201086062,-17.895086463],[-40.20128421,-17.895947955],[-40.201092683,-17.896017105],[-40.200993525,-17.896413234],[-40.200643246,-17.896451036],[-40.200613406,-17.896775919],[-40.200583818,-17.897117565],[-40.201654355,-17.897312445],[-40.201679752,-17.897078254],[-40.201714034,-17.896800981],[-40.201892233,-17.896111402]]]},"properties":{"name":"Usina Hidroelétrica Santa Clara","tags":{"name":"Usina Hidroelétrica Santa Clara","power":"plant","material":"concrete","operator":"Queiroz Galvão Energia S/A","waterway":"dam","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"60 MW"},"osm_id":321565322,"output":"60 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":324492601,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[23.436488967,61.463460597],[23.437050135,61.463546679],[23.437369486,61.463071845],[23.436808317,61.462985762],[23.436488967,61.463460597]]]},"properties":{"name":"Melon vesivoimala","tags":{"name":"Melon vesivoimala","power":"plant","operator":"Pohjolan voima","waterway":"dam","wikidata":"Q14835059","wikipedia":"fi:Melon vesivoimalaitos","plant:source":"hydro","plant:output:electricity":"67.9 MW"},"osm_id":324492601,"output":"67.9 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":330388003,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[78.187880046,31.496066473],[78.188225464,31.49623168],[78.188320096,31.496238051],[78.188378685,31.496010147],[78.188734581,31.496092038],[78.18883969,31.496044764],[78.189285691,31.496081896],[78.189366744,31.496254731],[78.189563467,31.496247019],[78.189558941,31.496159931],[78.189637061,31.496156159],[78.189658099,31.495887268],[78.189618956,31.495304642],[78.189483839,31.495098447],[78.189154934,31.495125353],[78.189069354,31.495446799],[78.188563339,31.495661879],[78.18841917,31.495769418],[78.188244994,31.495719462],[78.188168384,31.495608067],[78.18812639,31.495582418],[78.187985155,31.495773274],[78.187896558,31.495925657],[78.187880046,31.496066473]]]},"properties":{"name":"Baspa 2","tags":{"name":"Baspa 2","power":"plant","operator":"JSW Energy","waterway":"dam","wikidata":"Q11960328","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"300 MW"},"osm_id":330388003,"output":"300 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":332949585,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-75.166269833,6.320384943],[-75.16503828,6.320865645],[-75.164348449,6.321260433],[-75.164273766,6.32114032],[-75.164206208,6.321031858],[-75.16490023,6.320637155],[-75.165895078,6.319760072],[-75.165995661,6.319878425],[-75.166048215,6.319949838],[-75.166138321,6.320092834],[-75.166188025,6.320187382],[-75.166225576,6.320269692],[-75.166269833,6.320384943]]]},"properties":{"name":null,"tags":{"power":"plant","building":"yes","waterway":"dam","plant:source":"hydro"},"osm_id":332949585,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":335658430,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-71.613192561,-37.912751813],[-71.613171019,-37.912281755],[-71.613039423,-37.911780853],[-71.612691071,-37.911115078],[-71.61258261,-37.910870662],[-71.612547406,-37.91039063],[-71.612398124,-37.910226764],[-71.612216656,-37.91000171],[-71.611998139,-37.909817224],[-71.610592662,-37.909474405],[-71.61068788,-37.909612538],[-71.611522969,-37.910620043],[-71.613192561,-37.912751813]]]},"properties":{"name":"Presa Pangue","tags":{"name":"Presa Pangue","power":"plant","operator":"Enel","waterway":"dam","wikidata":"Q8343540","wikipedia":"es:Central hidroeléctrica de Pangue","start_date":"1996","plant:method":"water-storage","plant:source":"hydro","operator:wikidata":"Q651222","plant:output:electricity":"467 MW"},"osm_id":335658430,"output":"467 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":336933592,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-94.869569054,50.114253143],[-94.869453049,50.114506863],[-94.869232856,50.114960157],[-94.869035714,50.114927383],[-94.868920127,50.115190072],[-94.868740168,50.115597768],[-94.868445125,50.11556642],[-94.868405646,50.115630038],[-94.86824178,50.115731627],[-94.86819811,50.115687371],[-94.868279163,50.115614196],[-94.868525172,50.115106421],[-94.869198909,50.113728352],[-94.869289769,50.113547052],[-94.86938834,50.11356348],[-94.869118276,50.114170497],[-94.869569054,50.114253143]]]},"properties":{"name":"Whitedog Falls Generating Station","tags":{"area":"yes","name":"Whitedog Falls Generating Station","power":"plant","operator":"Ontario Power Generation","waterway":"dam","plant:type":"hydrodynamic_screw","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"68 MW"},"osm_id":336933592,"output":"68 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":342883938,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.413505736,46.145737737],[14.413509173,46.145876625],[14.413509173,46.145929682],[14.413590226,46.145929179],[14.413588717,46.145974274],[14.413589052,46.145978716],[14.413896333,46.145977543],[14.413902954,46.146047029],[14.413922065,46.146047364],[14.413926591,46.146110731],[14.413958023,46.146096482],[14.414008483,46.146074186],[14.414055337,46.146053232],[14.414104455,46.146033534],[14.414118537,46.146020291],[14.41413002,46.146003778],[14.414130439,46.145963042],[14.414225574,46.145961198],[14.414319032,46.14595927],[14.414427159,46.145957091],[14.414519443,46.145955163],[14.414612482,46.145953235],[14.41461726,46.145992882],[14.414630671,46.146014339],[14.414692194,46.146037474],[14.414739552,46.146054656],[14.414791185,46.146073432],[14.415351934,46.146277364],[14.415305163,46.146053399],[14.415210783,46.145989278],[14.41519075,46.145890874],[14.415234084,46.145832368],[14.415228552,46.145700521],[14.414998134,46.145705131],[14.414846673,46.145708232],[14.414805937,46.145492147],[14.414764027,46.145360551],[14.414716837,46.145241696],[14.414653721,46.145158547],[14.414618266,46.145124517],[14.414610555,46.145014965],[14.414591779,46.145015133],[14.414598652,46.145138598],[14.414603933,46.145235242],[14.414610555,46.145339848],[14.414615165,46.145482089],[14.41460234,46.145609745],[14.414602424,46.145655259],[14.414604855,46.1457141],[14.4145062,46.145716028],[14.41441182,46.145717955],[14.414409724,46.145669257],[14.414371587,46.145439341],[14.414347195,46.145416877],[14.414324145,46.145438587],[14.414306375,46.145671352],[14.414308387,46.145719967],[14.414215432,46.145721895],[14.414115855,46.145723823],[14.414113843,46.14567504],[14.414110742,46.145610667],[14.414087021,46.14549793],[14.414076376,46.145357953],[14.414068665,46.145243037],[14.414063468,46.145153183],[14.414059277,46.145013959],[14.414031197,46.145014965],[14.414037987,46.145137173],[14.413983672,46.14520272],[14.413955174,46.145268182],[14.413931956,46.145379327],[14.413905134,46.145530787],[14.413860626,46.145638243],[14.413854758,46.145730193],[14.413767084,46.145731953],[14.413717044,46.145732959],[14.413570277,46.145735893],[14.413505736,46.145737737]]]},"properties":{"name":"Hidroelektrarna Medvode","tags":{"name":"Hidroelektrarna Medvode","note":"height estimated","power":"plant","height":"2","operator":"Savske elektrarne Ljubljana","waterway":"dam","wikidata":"Q64062510","wikipedia":"sl:Hidroelektrarna Medvode","start_date":"1953-01-01","plant:method":"run-of-the-river","plant:source":"hydro","construction_date":"1952","plant:output:electricity":"25 MW"},"osm_id":342883938,"output":"25 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":353307082,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-93.102698566,16.941254129],[-93.102512656,16.943216752],[-93.102687167,16.94437027],[-93.101610176,16.944863628],[-93.100552129,16.943144165],[-93.098856973,16.94287938],[-93.097533554,16.943213064],[-93.096816733,16.942741582],[-93.09708772,16.942631276],[-93.097141448,16.942613255],[-93.09775383,16.94236272],[-93.098485235,16.942084776],[-93.098613981,16.942056445],[-93.098863427,16.942066755],[-93.099103652,16.942116376],[-93.099255783,16.942120483],[-93.099437419,16.942061642],[-93.099512521,16.942023169],[-93.099531213,16.941984696],[-93.099542025,16.941866679],[-93.099584941,16.941810185],[-93.099630538,16.941789649],[-93.099712262,16.941793337],[-93.099822316,16.941876821],[-93.099861125,16.941880761],[-93.099914853,16.94187439],[-93.102092722,16.940907035],[-93.102467142,16.940786252],[-93.102698566,16.941254129]]]},"properties":{"name":"Presa Chicoasén","tags":{"name":"Presa Chicoasén","power":"plant","name:en":"Manuel Moreno Torres Dam","name:fr":"centrale hydroélectrique Manuel Moreno Torres","operator":"Comisión Federal de Electricidad","waterway":"dam","wikidata":"Q1435446","plant:method":"water-storage","plant:source":"hydro","official_name":"Presa Manuel Moreno Torres","operator:short":"CFE","operator:wikidata":"Q1114680","plant:output:electricity":"2400 MW"},"osm_id":353307082,"output":"2400 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":357058429,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-122.55847314,45.957240888],[-122.558435169,45.957279948],[-122.558366019,45.95724818],[-122.557812981,45.956950623],[-122.556543458,45.957176264],[-122.55637867,45.957192441],[-122.556242967,45.957191183],[-122.556117406,45.957180035],[-122.55598011,45.957168385],[-122.555970471,45.95719286],[-122.555851867,45.957170145],[-122.555845329,45.957168971],[-122.555855052,45.957144496],[-122.555731251,45.957116165],[-122.55568733,45.957102587],[-122.555669728,45.957127397],[-122.555560931,45.95708993],[-122.555578449,45.95706512],[-122.555485913,45.957028491],[-122.555390276,45.956987755],[-122.555368231,45.957008374],[-122.555268319,45.956956742],[-122.555290447,45.956936122],[-122.555260524,45.956919777],[-122.555069249,45.95679187],[-122.55490203,45.956645354],[-122.554781749,45.956475536],[-122.554692063,45.956295493],[-122.554653255,45.956112265],[-122.555474933,45.956066583],[-122.555525057,45.956091562],[-122.555720774,45.956189378],[-122.555659838,45.956260457],[-122.555552717,45.956438069],[-122.556129056,45.956570252],[-122.556296862,45.95661124],[-122.556266101,45.956675361],[-122.556325025,45.956691371],[-122.556494005,45.95667008],[-122.556565754,45.956627249],[-122.556929361,45.956456426],[-122.557018963,45.956394148],[-122.55708803,45.956301528],[-122.55728534,45.955838679],[-122.557604942,45.95665382],[-122.557699322,45.956758845],[-122.557778364,45.956776782],[-122.557883054,45.956868396],[-122.55831556,45.957146424],[-122.55847314,45.957240888]]]},"properties":{"name":"Merwin Dam","tags":{"name":"Merwin Dam","power":"plant","operator":"PacifiCorp","waterway":"dam","wikidata":"Q6820955","wikipedia":"en:Merwin Dam","ref:US:EIA":"3847","ref:US:NID":"WA00149","start_date":"1931-07","plant:method":"run-of-the-river","plant:source":"hydro","gnis:feature_id":"1511644","operator:wikidata":"Q2045479","plant:output:electricity":"136 MW"},"osm_id":357058429,"output":"136 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":358326125,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[49.386148898,36.759566725],[49.386402031,36.759692873],[49.386642341,36.759570748],[49.387342397,36.759957489],[49.387636351,36.759880376],[49.387821339,36.759789348],[49.388470434,36.759474776],[49.38903462,36.759220469],[49.389376434,36.758990553],[49.389822686,36.757932086],[49.388046393,36.758682937],[49.386639323,36.759332367],[49.386500938,36.759398416],[49.386148898,36.759566725]]]},"properties":{"name":"تاج سد سفیدرود","tags":{"name":"تاج سد سفیدرود","power":"plant","name:en":"Sefidrud Dam","operator":"Gilan Regional Water Authority","waterway":"dam","wikidata":"Q1163313","start_date":"1962","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"87.5 MW"},"osm_id":358326125,"output":"87.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":358441011,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[119.031921826,26.554175236],[119.031967424,26.554498358],[119.032035988,26.554777224],[119.032305885,26.555022143],[119.032644262,26.555338476],[119.032796226,26.555391198],[119.032833861,26.555345517],[119.032081585,26.553716075],[119.031921826,26.554175236]]]},"properties":{"name":"Wangyuan Minhou County","tags":{"name":"Wangyuan Minhou County","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":358441011,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":365736568,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-19.126106658,64.172730168],[-19.12555362,64.173135349],[-19.125030841,64.173394182],[-19.124513259,64.173258228],[-19.125474076,64.172563955],[-19.126106658,64.172730168]]]},"properties":{"name":"Sigölduvirkjun","tags":{"name":"Sigölduvirkjun","power":"plant","building":"yes","waterway":"dam","wikidata":"Q2944563","ref:HNITNUM":"10082776","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"150 MW"},"osm_id":365736568,"output":"150 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":368784611,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[112.467037061,63.030625788],[112.467330679,63.031375717],[112.467895955,63.031642178],[112.46991943,63.032735932],[112.472453699,63.033866735],[112.474767691,63.034649437],[112.475323662,63.034479787],[112.47589229,63.034265797],[112.476278445,63.034061362],[112.476305351,63.033939741],[112.476404425,63.033915182],[112.477789786,63.033634975],[112.478326898,63.033534644],[112.478418848,63.033888695],[112.477898583,63.034214667],[112.477002641,63.034598978],[112.476383721,63.034759826],[112.481158976,63.034831073],[112.481376151,63.034506693],[112.48093258,63.033925659],[112.482343087,63.033751735],[112.482019462,63.032782619],[112.481873281,63.03207812],[112.481602797,63.031805122],[112.48160967,63.031792381],[112.481632972,63.031785843],[112.481984844,63.031708059],[112.481206669,63.030206441],[112.481047496,63.030190935],[112.480092546,63.030295541],[112.478867782,63.030466783],[112.47806488,63.030723772],[112.477592727,63.030937846],[112.477088556,63.031042452],[112.476850677,63.031257448],[112.476798877,63.031864717],[112.47663258,63.031896317],[112.476630652,63.031996984],[112.475064326,63.032269479],[112.474288246,63.031502284],[112.473692879,63.031716357],[112.473183259,63.031910985],[112.472333753,63.031897239],[112.471917257,63.031631197],[112.470742449,63.030964585],[112.470511779,63.030862493],[112.470579673,63.030812285],[112.470452771,63.030774818],[112.471316442,63.030353963],[112.472265944,63.030504753],[112.47364812,63.03046913],[112.476019192,63.0299145],[112.476482376,63.029560867],[112.476825699,63.029273787],[112.476047859,63.029154596],[112.474583289,63.029029287],[112.473467574,63.02903046],[112.47255512,63.029037334],[112.471665045,63.029040183],[112.47101067,63.029137497],[112.47051488,63.029303459],[112.469086437,63.029588695],[112.467037061,63.030625788]]]},"properties":{"name":"Вилюйская ГЭС","tags":{"name":"Вилюйская ГЭС","power":"plant","operator":"ПАО «Якутскэнерго»","waterway":"dam","wikidata":"Q4111427","start_date":"1967","plant:source":"hydro","plant:output:electricity":"680 MW"},"osm_id":368784611,"output":"680 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":368942941,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[111.340189254,30.3997904],[111.340529559,30.399965163],[111.340703735,30.399789897],[111.342140225,30.40059238],[111.342285735,30.400402866],[111.342288417,30.400280993],[111.342188086,30.40009592],[111.341301197,30.399532992],[111.341839483,30.398871576],[111.341636138,30.398704524],[111.342739532,30.397455034],[111.343468757,30.397878069],[111.343846278,30.397605322],[111.342981517,30.397104419],[111.343259042,30.396851118],[111.342868697,30.396622963],[111.340189254,30.3997904]]]},"properties":{"name":"高坝洲水电站","tags":{"name":"高坝洲水电站","power":"plant","name:en":"Gaobazhou Dam","name:fr":"Barrage de Gaobazhou","building":"dam","waterway":"dam","wikidata":"Q11971553","plant:source":"hydro","plant:output:electricity":"252 MW"},"osm_id":368942941,"output":"252 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":372229065,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.103181368,45.239679988],[4.103197881,45.239747629],[4.103366022,45.239727261],[4.103349593,45.239659536],[4.103181368,45.239679988]]]},"properties":{"name":"Micro-Centrale de Ranc","tags":{"name":"Micro-Centrale de Ranc","power":"plant","building":"yes","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","ref:EU:ENTSOE_EIC":"17W000000158797P","plant:output:electricity":"1.046 MW"},"osm_id":372229065,"output":"1.046 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":372317943,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3.975756575,45.201116779],[3.975926225,45.201189031],[3.975999818,45.20109683],[3.976046589,45.201041761],[3.975882974,45.200971772],[3.975824217,45.201037151],[3.975756575,45.201116779]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","start_date":"2004-11-04","plant:method":"run-of-the-river","plant:source":"hydro","ref:EU:ENTSOE_EIC":"17W000001495779Q","plant:output:electricity":"0.36 MW"},"osm_id":372317943,"output":"0.36 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":375928041,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.033201337,27.833571418],[99.035920846,27.833793119],[99.036028386,27.833807536],[99.03615227,27.833824048],[99.036525433,27.833873921],[99.036362153,27.833279225],[99.033738953,27.833099098],[99.033201337,27.833571418]]]},"properties":{"name":"里底水电站","tags":{"name":"里底水电站","power":"plant","name:en":"Lidi Hydropower Station","waterway":"dam","wikidata":"Q11983932","plant:source":"hydro","plant:output:electricity":"420 MW"},"osm_id":375928041,"output":"420 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":375928045,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.922293168,27.936900838],[98.925018292,27.940881823],[98.925844329,27.940407994],[98.923076373,27.936493394],[98.922293168,27.936900838]]]},"properties":{"name":"乌弄龙水电站","tags":{"name":"乌弄龙水电站","power":"plant","name:en":"Wunonglong Hydropower Station","waterway":"dam","wikidata":"Q12010812","plant:source":"hydro","plant:output:electricity":"990 MW"},"osm_id":375928045,"output":"990 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":377454464,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[36.885581347,-7.634047769],[36.88565603,-7.633676619],[36.885663238,-7.633614257],[36.885781255,-7.633561032],[36.885901955,-7.633608977],[36.885936824,-7.63400762],[36.886368659,-7.634770625],[36.886580554,-7.634733409],[36.886770991,-7.635469843],[36.887044576,-7.63548317],[36.887275246,-7.635725072],[36.887218836,-7.635799419],[36.887375829,-7.635968147],[36.887495187,-7.636091864],[36.88689169,-7.636732577],[36.887096376,-7.637201125],[36.886633192,-7.636729811],[36.886421298,-7.636488077],[36.886330438,-7.636557646],[36.885913773,-7.635853734],[36.885706154,-7.635291728],[36.885582269,-7.634699546],[36.885581347,-7.634047769]]]},"properties":{"name":"Kidatu Dam","tags":{"name":"Kidatu Dam","power":"plant","waterway":"dam","wikidata":"Q3437276","wikipedia":"en:Kidatu Dam","plant:source":"hydro"},"osm_id":377454464,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":381244723,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-84.334574645,46.498270274],[-84.334457215,46.498414778],[-84.334397703,46.498477643],[-84.334361745,46.498463058],[-84.329784639,46.496610238],[-84.329962335,46.4964022],[-84.334540028,46.498256947],[-84.334574645,46.498270274]]]},"properties":{"name":"Cloverland Hydroelectric Plant","tags":{"name":"Cloverland Hydroelectric Plant","power":"plant","alt_name":"Cloverland Electric Cooperative Hydroelectric Plant;Saint Marys Falls Hydropower Plant","building":"yes","operator":"Cloverland Electric Co-op","waterway":"dam","wikidata":"Q15995449","ref:US:EIA":"1751","start_date":"1901-07","plant:source":"hydro","plant:output:electricity":"41.8 MW"},"osm_id":381244723,"output":"41.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":381244727,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-84.349469622,46.506472218],[-84.349144321,46.506496526],[-84.349049019,46.505890682],[-84.349246664,46.50587593],[-84.349276336,46.505873666],[-84.349374069,46.505866374],[-84.349384714,46.505933848],[-84.349469622,46.506472218]]]},"properties":{"name":"Saint Marys Falls Power Plant","tags":{"name":"Saint Marys Falls Power Plant","power":"plant","alt_name":"Soo Locks Dam","building":"yes","heritage":"2","historic":"maritime","nrhp:nhl":"yes","operator":"U.S. Army Corps of Engineers","ref:nrhp":"66000394","waterway":"dam","ref:US:EIA":"1865","ref:US:NID":"MI00650","source_ref":"https://catalog.archives.gov/id/25338738","start_date":"1932-07","plant:source":"hydro","nrhp:criteria":"(A)","heritage:website":"https://www.nps.gov/subjects/nationalregister/index.htm","protection_title":"U.S Army Corps of Engineers","heritage:operator":"nrhp","nrhp:inscription_date":"1966-11-13","plant:output:electricity":"23.7 MW"},"osm_id":381244727,"output":"23.7 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":382238871,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[110.73807197,29.540257745],[110.738274728,29.540463689],[110.739322718,29.541070874],[110.74047238,29.541779899],[110.740624595,29.541890121],[110.740709085,29.541750479],[110.740531556,29.541596084],[110.740319494,29.54058464],[110.739938285,29.540249196],[110.73950888,29.540000337],[110.738173391,29.540117935],[110.73807197,29.540257745]]]},"properties":{"name":null,"tags":{"power":"plant","name:en":"Jiangya_Dam","building":"dam","waterway":"dam","wikidata":"Q3376689","wikipedia":"en:Jiangya Dam","plant:source":"hydro"},"osm_id":382238871,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":382451611,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[108.83191923,29.50008848],[108.832145793,29.500558621],[108.832272275,29.500809156],[108.83209525,29.500928179],[108.83218108,29.501093973],[108.832307144,29.501245686],[108.832505879,29.501153317],[108.832712158,29.501002862],[108.832519039,29.500872188],[108.832278394,29.500506151],[108.83204697,29.499975744],[108.83191923,29.50008848]]]},"properties":{"name":"Zhoubai Hydroelectric","tags":{"name":"Zhoubai Hydroelectric","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":382451611,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":383927785,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[132.044123848,34.201588243],[132.044302969,34.201841293],[132.04513085,34.201454887],[132.045600739,34.201226648],[132.045974321,34.201055657],[132.045920844,34.200910567],[132.045531924,34.200821886],[132.045346851,34.200821886],[132.045279796,34.200782575],[132.045021885,34.200887265],[132.044603628,34.201070996],[132.044168943,34.20133117],[132.044123848,34.201588243]]]},"properties":{"name":"生見川ダム","tags":{"name":"生見川ダム","power":"plant","building":"yes","waterway":"dam","plant:source":"hydro"},"osm_id":383927785,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":384312446,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[69.478923644,34.556066389],[69.478939234,34.556143334],[69.479062448,34.556127241],[69.479073429,34.556179544],[69.479122882,34.556176275],[69.479117685,34.556121877],[69.479198822,34.55610922],[69.479216759,34.556166385],[69.479269984,34.55615926],[69.479263698,34.556101676],[69.479356318,34.556085667],[69.479363945,34.556139143],[69.47941365,34.556130845],[69.47940619,34.556078794],[69.479426894,34.556075944],[69.479459331,34.556071753],[69.479469725,34.556119697],[69.479517837,34.556112824],[69.479494955,34.55599246],[69.479229584,34.55602758],[69.478923644,34.556066389]]]},"properties":{"name":"Band-i Mahipar Dam","tags":{"name":"Band-i Mahipar Dam","power":"plant","waterway":"dam","start_date":"1966","plant:source":"hydro"},"osm_id":384312446,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":386119798,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[110.689656253,26.727736958],[110.689656673,26.727891688],[110.691007584,26.729191134],[110.691255185,26.729014024],[110.691021246,26.727941476],[110.690405177,26.727929155],[110.689951045,26.727644421],[110.689656253,26.727736958]]]},"properties":{"name":null,"tags":{"power":"plant","building":"dam","plant:method":"combustion","plant:source":"waste","plant:output:electricity":"yes"},"osm_id":386119798,"output":"yes","source":"waste","construction":"","source_layer":"power_plant"}},{"id":388909583,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[133.211677949,33.562267464],[133.212853427,33.562784543],[133.212908496,33.562714051],[133.211725391,33.562181298],[133.211677949,33.562267464]]]},"properties":{"name":"筏津ダム","tags":{"name":"筏津ダム","power":"plant","waterway":"dam","wikidata":"Q5995307","plant:source":"hydro"},"osm_id":388909583,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":390091327,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.644128637,28.248596157],[101.644317984,28.248932858],[101.645076965,28.248601856],[101.645817171,28.248286529],[101.645632518,28.247950163],[101.644896168,28.248263647],[101.644460979,28.24845115],[101.644128637,28.248596157]]]},"properties":{"name":"锦屏二级水电站","tags":{"name":"锦屏二级水电站","power":"plant","height":"37","name:en":"Jinping-II Dam","building":"yes","waterway":"dam","wikidata":"Q106512820","plant:source":"hydro"},"osm_id":390091327,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":391896477,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.112939936,61.337538763],[22.113145796,61.337719393],[22.113448466,61.337985183],[22.113973173,61.337893066],[22.114127149,61.33797722],[22.114496288,61.338247788],[22.114594692,61.338325824],[22.11498797,61.338637798],[22.114720839,61.338727065],[22.114543227,61.338859248],[22.114918736,61.338779536],[22.115343279,61.338732933],[22.115511839,61.338714493],[22.11540673,61.338651544],[22.115310674,61.338583735],[22.113780054,61.337374478],[22.113709479,61.337318738],[22.112939936,61.337538763]]]},"properties":{"name":"Harjavallan vesivoimalaitos","tags":{"name":"Harjavallan vesivoimalaitos","layer":"1","power":"plant","building":"industrial","operator":"Länsi-Suomen Voima","waterway":"dam","wikidata":"Q11861542","wikipedia":"fi:Harjavallan vesivoimalaitos","start_date":"1939","plant:source":"hydro","plant:output:electricity":"105 MW"},"osm_id":391896477,"output":"105 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":391905646,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[89.530855388,27.110349967],[89.530953959,27.110530764],[89.53101062,27.110506289],[89.531339191,27.110364383],[89.531491742,27.110298418],[89.531393171,27.11011762],[89.531246739,27.11018082],[89.530902578,27.110329599],[89.530855388,27.110349967]]]},"properties":{"name":"Chhukha Hydroelectric Power Plant Bhutan","tags":{"name":"Chhukha Hydroelectric Power Plant Bhutan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":391905646,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":391932128,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[89.595214322,27.035524046],[89.59533234,27.035679195],[89.595399479,27.035798554],[89.595439628,27.03588933],[89.595463181,27.035982117],[89.595485226,27.036137853],[89.595490674,27.036350418],[89.595467456,27.036682258],[89.595530823,27.036670523],[89.595557645,27.036539179],[89.59559528,27.036462736],[89.595689157,27.036307419],[89.59572939,27.036195185],[89.595753446,27.036054118],[89.595756129,27.035961079],[89.595745484,27.03588933],[89.595710531,27.035784221],[89.595664934,27.035724458],[89.595576421,27.035729235],[89.595504085,27.035609877],[89.595418254,27.035631335],[89.595289424,27.035490351],[89.595214322,27.035524046]]]},"properties":{"name":"Tala Hydroelectric Power Plant Bhutan","tags":{"name":"Tala Hydroelectric Power Plant Bhutan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":391932128,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":392308130,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.642313846,43.823883586],[4.642446029,43.823963717],[4.643864163,43.824687327],[4.643714295,43.824837028],[4.643676241,43.824885559],[4.643674229,43.824893941],[4.643671295,43.824906681],[4.6436749,43.824939455],[4.64369225,43.824969126],[4.643730639,43.82500475],[4.643793923,43.825057053],[4.643880591,43.825103069],[4.643955274,43.82520558],[4.64400917,43.825327118],[4.644418039,43.824933755],[4.644945428,43.825211531],[4.64536159,43.824795956],[4.644561537,43.824394798],[4.643189839,43.823671692],[4.642858167,43.824003364],[4.642569159,43.823857267],[4.642759428,43.82366859],[4.64287887,43.823551328],[4.642882977,43.82352702],[4.642797817,43.823472789],[4.642722799,43.823533474],[4.642313846,43.823883586]]]},"properties":{"name":"Centrale Hydroélectrique de Beaucaire","tags":{"name":"Centrale Hydroélectrique de Beaucaire","power":"plant","operator":"CNR","waterway":"dam","wikidata":"Q2885316","start_date":"1969","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1121170","ref:EU:ENTSOE_EIC":"17W100P100P0327D","operator:wikipedia":"fr:Compagnie nationale du Rhône","old_ref:EU:ENTSOE_EIC":"17W000000012924V","plant:output:electricity":"210 MW"},"osm_id":392308130,"output":"210 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":401638972,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.044504683,29.234712983],[103.044775335,29.236322225],[103.044972393,29.237493764],[103.045897588,29.237375244],[103.045429878,29.234594547],[103.044504683,29.234712983]]]},"properties":{"name":"枕头坝一级水电站","tags":{"name":"枕头坝一级水电站","power":"plant","name:en":"Zhenzhuba I Hydropower Station","waterway":"dam","wikidata":"Q84763813","plant:source":"hydro","plant:output:electricity":"720 MW"},"osm_id":401638972,"output":"720 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":401661526,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.834708545,28.088422094],[104.834980454,28.089371764],[104.83513795,28.089336644],[104.835169047,28.089445357],[104.835386977,28.089396742],[104.835315647,28.089147883],[104.835051198,28.088224197],[104.835023454,28.088127219],[104.83487895,28.088159573],[104.834939383,28.088370545],[104.834708545,28.088422094]]]},"properties":{"name":"Luodu Small","tags":{"name":"Luodu Small","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":401661526,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":402535304,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-115.589453321,52.962849726],[-115.589001955,52.962891133],[-115.588881423,52.962414538],[-115.589332957,52.962373131],[-115.589453321,52.962849726]]]},"properties":{"name":"Brazeau Rubber Dam","tags":{"name":"Brazeau Rubber Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":402535304,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":402765435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.069998098,32.523327548],[104.07030387,32.52376852],[104.070521129,32.523718899],[104.07022332,32.523316232],[104.070234133,32.522974754],[104.070014192,32.52297249],[104.069998098,32.523327548]]]},"properties":{"name":"Sichuan Pingwu County Sancha","tags":{"name":"Sichuan Pingwu County Sancha","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":402765435,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":405548648,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.533348676,36.120492625],[103.533997435,36.120755817],[103.53408469,36.120784064],[103.534176221,36.120810718],[103.534372357,36.120852292],[103.534558771,36.120888083],[103.534700257,36.120928651],[103.534905027,36.121000317],[103.535149444,36.121089584],[103.53544457,36.121189161],[103.535657722,36.121267196],[103.535869617,36.121342466],[103.536145298,36.12143408],[103.536582162,36.121699619],[103.537110641,36.121000317],[103.536394324,36.120726899],[103.5351419,36.120363376],[103.534848869,36.120809125],[103.534195667,36.12064945],[103.534026017,36.120590861],[103.533788223,36.120486171],[103.533436266,36.120367483],[103.533348676,36.120492625]]]},"properties":{"name":null,"tags":{"power":"plant","name:en":"Chaijiaxia Power Plant","waterway":"dam","wikidata":"Q84813835","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"96 MW"},"osm_id":405548648,"output":"96 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":406463333,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[108.847843421,13.502068062],[108.84792263,13.50204585],[108.848072833,13.501975442],[108.848214991,13.50203026],[108.848486229,13.501599262],[108.848649425,13.501345626],[108.848599888,13.501216545],[108.848693765,13.501063994],[108.848593182,13.50092058],[108.848222618,13.501488789],[108.847843421,13.502068062]]]},"properties":{"name":"La Hieng 2","tags":{"name":"La Hieng 2","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":406463333,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":406507318,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.011869265,36.146787828],[104.011877227,36.146806269],[104.011913437,36.146825798],[104.012596143,36.14696762],[104.012712736,36.146990335],[104.012833435,36.14700559],[104.01344632,36.147040291],[104.01387145,36.147082452],[104.013867427,36.14715395],[104.013561739,36.14713442],[104.013552351,36.147210276],[104.01385544,36.147243888],[104.013847394,36.147316391],[104.013518739,36.147306668],[104.013509435,36.147362995],[104.013816548,36.147396438],[104.013805736,36.147466846],[104.013525445,36.147464751],[104.013506753,36.147535075],[104.014097929,36.147557036],[104.014164397,36.146985641],[104.014689272,36.147024785],[104.01488675,36.147009362],[104.015438782,36.146978181],[104.015490163,36.146964184],[104.015555039,36.146936859],[104.015580855,36.146923364],[104.015589321,36.146902493],[104.015564762,36.146886986],[104.015396621,36.146887741],[104.015035948,36.146879694],[104.014685249,36.146833175],[104.014193566,36.146830325],[104.01422064,36.146445595],[104.014228267,36.145380088],[104.013477249,36.145403892],[104.013430143,36.145548229],[104.013413966,36.145662474],[104.01340374,36.145884427],[104.013372727,36.146056424],[104.013375157,36.146306037],[104.013341043,36.146463533],[104.01334515,36.146588088],[104.013335092,36.146723958],[104.011913437,36.146751032],[104.011885358,36.146759749],[104.011869265,36.146787828]]]},"properties":{"name":"小峡水电站","tags":{"name":"小峡水电站","power":"plant","name:en":"Xiaoxia Power Station","alt_name":"小峡电站","waterway":"dam","wikidata":"Q12010868","plant:source":"hydro","plant:output:electricity":"230 MW"},"osm_id":406507318,"output":"230 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":407956070,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[126.335958519,38.947378234],[126.336847337,38.948384817],[126.337640265,38.949081186],[126.338594544,38.949259133],[126.33986105,38.949323087],[126.338435959,38.948875997],[126.338176958,38.948749765],[126.336342578,38.947018483],[126.336177874,38.946824861],[126.335958519,38.947378234]]]},"properties":{"name":"남강발전소","tags":{"name":"남강발전소","power":"plant","name:ko":"남강발전소","material":"concrete","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":407956070,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":407977032,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.001175734,26.903740244],[115.003156797,26.908138061],[115.002896036,26.908231436],[115.003464161,26.909492744],[115.003611934,26.909439855],[115.004171426,26.90923936],[115.003612185,26.907997832],[115.003462736,26.908051392],[115.001472956,26.903633878],[115.001175734,26.903740244]]]},"properties":{"name":"石虎塘航电枢纽","tags":{"name":"石虎塘航电枢纽","power":"plant","name:en":"Shihutang Power Plant","waterway":"dam","wikidata":"Q84764993","plant:source":"hydro","plant:output:electricity":"120 MW"},"osm_id":407977032,"output":"120 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":408793870,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.714725827,30.466473385],[102.715122794,30.466865742],[102.715624619,30.467097166],[102.715805584,30.46717252],[102.715851349,30.466869681],[102.715411467,30.466617638],[102.714725827,30.466473385]]]},"properties":{"name":"Sichuan Longdong","tags":{"name":"Sichuan Longdong","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":408793870,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":409163571,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[126.610389489,37.31298733],[126.610578753,37.313065617],[126.612463759,37.313833232],[126.612423442,37.313980334],[126.612530563,37.314088377],[126.612732818,37.314074882],[126.612911352,37.313860222],[126.614410288,37.31448987],[126.614624697,37.314579976],[126.61482645,37.314193235],[126.614648753,37.31411587],[126.614662248,37.314075888],[126.614155227,37.313863072],[126.613637644,37.313617649],[126.613160127,37.313418747],[126.613149482,37.313407096],[126.613151494,37.313384633],[126.613168845,37.313317996],[126.613156858,37.313253959],[126.613121235,37.313216659],[126.613091731,37.313202745],[126.613060969,37.313193777],[126.613003218,37.313201153],[126.612941527,37.313231579],[126.612877154,37.313285978],[126.612853769,37.313290755],[126.612821582,37.3132812],[126.610912436,37.312535043],[126.610877651,37.312587346],[126.610667098,37.312506293],[126.610502142,37.312791948],[126.610389489,37.31298733]]]},"properties":{"name":"시화호조력발전소","tags":{"name":"시화호조력발전소","power":"plant","name:en":"Sihwa Lake Tidal Power Station","name:ja":"始華湖潮力発電所","name:ko":"시화호조력발전소","operator":"한국수자원공사","waterway":"dam","wikidata":"Q562741","plant:method":"barrage","plant:source":"tidal","plant:output:electricity":"254 MW"},"osm_id":409163571,"output":"254 MW","source":"tidal","construction":"","source_layer":"power_plant"}},{"id":412348687,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.40761292,36.138761737],[103.407637227,36.138831055],[103.407678131,36.138870031],[103.407744683,36.138901463],[103.408075768,36.139042782],[103.408612964,36.139273955],[103.408639451,36.13923917],[103.408669291,36.139201452],[103.408745734,36.139106569],[103.408811448,36.139014535],[103.408674068,36.138942619],[103.409124596,36.138351443],[103.409251246,36.138181123],[103.409277146,36.138193025],[103.409333724,36.138118677],[103.409148568,36.138013568],[103.409114537,36.137993955],[103.409146389,36.137956823],[103.409132139,36.137949447],[103.409149825,36.137929749],[103.408999957,36.137854145],[103.409308327,36.137442593],[103.409648297,36.136967591],[103.409201625,36.136697442],[103.409170193,36.136678499],[103.408920748,36.137037999],[103.408800635,36.137206475],[103.408653198,36.137399175],[103.408532498,36.137555162],[103.408383216,36.137752556],[103.408415487,36.137770996],[103.408295458,36.137923128],[103.408178027,36.138074421],[103.408072835,36.138206687],[103.407923972,36.138391089],[103.407814923,36.138525535],[103.407720459,36.138644139],[103.40761292,36.138761737]]]},"properties":{"name":"八盘峡水电站","tags":{"name":"八盘峡水电站","power":"plant","name:en":"Bapanxia Powerplant","waterway":"dam","wikidata":"Q11960159","plant:source":"hydro","plant:output:electricity":"226.4 MW"},"osm_id":412348687,"output":"226.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":415687971,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-117.414780207,48.780415494],[-117.414700747,48.780423038],[-117.414099932,48.780480537],[-117.41356986,48.780531248],[-117.413520826,48.780309128],[-117.413111454,48.780348271],[-117.412850861,48.780373249],[-117.412542407,48.780312564],[-117.412000768,48.780205946],[-117.410986977,48.780233942],[-117.410834007,48.780207623],[-117.411984675,48.780168898],[-117.412835019,48.78033377],[-117.413098965,48.780306864],[-117.414040839,48.780211059],[-117.414400255,48.780183986],[-117.414722456,48.78015314],[-117.414780207,48.780415494]]]},"properties":{"name":"Pend Oreille County Pud Dam","tags":{"name":"Pend Oreille County Pud Dam","power":"plant","alt_name":"Box Canyon Forebay Dam","operator":"PUD No 1 of Pend Oreille County","waterway":"dam","wikidata":"Q11064575","ref:US:EIA":"3891","ref:US:NID":"WA00013S001","start_date":"1955-06","plant:method":"water-storage","plant:source":"hydro","gnis:feature_id":"1532884","plant:output:electricity":"90 MW"},"osm_id":415687971,"output":"90 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":417102198,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-92.538610313,45.946390347],[-92.538431946,45.946507777],[-92.538504366,45.94656469],[-92.538386349,45.946643983],[-92.538302278,45.946579023],[-92.538336225,45.946551531],[-92.538455415,45.946475926],[-92.538597405,45.946378528],[-92.538610313,45.946390347]]]},"properties":{"name":"Clam River 2WP516 Dam","tags":{"name":"Clam River 2WP516 Dam","owner":"Flambeau Hydro, LLC.","power":"plant","website":"http://www.renewableworldenergies.com/hydro-plant-dam/clam-river","operator":"Renewable World Energies","waterway":"dam","ref:US:EIA":"4018","ref:US:NID":"WI00004","start_date":"1942-04","plant:source":"hydro","gnis:feature_id":"1791669","plant:output:electricity":"1.2 MW"},"osm_id":417102198,"output":"1.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":422000596,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.991392167,27.819919728],[102.992528669,27.820057359],[102.992566974,27.819848566],[102.992325576,27.819350346],[102.992352398,27.819217576],[102.992094822,27.819189078],[102.992110999,27.81906578],[102.991585286,27.819018255],[102.991563074,27.819174829],[102.99150482,27.819184384],[102.991493253,27.819535418],[102.99140826,27.819563833],[102.991392167,27.819919728]]]},"properties":{"name":"Lianbu Hydroelectric","tags":{"name":"Lianbu Hydroelectric","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":422000596,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":422442088,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.87107294,23.094124767],[101.871123064,23.094407824],[101.872153786,23.095006963],[101.872289741,23.095224222],[101.872361238,23.095309717],[101.872440028,23.095559917],[101.872611773,23.095678437],[101.872826518,23.095546757],[101.872447153,23.094137843],[101.872618982,23.093275429],[101.872833642,23.092577552],[101.871695631,23.093525629],[101.871480971,23.093598049],[101.871373599,23.093867946],[101.87107294,23.094124767]]]},"properties":{"name":"Sinanjiang Dam","tags":{"name":"Sinanjiang Dam","power":"plant","building":"dam","waterway":"dam","wikidata":"Q12000558","plant:source":"hydro"},"osm_id":422442088,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":423708974,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.598340549,45.796150097],[1.598562334,45.796526193],[1.598570632,45.796550249],[1.598683285,45.796529881],[1.598797278,45.796710343],[1.598843798,45.796696848],[1.598742126,45.796504148],[1.598703653,45.796502556],[1.598470217,45.796115982],[1.598521095,45.796103242],[1.598394696,45.79588263],[1.59835463,45.795922863],[1.598402072,45.796049095],[1.598424451,45.796108858],[1.598340549,45.796150097]]]},"properties":{"name":"Barrage de Langleret","tags":{"name":"Barrage de Langleret","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q11983178","start_date":"2006-01-12","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000058730R","plant:output:electricity":"3.2 MW"},"osm_id":423708974,"output":"3.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":423708976,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[1.675032783,45.81621126],[1.675132696,45.816293487],[1.675190531,45.816341096],[1.675107382,45.816393986],[1.675162032,45.816449222],[1.675471073,45.816718868],[1.675558496,45.816685592],[1.67576905,45.816866138],[1.675741892,45.816779385],[1.675613062,45.816660446],[1.675580624,45.816671175],[1.675225148,45.816330032],[1.675255742,45.816310167],[1.675068239,45.816143618],[1.675032783,45.81621126]]]},"properties":{"name":"Barrage du Martineix","tags":{"name":"Barrage du Martineix","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q11988572","start_date":"2006-01-12","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000164488F","plant:output:electricity":"3.2 MW"},"osm_id":423708976,"output":"3.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":423729539,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[0.85308299,44.109920391],[0.853258423,44.109973532],[0.853219028,44.110071768],[0.853222297,44.110137985],[0.8533176,44.110219792],[0.854790719,44.110594631],[0.855272427,44.110633691],[0.85529422,44.110598654],[0.855316851,44.110562277],[0.85532867,44.110538472],[0.855451129,44.110273017],[0.855031531,44.110169585],[0.85402235,44.109920558],[0.85413517,44.109690224],[0.854119496,44.109658037],[0.854084292,44.109639094],[0.854009358,44.109620821],[0.85409829,44.109441532],[0.854185965,44.109264758],[0.854290571,44.109290071],[0.854328289,44.109279594],[0.854365421,44.109254532],[0.854461143,44.109054037],[0.854144558,44.1089843],[0.854165429,44.108940043],[0.8541863,44.108911545],[0.854231143,44.108877849],[0.854301132,44.108868881],[0.854347065,44.108721192],[0.85430415,44.108719096],[0.854277663,44.108703925],[0.854279171,44.108680539],[0.854320997,44.108636367],[0.853753961,44.10848499],[0.853727558,44.108538885],[0.853563022,44.108874329],[0.85347652,44.109058479],[0.853483477,44.109076417],[0.853762762,44.109144645],[0.853678357,44.109322761],[0.853592861,44.109503391],[0.853303183,44.109432648],[0.853277534,44.109437174],[0.853250041,44.109491237],[0.853188686,44.109612188],[0.853090366,44.109789549],[0.85308299,44.109920391]]]},"properties":{"name":"Centrale Hydraulique de Golfech","tags":{"name":"Centrale Hydraulique de Golfech","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q56356833","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W0000000120884","plant:output:electricity":"69 MW"},"osm_id":423729539,"output":"69 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":424959032,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.159142656,36.305616176],[104.159186242,36.305765961],[104.159255225,36.305969557],[104.159410877,36.305932761],[104.159772975,36.305846343],[104.159823937,36.30596084],[104.159925861,36.305939299],[104.160187377,36.306754187],[104.16020347,36.306812442],[104.159738023,36.306911935],[104.15978362,36.307058953],[104.160148485,36.306985444],[104.160274548,36.306962729],[104.160346968,36.307154088],[104.160456939,36.307316194],[104.160769332,36.307750544],[104.160819037,36.307781976],[104.160860527,36.307781976],[104.160883326,36.307761357],[104.160879386,36.307710563],[104.160666151,36.307279397],[104.160585685,36.307063228],[104.160325427,36.306196456],[104.160489041,36.306151026],[104.160754664,36.306810346],[104.160910148,36.307061049],[104.160938395,36.307067587],[104.160958428,36.307062139],[104.160966558,36.307039424],[104.160961194,36.307007069],[104.160886092,36.306851334],[104.160820378,36.306690317],[104.160670174,36.306304582],[104.160489041,36.305928486],[104.16169746,36.305577117],[104.161987139,36.305484245],[104.161806676,36.305007231],[104.161757726,36.304877982],[104.161320526,36.305042268],[104.16085927,36.305195656],[104.160537824,36.305287271],[104.160328193,36.30534695],[104.159665687,36.305515594],[104.159142656,36.305616176]]]},"properties":{"name":"大峡水电站","tags":{"name":"大峡水电站","power":"plant","name:en":"Daxia Hydropower Station","waterway":"dam","wikidata":"Q17764937","plant:source":"hydro","plant:output:electricity":"324.5 MW"},"osm_id":424959032,"output":"324.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":427961750,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[13.653921242,45.984217792],[13.654281161,45.984358273],[13.654320975,45.984357267],[13.654370931,45.984296917],[13.654517195,45.984354752],[13.654485428,45.984394566],[13.654482243,45.98441988],[13.65449096,45.984432285],[13.654519542,45.98443195],[13.654546364,45.984416443],[13.654579976,45.984377467],[13.654723558,45.984434632],[13.654685085,45.98448199],[13.654687767,45.98451317],[13.654700675,45.984532616],[13.654736298,45.984535131],[13.654755074,45.984519792],[13.654804946,45.984459778],[13.655315739,45.984661781],[13.655290845,45.984690028],[13.655265951,45.984718443],[13.655267543,45.984743253],[13.65528942,45.984765046],[13.655316493,45.984769824],[13.655361085,45.984785834],[13.655667863,45.984409067],[13.65552009,45.984357434],[13.655068221,45.984181247],[13.655218593,45.983988631],[13.655216749,45.983961976],[13.655201242,45.983950661],[13.655173582,45.983946218],[13.655147933,45.983950828],[13.655067048,45.984047136],[13.655057074,45.984043784],[13.654940984,45.984185689],[13.654783321,45.984118886],[13.654907037,45.983942363],[13.654888765,45.983934484],[13.654734035,45.984100445],[13.654578383,45.984033893],[13.65452256,45.984011681],[13.654480231,45.984061972],[13.65414177,45.983930544],[13.653921242,45.984217792]]]},"properties":{"name":"HE Solkan","tags":{"name":"HE Solkan","power":"plant","website":"https://www.seng.si/hidroelektrarne/velike-hidroelektrarne/2017060910015833","operator":"Soške elektrarne Nova Gorica d.o.o.","waterway":"dam","wikidata":"Q12790969","start_date":"1984","plant:method":"water-storage","plant:source":"hydro","operator:abbr":"SENG","ref:gurs:hs_mid":"13765707","ref:gurs:sta_sid":"31444240","wikimedia_commons":"Category:Solkan_Hydroelectric_Power_Plant","plant:output:electricity":"32 MW"},"osm_id":427961750,"output":"32 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":428016924,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-49.947288816,-24.15952216],[-49.947262413,-24.159488213],[-49.946966867,-24.159108848],[-49.946940045,-24.159074566],[-49.946915654,-24.159043134],[-49.946872235,-24.159071381],[-49.946911379,-24.159121421],[-49.946990839,-24.159223512],[-49.947200219,-24.159492236],[-49.947245482,-24.159550323],[-49.947288816,-24.15952216]]]},"properties":{"name":"CGH Rio das Cinzas","tags":{"name":"CGH Rio das Cinzas","power":"plant","operator":"Homero Wagner Fronja","waterway":"dam","generator:method":"water-storage","generator:source":"hydro","plant:output:electricity":"1.4 MW"},"osm_id":428016924,"output":"1.4 MW","source":"","construction":"","source_layer":"power_plant"}},{"id":429545514,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[75.820910386,32.676488975],[75.821016333,32.676493333],[75.821016333,32.676536416],[75.821534334,32.67658587],[75.821899282,32.676636077],[75.822024592,32.676589977],[75.822029873,32.676511187],[75.82156057,32.676451424],[75.821318249,32.676420579],[75.820931592,32.676393756],[75.820910386,32.676488975]]]},"properties":{"name":"SEWA-II","tags":{"name":"SEWA-II","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":429545514,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":429548144,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[78.177697375,31.500005297],[78.177736267,31.500036394],[78.178567919,31.499279843],[78.178671436,31.499360645],[78.17876766,31.499435747],[78.177942797,31.500192884],[78.178046397,31.500274943],[78.178069867,31.500253485],[78.17800893,31.500205121],[78.178811833,31.499468352],[78.178884839,31.499525433],[78.178923563,31.499489391],[78.179152976,31.499668428],[78.179245596,31.499582262],[78.17880563,31.499238688],[78.177789743,31.498445341],[78.177712043,31.498517593],[78.178207749,31.498904669],[78.17807917,31.499024447],[78.178132479,31.499066021],[78.17820733,31.498996283],[78.178529782,31.499248076],[78.177697375,31.500005297]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":429548144,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":430539526,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-38.203501845,-9.398551543],[-38.203449626,-9.398450709],[-38.203202946,-9.398177626],[-38.202942353,-9.397900018],[-38.203244604,-9.397512355],[-38.203358179,-9.39718194],[-38.203361029,-9.397054451],[-38.203304535,-9.396960574],[-38.203208562,-9.396909528],[-38.203083253,-9.396897877],[-38.202927349,-9.396982535],[-38.202697937,-9.396743567],[-38.202776307,-9.39661591],[-38.202771613,-9.396464868],[-38.202665834,-9.396318604],[-38.202532813,-9.396259847],[-38.202328965,-9.396330674],[-38.201835523,-9.39571385],[-38.20115114,-9.395107671],[-38.200336754,-9.395889283],[-38.200193675,-9.396026579],[-38.200447647,-9.396347857],[-38.201404441,-9.397346728],[-38.201789422,-9.397748724],[-38.203079146,-9.398886149],[-38.203501845,-9.398551543]]]},"properties":{"name":"Usina Paulo Afonso I, II e III","tags":{"name":"Usina Paulo Afonso I, II e III","power":"plant","operator":"Companhia Hidro-Elétrica do São Francisco","waterway":"dam","start_date":"1948","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"1417.2 MW"},"osm_id":430539526,"output":"1417.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":441711169,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.91231213,45.768427036],[4.91231213,45.768443297],[4.91232529,45.768443632],[4.912325122,45.768453271],[4.912467447,45.768456037],[4.915420559,45.768511274],[4.915540672,45.768513369],[4.915542264,45.768523344],[4.915588532,45.768522673],[4.915590544,45.768467269],[4.915438077,45.768462575],[4.915436066,45.768480177],[4.914424957,45.768459222],[4.914424621,45.768441285],[4.914384053,45.76843919],[4.91438179,45.768458887],[4.914253463,45.768457881],[4.914134356,45.768439357],[4.914167297,45.767724465],[4.914203591,45.76702005],[4.913597076,45.76699482],[4.91358794,45.767165979],[4.913749291,45.767166481],[4.913746274,45.76727838],[4.913696234,45.767286929],[4.91374552,45.767295395],[4.913743256,45.767348955],[4.913694222,45.767357756],[4.913742418,45.767367396],[4.913740155,45.76742213],[4.913691372,45.76743026],[4.913739485,45.767439145],[4.913737138,45.767493627],[4.91368802,45.767501255],[4.913736467,45.767510056],[4.913734036,45.767565292],[4.91368584,45.767573926],[4.913733366,45.767582727],[4.913730935,45.767637293],[4.913680476,45.767645926],[4.913730264,45.767655985],[4.913727918,45.767708623],[4.913678129,45.767716586],[4.913727247,45.767724884],[4.91372599,45.767755059],[4.913674692,45.767764363],[4.913725235,45.767773667],[4.913723978,45.767803841],[4.913676369,45.767812726],[4.91372314,45.767821024],[4.913721883,45.767852624],[4.913672429,45.767860922],[4.913721128,45.767869639],[4.913718865,45.76792387],[4.913669999,45.767932336],[4.913718027,45.767941137],[4.913715764,45.76799629],[4.913665556,45.768005259],[4.913715093,45.768013473],[4.913712746,45.768067536],[4.913662539,45.768076253],[4.913711992,45.768084887],[4.913709645,45.76814004],[4.913659354,45.768148254],[4.913708891,45.76815739],[4.913706544,45.76821204],[4.913657342,45.768220841],[4.913705873,45.768229726],[4.913703442,45.76828446],[4.91365357,45.768293261],[4.913702772,45.768301978],[4.913700425,45.768356628],[4.913649966,45.768364842],[4.913699754,45.768373056],[4.913697324,45.768428042],[4.912666936,45.768404153],[4.912616226,45.768371632],[4.912498292,45.76837029],[4.912488234,45.768394849],[4.912329984,45.768393424],[4.912329146,45.768404572],[4.912327637,45.768426868],[4.91231213,45.768427036]]]},"properties":{"name":"Centrale Hydroélectrique de Cusset","tags":{"name":"Centrale Hydroélectrique de Cusset","power":"plant","operator":"Électricité de France","waterway":"dam","wikidata":"Q8571","wikipedia":"fr:Centrale hydroélectrique de Cusset","start_date":"1899","plant:method":"run-of-the-river","plant:source":"hydro","operator:short":"EDF","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W000000012113V;17W000000396377J;17W000000161177A;17W000000012120Y;17W0000004129049","plant:output:electricity":"63 MW"},"osm_id":441711169,"output":"63 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":449507010,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.630739685,29.257616785],[103.631129276,29.258196897],[103.633421642,29.256997949],[103.633246125,29.256737356],[103.634165369,29.256226898],[103.635385019,29.255549473],[103.635118894,29.255184525],[103.630739685,29.257616785]]]},"properties":{"name":"铜街子水电站","tags":{"name":"铜街子水电站","power":"plant","name:en":"Tongjiezi Hydropower Station","waterway":"dam","wikidata":"Q12006766","plant:source":"hydro","plant:output:electricity":"600 MW"},"osm_id":449507010,"output":"600 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":449516459,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[24.940464582,49.450035261],[24.940700784,49.450088486],[24.940919133,49.45013752],[24.940945955,49.450102232],[24.940976549,49.450030148],[24.940783178,49.44998673],[24.940521998,49.449927889],[24.94049132,49.450001147],[24.940464582,49.450035261]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":449516459,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":450364019,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.594772083,24.66057473],[116.595007279,24.660671374],[116.595497285,24.660936745],[116.595848487,24.661171354],[116.596404961,24.661664294],[116.597051374,24.662371224],[116.597196045,24.662239963],[116.596736298,24.661382578],[116.596922628,24.661110753],[116.596703944,24.6610095],[116.596582239,24.660953089],[116.596299098,24.660821829],[116.595997266,24.660901122],[116.595759555,24.66078696],[116.595653272,24.660735998],[116.595180533,24.660604989],[116.594947265,24.660461155],[116.594772083,24.66057473]]]},"properties":{"name":"棉花糖水库","tags":{"name":"棉花糖水库","power":"plant","name:en":"Mianhuatan Dam","name:mga":"棉花糖水库","waterway":"dam","wikidata":"Q3376729","name:zh-Hant":"棉花灘水庫","plant:source":"hydro","plant:output:electricity":"600 MW"},"osm_id":450364019,"output":"600 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":451833449,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[110.763252548,24.97589078],[110.763374589,24.976085827],[110.763824697,24.975802854],[110.764165841,24.975203967],[110.764158548,24.974703818],[110.763738783,24.974936835],[110.763252548,24.97589078]]]},"properties":{"name":"峻山水电站","tags":{"name":"峻山水电站","power":"plant","building":"dam","operator":"桂东电力","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"9.6 MW"},"osm_id":451833449,"output":"9.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":458830911,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[110.734242698,24.373436628],[110.735540971,24.374716796],[110.73665677,24.376158232],[110.736554846,24.375381397],[110.736066684,24.374731464],[110.735009893,24.373661346],[110.734242698,24.373436628]]]},"properties":{"name":"Guangxi Bajiangkou","tags":{"name":"Guangxi Bajiangkou","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":458830911,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":463259261,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[105.923336635,31.760937044],[105.923440319,31.761067718],[105.923991848,31.760787846],[105.927263053,31.759228728],[105.927385262,31.75917215],[105.926933058,31.758490534],[105.926464426,31.758705781],[105.925854391,31.759006273],[105.926067459,31.759372646],[105.925617183,31.759607339],[105.925747857,31.759829627],[105.923920686,31.760689191],[105.92381566,31.760741243],[105.923569065,31.760657172],[105.923399583,31.760735375],[105.923496478,31.760871246],[105.923336635,31.760937044]]]},"properties":{"name":"Sichuan Jialingjiang River Cangxi","tags":{"name":"Sichuan Jialingjiang River Cangxi","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":463259261,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":464513374,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.608230335,29.336525447],[103.609111022,29.336836583],[103.609151842,29.336851],[103.609427774,29.336257477],[103.610579783,29.333779284],[103.611335495,29.332153697],[103.611280678,29.332131988],[103.611150423,29.33208044],[103.610455982,29.333656237],[103.61042455,29.333727484],[103.61039228,29.333800741],[103.61005013,29.333675264],[103.609308081,29.335396237],[103.608829055,29.335237483],[103.608765855,29.335373438],[103.608501825,29.335941228],[103.608358327,29.336250017],[103.608230335,29.336525447]]]},"properties":{"name":"沙湾水电站","tags":{"name":"沙湾水电站","power":"plant","name:en":"Shawan Power Plant","waterway":"dam","wikidata":"Q19388334","plant:source":"hydro","plant:output:electricity":"480 MW"},"osm_id":464513374,"output":"480 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":464513393,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.629074033,29.503517433],[103.629129354,29.503529838],[103.629454572,29.503714827],[103.629848521,29.503206548],[103.62992949,29.503271424],[103.63118116,29.501695878],[103.631374446,29.501763771],[103.631451728,29.501650364],[103.631559519,29.50151508],[103.631744675,29.501626727],[103.631816927,29.501536119],[103.632309783,29.500917618],[103.632577417,29.500581671],[103.632431321,29.500493578],[103.632814793,29.50001254],[103.632221438,29.499654633],[103.632133847,29.499601492],[103.632086489,29.499660836],[103.63214248,29.499694698],[103.631525656,29.500469186],[103.630866252,29.501297067],[103.629074033,29.503517433]]]},"properties":{"name":"安谷水电站","tags":{"name":"安谷水电站","power":"plant","name:en":"Angu Hydropower Station","waterway":"dam","wikidata":"Q17748600","plant:source":"hydro","plant:output:electricity":"772 MW"},"osm_id":464513393,"output":"772 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":467733390,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[49.566488562,36.990765096],[49.566510858,36.990787894],[49.566771032,36.990885292],[49.566827945,36.990839778],[49.566913357,36.990881939],[49.567025172,36.990904738],[49.567086192,36.990906331],[49.567234552,36.990683959],[49.567592291,36.990849585],[49.567695975,36.990700136],[49.567328094,36.990529648],[49.568287654,36.98920472],[49.568102665,36.989124757],[49.56696214,36.990692089],[49.566706073,36.990657975],[49.56669174,36.990631991],[49.566663325,36.990618999],[49.566618649,36.990638445],[49.566494681,36.990747242],[49.566488562,36.990765096]]]},"properties":{"name":"سد تاریک","tags":{"area":"yes","name":"سد تاریک","power":"plant","name:en":"Tarik Dam","waterway":"dam","wikidata":"Q7686059","start_date":"2016","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"2.8 MW"},"osm_id":467733390,"output":"2.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":471503530,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.083077388,34.472945737],[104.083102366,34.47297499],[104.083341585,34.473254945],[104.083671581,34.473063251],[104.083908873,34.47334086],[104.083926223,34.473330718],[104.083515091,34.472849764],[104.083673844,34.472757563],[104.084029321,34.472551033],[104.083955979,34.472464951],[104.083939047,34.472445254],[104.083599999,34.472642144],[104.083077388,34.472945737]]]},"properties":{"name":"岷县龙望台水电集团有限责任公司","tags":{"name":"岷县龙望台水电集团有限责任公司","power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"20.5 MW"},"osm_id":471503530,"output":"20.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":471631886,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.114933359,26.559482824],[99.115335271,26.560214481],[99.114949033,26.561320137],[99.115204178,26.561450141],[99.115387658,26.561225087],[99.116490298,26.560155053],[99.116938394,26.559512161],[99.11718675,26.559088372],[99.117348856,26.558627535],[99.117854452,26.558441038],[99.118314451,26.558253283],[99.117654125,26.557740059],[99.116624073,26.557674261],[99.116419387,26.558077766],[99.115601732,26.558509853],[99.115303169,26.558977563],[99.114933359,26.559482824]]]},"properties":{"name":"黄登水电站","tags":{"name":"黄登水电站","power":"plant","name:en":"Huangdeng Dam","name:fr":"Barrage de Huangdeng","waterway":"dam","wikidata":"Q6650802","plant:source":"hydro","plant:output:electricity":"1900 MW"},"osm_id":471631886,"output":"1900 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":472968510,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.343252032,21.411647639],[102.343372061,21.411871185],[102.343583956,21.411801196],[102.344363221,21.41154538],[102.346198188,21.410919922],[102.346690038,21.410737281],[102.346442352,21.410492948],[102.345572562,21.410362526],[102.34457235,21.410586071],[102.344592383,21.410939955],[102.344326928,21.411030983],[102.34410179,21.41110818],[102.343832144,21.411200632],[102.343252032,21.411647639]]]},"properties":{"name":"ເຂື່ອນໄຟຟ້າ ນ້ຳອູ 5","tags":{"name":"ເຂື່ອນໄຟຟ້າ ນ້ຳອູ 5","power":"plant","name:en":"Nam Ou 5 Hydropower Plant","name:ja":"ナム・ウー5水力発電所","name:lo":"ເຂື່ອນໄຟຟ້າ ນ້ຳອູ 5","waterway":"dam","wikidata":"Q61448340","start_date":"2016","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"240 MW"},"osm_id":472968510,"output":"240 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":473169512,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.003617745,23.878852792],[99.003777588,23.879219751],[99.003980347,23.878936862],[99.004206742,23.878596892],[99.003918153,23.878465296],[99.003617745,23.878852792]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":473169512,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":477145794,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.127107825,3.81408936],[10.127117297,3.814266051],[10.12722995,3.814500157],[10.127243361,3.814628652],[10.127799919,3.814588586],[10.127799919,3.814537624],[10.12800779,3.814533685],[10.127981052,3.813983161],[10.128063697,3.813984754],[10.128061351,3.813936558],[10.128056238,3.813827174],[10.128050454,3.813703793],[10.12804509,3.813588458],[10.128039138,3.813463483],[10.128033942,3.813353429],[10.128028326,3.813233065],[10.128023297,3.813125441],[10.128017765,3.813008095],[10.12801349,3.812916983],[10.12811139,3.812924779],[10.128216583,3.812933077],[10.128332421,3.812942297],[10.128443062,3.81295093],[10.128544064,3.812959061],[10.128880262,3.812985631],[10.128905241,3.81262588],[10.129005572,3.812349361],[10.128830223,3.81226462],[10.128830725,3.812120367],[10.128643725,3.812103352],[10.128536185,3.812093545],[10.128420934,3.812083152],[10.128289674,3.812071166],[10.128166124,3.812059934],[10.128070571,3.812051217],[10.128043749,3.812050295],[10.127947692,3.812046858],[10.127944255,3.812084577],[10.127911147,3.812451285],[10.127554329,3.812453297],[10.127564052,3.812712716],[10.1273105,3.812730486],[10.127318295,3.813008178],[10.12732148,3.81312125],[10.127324749,3.813237759],[10.127328437,3.813371031],[10.127332796,3.8134893],[10.127338579,3.813594325],[10.127344865,3.813708151],[10.127352074,3.813837149],[10.127357857,3.81394209],[10.127359869,3.813977462],[10.127363138,3.814111321],[10.127107825,3.81408936]]]},"properties":{"name":"Barrage hydroélectrique d'Édéa","tags":{"name":"Barrage hydroélectrique d'Édéa","power":"plant","waterway":"dam","wikidata":"Q5336726","wikipedia":"fr:Barrage hydroélectrique d'Édéa","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"276 MW"},"osm_id":477145794,"output":"276 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":479648364,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.616339338,15.799822816],[107.616342858,15.799831282],[107.616410584,15.799993472],[107.618275474,15.799947958],[107.618303469,15.799773447],[107.618315539,15.799698177],[107.617472823,15.799334654],[107.6168591,15.799380168],[107.616339338,15.799822816]]]},"properties":{"name":"Đập Thuỷ điện A Vương","tags":{"name":"Đập Thuỷ điện A Vương","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":479648364,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":479981117,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[106.776350348,39.670357868],[106.776713033,39.670618377],[106.77678495,39.67067068],[106.777700672,39.669838525],[106.778837342,39.668805623],[106.778909846,39.668804533],[106.778984948,39.668829344],[106.779062648,39.668868571],[106.779112353,39.668896483],[106.779172618,39.668903691],[106.77923037,39.668874774],[106.779269262,39.668835546],[106.779369845,39.668745776],[106.779727836,39.668450482],[106.779998739,39.668188296],[106.780296464,39.667945221],[106.780677338,39.667589577],[106.780099154,39.667124297],[106.777279733,39.66954365],[106.776350348,39.670357868]]]},"properties":{"name":"海勃湾水利枢纽","tags":{"name":"海勃湾水利枢纽","power":"plant","waterway":"dam","wikidata":"Q11149728","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":479981117,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":480696576,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[108.709509329,12.937716533],[108.70971192,12.938131354],[108.709914678,12.937861456],[108.71025909,12.938118194],[108.710171248,12.93826295],[108.710576597,12.938526225],[108.710785977,12.939250338],[108.711062831,12.939704553],[108.713683433,12.941205333],[108.714325068,12.941080359],[108.715358472,12.941699027],[108.715865075,12.942212586],[108.716601257,12.94309453],[108.717452272,12.94345009],[108.717850748,12.94319989],[108.717985864,12.942837876],[108.717425282,12.942653558],[108.717046923,12.94233756],[108.716668732,12.941810925],[108.716155424,12.9411594],[108.715682684,12.940817083],[108.71504105,12.940461523],[108.713818549,12.939895493],[108.713284957,12.939605898],[108.712568975,12.939072641],[108.710718335,12.937966733],[108.710576597,12.937841675],[108.710427986,12.937881154],[108.710319859,12.938039153],[108.709914678,12.937729777],[108.709617456,12.937525677],[108.709530032,12.937679988],[108.709509329,12.937716533]]]},"properties":{"name":"Đập thủy điện Krông H'Năng","tags":{"name":"Đập thủy điện Krông H'Năng","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":480696576,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":485543356,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.993927969,21.499674051],[103.994182778,21.499793828],[103.994311441,21.49981889],[103.994566334,21.499484452],[103.994933797,21.49930223],[103.995097328,21.499202485],[103.995607031,21.498386339],[103.996588636,21.498620949],[103.997650875,21.494905],[103.999482153,21.495850394],[103.999658257,21.49478363],[103.999807119,21.494324134],[103.999501599,21.494176277],[103.999217285,21.494126237],[103.99875058,21.494061445],[103.997881545,21.493946697],[103.996937323,21.49361712],[103.996797932,21.493202887],[103.996741187,21.493191487],[103.996544463,21.493160223],[103.996290073,21.493879893],[103.996117657,21.494501662],[103.99622109,21.494543572],[103.996146826,21.49499016],[103.996117657,21.495439262],[103.996067282,21.495912923],[103.995719684,21.497021849],[103.995588591,21.49740968],[103.995353982,21.498052153],[103.995102022,21.498348285],[103.994590223,21.498888583],[103.993927969,21.499674051]]]},"properties":{"name":"Nhà máy thủy điện Sơn La","tags":{"name":"Nhà máy thủy điện Sơn La","power":"plant","name:de":"Sơn-La-Talsperre","name:en":"Sơn La Dam","name:fr":"Barrage de Sơn La","name:no":"Son La-demningen","name:vi":"Nhà máy thủy điện Sơn La","building":"dam","waterway":"dam","wikidata":"Q992482","wikipedia":"vi:Nhà máy thủy điện Sơn La","start_date":"2012-12-20","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"2400 MW"},"osm_id":485543356,"output":"2400 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":488751534,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[24.318200472,45.683949773],[24.318253027,45.684226879],[24.318266522,45.684297874],[24.318934727,45.684235848],[24.319813067,45.684154459],[24.320033176,45.684134091],[24.31997643,45.683835528],[24.319250054,45.683902918],[24.319260196,45.683956227],[24.319029024,45.683977769],[24.319009158,45.683873079],[24.318945121,45.68387903],[24.318965824,45.683987995],[24.318902373,45.683993862],[24.318768095,45.684006267],[24.318748481,45.683902918],[24.318680839,45.683909289],[24.318700034,45.684010794],[24.318515045,45.684027893],[24.318494761,45.683920856],[24.318432986,45.683926639],[24.318453941,45.684036945],[24.318264091,45.684054547],[24.318243472,45.68394575],[24.318200472,45.683949773]]]},"properties":{"name":"Barajul Racoviță","tags":{"name":"Barajul Racoviță","power":"plant","waterway":"dam"},"osm_id":488751534,"output":"","source":"","construction":"","source_layer":"power_plant"}},{"id":489244100,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[77.239825022,32.259982148],[77.239857293,32.260095555],[77.23987875,32.260478859],[77.23994304,32.260528732],[77.239953852,32.260648928],[77.239982267,32.260671392],[77.240217547,32.260297391],[77.240203465,32.260224972],[77.240016968,32.259996649],[77.240137332,32.259926827],[77.240315951,32.259823395],[77.24056087,32.259972844],[77.240606467,32.260009641],[77.24055802,32.260183146],[77.240552488,32.260248776],[77.240605545,32.26030284],[77.240664135,32.260312143],[77.240970326,32.260272497],[77.24080143,32.259952644],[77.240712834,32.259866478],[77.240385688,32.259655589],[77.239975226,32.259891372],[77.239825022,32.259982148]]]},"properties":{"name":"Allain Duhangan Hydro Project","tags":{"area":"yes","name":"Allain Duhangan Hydro Project","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":489244100,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":489803545,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[97.505688751,23.698408018],[97.506042887,23.698368791],[97.506117989,23.698290169],[97.506182278,23.698417825],[97.506793821,23.698250858],[97.507043435,23.698177684],[97.506957185,23.69787627],[97.505781623,23.698113059],[97.505688751,23.698408018]]]},"properties":{"name":"ရွှေလီ ၁ တာတမံ","tags":{"name":"ရွှေလီ ၁ တာတမံ","power":"plant","name:en":"Shweli I Dam","name:my":"ရွှေလီ ၁ တာတမံ","name:no":"Shweli-demningen","building":"dam","waterway":"dam","wikidata":"Q12000263","wikipedia":"en:Shweli I Dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":489803545,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":495564974,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-72.001572906,44.325935782],[-72.000993549,44.327566649],[-72.000483929,44.328303418],[-72.000210344,44.328790742],[-71.999909936,44.328905993],[-71.999865512,44.328816726],[-72.000844015,44.325950618],[-72.000640922,44.325630345],[-72.000525671,44.325527918],[-72.00039617,44.325296326],[-72.000193244,44.324933558],[-72.000059469,44.324940263],[-71.99956083,44.324018924],[-71.999397634,44.323612067],[-71.999826704,44.323541072],[-72.000202213,44.323631177],[-72.000322913,44.323867296],[-72.000408827,44.324074496],[-72.000880812,44.324320086],[-72.001097903,44.324829119],[-72.001292783,44.325292471],[-72.001572906,44.325935782]]]},"properties":{"name":"Comerford Station Dam","tags":{"area":"yes","name":"Comerford Station Dam","power":"plant","alt_name":"Comerford Dam","operator":"Great River Hydro, LLC","waterway":"dam","wikidata":"Q5486118","ref:US:EIA":"2349","ref:US:NID":"NH00165","start_date":"1930-09","plant:method":"water-storage","plant:source":"hydro","gnis:feature_id":"1775054","plant:output:electricity":"167.8 MW"},"osm_id":495564974,"output":"167.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":495564979,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-71.876765445,44.336128595],[-71.876644243,44.336190873],[-71.876515832,44.336257257],[-71.876455567,44.336286762],[-71.876208468,44.336407545],[-71.875979139,44.336453561],[-71.875923735,44.336415089],[-71.87554965,44.336155166],[-71.87544144,44.336041423],[-71.874820425,44.33638768],[-71.874900304,44.336442162],[-71.874641136,44.336639724],[-71.874421362,44.336807278],[-71.87425297,44.336935689],[-71.873499772,44.337384456],[-71.87354604,44.337632476],[-71.873378235,44.337723923],[-71.870622013,44.339227552],[-71.870412801,44.339457719],[-71.870396708,44.340040932],[-71.869706039,44.340221981],[-71.869680055,44.340090972],[-71.869642001,44.339233923],[-71.869721043,44.338784988],[-71.869837048,44.338458932],[-71.869940062,44.338255922],[-71.870176012,44.33796792],[-71.870421015,44.33763893],[-71.870649003,44.337465928],[-71.87113507,44.337045994],[-71.872341058,44.33628299],[-71.872638029,44.336116944],[-71.872911027,44.335989958],[-71.873349066,44.335752918],[-71.873384102,44.335766162],[-71.873471609,44.335843694],[-71.874336538,44.335370284],[-71.875239436,44.334876087],[-71.875849639,44.334542068],[-71.875962375,44.33471549],[-71.875670015,44.33492839],[-71.876327156,44.335693826],[-71.876765445,44.336128595]]]},"properties":{"name":"Moore Dam","tags":{"area":"yes","name":"Moore Dam","power":"plant","operator":"Great River Hydro, LLC","waterway":"dam","wikidata":"Q26831326","wikipedia":"en:Moore Dam","ref:US:EIA":"2351","ref:US:NID":"NH00414","start_date":"1957-01","ref:US:FERC":"2077","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"190.8 MW"},"osm_id":495564979,"output":"190.8 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":497315798,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[118.927175361,28.985040395],[118.92730679,28.985062775],[118.928122097,28.981406673],[118.928003326,28.981386389],[118.928096952,28.980966874],[118.928319575,28.981004844],[118.928456368,28.980392127],[118.928616797,28.980419536],[118.928671782,28.980173443],[118.928095024,28.980074872],[118.928072141,28.980177383],[118.92779403,28.981423269],[118.927975582,28.981454282],[118.927175361,28.985040395]]]},"properties":{"name":"Tadi","tags":{"area":"yes","name":"Tadi","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":497315798,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":502463895,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-112.062541081,43.468771769],[-112.062402109,43.468776044],[-112.061835409,43.469221626],[-112.061789728,43.469186171],[-112.061736754,43.469227242],[-112.061184303,43.469429665],[-112.061223111,43.469483477],[-112.061014653,43.469562769],[-112.060801669,43.469968034],[-112.060738805,43.469950684],[-112.060731764,43.470054284],[-112.060824803,43.470185461],[-112.061449758,43.470559042],[-112.061518824,43.470735649],[-112.061444561,43.471071847],[-112.061719823,43.471680373],[-112.06169342,43.471686576],[-112.061661149,43.471615162],[-112.061413883,43.471068327],[-112.061482531,43.470735984],[-112.061420672,43.470575974],[-112.060790102,43.470198621],[-112.060778032,43.470177666],[-112.060708714,43.470057553],[-112.060712653,43.469949343],[-112.060922033,43.469489176],[-112.061711524,43.469201593],[-112.062166075,43.468830526],[-112.062272274,43.468743774],[-112.062270346,43.468693398],[-112.062317452,43.468692393],[-112.062314519,43.468616453],[-112.062309909,43.468496088],[-112.062534879,43.468493574],[-112.06253731,43.46860673],[-112.062541081,43.468771769]]]},"properties":{"name":"Lower Idaho Falls Dam","tags":{"area":"yes","name":"Lower Idaho Falls Dam","power":"plant","alt_name":"Lower Diversion","waterway":"dam","ref:US:NID":"ID00340","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":502463895,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":504108035,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-6.464664569,32.106017232],[-6.464415208,32.106764227],[-6.464188142,32.106910743],[-6.464118404,32.107158344],[-6.464533141,32.107467552],[-6.464002231,32.107827471],[-6.463948419,32.107863933],[-6.463697633,32.107518598],[-6.463589003,32.107337968],[-6.463508537,32.107181143],[-6.463529324,32.106996657],[-6.463523122,32.106803789],[-6.463625968,32.106567755],[-6.463625632,32.106474884],[-6.463813135,32.106326691],[-6.464186549,32.106058135],[-6.464219406,32.105977921],[-6.464289144,32.105933832],[-6.464371873,32.105849594],[-6.464428116,32.105779186],[-6.464534482,32.105886222],[-6.464664569,32.106017232]]]},"properties":{"name":"Barrage Bin el Ouidane","tags":{"name":"Barrage Bin el Ouidane","power":"plant","height":"133.0","capacity":"1484.0","waterway":"dam","wikidata":"Q2884936","wikipedia":"en:Bin el Ouidane Dam","plant:source":"hydro","year_completed":"1953","associated_river":"El Abid","generator:method":"water-storage","plant:output:electricity":"135 MW"},"osm_id":504108035,"output":"135 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":504108037,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-7.264960554,31.64551983],[-7.262799867,31.645965244],[-7.261748357,31.646695979],[-7.260954423,31.647390084],[-7.260782762,31.647992827],[-7.25988154,31.648285187],[-7.259189446,31.648248475],[-7.259227081,31.647892328],[-7.259023233,31.647463174],[-7.258245476,31.646831346],[-7.257902069,31.646644178],[-7.258738919,31.646109832],[-7.259978183,31.645539024],[-7.261211328,31.645185895],[-7.261748357,31.645032087],[-7.263164563,31.64488138],[-7.264280362,31.644936198],[-7.264902635,31.645179776],[-7.264960554,31.64551983]]]},"properties":{"name":"Barrage Moulay Youssef","tags":{"name":"Barrage Moulay Youssef","power":"plant","height":"100.0","capacity":"197.0","waterway":"dam","plant:method":"water-storage","plant:source":"hydro","year_completed":"1969","associated_river":"Tessaout","plant:output:electricity":"24 MW"},"osm_id":504108037,"output":"24 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":504446922,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.789486131,47.676707942],[10.789552097,47.676746163],[10.789697355,47.676830485],[10.78983574,47.676722275],[10.790099938,47.676515745],[10.790085018,47.676507195],[10.790474776,47.676202597],[10.790566894,47.676130596],[10.790375451,47.676019536],[10.790363297,47.676012495],[10.789852672,47.676411474],[10.78979249,47.676376521],[10.789740941,47.676416754],[10.789808499,47.676455982],[10.789593419,47.676623955],[10.789486131,47.676707942]]]},"properties":{"name":"Lechstaustufe 2 - Premer Lechsee","tags":{"name":"Lechstaustufe 2 - Premer Lechsee","power":"plant","operator":"Uniper Kraftwerke","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q1273399","plant:output:electricity":"19.2 MW"},"osm_id":504446922,"output":"19.2 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":506145556,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[33.183737364,0.443480085],[33.183997538,0.443533646],[33.183969375,0.44366784],[33.1841635,0.443705139],[33.185647096,0.443970846],[33.185703423,0.443705391],[33.185754385,0.443705391],[33.185775842,0.44356315],[33.185731335,0.443555355],[33.184302723,0.443304568],[33.184217479,0.443289565],[33.184182526,0.443367349],[33.183764186,0.443324433],[33.183737364,0.443480085]]]},"properties":{"name":"Nalubaale Power Station","tags":{"name":"Nalubaale Power Station","power":"plant","operator":"UEGCL","waterway":"dam","wikidata":"Q488982","wikipedia":"en:Nalubaale Hydroelectric Power Station","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"180 MW"},"osm_id":506145556,"output":"180 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":506145670,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[48.618503192,-18.794065275],[48.618513837,-18.793872324],[48.618691031,-18.793868636],[48.618690444,-18.793843155],[48.618715841,-18.793842652],[48.618716428,-18.793868133],[48.618788177,-18.793869977],[48.618788931,-18.793838545],[48.618811646,-18.793839048],[48.618810724,-18.79387048],[48.618889598,-18.793872324],[48.618890436,-18.793840557],[48.618905356,-18.793840976],[48.618904434,-18.793872743],[48.619030917,-18.79387048],[48.619026139,-18.79401314],[48.619718484,-18.794203744],[48.619717898,-18.794214306],[48.619808087,-18.794153034],[48.61995125,-18.793703512],[48.619996764,-18.793717175],[48.619796939,-18.79431514],[48.619771542,-18.794290749],[48.619677329,-18.794265603],[48.619681017,-18.79425303],[48.618948355,-18.794057648],[48.618736461,-18.794060246],[48.618715087,-18.794110957],[48.618680889,-18.794110957],[48.618680889,-18.794076507],[48.618590029,-18.794076507],[48.618590029,-18.794110957],[48.618575528,-18.794110957],[48.618503192,-18.794065275]]]},"properties":{"name":"Barrage d'Andekaleka","tags":{"name":"Barrage d'Andekaleka","power":"plant","height":"10.0","country":"MG","capacity":"0.0 (million m^3)","waterway":"dam","wikidata":"Q16247139","start_date":"1982","plant:source":"hydro","associated_river":"Vohitra"},"osm_id":506145670,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":506146310,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-7.925635321,12.638061232],[-7.925396605,12.638275976],[-7.925335417,12.638230798],[-7.925214634,12.638073553],[-7.925149255,12.637982358],[-7.925068034,12.637830981],[-7.925259812,12.637637275],[-7.925377075,12.637764261],[-7.925436251,12.637837686],[-7.92552468,12.637942879],[-7.925635321,12.638061232]]]},"properties":{"name":"Centrale hydroélectrique de Sotuba","tags":{"name":"Centrale hydroélectrique de Sotuba","power":"plant","operator":"EDM-SA","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","year_completed":"1929","associated_river":"Niger","plant:output:electricity":"5.7 MW"},"osm_id":506146310,"output":"5.7 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":512212859,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.615507231,26.955381234],[109.616616492,26.956527794],[109.616390516,26.956745472],[109.616466037,26.956831051],[109.616640548,26.957028864],[109.616962916,26.956838344],[109.617316381,26.957203459],[109.617424927,26.957127771],[109.617501035,26.957074629],[109.617042796,26.956560148],[109.616905584,26.956406089],[109.616944979,26.956340542],[109.615813255,26.955151737],[109.615624494,26.955176883],[109.615507231,26.955381234]]]},"properties":{"name":null,"tags":{"power":"plant","building":"dam","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":512212859,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":513690648,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.140696672,32.807048516],[76.141674253,32.807242473],[76.142199631,32.807253118],[76.142371376,32.807256639],[76.14260213,32.807261249],[76.142606991,32.807215568],[76.142652673,32.806779457],[76.14243248,32.80681047],[76.142252437,32.80683746],[76.142269536,32.806192724],[76.141474428,32.806110749],[76.140747382,32.80699965],[76.140696672,32.807048516]]]},"properties":{"name":"Baira Dam","tags":{"name":"Baira Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":513690648,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":514354228,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-85.96574824,41.693824563],[-85.965654027,41.693844093],[-85.965364851,41.693066252],[-85.964909714,41.693160549],[-85.964797732,41.692859052],[-85.964883646,41.692841282],[-85.964843078,41.692731982],[-85.965001747,41.692699041],[-85.965152202,41.692667861],[-85.965213223,41.692831894],[-85.965297964,41.693059882],[-85.965452107,41.693027863],[-85.96574824,41.693824563]]]},"properties":{"name":"Elkhart Hydro Dam","tags":{"name":"Elkhart Hydro Dam","power":"plant","alt_name":"Elkhart Dam","building":"yes","operator":"American Electric Power","waterway":"dam","ref:US:NID":"IN03010","plant:method":"water-storage","plant:source":"hydro","gnis:feature_id":"448710","plant:output:electricity":"yes"},"osm_id":514354228,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":515132909,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[75.797429825,33.368835099],[75.798381758,33.369238855],[75.79911509,33.369550075],[75.79917829,33.369446391],[75.799120287,33.369421748],[75.799096231,33.36946106],[75.798788196,33.369330386],[75.798841924,33.369241873],[75.798474545,33.369085969],[75.798203977,33.368971221],[75.798149495,33.369060824],[75.797468298,33.368771732],[75.797429825,33.368835099]]]},"properties":{"name":"Dul Hasti Dam","tags":{"name":"Dul Hasti Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":515132909,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":515134235,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.178458417,28.23441339],[104.178670479,28.23467742],[104.178468559,28.234803232],[104.178672742,28.235057539],[104.178880948,28.234927871],[104.179222343,28.235353085],[104.179555776,28.235768324],[104.179692987,28.235682829],[104.179938828,28.235989271],[104.180114597,28.235879888],[104.179539934,28.235164073],[104.179280681,28.234841034],[104.17932653,28.234812452],[104.179380761,28.234778757],[104.179084796,28.234410037],[104.179031739,28.234443146],[104.179410769,28.234197723],[104.17917004,28.233989685],[104.178748347,28.23423276],[104.178458417,28.23441339]]]},"properties":{"name":"Yunnan Yanjin Hengjiang Wannianqiao","tags":{"area":"yes","name":"Yunnan Yanjin Hengjiang Wannianqiao","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":515134235,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":515331740,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.23708019,28.140933544],[104.237102151,28.141045359],[104.237126123,28.14116698],[104.237411275,28.141123394],[104.237478247,28.141463699],[104.23837553,28.141326236],[104.238330183,28.141095734],[104.238661352,28.141045023],[104.23963139,28.14089658],[104.239589648,28.14068435],[104.240201276,28.140590724],[104.240175459,28.140459631],[104.239958368,28.140492824],[104.238582898,28.140703461],[104.237358721,28.14089088],[104.23708019,28.140933544]]]},"properties":{"name":"Yunnan Sayutuo Hydro","tags":{"area":"yes","name":"Yunnan Sayutuo Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":515331740,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":520579577,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[33.971294364,49.872060817],[33.971497122,49.872110773],[33.971529476,49.872056291],[33.971463762,49.872040197],[33.971534254,49.871921258],[33.97175495,49.871548766],[33.971520675,49.871491099],[33.971379776,49.871728977],[33.971559484,49.87177315],[33.971479269,49.871908434],[33.971408442,49.87202796],[33.97132588,49.872007676],[33.971294364,49.872060817]]]},"properties":{"name":"Шашацька ГЕС","tags":{"name":"Шашацька ГЕС","power":"plant","name:ru":"Шишацкая ГЭС","name:uk":"Шашацька ГЕС","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro"},"osm_id":520579577,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":526606772,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.771068443,31.916304923],[104.771285618,31.916347336],[104.77322628,31.916726533],[104.774604684,31.917063318],[104.775872028,31.917272698],[104.776367063,31.917264986],[104.778046126,31.917495237],[104.778502353,31.917565059],[104.778511992,31.917279487],[104.776905181,31.917037921],[104.776365722,31.916787972],[104.775721824,31.916073918],[104.774357334,31.915782228],[104.774051897,31.916365273],[104.773093594,31.916318167],[104.772594955,31.916274162],[104.77186992,31.91612027],[104.771151172,31.915967719],[104.771068443,31.916304923]]]},"properties":{"name":null,"tags":{"power":"plant","name:en":"Wudu Dam","building":"dam","waterway":"dam","wikidata":"Q12010811","wikipedia":"en:Wudu Dam","plant:source":"hydro"},"osm_id":526606772,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":531922742,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[111.408457676,34.308720449],[111.408499166,34.308846262],[111.408911891,34.31009592],[111.408946592,34.310378054],[111.408959668,34.310484253],[111.409047259,34.310477631],[111.409031333,34.310102541],[111.409278348,34.310063062],[111.409242054,34.309625276],[111.40909185,34.309213137],[111.409295782,34.309106771],[111.409150691,34.308798569],[111.408664876,34.308931086],[111.408553229,34.308694046],[111.408457676,34.308720449]]]},"properties":{"name":"Luoning Yumenhechina","tags":{"name":"Luoning Yumenhechina","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":531922742,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":533446320,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.244128449,28.515517696],[104.245412556,28.515760771],[104.246070368,28.515885242],[104.246145051,28.515580727],[104.245509535,28.515460447],[104.244203048,28.515213097],[104.244128449,28.515517696]]]},"properties":{"name":null,"tags":{"layer":"1","power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":533446320,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":533877530,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.733908186,28.892657992],[107.734370532,28.893010619],[107.735239652,28.892137057],[107.734777138,28.891784347],[107.733908186,28.892657992]]]},"properties":{"name":"角木塘水电站","tags":{"name":"角木塘水电站","power":"plant","name:en":"Jiaomutang Power Plant","waterway":"dam","wikidata":"Q84763841","plant:source":"hydro","plant:output:electricity":"70 MW"},"osm_id":533877530,"output":"70 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":533891403,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[108.469777091,28.497340281],[108.470974446,28.49827369],[108.471356829,28.497897091],[108.472319742,28.49694893],[108.471525892,28.49622289],[108.472802539,28.495020673],[108.472437759,28.494700066],[108.47240004,28.494737449],[108.47235394,28.494783298],[108.472307756,28.494828979],[108.470941422,28.496184836],[108.470680158,28.495981491],[108.469866359,28.49678892],[108.470127623,28.496992348],[108.469777091,28.497340281]]]},"properties":{"name":"沙沱大坝","tags":{"name":"沙沱大坝","power":"plant","name:en":"Shatuo Dam","waterway":"dam","wikidata":"Q5422849","plant:source":"hydro","plant:output:electricity":"1120 MW"},"osm_id":533891403,"output":"1120 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":534655160,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.681327299,27.114567154],[109.682722048,27.114649464],[109.682940732,27.113037456],[109.682236987,27.113014322],[109.68194974,27.112526328],[109.681484125,27.112540074],[109.681654361,27.112877781],[109.681663749,27.113070732],[109.681486556,27.114379399],[109.68143618,27.114438827],[109.681327299,27.114567154]]]},"properties":{"name":"托口水电站","tags":{"name":"托口水电站","power":"plant","name:en":"Tuokou Power Station","waterway":"dam","wikidata":"Q19393504","plant:source":"hydro","plant:output:electricity":"830 MW"},"osm_id":534655160,"output":"830 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":542530291,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-162.272437313,55.139921838],[-162.272155933,55.140006663],[-162.272078819,55.139922928],[-162.272360283,55.13983827],[-162.272437313,55.139921838]]]},"properties":{"name":"Delta Creek Hydroelectric Plant","tags":{"area":"yes","name":"Delta Creek Hydroelectric Plant","power":"plant","website":"http://www.akenergyauthority.org/WaterfallCreek","operator":"City of King Cove","waterway":"dam","description":"\"The plant will be a 375 kW, run-of-river system and is estimated to produce 1,000 MWh/yr. The project consists of a small diversion dam and intake, a 4,500-ft penstock, a Pelton impulse turbine, and 5,000 feet of access roads. \" www.akenergyauthority.org","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"375 kW"},"osm_id":542530291,"output":"375 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":542531157,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-162.270226083,55.139578431],[-162.269876474,55.139696449],[-162.269856357,55.139677086],[-162.27020605,55.139559069],[-162.270226083,55.139578431]]]},"properties":{"name":"Clear Creek Dam","tags":{"area":"yes","name":"Clear Creek Dam","power":"plant","operator":"City of King Cove","waterway":"dam","description":"https://www.alaskapublic.org/2017/12/14/king-cove-closer-to-goal-of-100-percent-renewable/","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":542531157,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":543184435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.521098653,24.65916699],[98.52121667,24.660015154],[98.521745569,24.66000694],[98.521656553,24.660570958],[98.522149492,24.660651425],[98.522285279,24.659729918],[98.521800889,24.657827142],[98.521407694,24.658026548],[98.521667282,24.659098677],[98.521098653,24.65916699]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":543184435,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":550460977,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-76.876300456,39.116424038],[-76.876154946,39.11649453],[-76.875423541,39.116849588],[-76.875449106,39.116883031],[-76.87480328,39.117201125],[-76.874769669,39.117160137],[-76.874116383,39.117458868],[-76.87407833,39.11736935],[-76.874116635,39.117312269],[-76.874643773,39.117010772],[-76.874495078,39.116824442],[-76.875140903,39.116506349],[-76.875305021,39.116705587],[-76.875688828,39.116561166],[-76.875939783,39.116470139],[-76.876121502,39.116372657],[-76.876232479,39.116342147],[-76.876300456,39.116424038]]]},"properties":{"name":"Rocky Gorge Dam","tags":{"name":"Rocky Gorge Dam","power":"plant","alt_name":"T. Howard Duckett Dam","waterway":"dam","ref:US:NID":"MD00020","plant:method":"water-storage","plant:source":"hydro","gnis:feature_id":"594256","plant:output:electricity":"yes"},"osm_id":550460977,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":550612014,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[111.818284796,23.067832665],[111.81893079,23.067946743],[111.818959121,23.067810537],[111.819911472,23.067978762],[111.819950867,23.067789582],[111.819341168,23.067682042],[111.819029696,23.067627057],[111.818960545,23.067614819],[111.818492332,23.067532258],[111.818352522,23.067507531],[111.818284796,23.067832665]]]},"properties":{"name":"Nanjiangkou Small Yunan County","tags":{"area":"yes","name":"Nanjiangkou Small Yunan County","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":550612014,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":552112929,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.273189436,43.236414349],[22.273429577,43.23657897],[22.274040283,43.236106147],[22.274424174,43.235808924],[22.274344043,43.23575419],[22.274147236,43.23590649],[22.273987225,43.235796771],[22.273820426,43.235925852],[22.273189436,43.236414349]]]},"properties":{"name":"МХЕ Вргудинац","tags":{"name":"МХЕ Вргудинац","power":"plant","height":"13.5 m","name:sr":"МХЕ Вргудинац","int_name":"MHE Vrgudinac","operator":"PowerGen","waterway":"dam","name:sr-Latn":"MHE Vrgudinac","plant:source":"hydro","plant:output:electricity":"1.52 MW"},"osm_id":552112929,"output":"1.52 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":552367375,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.801160498,27.238586799],[115.801209197,27.238709929],[115.802319548,27.238032671],[115.80229457,27.237924293],[115.801160498,27.238586799]]]},"properties":{"name":"Jiangxi Lean County Dongan Small","tags":{"name":"Jiangxi Lean County Dongan Small","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":552367375,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":553178704,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[136.994999899,36.574805775],[136.995183462,36.574927983],[136.995054968,36.575054047],[136.996017881,36.575656789],[136.996207731,36.575479931],[136.995131495,36.574792531],[136.995065026,36.574750119],[136.994999899,36.574805775]]]},"properties":{"name":"庄川合口ダム","tags":{"name":"庄川合口ダム","power":"plant","building":"yes","waterway":"dam","wikidata":"Q11485906","wikipedia":"ja:庄川合口ダム","plant:source":"hydro"},"osm_id":553178704,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":555979607,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.395868557,36.015999629],[128.395901246,36.01614933],[128.395916333,36.016205153],[128.397143276,36.015988481],[128.398677165,36.015717662],[128.400114996,36.015463774],[128.400176855,36.015452877],[128.400162773,36.015400574],[128.400161851,36.015397054],[128.400125725,36.015263362],[128.398800379,36.015497469],[128.398752769,36.015304685],[128.398677584,36.015323293],[128.398729384,36.01552647],[128.398336357,36.015595873],[128.398276426,36.01539697],[128.398201576,36.015397892],[128.398241138,36.015609284],[128.397831598,36.015681536],[128.397796394,36.015477352],[128.397725148,36.015485148],[128.397756077,36.015690588],[128.397326589,36.01576636],[128.397329271,36.015563183],[128.397244027,36.015562345],[128.397256684,36.015775664],[128.396870362,36.015843893],[128.396841276,36.015646332],[128.396761397,36.015654546],[128.396794254,36.015849257],[128.395868557,36.015999629]]]},"properties":{"name":null,"tags":{"area":"yes","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":555979607,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":558256306,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-44.754795018,-20.237834602],[-44.754504921,-20.237594712],[-44.75424944,-20.237910375],[-44.754492264,-20.238129645],[-44.754795018,-20.237834602]]]},"properties":{"name":null,"tags":{"area":"yes","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":558256306,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":558327397,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[92.199621842,25.262672735],[92.200126181,25.26291162],[92.200199942,25.262949254],[92.20088357,25.263253769],[92.202207575,25.263704883],[92.20249189,25.263799431],[92.20256565,25.263737656],[92.202372447,25.263691556],[92.202395246,25.26350598],[92.202047984,25.263365248],[92.201900462,25.263239184],[92.201864253,25.263156623],[92.201848159,25.263047574],[92.201877664,25.262968784],[92.201777165,25.262929724],[92.201549093,25.262841379],[92.201461838,25.263025781],[92.20138003,25.263017232],[92.200669245,25.262724955],[92.200599591,25.262620684],[92.200669077,25.262499146],[92.200695061,25.262453968],[92.200755159,25.262348942],[92.200612918,25.26229555],[92.200380991,25.262630323],[92.200234727,25.262667958],[92.200030963,25.26265572],[92.199825774,25.262613391],[92.199767436,25.26259755],[92.199715133,25.262615738],[92.199662746,25.262592772],[92.199621842,25.262672735]]]},"properties":{"name":"Myntdu-Leshka Hydro Project Dam","tags":{"name":"Myntdu-Leshka Hydro Project Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":558327397,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":559562810,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.894561848,34.453510031],[103.894768629,34.453798536],[103.895053278,34.454195755],[103.895458795,34.454761617],[103.895733638,34.454379235],[103.895178839,34.45365772],[103.895004077,34.453430487],[103.894561848,34.453510031]]]},"properties":{"name":"Hongyuan Small","tags":{"name":"Hongyuan Small","power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":559562810,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":563839906,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.750975177,31.794716281],[104.751073999,31.794912586],[104.751394272,31.794796161],[104.753254132,31.794119825],[104.753155394,31.793923521],[104.751814038,31.794411264],[104.750975177,31.794716281]]]},"properties":{"name":"Sichuan Keguang 1st Level","tags":{"name":"Sichuan Keguang 1st Level","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":563839906,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":564442520,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[69.666160357,41.550679343],[69.666946077,41.55144394],[69.667100472,41.551363558],[69.66725495,41.551283091],[69.66725889,41.551244954],[69.667755434,41.551084021],[69.667786782,41.551104557],[69.668076125,41.551007997],[69.668085848,41.550848238],[69.668528329,41.550492929],[69.66832599,41.550104931],[69.668196489,41.549893623],[69.667345223,41.550177518],[69.666884051,41.550326129],[69.666631336,41.550418163],[69.66631735,41.550606085],[69.666202267,41.550630141],[69.666160357,41.550679343]]]},"properties":{"name":"Таваксайская ГЭС","tags":{"name":"Таваксайская ГЭС","power":"plant","name:en":"Tavaksay hydroelectric power","name:ru":"Таваксайская ГЭС","operator":"O'zbekenergo","waterway":"dam","wikidata":"Q4516269","wikipedia":"ru:Чирчик-Бозсуйский каскад ГЭС","alt_name:ru":"ГЭС-8","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"73.6 MW"},"osm_id":564442520,"output":"73.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":568228195,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.7014977,28.575921043],[103.701518236,28.576107121],[103.702206138,28.575838062],[103.702247377,28.575633124],[103.702044032,28.575725158],[103.701554026,28.575894556],[103.7014977,28.575921043]]]},"properties":{"name":"Pingbian Guanyintuo Hydro","tags":{"name":"Pingbian Guanyintuo Hydro","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":568228195,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":570871683,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.013411367,27.433545927],[104.013611024,27.433737873],[104.013962226,27.433450038],[104.013873881,27.433365046],[104.014035484,27.433232695],[104.013924172,27.433125658],[104.013411367,27.433545927]]]},"properties":{"name":"Yiliang County Xionggou Small","tags":{"name":"Yiliang County Xionggou Small","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":570871683,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":576288694,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.884273337,25.474558279],[104.884523034,25.474599686],[104.884676758,25.474585772],[104.884879181,25.474514693],[104.884953277,25.474488625],[104.885122256,25.474343032],[104.885099122,25.474259883],[104.885206746,25.474072548],[104.885241531,25.473968528],[104.885180175,25.473961655],[104.885118652,25.474107333],[104.885011029,25.47426684],[104.88493425,25.474357029],[104.884838864,25.474417631],[104.884726798,25.474488877],[104.884526974,25.474530451],[104.884296388,25.474509664],[104.884273337,25.474558279]]]},"properties":{"name":"Guizhou Louxiahe Small","tags":{"name":"Guizhou Louxiahe Small","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":576288694,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":580930783,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.746828541,27.822050743],[103.747111095,27.822515688],[103.747322822,27.822390797],[103.747048231,27.822002128],[103.746828541,27.822050743]]]},"properties":{"name":"Yunnan Youfanggou","tags":{"name":"Yunnan Youfanggou","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":580930783,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":581573938,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.536478981,37.40428126],[127.536575625,37.404287462],[127.53696597,37.404347728],[127.537029169,37.404193417],[127.537104774,37.404204481],[127.537151126,37.404043716],[127.537585895,37.404262819],[127.538120325,37.404497261],[127.538640255,37.404645537],[127.53917435,37.40476808],[127.539691429,37.404834381],[127.540030226,37.404866149],[127.54069357,37.404875872],[127.541362194,37.404836896],[127.54195865,37.404860617],[127.542275151,37.404874782],[127.542585281,37.404936724],[127.542949056,37.405009647],[127.543396063,37.405134118],[127.5438108,37.405274264],[127.544185638,37.405436873],[127.544218579,37.405382474],[127.544149848,37.405347354],[127.543440571,37.405072344],[127.543221133,37.405010066],[127.54279726,37.404907472],[127.542591652,37.404876794],[127.542164761,37.404810996],[127.541522121,37.404759196],[127.540604219,37.404778139],[127.53995722,37.404794064],[127.539595792,37.404742851],[127.539092794,37.404676047],[127.53873145,37.404589043],[127.538448896,37.40450908],[127.537807764,37.404280757],[127.537181888,37.40395512],[127.536691379,37.403859734],[127.536607643,37.403844646],[127.536478981,37.40428126]]]},"properties":{"name":"Ipo","tags":{"area":"yes","name":"Ipo","power":"plant","name:en":"Ipo bo","name:ko":"이포보","waterway":"dam","plant:source":"hydro"},"osm_id":581573938,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":582994759,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.406214664,28.334526171],[107.406383141,28.334726666],[107.406458243,28.335012321],[107.406481879,28.335384394],[107.406702072,28.335409875],[107.406841798,28.335135032],[107.406744149,28.33472105],[107.406464361,28.334348558],[107.406214664,28.334526171]]]},"properties":{"name":"Niudu On Furong River","tags":{"name":"Niudu On Furong River","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":582994759,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":583372104,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[2.00841393,45.151720966],[2.00849976,45.151733288],[2.008647282,45.151801349],[2.00860839,45.151865638],[2.008793462,45.151932861],[2.00883906,45.151868991],[2.00898524,45.151915343],[2.009160925,45.151953648],[2.009323869,45.151979129],[2.009402324,45.151982984],[2.009400396,45.152046771],[2.009585384,45.152054817],[2.00959209,45.151990025],[2.009672556,45.15199145],[2.009732822,45.151989522],[2.009781856,45.151986253],[2.009797949,45.152051046],[2.009984363,45.15203403],[2.009972293,45.151970663],[2.010061476,45.151958845],[2.010115121,45.151948451],[2.010157617,45.151936884],[2.010194916,45.151996144],[2.010370936,45.151951469],[2.010336319,45.151891119],[2.010420222,45.15186656],[2.010527594,45.151831691],[2.010652233,45.151779556],[2.01068509,45.151818364],[2.010747451,45.151794727],[2.010706548,45.151754997],[2.010770921,45.151727588],[2.01086748,45.151674195],[2.01098072,45.151597585],[2.011112232,45.151497253],[2.011251623,45.151358281],[2.011333598,45.151263147],[2.011280373,45.151213274],[2.011187334,45.151126019],[2.011185993,45.151243365],[2.011117596,45.151259459],[2.010991532,45.151258453],[2.010870833,45.151281168],[2.01066028,45.151345457],[2.010605294,45.151389965],[2.010547627,45.15144386],[2.010476548,45.151382337],[2.010412175,45.151403124],[2.010294158,45.151288795],[2.010233389,45.151274462],[2.010213692,45.151269852],[2.010141272,45.151307655],[2.009896521,45.151371944],[2.009639029,45.151394659],[2.009481449,45.151389965],[2.009382878,45.151371944],[2.009354798,45.151360628],[2.009333927,45.151330369],[2.009317666,45.151365154],[2.009308362,45.151384852],[2.009240385,45.151529691],[2.009162266,45.151511],[2.009132845,45.151511251],[2.009068389,45.151511922],[2.009038884,45.15154503],[2.008923549,45.151579061],[2.008818943,45.151606553],[2.008717019,45.151621641],[2.008623142,45.15160362],[2.00853597,45.151578139],[2.008515686,45.151601859],[2.008454666,45.151672435],[2.00841393,45.151720966]]]},"properties":{"name":"Barrage du Chastang","tags":{"name":"Barrage du Chastang","layer":"1","power":"plant","operator":"EDF","waterway":"dam","wikidata":"Q808808","wikipedia":"fr:Barrage du Chastang","plant:method":"water-storage","plant:source":"hydro","operator:wikidata":"Q274591","ref:EU:ENTSOE_EIC":"17W100P100P0302T","old_ref:EU:ENTSOE_EIC":"17W000000012914Y","plant:output:electricity":"293 MW"},"osm_id":583372104,"output":"293 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":583844907,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.681702578,37.277325517],[127.682008518,37.277411097],[127.682143718,37.277104654],[127.683916323,37.277599857],[127.686056139,37.278197738],[127.686109448,37.278076871],[127.683962591,37.277480583],[127.682738665,37.277135584],[127.681890752,37.276898879],[127.681702578,37.277325517]]]},"properties":{"name":"강천보","tags":{"area":"yes","name":"강천보","power":"plant","name:en":"Gangcheon","name:ko":"강천보","waterway":"dam","plant:source":"hydro"},"osm_id":583844907,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":584190367,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.60548719,37.326916796],[127.605618283,37.327004052],[127.605826657,37.327126763],[127.605999743,37.326929453],[127.60723314,37.327647782],[127.61053494,37.329596071],[127.610604677,37.329528932],[127.608752779,37.328428053],[127.608431836,37.328242394],[127.606176434,37.326932722],[127.606054561,37.326859045],[127.605732361,37.326664166],[127.60548719,37.326916796]]]},"properties":{"name":"여주보","tags":{"area":"yes","name":"여주보","power":"plant","name:en":"Yeoju","name:ko":"여주보","waterway":"dam","plant:source":"hydro"},"osm_id":584190367,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":584671759,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.420006318,37.723335638],[127.420031547,37.723403531],[127.420244448,37.723933267],[127.420965291,37.723719361],[127.421071658,37.723715254],[127.421079453,37.724119178],[127.421228483,37.727303547],[127.421290928,37.727303044],[127.421361923,37.72730229],[127.421372819,37.727302457],[127.421360498,37.72706198],[127.421213396,37.724209619],[127.421197638,37.723650965],[127.421327557,37.72316054],[127.421171486,37.723137993],[127.421059085,37.723627579],[127.420954898,37.723619449],[127.420744261,37.723119552],[127.420006318,37.723335638]]]},"properties":{"name":"청평댐","tags":{"name":"청평댐","power":"plant","name:ja":"清平ダム","operator":"한국수력원자력","waterway":"dam","wikidata":"Q12619304","plant:source":"hydro","plant:output:electricity":"139.6 MW"},"osm_id":584671759,"output":"139.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":586704039,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[18.009138936,48.881248364],[18.009157125,48.881255153],[18.009215547,48.881214166],[18.009247985,48.881202599],[18.009292157,48.881198492],[18.009318225,48.881202264],[18.009390896,48.881230259],[18.009685353,48.881359341],[18.009704296,48.881367387],[18.009732794,48.881399574],[18.009746373,48.88143285],[18.009741763,48.881475933],[18.009758527,48.881477777],[18.009745451,48.881517423],[18.009755425,48.881518345],[18.009772776,48.881458079],[18.009772189,48.881426396],[18.009759365,48.881394964],[18.009733548,48.88136328],[18.009716785,48.881349953],[18.009658028,48.88132179],[18.009793479,48.881186338],[18.009801442,48.881190194],[18.009814182,48.881194385],[18.009830527,48.881194049],[18.009824744,48.881185751],[18.009814769,48.881179633],[18.009804459,48.88117452],[18.009872353,48.881107381],[18.009938989,48.881040241],[18.009950221,48.881045438],[18.00996556,48.88104854],[18.009976372,48.88104812],[18.009972265,48.881040996],[18.009961117,48.88103362],[18.009950472,48.881028591],[18.010085337,48.880892971],[18.01009506,48.880897246],[18.010109225,48.880901353],[18.010121631,48.880900348],[18.01011526,48.880893642],[18.010103023,48.880888529],[18.010094557,48.880885679],[18.010097323,48.880882997],[18.010175023,48.88091577],[18.01018659,48.880917447],[18.010206455,48.880913759],[18.010224393,48.880908059],[18.010237217,48.88090085],[18.010244677,48.880893223],[18.010248868,48.880886936],[18.010249119,48.880875621],[18.010236379,48.880863551],[18.010277618,48.880821977],[18.010306116,48.8808317],[18.010350792,48.880842345],[18.010468222,48.880868496],[18.010506444,48.880874196],[18.010541648,48.880877381],[18.010565536,48.880877381],[18.010599148,48.880876124],[18.010599148,48.880865982],[18.010564698,48.880867323],[18.010542318,48.88086682],[18.010509964,48.880865227],[18.010472497,48.88085936],[18.010313995,48.88082315],[18.010285664,48.880813679],[18.010167228,48.880763722],[18.010161277,48.880761376],[18.00991795,48.88065568],[18.00991267,48.880652578],[18.009879729,48.880638581],[18.009819798,48.880616536],[18.009791635,48.880609328],[18.00971712,48.880601616],[18.00971712,48.880607651],[18.009790043,48.880615279],[18.009817619,48.88062123],[18.009876963,48.880643945],[18.009909652,48.880657189],[18.009862295,48.880711168],[18.009858271,48.880708234],[18.009845028,48.880700523],[18.00982994,48.880700774],[18.009817954,48.88070463],[18.009810075,48.880709994],[18.009790126,48.880730363],[18.009786774,48.880736984],[18.009787276,48.880743438],[18.009793647,48.880753999],[18.00980865,48.880760621],[18.009806387,48.880767327],[18.00981083,48.880773278],[18.00981829,48.880778056],[18.009945862,48.880833125],[18.010067484,48.880885679],[18.009932619,48.881020544],[18.009672947,48.880908394],[18.00966884,48.880906969],[18.009664565,48.880907891],[18.009660961,48.880908646],[18.009658279,48.880910657],[18.00965786,48.880914094],[18.00965895,48.880916692],[18.009661296,48.880918872],[18.009920968,48.881032362],[18.009786187,48.881166389],[18.009527186,48.881054155],[18.009518134,48.88105382],[18.009513188,48.881056838],[18.009511763,48.881062621],[18.009515619,48.881066561],[18.009774871,48.881178375],[18.00963942,48.881313575],[18.009372121,48.88119757],[18.009357117,48.881191786],[18.009322668,48.881182231],[18.009270951,48.881179046],[18.009212697,48.881192624],[18.009195766,48.881200839],[18.009182187,48.881207963],[18.009138936,48.881248364]]]},"properties":{"name":"Vodné dielo Trenčianske Biskupice 2","tags":{"area":"yes","name":"Vodné dielo Trenčianske Biskupice 2","power":"plant","alt_name":"Vodné dielo Adrián","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":586704039,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":595085442,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.489457067,25.264010152],[102.489551698,25.26426647],[102.489607354,25.264417428],[102.489716654,25.264384488],[102.489659825,25.264230428],[102.48956645,25.263977127],[102.489457067,25.264010152]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":595085442,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":595362744,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-91.364522937,14.56223716],[-91.364077104,14.563210466],[-91.364061514,14.563244497],[-91.36399186,14.563171742],[-91.364018682,14.563075685],[-91.363956907,14.563039392],[-91.363973001,14.562984825],[-91.364065453,14.562781397],[-91.364157235,14.562579141],[-91.364187661,14.562512337],[-91.364249352,14.562525329],[-91.36438078,14.56226834],[-91.364522937,14.56223716]]]},"properties":{"name":"Panan","tags":{"name":"Panan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":595362744,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":595421810,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.784753586,26.822954959],[101.786082789,26.823520486],[101.786192089,26.823315883],[101.78486297,26.822750356],[101.784753586,26.822954959]]]},"properties":{"name":"二滩水电站","tags":{"name":"二滩水电站","power":"plant","name:en":"Ertan hydropower station","name:fr":"Centrale hydroélectrique d'Ertan","name:zh":"二滩水电站","building":"yes","operator":"Yalong Hydro","waterway":"dam","wikidata":"Q1362429","wikipedia":"zh:二滩水电站","plant:method":"water-storage","plant:source":"hydro","wikipedia:en":"Ertan Dam","plant:output:electricity":"3300 MW"},"osm_id":595421810,"output":"3300 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":599145068,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.505001058,28.759553286],[109.505191243,28.759897698],[109.505302974,28.760100038],[109.50565954,28.760745947],[109.506056926,28.761465869],[109.506146864,28.761628645],[109.506288854,28.761226146],[109.505758782,28.760259545],[109.506265133,28.76003139],[109.506144769,28.75973115],[109.505929186,28.759193367],[109.505001058,28.759553286]]]},"properties":{"name":"碗米坡水电站","tags":{"name":"碗米坡水电站","power":"plant","name:en":"Wanmipo Hydropower Station","name:zh":"碗米坡水电站","alt_name":"Wanmipo Hydropower Plant","waterway":"dam","wikidata":"Q12010400","plant:source":"hydro","plant:output:electricity":"240 MW"},"osm_id":599145068,"output":"240 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":606808107,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-85.794389449,34.132460592],[-85.793525946,34.132564025],[-85.793476576,34.132292954],[-85.793451514,34.132155491],[-85.794292806,34.132062117],[-85.794323903,34.13219036],[-85.794389449,34.132460592]]]},"properties":{"name":"Weiss Dam","tags":{"name":"Weiss Dam","power":"plant","alt_name":"Weiss - Main Dam","operator":"Alabama Power","waterway":"dam","wikidata":"Q31580956","ref:US:EIA":"20","ref:US:NID":"AL01415","start_date":"1961-06","plant:method":"run-of-the-river","plant:source":"hydro","operator:wikidata":"Q4705296","plant:output:electricity":"87.6 MW"},"osm_id":606808107,"output":"87.6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":617189229,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.716223925,25.888767123],[102.71622602,25.88891389],[102.71653724,25.889087563],[102.716796996,25.888509799],[102.717138726,25.88807746],[102.717246098,25.887877133],[102.717017859,25.887715027],[102.716760367,25.88758955],[102.716581162,25.888421537],[102.716483596,25.888662852],[102.716223925,25.888767123]]]},"properties":{"name":"Yunnan Qianchang","tags":{"name":"Yunnan Qianchang","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":617189229,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":621782031,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.725491257,31.737715652],[104.725585219,31.737888235],[104.726685343,31.73741558],[104.727631995,31.737016853],[104.727695782,31.73693186],[104.727497382,31.736630782],[104.727175768,31.736735556],[104.72681082,31.73688534],[104.726953061,31.737124895],[104.726597082,31.737263783],[104.725491257,31.737715652]]]},"properties":{"name":"Sichuan Keguang 3rd Level","tags":{"name":"Sichuan Keguang 3rd Level","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":621782031,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":621791563,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.888423375,29.275596973],[107.888737696,29.2760387],[107.892034969,29.274253354],[107.89162149,29.273672237],[107.891310354,29.273578695],[107.889576892,29.274516546],[107.889811083,29.274845871],[107.889235916,29.275157175],[107.888423375,29.275596973]]]},"properties":{"name":"银盘水电站","tags":{"name":"银盘水电站","power":"plant","name:en":"Yinpan Dam","waterway":"dam","wikidata":"Q17303441","plant:source":"hydro","plant:output:electricity":"600 MW"},"osm_id":621791563,"output":"600 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":624211586,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.653198673,15.859759124],[107.653260364,15.859817126],[107.653310823,15.85985233],[107.653386344,15.859904885],[107.653501763,15.859968084],[107.653604944,15.860006725],[107.653759255,15.860057184],[107.653937538,15.86009968],[107.654122694,15.860108733],[107.654291673,15.860084174],[107.654213889,15.859907483],[107.653959079,15.859931958],[107.653786077,15.859910082],[107.653566136,15.859839087],[107.653501763,15.859808158],[107.65343739,15.859775887],[107.653393636,15.85974236],[107.653271093,15.859648147],[107.653198673,15.859759124]]]},"properties":{"name":"Đập Thuỷ điện Za Hung","tags":{"area":"yes","name":"Đập Thuỷ điện Za Hung","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":624211586,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":625597726,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.550957821,35.377473286],[128.551015992,35.3776224],[128.55117156,35.37801702],[128.551193688,35.378071167],[128.551402146,35.378616745],[128.551529551,35.378984124],[128.551652933,35.379283777],[128.551451851,35.379346138],[128.551490743,35.379455438],[128.55169853,35.379415038],[128.551800454,35.379678565],[128.551618064,35.379728018],[128.551675983,35.379859949],[128.551860888,35.379818459],[128.551896259,35.379909821],[128.55195334,35.380073268],[128.551760221,35.380124649],[128.551825935,35.380281056],[128.552013774,35.380221963],[128.552107651,35.380483395],[128.551927943,35.380536871],[128.551969433,35.380666959],[128.552163977,35.380612392],[128.552328849,35.381050766],[128.552558262,35.38163247],[128.552669574,35.381922232],[128.552711148,35.382054583],[128.552777868,35.38202994],[128.553145666,35.381902535],[128.553114737,35.381897171],[128.553091938,35.381870851],[128.553071821,35.381827182],[128.553079952,35.381807484],[128.553150946,35.381768089],[128.553165028,35.381746799],[128.553158322,35.381726599],[128.553146252,35.381718385],[128.55282246,35.381805221],[128.552697653,35.381461982],[128.552558262,35.381125197],[128.552442843,35.380832166],[128.55234905,35.38054232],[128.552506546,35.38048549],[128.552465055,35.380367389],[128.552232374,35.380424219],[128.552139754,35.380195728],[128.552397246,35.380119117],[128.552338237,35.379954664],[128.552075464,35.380019624],[128.551985527,35.379774789],[128.552238325,35.379717876],[128.552194739,35.379546717],[128.55191713,35.379610168],[128.551829372,35.379349659],[128.552057946,35.379276652],[128.552013019,35.379162994],[128.551838759,35.37920725],[128.551734153,35.378929558],[128.55148169,35.378257078],[128.551389154,35.378033448],[128.551461155,35.378023055],[128.55147465,35.378027916],[128.551494096,35.378073346],[128.551509434,35.378076699],[128.551538939,35.378071167],[128.55165025,35.378026324],[128.551656621,35.37802054],[128.551657626,35.377980978],[128.551659051,35.377976871],[128.551692579,35.377967567],[128.551696518,35.377961783],[128.551693585,35.377935045],[128.551694255,35.377925992],[128.551699285,35.377922137],[128.551744547,35.377903278],[128.551749827,35.377900092],[128.551753599,35.377888525],[128.551610017,35.377533133],[128.551561151,35.377408158],[128.551530976,35.377322579],[128.551046083,35.377448475],[128.550957821,35.377473286]]]},"properties":{"name":"창녕함안보","tags":{"name":"창녕함안보","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":625597726,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":625599010,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.414995557,35.732286959],[128.415381879,35.73259273],[128.415482378,35.732672358],[128.415566867,35.73274855],[128.415772056,35.732931443],[128.415104019,35.733301923],[128.415153723,35.733353891],[128.415749257,35.733026159],[128.415817738,35.732994559],[128.416328531,35.733521697],[128.416390557,35.733596966],[128.416755421,35.734001477],[128.416810658,35.73406652],[128.416850891,35.734113794],[128.417058092,35.73435754],[128.417123051,35.73440758],[128.417488586,35.734845283],[128.417577099,35.734944273],[128.418307917,35.735793863],[128.418380588,35.735879358],[128.418448817,35.735959992],[128.418510592,35.736027467],[128.418591058,35.735988239],[128.418503718,35.735881957],[128.418448817,35.735815153],[128.418510592,35.735781458],[128.418451583,35.735707446],[128.418385869,35.73574332],[128.417958057,35.735237137],[128.418223595,35.735079222],[128.418224936,35.735033541],[128.418218147,35.735009652],[128.418196773,35.734991128],[128.418163246,35.734980232],[128.418127036,35.734988949],[128.417868119,35.735140242],[128.417638874,35.73487244],[128.417770218,35.734784263],[128.41787759,35.734729864],[128.417913716,35.734690637],[128.417915141,35.734675466],[128.417899048,35.734642776],[128.417865437,35.734640597],[128.41783325,35.734643866],[128.417569136,35.73479189],[128.417207038,35.734381429],[128.417465871,35.734227956],[128.417487245,35.734179089],[128.417472577,35.734156123],[128.417459166,35.734148579],[128.417402839,35.734137683],[128.417119866,35.73427699],[128.416898584,35.734032071],[128.417173427,35.733863343],[128.417180132,35.73384046],[128.417180132,35.733821936],[128.417160099,35.733805676],[128.417122548,35.7337926],[128.416823482,35.733948252],[128.416457361,35.733533431],[128.416720133,35.733370152],[128.416734969,35.733352718],[128.416734969,35.733329835],[128.416718876,35.733313574],[128.416700017,35.733300498],[128.416657185,35.733301588],[128.4163836,35.733459419],[128.416138178,35.733193797],[128.41639567,35.733047952],[128.416419726,35.733018531],[128.416415786,35.732998917],[128.416403633,35.732984752],[128.416379577,35.732976035],[128.416343367,35.73297176],[128.416307157,35.732984752],[128.41606844,35.733118695],[128.4157278,35.732775791],[128.415471649,35.732558029],[128.415562844,35.73249483],[128.41532002,35.732311937],[128.415365618,35.732274972],[128.415277189,35.732216131],[128.415337538,35.732175982],[128.415251624,35.732114962],[128.414995557,35.732286959]]]},"properties":{"name":"달성보","tags":{"area":"yes","name":"달성보","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":625599010,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":625602096,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.097195254,36.463046255],[127.097254682,36.463161758],[127.097217383,36.463179779],[127.097296675,36.463298886],[127.098216673,36.462874929],[127.098968697,36.462528086],[127.100227156,36.461953423],[127.101436749,36.461416394],[127.101350331,36.461296198],[127.100136129,36.461857618],[127.100059099,36.46175628],[127.097195254,36.463046255]]]},"properties":{"name":"공주보","tags":{"area":"yes","name":"공주보","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":625602096,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":625604244,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[127.259395558,36.472615371],[127.259486753,36.472658538],[127.259674592,36.472753421],[127.259749694,36.472805138],[127.259808618,36.472800863],[127.260189492,36.47261973],[127.26011439,36.472511855],[127.263354582,36.470825164],[127.263306219,36.470756181],[127.260060746,36.47245578],[127.259958822,36.472352263],[127.259395558,36.472615371]]]},"properties":{"name":"세종보","tags":{"area":"yes","name":"세종보","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":625604244,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":625612184,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.353912437,35.590870422],[128.354001872,35.590886934],[128.353942193,35.591062535],[128.354256095,35.591138894],[128.354403365,35.590696078],[128.355941528,35.59103412],[128.357375253,35.591354728],[128.357546831,35.591400577],[128.35774808,35.59142237],[128.357871462,35.591444163],[128.358204056,35.591509626],[128.358241523,35.59142237],[128.358257867,35.591365625],[128.357981432,35.59122615],[128.357678594,35.591154149],[128.357563259,35.591138894],[128.357461251,35.591134536],[128.356943669,35.591032025],[128.356967725,35.590966562],[128.357077779,35.590994977],[128.357104518,35.590957845],[128.357112648,35.590936052],[128.357978918,35.591108384],[128.357962824,35.591079969],[128.354445023,35.59031839],[128.354371849,35.590513353],[128.354136067,35.590443951],[128.354050655,35.590724996],[128.353965327,35.59069524],[128.353912437,35.590870422]]]},"properties":{"name":"합천창녕보","tags":{"area":"yes","name":"합천창녕보","power":"plant","name:en":"Hapcheon Changnyeong","waterway":"dam","plant:source":"hydro"},"osm_id":625612184,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":626566371,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[105.019992275,37.447641431],[105.020177264,37.447652328],[105.020827783,37.447668421],[105.020901544,37.447169782],[105.020999445,37.446129587],[105.021000786,37.446070998],[105.020670874,37.446052893],[105.020621253,37.445009346],[105.020603735,37.444678261],[105.020220263,37.444681698],[105.02000292,37.444680356],[105.020026222,37.444898286],[105.02012362,37.445810069],[105.020129068,37.445976199],[105.020120937,37.446135874],[105.020110292,37.446259339],[105.020030329,37.447195598],[105.019992275,37.447641431]]]},"properties":{"name":"Shapotou Dam","tags":{"name":"Shapotou Dam","power":"plant","name:en":"Shapotou Dam","waterway":"dam","wikidata":"Q19388327","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"120.3 MW"},"osm_id":626566371,"output":"120.3 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":636380202,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[108.105527743,14.184431256],[108.105868383,14.185229548],[108.10669442,14.185494836],[108.107137068,14.185125529],[108.107287356,14.185281265],[108.107491036,14.185492237],[108.107617183,14.185442784],[108.107385843,14.185204151],[108.10728459,14.185099545],[108.107177217,14.185083955],[108.107190796,14.184959064],[108.105659171,14.184371325],[108.105557247,14.184334948],[108.105527743,14.184431256]]]},"properties":{"name":"Đập thủy điện Đak Đoa","tags":{"name":"Đập thủy điện Đak Đoa","power":"plant","waterway":"dam","wikidata":"Q57556665","plant:source":"hydro"},"osm_id":636380202,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":638056336,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[119.430206395,27.364952371],[119.430212263,27.365105425],[119.430263728,27.365295694],[119.43036012,27.365508091],[119.43061166,27.365933221],[119.431031845,27.366443847],[119.431080125,27.366364973],[119.430581234,27.365798021],[119.430452572,27.365545558],[119.43029692,27.365030993],[119.430433796,27.364814237],[119.43060747,27.364256254],[119.430537564,27.364318699],[119.430368669,27.364522379],[119.43026993,27.364665961],[119.430229446,27.364819853],[119.430206395,27.364952371]]]},"properties":{"name":"Fujian Niutoushan","tags":{"name":"Fujian Niutoushan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":638056336,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":640515502,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-81.595812258,8.21448216],[-81.595533979,8.214609816],[-81.595454267,8.214706627],[-81.595382183,8.214756164],[-81.595326108,8.214868482],[-81.595013882,8.215093117],[-81.59477198,8.215211637],[-81.594457575,8.215279363],[-81.59401828,8.215188754],[-81.593772438,8.215099236],[-81.59354663,8.214973256],[-81.593322833,8.214771671],[-81.59298454,8.214429857],[-81.592763425,8.214290969],[-81.592358411,8.214397167],[-81.592277945,8.214583078],[-81.592084826,8.214540582],[-81.59205264,8.214208658],[-81.592194713,8.213887464],[-81.59248984,8.213712282],[-81.592902816,8.21365386],[-81.593195261,8.213799873],[-81.593458955,8.213958626],[-81.593787945,8.213670624],[-81.593998331,8.213882099],[-81.593463481,8.214560195],[-81.593612512,8.214717775],[-81.593935047,8.214803187],[-81.594007635,8.21484803],[-81.594143924,8.214866051],[-81.594204023,8.214784328],[-81.594290859,8.214356934],[-81.594471908,8.214338746],[-81.594404937,8.214731689],[-81.594537874,8.214705119],[-81.594630242,8.214691875],[-81.594792265,8.214625071],[-81.594920675,8.214038841],[-81.595311104,8.214114697],[-81.595626767,8.21415426],[-81.595807984,8.214217124],[-81.595812258,8.21448216]]]},"properties":{"name":"Dique Barro Blanco","tags":{"name":"Dique Barro Blanco","power":"plant","waterway":"dam","wikidata":"Q2885685","plant:source":"hydro","plant:output:electricity":"28.56 MW"},"osm_id":640515502,"output":"28.56 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":645192824,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.107238493,25.807661817],[115.107320384,25.80819675],[115.108024464,25.808173783],[115.108061931,25.808668735],[115.108118341,25.808661526],[115.108091435,25.808166575],[115.108547494,25.808155678],[115.108567443,25.808510652],[115.108631984,25.808508137],[115.108626536,25.808218459],[115.108990059,25.808209993],[115.109036998,25.808796726],[115.109542594,25.808777448],[115.109474198,25.807630468],[115.109557346,25.807629295],[115.109551982,25.807553187],[115.109319971,25.807558049],[115.108489743,25.807582188],[115.107238493,25.807661817]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":645192824,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":647890379,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[111.43148838,27.677907825],[111.43314456,27.678950282],[111.435221931,27.680264062],[111.435393089,27.679999948],[111.435275072,27.679876483],[111.433386797,27.678694383],[111.431804545,27.677703977],[111.431692144,27.677633569],[111.43148838,27.677907825]]]},"properties":{"name":"Hunan Lengshuijiang Langshitan","tags":{"name":"Hunan Lengshuijiang Langshitan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":647890379,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":652042171,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.359513745,24.412193794],[115.359698566,24.413070792],[115.359717593,24.413160981],[115.360565171,24.413986431],[115.360194775,24.412396636],[115.359749947,24.410249946],[115.359574933,24.411690041],[115.359513745,24.412193794]]]},"properties":{"name":"Fengshuba Dam","tags":{"name":"Fengshuba Dam","owner":"Guangdong Energy Group","power":"plant","name:en":"Fengshuba Dam","waterway":"dam","wikidata":"Q11968703","plant:source":"hydro","plant:output:electricity":"160 MW"},"osm_id":652042171,"output":"160 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":652591257,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.632512684,26.323856378],[102.632696751,26.324083779],[102.633062537,26.324507065],[102.633018951,26.324816944],[102.633688749,26.325150963],[102.634355865,26.32526613],[102.634491987,26.325006291],[102.634880656,26.32496346],[102.63526396,26.324887185],[102.635402681,26.324799929],[102.635526817,26.324772855],[102.635460181,26.324632794],[102.63539849,26.324503377],[102.635003199,26.324566744],[102.634497771,26.324647797],[102.634141288,26.324628687],[102.633800899,26.324556686],[102.633538294,26.324429365],[102.63331324,26.324253932],[102.632809488,26.323657895],[102.632717119,26.323719669],[102.632512684,26.323856378]]]},"properties":{"name":"乌东德大坝","tags":{"area":"yes","name":"乌东德大坝","power":"plant","height":"270","name:de":"Wudongde-Talsperre","name:en":"Wudongde Dam","name:fa":"سد وودونگده","name:fr":"Barrage de Wudongde","name:he":"סכר וודונגדה","name:no":"Wudongde-demningen","name:pt":"Usina Hidrelétrica de Wudongde","name:ru":"ГЭС Удундэ","name:uk":"ГЕС Удунде","name:vi":"Đập Ô Đông Đức","material":"concrete","waterway":"dam","wikidata":"Q908346","wikipedia":"zh:乌东德水电站","plant:source":"hydro","wikipedia:en":"Wudongde Dam","plant:output:electricity":"10200 MW"},"osm_id":652591257,"output":"10200 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":652865857,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.268557087,40.724535329],[128.269747987,40.723730331],[128.270445362,40.723616589],[128.272106152,40.723474683],[128.272138339,40.723173773],[128.270614844,40.723238816],[128.269563418,40.72349086],[128.268557087,40.724535329]]]},"properties":{"name":"내정댐","tags":{"name":"내정댐","power":"plant","name:en":"Naejung Dam","name:ko":"내정댐","waterway":"dam","plant:source":"hydro"},"osm_id":652865857,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":653420643,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.641008892,26.573969938],[101.641171166,26.574492382],[101.644352769,26.573702052],[101.644190579,26.573179441],[101.641008892,26.573969938]]]},"properties":{"name":"金沙水电站","tags":{"area":"yes","name":"金沙水电站","power":"plant","height":"66","name:en":"Jinsha Dam","waterway":"dam","wikidata":"Q19853879","wikipedia":"zh:金沙水电站","plant:source":"hydro","plant:output:electricity":"560 MW"},"osm_id":653420643,"output":"560 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":653876448,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.629276742,28.184038319],[101.629389731,28.184152565],[101.629512777,28.184276784],[101.629660047,28.183799267],[101.629824584,28.183470278],[101.630080818,28.183085967],[101.630425566,28.182776172],[101.630617763,28.182668716],[101.63080929,28.182561763],[101.631123527,28.182486242],[101.631280772,28.182448524],[101.631825763,28.1824818],[101.632431775,28.182633848],[101.632816672,28.182798971],[101.633202072,28.18303182],[101.633153121,28.182683133],[101.632712065,28.182444584],[101.632314344,28.182333189],[101.631711685,28.182226319],[101.631150517,28.182227074],[101.630744749,28.182339056],[101.6305953,28.18240016],[101.630295395,28.182554639],[101.629950647,28.182864434],[101.629659041,28.183217647],[101.629459216,28.183552337],[101.629276742,28.184038319]]]},"properties":{"name":"锦屏一级水电站","tags":{"name":"锦屏一级水电站","power":"plant","height":"305","name:cs":"Ťin-pching I","name:de":"Jinping I","name:el":"Φράγμα Τσινπίν Ι","name:en":"Jinping-I Hydropower Station","name:fa":"سد جین پینگ","name:fr":"Barrage de Jinping I","name:ml":"ജിൻപിങ്-1 അണക്കെട്ട്","name:no":"Jinping vannkraftverk","name:pl":"Zapora Jinping-I","name:ps":"جينپينګ يو برېښنابند","name:ru":"ГЭС Цзиньпин-I","name:te":"జిన్‌పింగ్-I డ్యామ్","name:uk":"ГЕС Цзіньпін-1","name:ur":"جن پنگ-1 ڈیم، چین","name:vi":"Đập Cẩm Bình-I","name:zh":"锦屏一级水电站","material":"concrete","waterway":"dam","wikidata":"Q488033","wikipedia":"en:Jinping-I Dam","plant:method":"water-storage","plant:source":"hydro","wikipedia:zh":"锦屏一级水电站","plant:output:electricity":"3600 MW"},"osm_id":653876448,"output":"3600 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":661432740,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[87.305871144,24.105462154],[87.306115057,24.105553182],[87.30680187,24.105905221],[87.308601632,24.106949858],[87.309894541,24.107690148],[87.310537936,24.108070686],[87.31074346,24.10819214],[87.311007658,24.108365897],[87.311016794,24.108343266],[87.311026433,24.108319293],[87.310409441,24.107910424],[87.310497619,24.107775727],[87.310291256,24.1076531],[87.31021364,24.107797855],[87.309527078,24.107415892],[87.309593966,24.107304496],[87.309471255,24.107211038],[87.309390286,24.107322853],[87.308879828,24.107018087],[87.30882434,24.106984978],[87.308895334,24.10682656],[87.308783939,24.106770569],[87.308715878,24.106720445],[87.308609763,24.106882132],[87.308301225,24.106661856],[87.308422679,24.106429593],[87.306466426,24.10541949],[87.306375483,24.105616381],[87.305884638,24.105376491],[87.305877095,24.105424435],[87.305871144,24.105462154]]]},"properties":{"name":"Massanjore Dam","tags":{"area":"yes","name":"Massanjore Dam","power":"plant","waterway":"dam","wikidata":"Q30623350","wikipedia":"en:Massanjore Dam","plant:source":"hydro"},"osm_id":661432740,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":666373545,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.161066219,24.480490295],[104.161624119,24.480685593],[104.16228939,24.480373032],[104.16247924,24.480641588],[104.162821473,24.480485853],[104.163251717,24.480309581],[104.163405189,24.479962989],[104.163139734,24.479424117],[104.162425261,24.47979158],[104.161702489,24.480163149],[104.161066219,24.480490295]]]},"properties":{"name":"凤凰谷水电站","tags":{"name":"凤凰谷水电站","power":"plant","name:en":"Fenghuanggu Hydropower Station","waterway":"dam","wikidata":"Q65205924","plant:source":"hydro","plant:output:electricity":"100 MW"},"osm_id":666373545,"output":"100 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":667431851,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[92.498668031,25.913512932],[92.499142866,25.91359013],[92.499523739,25.913517878],[92.499976949,25.913283855],[92.500188843,25.912989482],[92.500183563,25.912777169],[92.500003855,25.912709694],[92.499383175,25.913228618],[92.498668031,25.913512932]]]},"properties":{"name":"KARBI LANGPI","tags":{"name":"KARBI LANGPI","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":667431851,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":668935457,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-89.730081155,15.590013889],[-89.729960204,15.589933674],[-89.729749651,15.589793948],[-89.729322425,15.589744243],[-89.729197283,15.589723037],[-89.729197283,15.589808197],[-89.729056551,15.589815489],[-89.728924704,15.589822363],[-89.729024532,15.589688671],[-89.729158643,15.589042762],[-89.729603806,15.589089281],[-89.730003539,15.589252058],[-89.730081155,15.590013889]]]},"properties":{"name":"Oxec","tags":{"name":"Oxec","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":668935457,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":670686429,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.879561185,9.329153325],[76.879580882,9.329221973],[76.879829825,9.33008774],[76.87986067,9.330066198],[76.879837033,9.329979362],[76.879889169,9.329965532],[76.87967652,9.329190876],[76.879650285,9.329198252],[76.879631677,9.329133628],[76.879561185,9.329153325]]]},"properties":{"name":"Lower Periyar Dam","tags":{"area":"yes","name":"Lower Periyar Dam","power":"plant","name:ml":"മണിയാർ ഡാം","operator":"Kerala State Electricity Board","waterway":"dam","wikidata":"Q48725617","plant:source":"hydro"},"osm_id":670686429,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":671323843,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[91.811065058,26.00596809],[91.811449871,26.007706665],[91.811515585,26.007747149],[91.81183745,26.007322857],[91.811483483,26.006199598],[91.811065058,26.00596809]]]},"properties":{"name":"Umtru Dam","tags":{"name":"Umtru Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":671323843,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":672455900,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[140.6057248,40.047366569],[140.605959074,40.047576284],[140.606030488,40.047804271],[140.605986818,40.04808079],[140.606058232,40.048162849],[140.606943696,40.047612745],[140.606852333,40.047551976],[140.606328297,40.047436474],[140.606066278,40.047196332],[140.6057248,40.047366569]]]},"properties":{"name":"森吉ダム","tags":{"name":"森吉ダム","power":"plant","building":"yes","waterway":"dam","wikidata":"Q34894001","wikipedia":"ja:森吉ダム","plant:source":"hydro"},"osm_id":672455900,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":674159021,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-83.842718616,33.321081217],[-83.842583164,33.321056155],[-83.842167338,33.320953645],[-83.841867769,33.320881141],[-83.841389917,33.320760693],[-83.840508476,33.320538489],[-83.839299051,33.320233555],[-83.839340206,33.320147054],[-83.839498875,33.320191143],[-83.839807748,33.320204722],[-83.840092314,33.320238752],[-83.84026934,33.320232131],[-83.840485677,33.320266748],[-83.840751467,33.320337743],[-83.841406261,33.320512673],[-83.841913953,33.320655752],[-83.842191729,33.320726076],[-83.842491047,33.320826491],[-83.84267394,33.320931768],[-83.842718616,33.321081217]]]},"properties":{"name":"Lloyd Shoals Dam","tags":{"name":"Lloyd Shoals Dam","note":"Was the largest hydroelectric dam in the world when built in 1915","owner":"Georgia Power Company","power":"plant","operator":"Georgia Power","waterway":"dam","ref:US:EIA":"712","ref:US:NID":"GA00873","start_date":"1911-02","plant:method":"run-of-the-river","plant:source":"hydro","gnis:feature_id":"328592","operator:wikidata":"Q5547666","plant:output:electricity":"14.4 MW"},"osm_id":674159021,"output":"14.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":680556817,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[114.791872487,26.443764951],[114.792344891,26.444027137],[114.79374995,26.444715962],[114.793599746,26.445133884],[114.792800448,26.446440287],[114.792859456,26.446512288],[114.793669567,26.44518669],[114.793916246,26.444836075],[114.795150146,26.445402775],[114.794479594,26.446579594],[114.794581434,26.446637178],[114.795166156,26.445662195],[114.796441462,26.446250772],[114.797183261,26.446531566],[114.797462127,26.446012726],[114.792054877,26.443438392],[114.791872487,26.443764951]]]},"properties":{"name":"万安水电站","tags":{"name":"万安水电站","power":"plant","name:en":"Wan'an Power Plant","waterway":"dam","wikidata":"Q12010379","plant:source":"hydro","plant:output:electricity":"500 MW"},"osm_id":680556817,"output":"500 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":684314668,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[92.395530053,29.246172721],[92.395542542,29.24631622],[92.396416775,29.246679827],[92.396651887,29.247388516],[92.397074,29.247807444],[92.398966298,29.247430175],[92.399051123,29.246649484],[92.39892112,29.245335788],[92.398496996,29.245268985],[92.395785869,29.246097368],[92.395530053,29.246172721]]]},"properties":{"name":"大古水电站","tags":{"name":"大古水电站","power":"plant","name:en":"Dagu Hydroelectric Plant","name:zh":"大古水电站","alt_name":"Dagu Hydropower Station","waterway":"dam","wikidata":"Q134455114","wikipedia":"en:Dagu Hydroelectric Plant","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"640 MW"},"osm_id":684314668,"output":"640 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":685562797,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[58.625172917,51.671282434],[58.625294119,51.671693063],[58.626510837,51.672128419],[58.626598595,51.672188769],[58.627245091,51.672250376],[58.627825119,51.672293962],[58.628352424,51.672385827],[58.628455773,51.672402759],[58.628919796,51.672462354],[58.629106796,51.671997745],[58.629187346,51.671305484],[58.628653754,51.671281176],[58.628257625,51.671615698],[58.628247651,51.671636653],[58.628238598,51.671645454],[58.62822284,51.671653584],[58.628194677,51.67165744],[58.628161736,51.671659368],[58.628015388,51.671665151],[58.627914051,51.671655596],[58.626980139,51.67156658],[58.62698433,51.671233567],[58.626988354,51.67101723],[58.626978966,51.670900051],[58.626631368,51.670488332],[58.626350994,51.670443824],[58.6257423,51.669601527],[58.625360169,51.669567161],[58.625180628,51.67057936],[58.625172917,51.671282434]]]},"properties":{"name":"Ириклинская ГЭС","tags":{"name":"Ириклинская ГЭС","power":"plant","operator":"Интер РАО","waterway":"dam","wikidata":"Q16656118","wikipedia":"ru:Ириклинская ГЭС","start_date":"1958-12-30","plant:source":"hydro","plant:output:electricity":"30 MW"},"osm_id":685562797,"output":"30 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":689507665,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-89.075761546,42.451134125],[-89.075634477,42.451201432],[-89.075460971,42.451293381],[-89.0753365,42.45115332],[-89.075500618,42.451074194],[-89.075638835,42.451007391],[-89.075761546,42.451134125]]]},"properties":{"name":"Rockton Dam","tags":{"name":"Rockton Dam","power":"plant","operator":"Eagle Creek Renewable Energy, LLC","waterway":"dam","ref:US:EIA":"903","start_date":"1929-01","plant:source":"hydro","plant:output:electricity":"1.1 MW"},"osm_id":689507665,"output":"1.1 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":692494863,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-86.351599932,41.83859889],[-86.351093246,41.83956876],[-86.350950167,41.83952727],[-86.351456853,41.838557484],[-86.351599932,41.83859889]]]},"properties":{"name":"Buchanan Dam","tags":{"name":"Buchanan Dam","power":"plant","waterway":"dam","wikidata":"Q33130089","ref:US:NID":"MI00157","plant:source":"hydro","gnis:feature_id":"2377432"},"osm_id":692494863,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":694618013,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.793870673,28.233936627],[115.794401751,28.23441867],[115.796284661,28.23288738],[115.796053992,28.232627458],[115.799782262,28.229536463],[115.799626694,28.229437138],[115.798301683,28.230562073],[115.795799182,28.232417156],[115.793870673,28.233936627]]]},"properties":{"name":"江西龙头山水电站枢纽工程","tags":{"name":"江西龙头山水电站枢纽工程","power":"plant","name:en":"Jiangxi Longtoushan Power Plant","waterway":"dam","wikidata":"Q84765067","plant:source":"hydro","plant:output:electricity":"240 MW"},"osm_id":694618013,"output":"240 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":694618693,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.43425593,27.910608065],[115.434642085,27.910715186],[115.434910138,27.910789449],[115.435068975,27.910833538],[115.43689908,27.911341062],[115.442150091,27.912797418],[115.442584357,27.91291795],[115.44274135,27.912476056],[115.437408364,27.910996985],[115.434810561,27.910276477],[115.434412923,27.910166171],[115.43425593,27.910608065]]]},"properties":{"name":"赣江新干航电枢纽","tags":{"name":"赣江新干航电枢纽","power":"plant","name:en":"Xingan Power Plant","building":"dam","waterway":"dam","wikidata":"Q84765042","plant:source":"hydro","plant:output:electricity":"112 MW"},"osm_id":694618693,"output":"112 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":695889316,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[106.168839226,28.698564631],[106.168908293,28.698604948],[106.168997644,28.698638224],[106.169690576,28.69856044],[106.170102965,28.698514172],[106.170217546,28.698496318],[106.170271442,28.69841954],[106.170140349,28.698413337],[106.169651684,28.698452481],[106.169117673,28.698495229],[106.169005271,28.698490786],[106.168910891,28.698457175],[106.168839226,28.698564631]]]},"properties":{"name":"Hejiang County Yuanxing Hydro","tags":{"area":"yes","name":"Hejiang County Yuanxing Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":695889316,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":695985721,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-10.651896603,6.495928734],[-10.651845305,6.496096791],[-10.651641206,6.496716465],[-10.651367202,6.496632478],[-10.65132722,6.496619738],[-10.650998482,6.496515299],[-10.650947436,6.496494345],[-10.65113452,6.495897972],[-10.651202749,6.495706781],[-10.651296123,6.49573704],[-10.651674315,6.495858242],[-10.651896603,6.495928734]]]},"properties":{"name":"Mount Coffee Hydropower Plant","tags":{"name":"Mount Coffee Hydropower Plant","power":"plant","operator":"Mount Coffee Hydropower Plant","waterway":"dam","wikidata":"Q1950287","start_date":"2016","opening_date":"2016","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"88 MW"},"osm_id":695985721,"output":"88 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":697321111,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.19282956,30.258523396],[102.193301126,30.260180834],[102.193593822,30.260419551],[102.193707732,30.260840993],[102.19349634,30.261178029],[102.193750144,30.262232221],[102.193574543,30.262509327],[102.193288637,30.263157918],[102.193293834,30.263425469],[102.193904874,30.263359084],[102.19855222,30.262853655],[102.198933178,30.26274578],[102.19925454,30.262627931],[102.199123866,30.262404385],[102.19874425,30.262209757],[102.198378631,30.261942794],[102.197236597,30.260349394],[102.196829991,30.259618995],[102.196244599,30.258972834],[102.195317644,30.258439074],[102.19282956,30.258523396]]]},"properties":{"name":"长河坝水电站","tags":{"name":"长河坝水电站","power":"plant","name:de":"Changheba-Talsperre","name:en":"Changheba Dam","name:fa":"سد چنگبا","name:fr":"Barrage de Changheba","name:no":"Changheba-demningen","name:zh":"长河坝水电站","alt_name":"長河壩大壩;长河坝大坝","waterway":"dam","wikidata":"Q1062031","wikipedia":"en:Changheba Dam","plant:source":"hydro","plant:output:electricity":"2600 MW"},"osm_id":697321111,"output":"2600 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":697553010,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.9776313,23.729876197],[107.977914273,23.730158751],[107.979495183,23.728689991],[107.979707078,23.728889731],[107.98111683,23.727710565],[107.980579718,23.727274119],[107.9776313,23.729876197]]]},"properties":{"name":"大化水电站","tags":{"name":"大化水电站","layer":"1","power":"plant","name:en":"Dahua Dam","name:zh":"大化水电站","building":"industrial","waterway":"dam","wikidata":"Q11964554","wikipedia":"zh:大化水电站","plant:source":"hydro","plant:output:electricity":"566 MW"},"osm_id":697553010,"output":"566 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":700595151,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-57.053959434,-9.191502944],[-57.052464102,-9.188953337],[-57.052133436,-9.188374902],[-57.05235606,-9.188247916],[-57.05234927,-9.188148507],[-57.052284981,-9.188116823],[-57.051873262,-9.188351852],[-57.051574866,-9.187836951],[-57.052017431,-9.187583902],[-57.051942413,-9.18745591],[-57.051605879,-9.187648359],[-57.050932142,-9.186499703],[-57.050592172,-9.185919926],[-57.049984903,-9.186266937],[-57.050962652,-9.18793393],[-57.050850837,-9.187997968],[-57.051004981,-9.188260657],[-57.051169266,-9.188166612],[-57.051444108,-9.188611355],[-57.051293905,-9.18871395],[-57.051362133,-9.188787711],[-57.051405133,-9.188910422],[-57.051395242,-9.189214936],[-57.051493646,-9.18937151],[-57.05156573,-9.189429932],[-57.051631947,-9.189464801],[-57.051713251,-9.18949003],[-57.051829508,-9.189497909],[-57.051987507,-9.189423478],[-57.05344797,-9.19181693],[-57.053959434,-9.191502944]]]},"properties":{"name":"Usina Hidrelétrica São Manoel","tags":{"name":"Usina Hidrelétrica São Manoel","power":"plant","waterway":"dam","wikidata":"Q10388444","start_date":"2018-05","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"700 MW"},"osm_id":700595151,"output":"700 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":702925439,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-55.439339931,-22.414137926],[-55.439305062,-22.414028961],[-55.439058215,-22.414080929],[-55.439065759,-22.414183859],[-55.439339931,-22.414137926]]]},"properties":{"name":"Pequena Central Hidrelétrica São João II","tags":{"name":"Pequena Central Hidrelétrica São João II","power":"plant","operator":"Sao Joao Energia LTDA","waterway":"dam","start_date":"1962-12-31","plant:source":"hydro","plant:output:electricity":"664 kW"},"osm_id":702925439,"output":"664 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":707971447,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.360514126,25.946950691],[115.360766253,25.947215978],[115.361074791,25.946979692],[115.361439571,25.947338941],[115.364389918,25.944808947],[115.365181253,25.945498778],[115.365216038,25.945469776],[115.364392684,25.944765529],[115.363569245,25.944051642],[115.363437733,25.944157757],[115.364078865,25.944772737],[115.362861142,25.945778566],[115.36205648,25.945009191],[115.362018845,25.945035678],[115.362831554,25.945826762],[115.361973247,25.946562357],[115.361565551,25.94623672],[115.361139164,25.946615499],[115.36105325,25.946535871],[115.360974543,25.946596388],[115.360514126,25.946950691]]]},"properties":{"name":"Jiangxi Yudu Yuezhou","tags":{"name":"Jiangxi Yudu Yuezhou","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":707971447,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":713874424,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[57.895737291,26.164566523],[57.895802586,26.164629555],[57.897429764,26.163395655],[57.897361033,26.163331868],[57.895737291,26.164566523]]]},"properties":{"name":"سد جگین","tags":{"area":"yes","name":"سد جگین","power":"plant","name:en":"Jagin Dam","waterway":"dam","plant:source":"hydro"},"osm_id":713874424,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":722566009,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[47.92920674,-18.921388312],[47.929289888,-18.921298878],[47.929391728,-18.92123308],[47.929526174,-18.921188069],[47.929672522,-18.921211706],[47.92971242,-18.921226626],[47.929704373,-18.921272307],[47.929524749,-18.921252023],[47.929480744,-18.921423768],[47.929326266,-18.921333243],[47.929240435,-18.921427121],[47.92920674,-18.921388312]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","wikidata":"Q16894585","plant:source":"hydro"},"osm_id":722566009,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":726604479,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-79.017843524,-3.980398975],[-79.01783707,-3.980336446],[-79.017727854,-3.980346169],[-79.017071803,-3.980412051],[-79.016644074,-3.980433508],[-79.016636949,-3.980516321],[-79.016717919,-3.980520764],[-79.016813975,-3.980730311],[-79.017160651,-3.980731736],[-79.017488802,-3.980699801],[-79.017496346,-3.980566361],[-79.017719221,-3.980406602],[-79.017843524,-3.980398975]]]},"properties":{"name":"Delsitanisagua hidroelectrica","tags":{"area":"yes","name":"Delsitanisagua hidroelectrica","power":"plant","material":"concrete","operator":"CELEC","waterway":"dam","plant:source":"hydro"},"osm_id":726604479,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":730033110,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-47.859628225,-20.493814908],[-47.859437118,-20.49356387],[-47.858604208,-20.494120009],[-47.858795483,-20.494371215],[-47.859628225,-20.493814908]]]},"properties":{"name":"PCH Anhanguera","tags":{"name":"PCH Anhanguera","power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":730033110,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":733894880,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[88.649121419,27.597056741],[88.649170788,27.597092532],[88.650247946,27.597708099],[88.651235083,27.598592222],[88.651760796,27.597322951],[88.650602082,27.596490963],[88.649121419,27.597056741]]]},"properties":{"name":"Teesta Stage III Dam","tags":{"name":"Teesta Stage III Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":733894880,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":736530301,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.255015151,22.60737097],[102.255213634,22.607611195],[102.255444388,22.607876147],[102.255607919,22.60806189],[102.255683021,22.608160881],[102.255792824,22.608320053],[102.255738258,22.608359364],[102.255819646,22.608473526],[102.255800954,22.608509316],[102.255826938,22.608528092],[102.2558634,22.608525577],[102.255902627,22.608561619],[102.25594747,22.608548292],[102.256021063,22.608511831],[102.256320046,22.608980463],[102.256462454,22.609204176],[102.256644928,22.609470637],[102.256831174,22.60937106],[102.256744338,22.609202919],[102.256876939,22.609047435],[102.256978696,22.608967974],[102.256938043,22.608898991],[102.256774261,22.608998987],[102.256308982,22.608345953],[102.256562869,22.608213184],[102.256805442,22.60805552],[102.25755185,22.607570376],[102.257503068,22.607491669],[102.25723602,22.607629468],[102.257170977,22.607557887],[102.256414426,22.607990477],[102.256058363,22.607541458],[102.256821367,22.607191681],[102.256745343,22.607033934],[102.255935652,22.60720635],[102.255710765,22.607154046],[102.255015151,22.60737097]]]},"properties":{"name":"土卡河水电站","tags":{"name":"土卡河水电站","power":"plant","name:en":"Tukahe Dam","building":"dam","waterway":"dam","wikidata":"Q7851808","wikipedia":"en:Tukahe Dam","plant:source":"hydro","plant:output:electricity":"165 MW"},"osm_id":736530301,"output":"165 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":738981152,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-72.530500668,-37.243960177],[-72.53014385,-37.24389614],[-72.530109065,-37.243988508],[-72.529773705,-37.243935283],[-72.529469107,-37.243881555],[-72.529484027,-37.243825983],[-72.529449326,-37.243791953],[-72.529387635,-37.243767561],[-72.529336002,-37.24373286],[-72.529344049,-37.243685167],[-72.529353353,-37.243639234],[-72.529335835,-37.243600007],[-72.529267019,-37.243563462],[-72.529235252,-37.243511159],[-72.529122767,-37.244085655],[-72.530065563,-37.244251532],[-72.530013679,-37.244464768],[-72.530020301,-37.244481867],[-72.530041843,-37.244490417],[-72.530064641,-37.244489243],[-72.530078974,-37.244475329],[-72.530130355,-37.244304506],[-72.530305956,-37.244308026],[-72.530406958,-37.244274164],[-72.530500668,-37.243960177]]]},"properties":{"name":"Hidroeléctrica Laja","tags":{"name":"Hidroeléctrica Laja","power":"plant","man_made":"dam","operator":"EMR","waterway":"dam","start_date":"2015-05-28","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"34.4 MW"},"osm_id":738981152,"output":"34.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":744378058,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.207578381,29.236787924],[103.20777259,29.236946928],[103.209417371,29.238293062],[103.209940737,29.237778329],[103.208884449,29.236910132],[103.208645649,29.237131246],[103.208154134,29.236727239],[103.207889685,29.236509896],[103.207755072,29.236599917],[103.207578381,29.236787924]]]},"properties":{"name":"沙坪二级水电站","tags":{"name":"沙坪二级水电站","power":"plant","name:en":"Shaping Power Plant","waterway":"dam","wikidata":"Q84763818","plant:source":"hydro","plant:output:electricity":"348 MW"},"osm_id":744378058,"output":"348 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":744763769,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.080949869,29.019966864],[103.082025267,29.02035679],[103.082161473,29.020128299],[103.082308156,29.019882374],[103.081239547,29.019403935],[103.081080459,29.019713144],[103.080949869,29.019966864]]]},"properties":{"name":"Sichuan Leshan Ebian Jinyan Hydro","tags":{"name":"Sichuan Leshan Ebian Jinyan Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":744763769,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":747127085,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[8.049390686,47.39984446],[8.049396386,47.399850662],[8.049470147,47.399929033],[8.049581542,47.400047469],[8.049496969,47.400083931],[8.049518678,47.400106981],[8.049603168,47.40007052],[8.049662344,47.400045039],[8.049854792,47.399962142],[8.050062999,47.399872455],[8.049997452,47.399801041],[8.049980353,47.399783859],[8.049962667,47.399764245],[8.049916651,47.399715546],[8.049877842,47.399673217],[8.049862755,47.399657795],[8.049856469,47.399650586],[8.049851691,47.399645892],[8.049828222,47.399620998],[8.049790335,47.399637259],[8.049813721,47.399662153],[8.04972898,47.399698782],[8.049712132,47.399680929],[8.049688076,47.399691322],[8.04970484,47.399709176],[8.049649519,47.39973298],[8.04962253,47.399744547],[8.049606269,47.399727281],[8.049582129,47.39973759],[8.049598474,47.399754941],[8.049516834,47.399790061],[8.049498226,47.399770364],[8.04947417,47.399780757],[8.049492778,47.399800455],[8.049390686,47.39984446]]]},"properties":{"name":"Kraftwerk Rüchlig","tags":{"name":"Kraftwerk Rüchlig","layer":"1","power":"plant","building":"industrial","operator":"Axpo","waterway":"dam","wikidata":"Q96748676","wikipedia":"de:Kraftwerk Rüchlig","start_date":"2015","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"11 MW"},"osm_id":747127085,"output":"11 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":749181146,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.233925493,27.98995862],[104.234017694,27.989987119],[104.233950723,27.990155763],[104.234056754,27.990188536],[104.234466126,27.989157227],[104.234346097,27.989120179],[104.234282982,27.989278932],[104.234204946,27.989254792],[104.233925493,27.98995862]]]},"properties":{"name":"Yunnan Whitewaters Development","tags":{"name":"Yunnan Whitewaters Development","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":749181146,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":752163447,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.95027268,26.017951446],[100.950478288,26.018032331],[100.950626061,26.018130567],[100.950722453,26.018355789],[100.950767464,26.018586794],[100.950748186,26.019037154],[100.950870226,26.019031454],[100.950940969,26.018887034],[100.950973156,26.018621327],[100.950863856,26.018297954],[100.950761094,26.018118916],[100.950613237,26.01797458],[100.95027268,26.017951446]]]},"properties":{"name":"Yunnan Dali Tiechuanqiao Hydro","tags":{"name":"Yunnan Dali Tiechuanqiao Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":752163447,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":757555554,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.561106245,27.349963852],[102.561883248,27.350497276],[102.56197394,27.350392921],[102.561196938,27.349859581],[102.561106245,27.349963852]]]},"properties":{"name":"Sichuan Gongdefang","tags":{"name":"Sichuan Gongdefang","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":757555554,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":757679580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.621038278,27.548374145],[102.621066777,27.548429968],[102.62237779,27.547904423],[102.622297827,27.547747765],[102.621536834,27.548052866],[102.621588382,27.548153617],[102.621038278,27.548374145]]]},"properties":{"name":"Sichuan Province Yingxiongpo","tags":{"name":"Sichuan Province Yingxiongpo","power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":757679580,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":761604397,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-6.656125343,5.80309889],[-6.655991148,5.803138285],[-6.655924512,5.803157731],[-6.655884866,5.803169382],[-6.655605413,5.802225663],[-6.655637851,5.802216192],[-6.655797359,5.802169421],[-6.655845806,5.802155255],[-6.65598394,5.802621624],[-6.656125343,5.80309889]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":761604397,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":762984889,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-90.163660326,15.299901217],[-90.163623865,15.299943629],[-90.163569383,15.299973552],[-90.163459244,15.300019653],[-90.163193454,15.30010716],[-90.163073258,15.300003644],[-90.163282973,15.299752857],[-90.163090776,15.299508441],[-90.163162777,15.299437446],[-90.163199741,15.299407858],[-90.1632647,15.299488659],[-90.163325721,15.299559738],[-90.163286242,15.299603156],[-90.163433009,15.299725951],[-90.163467626,15.299756042],[-90.163501405,15.299784876],[-90.163660326,15.299901217]]]},"properties":{"name":null,"tags":{"area":"yes","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":762984889,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":770272326,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[24.345170922,45.240375434],[24.345289442,45.240388091],[24.34694294,45.240566961],[24.347043272,45.240576935],[24.347123319,45.240585065],[24.347162295,45.240400496],[24.347200181,45.240231936],[24.347083756,45.240218944],[24.347118122,45.240065974],[24.347021144,45.240055162],[24.346654854,45.240014342],[24.346736159,45.239652327],[24.346685197,45.239646628],[24.346547398,45.240260434],[24.345399832,45.240132694],[24.345548192,45.239360469],[24.345512317,45.239355859],[24.345339985,45.240146943],[24.345311571,45.240273342],[24.345199169,45.240260853],[24.345170922,45.240375434]]]},"properties":{"name":"CALIMANESTI (Olt)","tags":{"area":"yes","name":"CALIMANESTI (Olt)","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":770272326,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":771345539,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[106.144117389,30.466414376],[106.144147564,30.466543374],[106.14436608,30.467523051],[106.14504778,30.467410063],[106.144872347,30.466623421],[106.144537239,30.466326786],[106.144117389,30.466414376]]]},"properties":{"name":"东西关水电站","tags":{"name":"东西关水电站","power":"plant","name:en":"Dongxiguan Hydropower Station","building":"dam","waterway":"dam","wikidata":"Q11965937","plant:source":"hydro","plant:output:electricity":"210 MW"},"osm_id":771345539,"output":"210 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":780739499,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[80.04877579,45.428258224],[80.048934879,45.42827809],[80.049119783,45.428301056],[80.049134368,45.428243556],[80.04892767,45.42821774],[80.048923647,45.42823333],[80.048786351,45.428216231],[80.04877579,45.428258224]]]},"properties":{"name":"Nizhne-Baskansk HPP-1","tags":{"name":"Nizhne-Baskansk HPP-1","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":780739499,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":788455003,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[114.998327479,25.48461212],[114.998705671,25.484708931],[114.998853192,25.484711362],[114.998855875,25.484779172],[114.999682246,25.484811023],[114.999720049,25.484331243],[114.998945896,25.484326717],[114.998960481,25.48406495],[114.999394999,25.48407945],[114.999400363,25.484018933],[114.998627887,25.483943831],[114.998812959,25.482495941],[114.998617158,25.482420839],[114.998370395,25.484023795],[114.998335442,25.484338535],[114.998327479,25.48461212]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":788455003,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":788874983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[106.386379034,29.901086439],[106.386537536,29.901195153],[106.386608866,29.901122649],[106.390490944,29.90445345],[106.390339567,29.90459787],[106.390948931,29.905177479],[106.391224947,29.90544],[106.391303653,29.905368167],[106.391680588,29.905022078],[106.391214554,29.904619244],[106.391596517,29.904277346],[106.393343641,29.902611862],[106.396028784,29.900064099],[106.396000285,29.900057226],[106.395272065,29.90074144],[106.394971993,29.900493252],[106.392713825,29.902404745],[106.391855183,29.90327705],[106.391287728,29.904018932],[106.391165268,29.903892784],[106.391251518,29.903823382],[106.390166983,29.902888549],[106.390453561,29.902632146],[106.390632263,29.902578754],[106.391075833,29.902175416],[106.391001989,29.902116659],[106.389852746,29.90313154],[106.389165681,29.902514632],[106.390102275,29.901705359],[106.38989893,29.901521041],[106.389033163,29.90227952],[106.388872985,29.902143397],[106.388703419,29.902290248],[106.388256748,29.901854892],[106.389251931,29.900989545],[106.389214967,29.900965489],[106.388235122,29.901841565],[106.386811121,29.900675642],[106.386379034,29.901086439]]]},"properties":{"name":"草街电站大坝","tags":{"name":"草街电站大坝","power":"plant","name:en":"Caojie Power Station","waterway":"dam","wikidata":"Q17761731","plant:source":"hydro","plant:output:electricity":"500 MW"},"osm_id":788874983,"output":"500 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":789335406,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[113.274573753,25.210262874],[113.274731165,25.210311741],[113.275301051,25.210488766],[113.275874876,25.210653471],[113.275985768,25.210685322],[113.276053075,25.210704684],[113.276196238,25.210745756],[113.276958069,25.21097542],[113.276988244,25.210815828],[113.276910795,25.210718431],[113.276600832,25.210500417],[113.276549284,25.210204452],[113.276368234,25.210161034],[113.276068833,25.210089285],[113.275621658,25.209981996],[113.275412949,25.209931873],[113.275223686,25.210056428],[113.274612477,25.210153826],[113.274573753,25.210262874]]]},"properties":{"name":"乐昌峡水利工程","tags":{"area":"yes","name":"乐昌峡水利工程","power":"plant","name:en":"Lechangxia Dam","waterway":"dam","wikidata":"Q84765483","plant:source":"hydro","plant:output:electricity":"132 MW"},"osm_id":789335406,"output":"132 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":799378202,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.898854063,9.335408321],[76.898927572,9.335584257],[76.899628048,9.335247891],[76.899697366,9.335336991],[76.899942621,9.335199192],[76.899876739,9.335102297],[76.900173877,9.334913118],[76.899874476,9.334508859],[76.899599968,9.334690746],[76.899445322,9.334468793],[76.899229321,9.334654285],[76.899521262,9.335098693],[76.898854063,9.335408321]]]},"properties":{"name":"Karikayam Dam","tags":{"area":"yes","name":"Karikayam Dam","power":"plant","name:ml":"കാരിക്കയം അണക്കെട്ട്","alt_name":"Ayyappa Hydroelectric Power Station","material":"concrete","operator":"private","waterway":"dam","wikidata":"Q120365960","plant:source":"hydro"},"osm_id":799378202,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":801753320,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[73.431229958,19.708138891],[73.431947617,19.707613178],[73.432854874,19.706774317],[73.434465122,19.704612038],[73.433566582,19.704166288],[73.432504427,19.705899917],[73.431903696,19.706803822],[73.43185156,19.707419221],[73.431229958,19.708138891]]]},"properties":{"name":"Middle Vaitarna Dam","tags":{"name":"Middle Vaitarna Dam","power":"plant","height":"84","name:mr":"मध्य वैतरणा धरण","material":"reinforced_concrete","operator":"MCGM","waterway":"dam","plant:source":"hydro"},"osm_id":801753320,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":805174036,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[108.33678244,12.45195113],[108.337023838,12.452104351],[108.33729558,12.452115751],[108.337556257,12.452126647],[108.337605291,12.452096221],[108.33765684,12.452070321],[108.337718782,12.452046265],[108.33778282,12.452028495],[108.337788268,12.452003517],[108.337270685,12.451976527],[108.33678244,12.45195113]]]},"properties":{"name":"Đập thủy điện Krông Kmar","tags":{"name":"Đập thủy điện Krông Kmar","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":805174036,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":808197847,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[24.925498526,48.034902958],[24.925667673,48.034909328],[24.925755264,48.034912681],[24.925769094,48.034749234],[24.925744284,48.034748228],[24.925748056,48.034703217],[24.925688544,48.034700954],[24.925630625,48.034698775],[24.925626853,48.034743953],[24.925591566,48.034742528],[24.925587375,48.034792484],[24.925508166,48.034789551],[24.925498526,48.034902958]]]},"properties":{"name":"ГЕС","tags":{"name":"ГЕС","power":"plant","name:uk":"ГЕС","building":"yes","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":808197847,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":817760424,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.192111632,25.919384958],[115.192114399,25.919418737],[115.193012939,25.919355957],[115.193122825,25.920301687],[115.192449675,25.920357175],[115.192452357,25.92040294],[115.192669532,25.920393385],[115.192814371,25.921015741],[115.192894838,25.921353448],[115.193318627,25.92127382],[115.19336699,25.920349883],[115.194182382,25.920291964],[115.194930634,25.921218332],[115.195057117,25.921191091],[115.194244072,25.920207558],[115.193348131,25.920275116],[115.193120143,25.917927848],[115.192881426,25.917947127],[115.193012855,25.919312539],[115.192111632,25.919384958]]]},"properties":{"name":"Jiangxi Yudu Xiashan","tags":{"name":"Jiangxi Yudu Xiashan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":817760424,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":819987091,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[97.672765563,24.457437798],[97.672995646,24.457541147],[97.673188765,24.456977129],[97.672939571,24.456934632],[97.672765563,24.457437798]]]},"properties":{"name":"Yingjiang County Gulikahe","tags":{"area":"yes","name":"Yingjiang County Gulikahe","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":819987091,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":823015162,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[78.830476976,30.239910291],[78.831097153,30.240248584],[78.831247525,30.240330643],[78.831615658,30.240720653],[78.831652287,30.240759461],[78.831960741,30.241236559],[78.832000471,30.241337645],[78.832240277,30.241329263],[78.832260394,30.241356337],[78.832488466,30.241280983],[78.832495171,30.241228848],[78.832349829,30.240956436],[78.832166433,30.240635828],[78.831992257,30.240402225],[78.831712553,30.240098884],[78.831533767,30.239950021],[78.831310138,30.239780036],[78.830914344,30.239600496],[78.830634892,30.239482646],[78.830476976,30.239910291]]]},"properties":{"name":"Shrinagar Dam","tags":{"name":"Shrinagar Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":823015162,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":824980838,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.516056163,27.808513718],[104.516670389,27.808955025],[104.516506774,27.809161472],[104.516933246,27.80943908],[104.517280843,27.809110426],[104.516256239,27.808320599],[104.516056163,27.808513718]]]},"properties":{"name":"Yiliang Baishuijiang Liuxi","tags":{"name":"Yiliang Baishuijiang Liuxi","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":824980838,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":834739408,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.96088704,45.592457444],[22.961221897,45.592471945],[22.961226843,45.592376643],[22.96123162,45.592286118],[22.961231872,45.592212274],[22.961529346,45.59216785],[22.961540158,45.591796028],[22.960927944,45.591785551],[22.960919394,45.591888984],[22.960911935,45.592046228],[22.960901541,45.592269271],[22.960894416,45.592364489],[22.96088704,45.592457444]]]},"properties":{"name":"Hateg","tags":{"area":"yes","name":"Hateg","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":834739408,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":837738958,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[106.079571454,33.08215029],[106.079610095,33.082364196],[106.080281737,33.08227912],[106.08105262,33.082181638],[106.081014147,33.081967732],[106.080974836,33.081729853],[106.08038475,33.081799591],[106.080412997,33.08204384],[106.080224069,33.082067728],[106.079571454,33.08215029]]]},"properties":{"name":"Shaanxi Juting","tags":{"name":"Shaanxi Juting","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":837738958,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":843180333,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.764836735,22.055201699],[100.764890631,22.055234891],[100.764957518,22.055261546],[100.765502174,22.054317827],[100.766511188,22.054818059],[100.766855936,22.054255131],[100.76729959,22.053491959],[100.767323478,22.05345072],[100.767650959,22.052887372],[100.767723127,22.05271789],[100.767039164,22.052378758],[100.767459097,22.051649365],[100.767855897,22.050958696],[100.767561776,22.050815784],[100.768139959,22.049792941],[100.768079693,22.04976855],[100.768001993,22.049725969],[100.767972321,22.049778273],[100.767184758,22.051141841],[100.767095323,22.05130118],[100.766440277,22.052433995],[100.766324271,22.052376495],[100.766026127,22.052912769],[100.765511395,22.053785325],[100.765622287,22.053840394],[100.765112332,22.054724098],[100.764836735,22.055201699]]]},"properties":{"name":"景洪大坝","tags":{"name":"景洪大坝","power":"plant","name:de":"Jinghong-Talsperre","name:en":"Jinghong Dam","end_date":"2008","waterway":"dam","wikidata":"Q1355376","start_date":"2003","plant:source":"hydro","plant:output:electricity":"1750 MW"},"osm_id":843180333,"output":"1750 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":844679320,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[92.543905497,29.141466322],[92.544177574,29.14190654],[92.545145265,29.141941744],[92.545528737,29.141761449],[92.54606258,29.141510327],[92.545921345,29.14130782],[92.547120963,29.140506762],[92.548068454,29.141598421],[92.548189321,29.141510327],[92.547040245,29.140022456],[92.547806351,29.13951183],[92.549570323,29.141422317],[92.549741649,29.141651227],[92.549892859,29.141941744],[92.549973576,29.141871336],[92.549822367,29.141572018],[92.54963084,29.141334391],[92.547876927,29.139432621],[92.548401047,29.139080497],[92.549842483,29.14066518],[92.550064268,29.140735672],[92.550497697,29.14026025],[92.549540064,29.139388532],[92.549157095,29.1389572],[92.549086519,29.138675484],[92.549026086,29.138490579],[92.548854676,29.138296873],[92.548522082,29.138067963],[92.548138945,29.137997555],[92.548077674,29.138344063],[92.547987736,29.138851588],[92.54426768,29.141234395],[92.543905497,29.141466322]]]},"properties":{"name":"加查水电站","tags":{"area":"yes","name":"加查水电站","power":"plant","name:en":"Jiacha Hydropower Station","waterway":"dam","wikidata":"Q104848230","plant:source":"hydro","plant:output:electricity":"360 MW"},"osm_id":844679320,"output":"360 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":847017389,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.801210388,29.042549052],[102.801395293,29.043129248],[102.801606517,29.043068646],[102.801800558,29.043023971],[102.802019326,29.042973428],[102.801976494,29.042725156],[102.80170425,29.042786092],[102.801563685,29.042817525],[102.801484393,29.042713589],[102.801332429,29.042537485],[102.801210388,29.042549052]]]},"properties":{"name":"苏雄电站","tags":{"name":"苏雄电站","power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro"},"osm_id":847017389,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":847307055,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.261812169,33.168706097],[104.261826334,33.168761333],[104.262347773,33.168667791],[104.262349365,33.16861004],[104.262356238,33.168354727],[104.262360597,33.168195136],[104.262219781,33.168192454],[104.262207795,33.168635018],[104.261812169,33.168706097]]]},"properties":{"name":"Sichuan Jiuzhaigou County Songbai","tags":{"name":"Sichuan Jiuzhaigou County Songbai","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":847307055,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":851384276,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-72.598436664,43.38129697],[-72.598383103,43.381317758],[-72.598351336,43.381274423],[-72.59817146,43.38102942],[-72.598208424,43.381015087],[-72.598395173,43.381269394],[-72.598411602,43.38126294],[-72.598436664,43.38129697]]]},"properties":{"name":"Cavendish Dam","tags":{"area":"yes","name":"Cavendish Dam","power":"plant","operator":"Green Mountain Power","waterway":"dam","wikidata":"Q35698290","ref:US:EIA":"3710","ref:US:NID":"VT00037","start_date":"1907-02","plant:method":"water-storage","plant:source":"hydro","operator:short":"GMP","gnis:feature_id":"1766364","operator:wikidata":"Q71871622","plant:output:electricity":"1.7 MW"},"osm_id":851384276,"output":"1.7 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":852572928,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-81.456461609,34.778431742],[-81.456238902,34.778678589],[-81.456177211,34.77868739],[-81.456159442,34.778815465],[-81.456129015,34.779033143],[-81.455914439,34.778997939],[-81.45598141,34.778592591],[-81.4561638,34.778585969],[-81.456416012,34.778370051],[-81.456461609,34.778431742]]]},"properties":{"name":"Lockhart Dam","tags":{"name":"Lockhart Dam","power":"plant","alt_name":"Lockhart Dam Powerhouse","building":"industrial","operator":"Lockhart Power","waterway":"dam","ref:US:EIA":"3275","ref:US:NID":"SC01059S003","start_date":"1921-01","plant:source":"hydro","plant:output:electricity":"18 MW"},"osm_id":852572928,"output":"18 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":854116561,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.386909091,18.618804459],[102.387952722,18.619809868],[102.388172328,18.619605182],[102.387836968,18.619282143],[102.387798076,18.619318437],[102.387394823,18.618929936],[102.387525832,18.618807895],[102.387220647,18.618513858],[102.386909091,18.618804459]]]},"properties":{"name":"ເຂື່ອນໄຟຟ້ານ້ຳລີກ 1","tags":{"name":"ເຂື່ອນໄຟຟ້ານ້ຳລີກ 1","power":"plant","name:en":"Nam Lik 1 hydro power station","waterway":"dam","wikidata":"Q61448331","start_date":"2018","plant:source":"hydro","plant:output:electricity":"64 MW"},"osm_id":854116561,"output":"64 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":864209612,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[144.126652528,-6.092431709],[144.126741041,-6.092423746],[144.126749087,-6.09266372],[144.126665939,-6.092674449],[144.126652528,-6.092431709]]]},"properties":{"name":null,"tags":{"area":"yes","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":864209612,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":867157747,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[12.87985877,47.285615883],[12.879859441,47.285626445],[12.879861872,47.285634072],[12.879866482,47.285639017],[12.879873019,47.285643376],[12.879929849,47.285661146],[12.879871846,47.28574597],[12.87992373,47.285761896],[12.880167643,47.285836495],[12.88023847,47.285732056],[12.880037891,47.285670869],[12.880096146,47.285589396],[12.880420777,47.285691153],[12.880432679,47.285675143],[12.880111652,47.285568442],[12.880174768,47.285481773],[12.88037652,47.285545391],[12.880479869,47.285393763],[12.880371575,47.2853599],[12.880331677,47.285415723],[12.880195723,47.285373646],[12.880151969,47.285360151],[12.880097487,47.285436175],[12.880035042,47.2854174],[12.880020792,47.285416981],[12.880013416,47.285427123],[12.880024397,47.285432739],[12.880082148,47.285450676],[12.880019284,47.285541033],[12.87996916,47.285524688],[12.879950133,47.285522928],[12.879945774,47.285525862],[12.879945188,47.285530891],[12.879952731,47.285537177],[12.879959856,47.285541117],[12.880007717,47.285556875],[12.879949462,47.285638431],[12.879897159,47.285621248],[12.87985877,47.285615883]]]},"properties":{"name":"Kraftwerk Gries","tags":{"name":"Kraftwerk Gries","power":"plant","website":"https://power.verbund.com/de/Gries","operator":"Verbund Hydro Power GmbH","waterway":"dam","start_date":"2019","plant:method":"run-of-the-river","plant:source":"hydro","generator:type":"kaplan_turbine","operator:wikidata":"Q688816","plant:output:electricity":"9 MW"},"osm_id":867157747,"output":"9 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":868384598,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.279871552,38.555037272],[100.280177575,38.555360143],[100.280349237,38.55527624],[100.280813259,38.554938534],[100.281102938,38.554644999],[100.28124543,38.554337719],[100.280403384,38.554703757],[100.279871552,38.555037272]]]},"properties":{"name":"Gansu Jingtieshan Hydro","tags":{"name":"Gansu Jingtieshan Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":868384598,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":871801733,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[69.86234559,41.601142759],[69.862429829,41.601795541],[69.862915141,41.602127548],[69.863288638,41.602379676],[69.863206831,41.602190329],[69.863212447,41.602019087],[69.864592443,41.600134248],[69.865112289,41.599409884],[69.865451924,41.599004787],[69.865784099,41.598811248],[69.866144018,41.598711923],[69.866401258,41.598688453],[69.866658583,41.598699937],[69.866551965,41.598551158],[69.865661136,41.598003736],[69.86557212,41.598007675],[69.865338517,41.598018069],[69.865282526,41.598117478],[69.865143554,41.59836399],[69.864936185,41.598513858],[69.864663773,41.598623661],[69.864505188,41.598839495],[69.86438365,41.598782247],[69.864288683,41.598905377],[69.864310392,41.598973354],[69.864485658,41.599128336],[69.864561347,41.59913764],[69.864557659,41.599183489],[69.864434193,41.599335872],[69.864074274,41.599779693],[69.863533725,41.599591855],[69.863436579,41.599718589],[69.86234559,41.601142759]]]},"properties":{"name":"Ходжикенская плотина","tags":{"area":"yes","name":"Ходжикенская плотина","power":"plant","name:en":"Hojikent damb","name:ru":"Ходжикенская плотина","waterway":"dam","plant:source":"hydro"},"osm_id":871801733,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":872866016,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.14867105,16.310233865],[107.149347051,16.312128594],[107.150698884,16.313714282],[107.15110658,16.313724592],[107.150387748,16.312890593],[107.151170953,16.312272763],[107.150902648,16.31193296],[107.150119443,16.31255079],[107.149690373,16.311984341],[107.148939355,16.31007947],[107.14867105,16.310233865]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":872866016,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":882870973,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-54.829644233,-17.605424653],[-54.828891119,-17.605358855],[-54.828756506,-17.605283753],[-54.828661539,-17.605372853],[-54.827284979,-17.607725066],[-54.827213146,-17.60782414],[-54.827010723,-17.608103258],[-54.826209665,-17.608924768],[-54.824158278,-17.609676708],[-54.820697557,-17.610701815],[-54.820307464,-17.610894683],[-54.820425816,-17.610991745],[-54.821346736,-17.610741042],[-54.822241253,-17.610640711],[-54.823497532,-17.610352374],[-54.824267159,-17.610277104],[-54.825559648,-17.609882149],[-54.825944377,-17.609596829],[-54.826608727,-17.609289632],[-54.826835625,-17.609368003],[-54.827013238,-17.60924898],[-54.82699354,-17.609038929],[-54.827233514,-17.608841452],[-54.827256648,-17.608543643],[-54.827561414,-17.608012146],[-54.827617741,-17.607913827],[-54.828009762,-17.607230282],[-54.828871338,-17.605559434],[-54.829644233,-17.605424653]]]},"properties":{"name":"Barragem da Usina Hidrelétrica Ponte de Pedra","tags":{"name":"Barragem da Usina Hidrelétrica Ponte de Pedra","power":"plant","operator":"Engie","waterway":"dam","plant:method":"water-storage","plant:source":"hydro","operator:wikidata":"Q13416787","plant:output:electricity":"176,1 MW"},"osm_id":882870973,"output":"176,1 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":884054499,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[16.34803826,46.955866867],[16.348229618,46.956230893],[16.348526254,46.956158222],[16.348313186,46.955752789],[16.348213022,46.955777265],[16.348234899,46.955818755],[16.34803826,46.955866867]]]},"properties":{"name":"Csörötneki vízerőmű","tags":{"name":"Csörötneki vízerőmű","power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"485 kW"},"osm_id":884054499,"output":"485 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":884934600,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.761245128,6.864596435],[9.761277398,6.868835834],[9.761363229,6.871189975],[9.761449059,6.8726812],[9.761513432,6.874353473],[9.761910399,6.877730039],[9.762157163,6.877687459],[9.762162276,6.876717338],[9.762135621,6.874662346],[9.762029925,6.874686235],[9.762014419,6.874032782],[9.761835298,6.874001936],[9.761803027,6.87272378],[9.76198349,6.872716236],[9.761910399,6.871179247],[9.761675958,6.866479178],[9.761635473,6.865399421],[9.761481246,6.864234253],[9.761309585,6.864159654],[9.761245128,6.864596435]]]},"properties":{"name":"Kashimbila Dam","tags":{"name":"Kashimbila Dam","note":"Dam not yet visible in Hd imageries","power":"plant","name:en":"Kashimbila Dam","name:fr":"Barrage de Kashimbila","waterway":"dam","wikipedia":"en:Kashimbila Hydroelectric Power Station","plant:source":"hydro","plant:output:electricity":"40 MW"},"osm_id":884934600,"output":"40 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":886485199,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[16.462897323,47.158582441],[16.462898329,47.15861052],[16.463072924,47.158546818],[16.463085077,47.158533239],[16.463186498,47.158532233],[16.463185828,47.158557044],[16.463242238,47.158623931],[16.463294793,47.158560564],[16.463295379,47.158534161],[16.46336344,47.158534832],[16.463364446,47.158491581],[16.463294793,47.158490827],[16.463298732,47.158314639],[16.463195132,47.15831355],[16.463191192,47.158490492],[16.463090945,47.158489486],[16.462897323,47.158582441]]]},"properties":{"name":"Pornóapáti vízerőmű","tags":{"name":"Pornóapáti vízerőmű","power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"113 kW"},"osm_id":886485199,"output":"113 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":886783751,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.715171996,30.087709597],[102.715319434,30.08783717],[102.715906838,30.088345449],[102.715977329,30.088135566],[102.715671641,30.087884947],[102.715406019,30.087664419],[102.715171996,30.087709597]]]},"properties":{"name":"Jiaojiping Hydroelectric","tags":{"name":"Jiaojiping Hydroelectric","power":"plant","material":"concrete","waterway":"dam","plant:source":"hydro","generator:method":"run-of-the-river"},"osm_id":886783751,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":887909891,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[74.750439845,34.648671331],[74.750755759,34.648776859],[74.751303768,34.649008367],[74.751629573,34.649199223],[74.751886143,34.649394186],[74.752211947,34.649670286],[74.752355781,34.649592754],[74.752483437,34.649524022],[74.752226783,34.649308859],[74.751910869,34.649061173],[74.751668968,34.648874341],[74.75137778,34.648744421],[74.750928594,34.648553565],[74.750637323,34.648464298],[74.75054529,34.648560858],[74.750439845,34.648671331]]]},"properties":{"name":"Kishenganga Dam","tags":{"area":"yes","name":"Kishenganga Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":887909891,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":888236337,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[119.67716157,27.179292377],[119.677230218,27.179369994],[119.678134123,27.180391329],[119.678170081,27.180431897],[119.678401841,27.18026954],[119.67739333,27.17913002],[119.67716157,27.179292377]]]},"properties":{"name":"Fuan","tags":{"name":"Fuan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":888236337,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":892210495,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.132284985,24.700872071],[116.132440553,24.701057227],[116.132741463,24.701354198],[116.133078248,24.701659383],[116.133494577,24.701931795],[116.133953235,24.70138387],[116.133776293,24.701213298],[116.133293495,24.700959829],[116.13335242,24.700876932],[116.133245132,24.700784396],[116.132960818,24.700925799],[116.132526384,24.700833179],[116.132360087,24.700779535],[116.132284985,24.700872071]]]},"properties":{"name":"Changtan Dam","tags":{"name":"Changtan Dam","owner":"Guangdong Energy Group","power":"plant","name:en":"Changtan Dam","waterway":"dam","wikidata":"Q11963427","plant:source":"hydro","plant:output:electricity":"64 MW"},"osm_id":892210495,"output":"64 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":907652419,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.059672439,39.14741688],[22.059691466,39.147584518],[22.06050912,39.147886518],[22.061653921,39.148128084],[22.062393875,39.148460259],[22.062720937,39.148719931],[22.063445217,39.148738119],[22.06381905,39.148538881],[22.064262956,39.148194553],[22.064714656,39.14772349],[22.06496385,39.147415455],[22.064955133,39.14727707],[22.064941387,39.14720113],[22.064878271,39.147113455],[22.064610469,39.146882282],[22.06295672,39.146914217],[22.061751234,39.146961659],[22.059939738,39.147062996],[22.059788863,39.147165423],[22.059672439,39.14741688]]]},"properties":{"name":"Φράγμα Τεχνητής Λίμνης Σμοκόβου","tags":{"area":"yes","name":"Φράγμα Τεχνητής Λίμνης Σμοκόβου","power":"plant","height":"104","length":"456","material":"stone","operator":"ΥΠ.Υ.ΜΕ.ΔΙ, ΔΕΗ","waterway":"dam","wikidata":"Q12880042","wikipedia":"el:Λίμνη Σμοκόβου","source_ref":"https://www.geogreece.gr/","start_date":"2002","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"10.37 MW"},"osm_id":907652419,"output":"10.37 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":908328141,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[114.670898239,23.645569339],[114.671540376,23.645578224],[114.671545154,23.645472025],[114.674760368,23.645547295],[114.674774869,23.645304052],[114.671549932,23.645246552],[114.671549932,23.644897194],[114.67099966,23.644870624],[114.671009299,23.645038681],[114.670903016,23.645038681],[114.670898239,23.645569339]]]},"properties":{"name":"Fengguang","tags":{"area":"yes","name":"Fengguang","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":908328141,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":911058617,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-101.118237134,56.250640182],[-101.116569303,56.251230771],[-101.116307452,56.251002447],[-101.117975116,56.250411859],[-101.118237134,56.250640182]]]},"properties":{"name":"Laurie River Generating Station Two","tags":{"name":"Laurie River Generating Station Two","power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"5 MW"},"osm_id":911058617,"output":"5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":911058618,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-101.011731559,56.224646056],[-101.011573141,56.224903045],[-101.010671919,56.225028354],[-101.009427374,56.224858286],[-101.008515339,56.224993989],[-101.008942062,56.224789219],[-101.009371132,56.22472954],[-101.010787338,56.224813023],[-101.011731559,56.224646056]]]},"properties":{"name":"Laurie River Generating Station","tags":{"name":"Laurie River Generating Station","power":"plant","operator":"Manitoba Hydro","waterway":"dam","start_date":"1952","plant:source":"hydro","operator:wikidata":"Q3285979"},"osm_id":911058618,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":913710457,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.55135732,25.636265727],[115.551589331,25.636840055],[115.551500818,25.63685816],[115.551592097,25.637189496],[115.552029297,25.637115652],[115.552103058,25.637380436],[115.552137843,25.637374485],[115.552057461,25.63709872],[115.55208554,25.637093943],[115.552066848,25.63700325],[115.552560375,25.63689328],[115.552600524,25.63700082],[115.552646122,25.636994785],[115.55257915,25.6367155],[115.552879558,25.636624891],[115.552850053,25.636565547],[115.552541599,25.636645343],[115.552026531,25.636756655],[115.551969451,25.636765623],[115.55194749,25.63663562],[115.551516996,25.636188362],[115.551535771,25.636177465],[115.551519678,25.636162965],[115.55135732,25.636265727]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":913710457,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":913722530,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.521010891,25.589035546],[115.521199987,25.589110564],[115.521128825,25.589275017],[115.521763251,25.589550866],[115.521564767,25.589937942],[115.52160894,25.589964597],[115.521712289,25.589772148],[115.522105149,25.589943977],[115.52238544,25.589526642],[115.522318468,25.589494037],[115.522661791,25.58910696],[115.522315786,25.588955751],[115.521995262,25.589196479],[115.521930889,25.589340396],[115.521060428,25.58895332],[115.521010891,25.589035546]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":913722530,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":916253356,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[112.971325548,26.232237818],[112.971454546,26.232940221],[112.971921418,26.232879033],[112.972163571,26.234002125],[112.972205481,26.234196585],[112.972069861,26.234220054],[112.972191818,26.234785833],[112.97235887,26.234756999],[112.972606639,26.234714],[112.972521059,26.234316865],[112.972482419,26.23413766],[112.972631617,26.234106396],[112.972584678,26.233888634],[112.972442437,26.233918725],[112.972193578,26.232764285],[112.972034993,26.232791778],[112.971898284,26.232158022],[112.971822344,26.232171182],[112.971325548,26.232237818]]]},"properties":{"name":"Shangbao Small","tags":{"name":"Shangbao Small","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":916253356,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":917234356,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[106.524097223,15.091365836],[106.524177773,15.091391736],[106.524673982,15.091596338],[106.524741037,15.091630034],[106.524861737,15.091699939],[106.525027363,15.091858021],[106.525079331,15.091955335],[106.52521034,15.092469062],[106.525323077,15.092461351],[106.525205059,15.09155753],[106.524958296,15.091378828],[106.525033398,15.091223427],[106.524920661,15.091164335],[106.524804069,15.091391065],[106.524727542,15.091365836],[106.524676664,15.091290734],[106.52477314,15.091104321],[106.524507685,15.09098513],[106.524394948,15.091145727],[106.524177773,15.091200126],[106.524097223,15.091365836]]]},"properties":{"name":null,"tags":{"power":"plant","name:en":"Houai Makchan","operator":"Xe Pian-Xe Namnoy Power Company","waterway":"dam","plant:source":"hydro"},"osm_id":917234356,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":922172627,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-87.358276813,33.459307937],[-87.358200454,33.459484125],[-87.354801843,33.457563998],[-87.354905276,33.457440197],[-87.358276813,33.459307937]]]},"properties":{"name":"John Hollis Bankhead Lock and Dam","tags":{"name":"John Hollis Bankhead Lock and Dam","power":"plant","waterway":"dam","wikidata":"Q31806119","ref:US:EIA":"2","ref:US:NID":"AL01427","start_date":"1963-07","plant:method":"water-storage","plant:source":"hydro","gnis:feature_id":"151895","name:etymology:wikidata":"Q461228","plant:output:electricity":"53.9 MW"},"osm_id":922172627,"output":"53.9 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":923582772,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.007731968,30.194982535],[101.008598824,30.195876382],[101.010464384,30.195720646],[101.013744055,30.195798178],[101.013634923,30.195378161],[101.013983443,30.195029139],[101.014025268,30.194885221],[101.014110428,30.194591939],[101.013457059,30.193945359],[101.013325296,30.192617581],[101.013513972,30.191186288],[101.013736763,30.190905829],[101.01327777,30.190639955],[101.013435098,30.190064035],[101.013033689,30.190063699],[101.012520549,30.190063196],[101.01093067,30.191138427],[101.010089462,30.19250694],[101.008816837,30.193783253],[101.008422385,30.194639045],[101.007732052,30.194727893],[101.007732052,30.194848844],[101.007731968,30.194982535]]]},"properties":{"name":"两河口大坝","tags":{"area":"yes","name":"两河口大坝","power":"plant","name:en":"Lianghekou Dam","waterway":"dam","wikidata":"Q1651356","wikipedia":"zh:两河口水电站","plant:source":"hydro","wikipedia:en":"Lianghekou Dam","plant:output:electricity":"3000 MW"},"osm_id":923582772,"output":"3000 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":926037694,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.932776596,29.301293542],[102.933306081,29.301545166],[102.933589473,29.301165299],[102.933921396,29.301269318],[102.934674762,29.300020498],[102.933898933,29.299652868],[102.932956891,29.301030098],[102.932776596,29.301293542]]]},"properties":{"name":"深溪沟水电站","tags":{"name":"深溪沟水电站","power":"plant","name:en":"Shenxigou Power Plant","waterway":"dam","wikidata":"Q19388348","plant:source":"hydro","plant:output:electricity":"660 MW"},"osm_id":926037694,"output":"660 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":931403417,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.776768283,47.960459336],[14.776900969,47.960506694],[14.776898287,47.960509963],[14.776983279,47.960540473],[14.776834249,47.960727054],[14.776773731,47.960837779],[14.776782868,47.960838366],[14.776843385,47.960735352],[14.77694665,47.960605181],[14.776994595,47.960539886],[14.777045221,47.960474843],[14.776950841,47.960441651],[14.776958133,47.960430922],[14.776979172,47.960430838],[14.776982357,47.960424635],[14.776984955,47.960411895],[14.776980932,47.960391946],[14.77707548,47.960222128],[14.777045138,47.960237886],[14.77701798,47.960286418],[14.776946063,47.960417092],[14.776923348,47.960429748],[14.776908093,47.960448272],[14.776805247,47.960412481],[14.776768283,47.960459336]]]},"properties":{"name":null,"tags":{"area":"yes","power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":931403417,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":938875334,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[114.545752988,26.948478736],[114.545849547,26.948612679],[114.54637526,26.948526597],[114.546820423,26.948162487],[114.54680433,26.947952772],[114.547673449,26.94727367],[114.547920129,26.947044174],[114.547871849,26.946996397],[114.547775373,26.947063284],[114.547635898,26.947221032],[114.547528526,26.947115839],[114.546659574,26.947852273],[114.546734676,26.947938355],[114.546552286,26.948081853],[114.546407447,26.948072298],[114.545752988,26.948478736]]]},"properties":{"name":"Jiangxi Gongge","tags":{"name":"Jiangxi Gongge","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":938875334,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":941065698,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-82.438898529,36.440132563],[-82.438894674,36.440232559],[-82.438037708,36.440210682],[-82.438035361,36.440271619],[-82.437971407,36.440270026],[-82.437953637,36.440727594],[-82.437908207,36.440726337],[-82.437912566,36.440613265],[-82.437000028,36.440590382],[-82.436997681,36.440651319],[-82.436888716,36.440648637],[-82.436908582,36.440144381],[-82.436343474,36.440129964],[-82.436345904,36.440067184],[-82.436565929,36.4400728],[-82.436788804,36.440078583],[-82.437547618,36.440097946],[-82.438484128,36.440121918],[-82.43869795,36.44012745],[-82.438898529,36.440132563]]]},"properties":{"name":"Boone Dam","tags":{"name":"Boone Dam","power":"plant","operator":"Tennessee Valley Authority","waterway":"dam","wikidata":"Q14709818","ref:US:EIA":"3395","ref:US:NID":"TN16306","start_date":"1953-03","plant:source":"hydro","operator:short":"TVA","gnis:feature_id":"1305348","operator:wikidata":"Q1367577","plant:output:electricity":"105.9 MW"},"osm_id":941065698,"output":"105.9 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":949769378,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[38.002804048,12.033880659],[38.002805975,12.033910247],[38.002808993,12.033958192],[38.002937571,12.033956683],[38.002942936,12.03405232],[38.003016696,12.034133792],[38.003034131,12.034195399],[38.002973781,12.034216354],[38.002979146,12.034293719],[38.003024743,12.0345233],[38.003060953,12.03484726],[38.003094481,12.035711686],[38.003133373,12.035815286],[38.00323823,12.035831966],[38.00379613,12.035593669],[38.004069966,12.035254453],[38.004082372,12.035109446],[38.004084299,12.035049934],[38.00415806,12.03497785],[38.00422872,12.035040044],[38.004224864,12.035095364],[38.004210782,12.035204581],[38.004254033,12.035204162],[38.004325782,12.035203239],[38.004396525,12.035289238],[38.004491576,12.035322849],[38.004771532,12.035255543],[38.005679124,12.035144818],[38.006204753,12.03513895],[38.007796644,12.035095448],[38.008068553,12.034999559],[38.008579095,12.034936276],[38.009059127,12.034633857],[38.009245037,12.034444091],[38.009439162,12.034276955],[38.009533123,12.034102947],[38.009882816,12.03383305],[38.010123461,12.033394173],[38.010373074,12.033287472],[38.010316496,12.033258722],[38.010207364,12.033164844],[38.010092196,12.033051186],[38.009987925,12.032851445],[38.009532117,12.032773326],[38.007420632,12.032782294],[38.005435546,12.032961248],[38.004412786,12.033094688],[38.003973072,12.033228128],[38.003811385,12.033333824],[38.003528328,12.033666082],[38.003368653,12.033720481],[38.003251222,12.033506323],[38.003133456,12.033524093],[38.00311686,12.033543623],[38.00309842,12.033605733],[38.002946791,12.033714027],[38.002913934,12.03385434],[38.002804048,12.033880659]]]},"properties":{"name":null,"tags":{"area":"yes","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":949769378,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":950266361,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[83.668919016,43.704229655],[83.669213976,43.704323365],[83.669145328,43.704436018],[83.669061844,43.704573481],[83.669333585,43.704659898],[83.669403993,43.70451749],[83.66946099,43.704401987],[83.669573643,43.704390336],[83.670061721,43.703562372],[83.669922246,43.703486767],[83.670064152,43.70320304],[83.67056606,43.702199139],[83.670421137,43.702166282],[83.669675567,43.703391716],[83.669468869,43.703325919],[83.669018845,43.704065538],[83.668919016,43.704229655]]]},"properties":{"name":"Xinjiang Kashi River Taledesayi","tags":{"name":"Xinjiang Kashi River Taledesayi","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":950266361,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":954514133,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[81.943057325,43.821145889],[81.943083979,43.821237084],[81.943633832,43.820950674],[81.943796357,43.82093215],[81.943962151,43.820913291],[81.94409249,43.820898455],[81.944596745,43.82095847],[81.944639661,43.820861659],[81.944476465,43.820820336],[81.944371691,43.820797286],[81.944264487,43.820781025],[81.944155773,43.820771888],[81.944046222,43.820769625],[81.943936838,43.820774571],[81.94381069,43.820789239],[81.943687141,43.820813295],[81.943567867,43.820846571],[81.943453957,43.820888565],[81.943346836,43.82093894],[81.943247594,43.820997194],[81.943059923,43.821108422],[81.943057325,43.821145889]]]},"properties":{"name":"Xinjiang Kashi River Wenquan","tags":{"name":"Xinjiang Kashi River Wenquan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":954514133,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":955818618,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.056163893,24.130711548],[116.056539654,24.130962334],[116.057601557,24.131671024],[116.057392345,24.13194528],[116.057658554,24.132139656],[116.057955525,24.132356496],[116.058368669,24.131827766],[116.056387857,24.130557237],[116.056292555,24.130496133],[116.056163893,24.130711548]]]},"properties":{"name":"龙上水电站","tags":{"name":"龙上水电站","power":"plant","name:en":"Longshang Dam","operator":"广东梅雁吉祥水电股份有限公司","waterway":"dam","plant:source":"hydro","plant:output:electricity":"25 MW"},"osm_id":955818618,"output":"25 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":955818619,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.1017534,24.240872387],[116.102063446,24.241158545],[116.102498048,24.241559535],[116.103091738,24.24210746],[116.102850339,24.242349529],[116.103255353,24.242731157],[116.10378115,24.242261519],[116.103688865,24.242176862],[116.103378734,24.241892297],[116.10330095,24.241960777],[116.102687144,24.241394495],[116.102239131,24.240981184],[116.102093034,24.240846486],[116.101922379,24.24068899],[116.1017534,24.240872387]]]},"properties":{"name":"三龙水电站","tags":{"name":"三龙水电站","power":"plant","name:en":"Sanlong Dam","operator":"广东梅雁吉祥水电股份有限公司","waterway":"dam","plant:source":"hydro","plant:output:electricity":"24 MW"},"osm_id":955818619,"output":"24 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":955818620,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.178915189,24.281242231],[116.179668889,24.281582033],[116.179703758,24.281535681],[116.181361363,24.282313186],[116.181304953,24.282450063],[116.181669733,24.282613845],[116.181763695,24.282403543],[116.181511567,24.282288627],[116.181551716,24.282185949],[116.179781542,24.281354632],[116.179937026,24.281122453],[116.179465041,24.280885245],[116.179405949,24.281000161],[116.179105542,24.280882898],[116.178915189,24.281242231]]]},"properties":{"name":"西阳水电站","tags":{"name":"西阳水电站","power":"plant","operator":"广东威华集团","waterway":"dam","plant:source":"hydro","plant:output:electricity":"20 MW"},"osm_id":955818620,"output":"20 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":955818621,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.250521452,24.342594577],[116.250680373,24.342771267],[116.250955383,24.342554846],[116.251538093,24.342100128],[116.251850487,24.34182956],[116.252180817,24.342101469],[116.252510226,24.341806678],[116.252603433,24.341730151],[116.252101525,24.341263447],[116.251336425,24.341857472],[116.250521452,24.342594577]]]},"properties":{"name":"丙村水电站","tags":{"name":"丙村水电站","power":"plant","name:en":"Bingcun Dam","operator":"广东梅雁吉祥水电股份有限公司","waterway":"dam","plant:source":"hydro","plant:output:electricity":"20 MW"},"osm_id":955818621,"output":"20 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":955818622,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.360862753,24.473312452],[116.361141787,24.473588217],[116.361257541,24.473471289],[116.361813261,24.472991593],[116.361695076,24.4728662],[116.363000558,24.471796417],[116.363043473,24.471430296],[116.36103475,24.473164092],[116.360862753,24.473312452]]]},"properties":{"name":"单竹窝水电站","tags":{"name":"单竹窝水电站","power":"plant","name:en":"Danzhuwo Dam","operator":"广东梅雁吉祥水电股份有限公司","waterway":"dam","wikidata":"Q24837633","plant:source":"hydro","plant:output:electricity":"34 MW"},"osm_id":955818622,"output":"34 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":955818624,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.602601032,24.238700133],[116.603747341,24.239150157],[116.604030398,24.238550599],[116.602884089,24.238100659],[116.602601032,24.238700133]]]},"properties":{"name":"龙湖水电站","tags":{"name":"龙湖水电站","power":"plant","name:en":"Longhu Dam","operator":"广东粤水电韩江水利开发有限公司","waterway":"dam","plant:source":"hydro","plant:output:electricity":"100 MW"},"osm_id":955818624,"output":"100 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":955818626,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.617499949,24.560999984],[116.618749942,24.562499925],[116.619191752,24.563010048],[116.619567261,24.562487939],[116.619420159,24.562331449],[116.61931505,24.562219634],[116.619089744,24.562390458],[116.618644581,24.561804982],[116.618467555,24.561863487],[116.618011664,24.561280694],[116.617898843,24.561136525],[116.617499949,24.560999984]]]},"properties":{"name":"Qingxi Dam","tags":{"name":"Qingxi Dam","owner":"Guangdong Energy Group","power":"plant","name:en":"Qingxi Dam","waterway":"dam","wikidata":"Q11996696","plant:source":"hydro","plant:output:electricity":"144 MW"},"osm_id":955818626,"output":"144 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":956666969,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.848459739,26.707898082],[101.848497373,26.708180888],[101.852606434,26.707505139],[101.852252466,26.706695279],[101.850235445,26.707035585],[101.850337369,26.707567416],[101.848459739,26.707898082]]]},"properties":{"name":"桐子林水电站","tags":{"area":"yes","name":"桐子林水电站","power":"plant","height":"66.63","name:en":"Tongzilin Hydropower Station","waterway":"dam","wikidata":"Q12006777","plant:source":"hydro","plant:output:electricity":"600 MW"},"osm_id":956666969,"output":"600 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":967389586,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.02828862,23.782480435],[99.028471094,23.782998353],[99.029264944,23.782487895],[99.028581065,23.782306175],[99.02828862,23.782480435]]]},"properties":{"name":"Zhenkang Quanqiaohe","tags":{"name":"Zhenkang Quanqiaohe","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":967389586,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":970600471,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.696247821,25.633927176],[115.696480167,25.633992387],[115.696417386,25.634218782],[115.696448148,25.634227332],[115.696521574,25.634008396],[115.696916194,25.634110823],[115.696792896,25.634544587],[115.696818963,25.634548023],[115.696951398,25.63412239],[115.697348364,25.634246442],[115.697177457,25.634779448],[115.697237975,25.634797134],[115.697310059,25.634588927],[115.69761625,25.634699736],[115.697908443,25.634740556],[115.698051187,25.634265721],[115.6974062,25.634066148],[115.696410681,25.633770267],[115.696301297,25.633740259],[115.696247821,25.633927176]]]},"properties":{"name":"Jiangxi Huichang Yingnaogang","tags":{"name":"Jiangxi Huichang Yingnaogang","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":970600471,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":970600500,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.593337663,25.837940184],[115.593354427,25.837949991],[115.593546121,25.838049065],[115.593863125,25.837609686],[115.594642223,25.838028697],[115.594218685,25.838698998],[115.594178284,25.838809472],[115.59426906,25.838708051],[115.594696034,25.838052837],[115.59532803,25.838385683],[115.595216551,25.838532198],[115.595261059,25.83855726],[115.595166595,25.838712242],[115.595583427,25.838930758],[115.595968743,25.839113567],[115.596025991,25.839034861],[115.596241993,25.838579472],[115.596150378,25.838534042],[115.596106625,25.838659687],[115.595994727,25.838607216],[115.59584469,25.838523816],[115.595874362,25.838490456],[115.595425931,25.83825702],[115.593956332,25.837480856],[115.593788274,25.837373484],[115.593739492,25.837444563],[115.59359482,25.837352278],[115.593542768,25.837426458],[115.593667156,25.83750508],[115.593337663,25.837940184]]]},"properties":{"name":"白鹅峡水电站","tags":{"name":"白鹅峡水电站","power":"plant","name:en":"Jiangxi Huichang Baie Hydro","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":970600500,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":971209669,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[93.81663857,27.340626495],[93.816745858,27.343314069],[93.817094545,27.3430663],[93.817024724,27.342060723],[93.816751139,27.340636051],[93.81663857,27.340626495]]]},"properties":{"name":"RANGANADI","tags":{"name":"RANGANADI","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":971209669,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":975502088,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[94.798201173,39.625886423],[94.798207627,39.626236535],[94.799004495,39.626227734],[94.798997957,39.625877538],[94.798201173,39.625886423]]]},"properties":{"name":null,"tags":{"area":"yes","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":975502088,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":977746758,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[75.523955063,37.832734226],[75.525239841,37.833226831],[75.525501021,37.833054247],[75.526977745,37.830755259],[75.526812453,37.830355275],[75.52597552,37.829875243],[75.52551175,37.829778348],[75.524962232,37.830821644],[75.524018849,37.832613192],[75.523955063,37.832734226]]]},"properties":{"name":"Kashgar Tarim River Xiabandi","tags":{"name":"Kashgar Tarim River Xiabandi","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":977746758,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":981043845,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[39.690656155,5.612033826],[39.690731257,5.612347393],[39.691617392,5.612767578],[39.692659262,5.612775541],[39.693957535,5.612017733],[39.694330362,5.61123654],[39.694591878,5.610955746],[39.694468328,5.610837561],[39.694422144,5.610860527],[39.69417404,5.611076529],[39.693959882,5.610857259],[39.693511953,5.610637234],[39.692956987,5.610364738],[39.692688683,5.610232974],[39.692467317,5.610321655],[39.692265983,5.610232974],[39.692090969,5.610140941],[39.691704647,5.610272369],[39.690968297,5.611527643],[39.690656155,5.612033826]]]},"properties":{"name":"Genale–Dawa III Dam","tags":{"area":"yes","name":"Genale–Dawa III Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":981043845,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":983797929,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.153901536,41.317561684],[128.155532319,41.319695381],[128.156854145,41.320559137],[128.157171736,41.320275577],[128.157163186,41.319334373],[128.156733949,41.318470534],[128.156339161,41.317619687],[128.155566685,41.316955673],[128.154330773,41.316066017],[128.154407971,41.3170524],[128.153901536,41.317561684]]]},"properties":{"name":"삼수댐","tags":{"name":"삼수댐","power":"plant","name:en":"Samsu Dam","name:ko":"삼수댐","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":983797929,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":987660016,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.942642554,21.831962365],[107.943039437,21.832783959],[107.943372031,21.833207245],[107.943726166,21.833640422],[107.944310888,21.834123471],[107.944863339,21.833620557],[107.944300159,21.833137591],[107.943946023,21.832759065],[107.943570598,21.832226227],[107.943248649,21.831464396],[107.942642554,21.831962365]]]},"properties":{"name":"大垌水库水电站","tags":{"name":"大垌水库水电站","layer":"1","power":"plant","name:zh":"大垌水库水电站","waterway":"dam","plant:source":"hydro","plant:output:electricity":"1600 kW"},"osm_id":987660016,"output":"1600 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":988882728,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[133.491154657,35.262556639],[133.491613231,35.263038598],[133.491747593,35.263047399],[133.491876171,35.261698248],[133.491736696,35.261724567],[133.491196147,35.262157074],[133.491154657,35.262556639]]]},"properties":{"name":"俣野川ダム","tags":{"name":"俣野川ダム","power":"plant","waterway":"dam","wikidata":"Q3435333","wikipedia":"ja:俣野川ダム","plant:source":"hydro"},"osm_id":988882728,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":997210758,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[44.331380582,36.281066169],[44.331518297,36.28113884],[44.332188095,36.28046066],[44.333915521,36.278711692],[44.33441986,36.27817743],[44.334991255,36.277622464],[44.334888241,36.277556331],[44.334261526,36.2777523],[44.333785434,36.277935779],[44.333464994,36.278095622],[44.333277491,36.278264769],[44.332965265,36.27854615],[44.332691847,36.278962479],[44.332427817,36.279367492],[44.332123722,36.279790275],[44.33196413,36.280098478],[44.331715523,36.280509694],[44.331380582,36.281066169]]]},"properties":{"name":"بەنداوی گۆمەسپان","tags":{"area":"yes","name":"بەنداوی گۆمەسپان","power":"plant","height":"70","name:en":"Gomaspan Dam","name:fr":"Barrage de Gomaspan","capacity":"115000000","material":"concrete","operator":"حکومەتی هەرێمی کوردستان","waterway":"dam","wikidata":"Q11960338","construction":"yes","plant:source":"hydro","plant:output:electricity":"2.4 MW"},"osm_id":997210758,"output":"2.4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1000114791,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[114.78866029,25.064522616],[114.789243084,25.064745827],[114.789323047,25.064776588],[114.789414578,25.064580535],[114.78944777,25.064509373],[114.789096485,25.064374844],[114.789119367,25.064325893],[114.789179884,25.064349027],[114.789257669,25.064182144],[114.789508874,25.064278368],[114.789525889,25.064241739],[114.789011324,25.064044764],[114.789157086,25.063732538],[114.789330675,25.063799091],[114.789341655,25.063775454],[114.789169659,25.063709656],[114.789445339,25.063119067],[114.789335033,25.063076822],[114.788914932,25.063976871],[114.78866029,25.064522616]]]},"properties":{"name":"Jiangxi Longyuan","tags":{"name":"Jiangxi Longyuan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1000114791,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1000921432,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.163068219,48.317742737],[21.163079618,48.317877266],[21.163163354,48.317973574],[21.163313054,48.317912973],[21.163394191,48.318013137],[21.163500893,48.317974664],[21.163739526,48.318020848],[21.163831224,48.3179952],[21.163856118,48.317950357],[21.163738855,48.317695966],[21.16336695,48.317514581],[21.163315737,48.317540146],[21.163452781,48.317689763],[21.163252872,48.317768553],[21.163196127,48.317710634],[21.163068219,48.317742737]]]},"properties":{"name":"Gibárti vízerőmű","tags":{"name":"Gibárti vízerőmű","power":"plant","operator":"Alteo Zrt.","waterway":"dam","wikidata":"Q56391938","wikipedia":"hu:Gibárti vízerőmű","start_date":"1903","description":"2020. decemberében fejezték be a modernizálást, amely során a kapacitása a korábbi közel kétszeresére nőtt","plant:method":"run-of-the-river","plant:source":"hydro","contact:website":"https://alteo.hu/eromuvek/vizeromuvek/gibart-vizeromu/","plant:output:electricity":"1 MW"},"osm_id":1000921432,"output":"1 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1001242839,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[16.476927455,47.098931957],[16.477060141,47.09892567],[16.477052849,47.098870433],[16.476936927,47.098876887],[16.476942459,47.098919887],[16.476927455,47.098931957]]]},"properties":{"name":"Szentpéterfai vízerőmű","tags":{"name":"Szentpéterfai vízerőmű","power":"plant","waterway":"dam","start_date":"1951","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"50 kW"},"osm_id":1001242839,"output":"50 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1002870271,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.338518999,37.494427371],[101.339257528,37.495018882],[101.339362218,37.494936571],[101.33890926,37.494573719],[101.33908813,37.494433154],[101.338802559,37.494204496],[101.338577169,37.494381689],[101.338518999,37.494427371]]]},"properties":{"name":"Qinghai Qingshizui Small","tags":{"name":"Qinghai Qingshizui Small","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1002870271,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1008775924,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.188435333,25.206669049],[98.188843029,25.207069453],[98.188746553,25.207350917],[98.188660639,25.207697928],[98.189513665,25.206758736],[98.188470286,25.206518594],[98.188435333,25.206669049]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1008775924,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1008912206,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.823641164,26.236289295],[104.823869152,26.236592468],[104.823780639,26.237311719],[104.824055566,26.23767147],[104.824153718,26.23768463],[104.824071575,26.237207029],[104.824074257,26.236805368],[104.824290259,26.236457687],[104.824494023,26.236332545],[104.823936123,26.236128027],[104.823641164,26.236289295]]]},"properties":{"name":"Shannipo On Beipan River","tags":{"name":"Shannipo On Beipan River","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1008912206,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1008990568,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[105.433089931,24.354828132],[105.433250947,24.354857469],[105.433430655,24.3540316],[105.433304591,24.353880055],[105.433089931,24.354828132]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":1008990568,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1009757711,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.82701276,21.859626752],[103.828176169,21.861295254],[103.828340119,21.863365416],[103.828815121,21.861777465],[103.828085644,21.86008474],[103.82701276,21.859626752]]]},"properties":{"name":"Thủy điện Bản Chát","tags":{"name":"Thủy điện Bản Chát","layer":"1","power":"plant","building":"dam","waterway":"dam","wikidata":"Q30919649","wikipedia":"vi:Thủy điện Bản Chát","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"220 MW"},"osm_id":1009757711,"output":"220 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1009757713,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.877100248,21.69557388],[103.877167135,21.695709919],[103.878082439,21.697570534],[103.878183944,21.69777698],[103.878489632,21.697054292],[103.878012283,21.696102276],[103.877100248,21.69557388]]]},"properties":{"name":"Đập thủy điện Huội Quảng","tags":{"name":"Đập thủy điện Huội Quảng","power":"plant","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro"},"osm_id":1009757713,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1011063793,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-52.908503225,-26.756935735],[-52.908499956,-26.7567427],[-52.906606316,-26.756768265],[-52.906609669,-26.756961636],[-52.908503225,-26.756935735]]]},"properties":{"name":"Pequena Central Hidrelétrica Lambari","tags":{"name":"Pequena Central Hidrelétrica Lambari","power":"plant","operator":"Mauê S.A.","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":1011063793,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1011363099,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.319735129,26.453161316],[100.319764298,26.453262737],[100.320233685,26.453154695],[100.3202046,26.453053273],[100.319735129,26.453161316]]]},"properties":{"name":"West Kiewa (Mount Beauty Hydro Scheme)","tags":{"name":"West Kiewa (Mount Beauty Hydro Scheme)","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1011363099,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1013004907,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.132154248,56.105138054],[14.132325825,56.105497721],[14.132781885,56.105654044],[14.13283863,56.105592353],[14.132975088,56.105536949],[14.133166195,56.105596879],[14.133238028,56.105510797],[14.133001826,56.105190357],[14.13277652,56.105126068],[14.132527075,56.105166469],[14.132443926,56.105073681],[14.132154248,56.105138054]]]},"properties":{"name":"Torsebro kraftverk","tags":{"name":"Torsebro kraftverk","power":"plant","operator":"Uniper Sverige AB","waterway":"dam","plant:source":"hydro","plant:output:electricity":"6 MW"},"osm_id":1013004907,"output":"6 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1019507890,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[73.554485174,34.846880123],[73.555133095,34.847172316],[73.55528179,34.846253241],[73.555296542,34.846108318],[73.555317581,34.84590338],[73.555429479,34.845085055],[73.554575447,34.84526887],[73.554529263,34.84609323],[73.554485174,34.846880123]]]},"properties":{"name":"Suki Kinari Hydropower Project","tags":{"area":"yes","name":"Suki Kinari Hydropower Project","power":"plant","height":"54.5","material":"composite","operator":"SK Hydro","waterway":"dam","plant:source":"hydro"},"osm_id":1019507890,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1023616275,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.283416249,23.045753726],[103.285304524,23.046701468],[103.285454476,23.046448418],[103.283566117,23.04550076],[103.283416249,23.045753726]]]},"properties":{"name":"Madushan Dam","tags":{"name":"Madushan Dam","power":"plant","waterway":"dam","wikidata":"Q17323768","plant:source":"hydro"},"osm_id":1023616275,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1025359646,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[15.605484033,45.611785359],[15.605515884,45.611796171],[15.605567181,45.611813522],[15.605687797,45.611623588],[15.605646558,45.611610596],[15.605609342,45.611598778],[15.605484033,45.611785359]]]},"properties":{"name":"Mikro hidroelektrana Bujan","tags":{"name":"Mikro hidroelektrana Bujan","power":"plant","waterway":"dam","wikidata":"Q16114621","wikipedia":"hr:Mikro hidroelektrana Bujan","start_date":"1995","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"45 kW"},"osm_id":1025359646,"output":"45 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1049200218,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-84.341299865,9.987081176],[-84.341260303,9.987287622],[-84.340656554,9.987197433],[-84.340685807,9.986975228],[-84.341299865,9.987081176]]]},"properties":{"name":"Embalse La Garita","tags":{"area":"yes","name":"Embalse La Garita","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1049200218,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1071102221,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.222415471,43.284667373],[22.222674555,43.285189566],[22.222744041,43.285329795],[22.223041683,43.285251424],[22.22296767,43.285102226],[22.222830794,43.284826294],[22.222713028,43.284589086],[22.222415471,43.284667373]]]},"properties":{"name":"МХЕ Црвени Брег","tags":{"name":"МХЕ Црвени Брег","power":"plant","height":"13.7 m","name:sr":"МХЕ Црвени Брег","int_name":"MHE Crveni Breg","operator":"MAMS Energija","waterway":"dam","name:sr-Latn":"MHE Crveni Breg","plant:source":"hydro","plant:output:electricity":"1.52 MW"},"osm_id":1071102221,"output":"1.52 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1073692869,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-115.392249938,32.673237467],[-115.391759429,32.673282562],[-115.391714167,32.672943681],[-115.39223485,32.672919961],[-115.392249938,32.673237467]]]},"properties":{"name":"Drop 5","tags":{"name":"Drop 5","power":"plant","operator":"Imperial Irrigation District","waterway":"dam","ref:US:EIA":"314","start_date":"1982-03","plant:source":"hydro","operator:short":"IID","operator:wikidata":"Q16985775","plant:output:electricity":"4 MW"},"osm_id":1073692869,"output":"4 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1075624920,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.534279496,32.454226887],[76.534385527,32.454243483],[76.534502958,32.453708634],[76.534396843,32.453692037],[76.534279496,32.454226887]]]},"properties":{"name":"BUDHIL","tags":{"name":"BUDHIL","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1075624920,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1091160135,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[67.430993861,53.872624095],[67.436247219,53.872995832],[67.436278483,53.872842276],[67.431025126,53.872470371],[67.430993861,53.872624095]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1091160135,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1091625216,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-90.503448683,14.761077025],[-90.503054734,14.761657556],[-90.502729516,14.76150643],[-90.503104103,14.760920535],[-90.503448683,14.761077025]]]},"properties":{"name":"Las Vacas","tags":{"name":"Las Vacas","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1091625216,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1094279221,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-70.453573523,-13.721682977],[-70.453270434,-13.721563116],[-70.453104221,-13.721816836],[-70.453297256,-13.72188607],[-70.453573272,-13.721980702],[-70.453573523,-13.721682977]]]},"properties":{"name":"San Gaban II","tags":{"name":"San Gaban II","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1094279221,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1112801048,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-85.417685011,42.690352887],[-85.417645868,42.690408375],[-85.417569844,42.690379374],[-85.417602617,42.690332771],[-85.417608904,42.690323886],[-85.417685011,42.690352887]]]},"properties":{"name":"Irving Dam","tags":{"name":"Irving Dam","power":"plant","operator":"Commonwealth Power","waterway":"dam","ref:US:NID":"MI00542","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"600 kW"},"osm_id":1112801048,"output":"600 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1115920908,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-85.465881625,42.71117957],[-85.465712478,42.71125442],[-85.465662774,42.711276464],[-85.465636874,42.711287948],[-85.465686075,42.711342262],[-85.465518773,42.711407055],[-85.465463201,42.71134989],[-85.465378627,42.711332791],[-85.465391871,42.711263053],[-85.46553034,42.711284427],[-85.465676269,42.711218462],[-85.465865281,42.711132966],[-85.465881625,42.71117957]]]},"properties":{"name":"Middleville Dam","tags":{"name":"Middleville Dam","power":"plant","material":"concrete","operator":"Commonwealth Power","waterway":"dam","ref:US:NID":"MI00543","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"0.35 MW"},"osm_id":1115920908,"output":"0.35 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1124765454,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[12.536909598,58.995615989],[12.537031135,58.995714561],[12.537169269,58.995668292],[12.537393485,58.995595957],[12.537271864,58.995497385],[12.537052593,58.995566788],[12.536909598,58.995615989]]]},"properties":{"name":"Käppesjöns kraftverk","tags":{"name":"Käppesjöns kraftverk","power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"75 kW"},"osm_id":1124765454,"output":"75 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1148112554,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.040922761,24.599348867],[103.042048785,24.599480966],[103.042098742,24.599128591],[103.042016264,24.599118868],[103.041166423,24.599019291],[103.040972717,24.598996492],[103.040922761,24.599348867]]]},"properties":{"name":null,"tags":{"power":"plant","building":"dam","waterway":"dam","plant:source":"hydro"},"osm_id":1148112554,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1150865361,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.557642291,37.926855958],[76.557912356,37.927515027],[76.563175353,37.925895644],[76.562878047,37.924990147],[76.557642291,37.926855958]]]},"properties":{"name":"新疆錾高水电站","tags":{"name":"新疆錾高水电站","power":"plant","operator":"中核新华","waterway":"dam","start_date":"2022年12月31日","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"185MW"},"osm_id":1150865361,"output":"185MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1160748236,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[78.698565099,44.888319324],[78.698817227,44.888921731],[78.699181923,44.888828692],[78.698905739,44.888256627],[78.698565099,44.888319324]]]},"properties":{"name":"Karatal HPP-3","tags":{"name":"Karatal HPP-3","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1160748236,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1160752557,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[78.71500595,44.881240471],[78.715422028,44.881386568],[78.715984873,44.880875858],[78.715585475,44.880698162],[78.71500595,44.881240471]]]},"properties":{"name":"Karatal HPP-2","tags":{"name":"Karatal HPP-2","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1160752557,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1160880996,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.308258233,24.525814682],[101.30839645,24.525969999],[101.309721881,24.525363317],[101.309640576,24.525193248],[101.30947562,24.525267344],[101.308314643,24.525789285],[101.308258233,24.525814682]]]},"properties":{"name":"大湾水电站大坝","tags":{"name":"大湾水电站大坝","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1160880996,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1167736975,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-94.204880361,45.620822056],[-94.204822107,45.621011487],[-94.203967236,45.620893386],[-94.203953238,45.620945689],[-94.203538837,45.620886345],[-94.20352836,45.620919454],[-94.203321075,45.621001932],[-94.203291739,45.621026239],[-94.20300952,45.62098785],[-94.203005916,45.620940157],[-94.202472156,45.620862457],[-94.202524459,45.620646791],[-94.203360638,45.62077143],[-94.203410929,45.620586692],[-94.203522912,45.620604043],[-94.203571862,45.620645366],[-94.203611424,45.62065182],[-94.203568006,45.620794731],[-94.203633469,45.620804454],[-94.203679066,45.620660956],[-94.20373137,45.620669422],[-94.203689711,45.62080546],[-94.203759449,45.620816692],[-94.203803705,45.620676966],[-94.203849303,45.62068342],[-94.203818457,45.620784757],[-94.203983413,45.620808226],[-94.204017109,45.620707979],[-94.204880361,45.620822056]]]},"properties":{"name":"Sartell Dam","tags":{"name":"Sartell Dam","power":"plant","waterway":"dam","wikidata":"Q7424650","wikipedia":"en:Sartell Dam","ref:US:NID":"MN00505","plant:source":"hydro"},"osm_id":1167736975,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1182754620,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.668814993,32.28023467],[76.669045663,32.280796928],[76.669925344,32.280901367],[76.6693568,32.279667718],[76.668814993,32.28023467]]]},"properties":{"name":"Bajoli Holi Dam","tags":{"name":"Bajoli Holi Dam","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1182754620,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1200820693,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[13.641123333,46.099191184],[13.642066884,46.099549427],[13.64213,46.099568119],[13.642185739,46.099575998],[13.642251872,46.099573148],[13.642293195,46.099562335],[13.642344828,46.099545823],[13.642382043,46.099524365],[13.642422696,46.099478432],[13.642483884,46.099521934],[13.642536438,46.09957935],[13.642565942,46.099637185],[13.642649258,46.099613968],[13.642606846,46.099546158],[13.642565523,46.099505673],[13.64246712,46.09941012],[13.642736095,46.099048189],[13.642624783,46.099007621],[13.642569044,46.099078951],[13.642553537,46.099078029],[13.64248732,46.099068976],[13.642461588,46.099062019],[13.64243837,46.099060175],[13.642412637,46.099066043],[13.642421438,46.099078867],[13.642436191,46.099089931],[13.642461085,46.09910066],[13.642541383,46.099119268],[13.64250509,46.099171738],[13.642492349,46.099175845],[13.642437951,46.099172074],[13.642391263,46.099179366],[13.642412973,46.099204428],[13.642460163,46.099223036],[13.642432419,46.099266454],[13.642415655,46.099272489],[13.64234357,46.099267376],[13.642321358,46.09927559],[13.642342648,46.099302245],[13.642394868,46.099317081],[13.642365866,46.099363349],[13.642346756,46.099359577],[13.642342732,46.09936536],[13.642265451,46.099350524],[13.642236953,46.099344741],[13.642205437,46.099356056],[13.642210969,46.099376257],[13.642252878,46.099387572],[13.642331417,46.099403246],[13.642303589,46.099451359],[13.642297386,46.099463429],[13.642268385,46.099495028],[13.64220946,46.099517995],[13.642166042,46.09951657],[13.64211332,46.099500057],[13.64116365,46.099141731],[13.641123333,46.099191184]]]},"properties":{"name":"HE Ajba","tags":{"area":"yes","name":"HE Ajba","power":"plant","operator":"Soške elektrarne Nova Gorica d.o.o.","waterway":"dam","wikidata":"Q19933911","wikipedia":"sl:Hidroelektrarna Ajba","plant:method":"water-storage","plant:source":"hydro","operator:abbr":"SENG","plant:output:electricity":"250kW"},"osm_id":1200820693,"output":"250kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1201689711,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.96215269,33.052261177],[107.962357544,33.05264356],[107.962191247,33.052706173],[107.962455361,33.053199364],[107.962633057,33.053132476],[107.962701621,33.053260552],[107.962782255,33.053230293],[107.962244389,33.05222656],[107.96215269,33.052261177]]]},"properties":{"name":"Baimianxia Shaanxi","tags":{"name":"Baimianxia Shaanxi","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1201689711,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1203479693,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[110.194573694,19.007647066],[110.194573694,19.007750834],[110.197143083,19.007181954],[110.197057671,19.00684366],[110.196394998,19.006993529],[110.196321824,19.006774426],[110.195159086,19.007020435],[110.195216083,19.007358728],[110.194573694,19.007647066]]]},"properties":{"name":"Niululing","tags":{"name":"Niululing","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1203479693,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1205505956,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.725898188,11.884379441],[107.725903972,11.884422524],[107.725913946,11.884458399],[107.725928363,11.884492681],[107.725960047,11.884559988],[107.725998771,11.88462369],[107.72604395,11.884683034],[107.726095163,11.884737516],[107.726151992,11.884786634],[107.726213599,11.884829633],[107.726279481,11.884866179],[107.726320217,11.884884535],[107.72635802,11.88490834],[107.72639205,11.884936922],[107.72642256,11.884971036],[107.726457597,11.885000624],[107.726496489,11.885025267],[107.726538398,11.885044462],[107.726582571,11.885057789],[107.726615931,11.885063656],[107.726649877,11.885062734],[107.726682986,11.885055023],[107.726713664,11.885040941],[107.726740737,11.885020992],[107.726756831,11.885004145],[107.726770242,11.884985369],[107.726805278,11.884954943],[107.726845092,11.884930635],[107.726888259,11.884913117],[107.72693394,11.884902891],[107.726968641,11.884900041],[107.727003594,11.88490155],[107.727062183,11.884907166],[107.727141728,11.884915967],[107.727245076,11.884929127],[107.727389832,11.884955362],[107.727470382,11.884761153],[107.727548417,11.884793675],[107.727622346,11.884834243],[107.727691329,11.884882691],[107.727754445,11.884938095],[107.727810939,11.884999954],[107.727849076,11.885050748],[107.727881347,11.88510523],[107.727907582,11.88516273],[107.727921747,11.885203383],[107.727932979,11.885244873],[107.727966423,11.885324166],[107.728007578,11.885399938],[107.728056026,11.885471436],[107.728111346,11.885538072],[107.728181838,11.88560672],[107.728257191,11.885670338],[107.728336987,11.885728341],[107.728508397,11.885826745],[107.728677376,11.885895057],[107.728632952,11.885988599],[107.728630521,11.886142323],[107.728671928,11.886237123],[107.728753484,11.886335442],[107.728811486,11.886522359],[107.728929839,11.886723692],[107.728942831,11.886818491],[107.72892942,11.886971126],[107.729004522,11.887120743],[107.72909865,11.887210429],[107.72918423,11.887265163],[107.729441722,11.887196851],[107.729428562,11.887314449],[107.729452451,11.887430454],[107.72950819,11.887531372],[107.729589243,11.887614186],[107.729885041,11.887768077],[107.730222245,11.887876623],[107.730336741,11.887823398],[107.730439587,11.887750643],[107.730782826,11.887404219],[107.731016849,11.887205316],[107.731302588,11.886991494],[107.731524792,11.886770966],[107.731651862,11.886590923],[107.731748421,11.88639378],[107.731814052,11.886175683],[107.731842383,11.885950126],[107.730839488,11.885329782],[107.730823143,11.885503958],[107.730806463,11.885720127],[107.730787772,11.885815178],[107.730712251,11.886152549],[107.730634718,11.886432253],[107.730459285,11.886633419],[107.730387787,11.886548427],[107.730311344,11.886467541],[107.730230375,11.886391098],[107.730137587,11.886313482],[107.730039938,11.886241649],[107.729130669,11.885643097],[107.728953644,11.885527595],[107.72875516,11.885422569],[107.728594228,11.885330787],[107.728451987,11.885215285],[107.728331371,11.885086623],[107.728229447,11.884939688],[107.728159626,11.884792585],[107.728060468,11.884543224],[107.727966591,11.884270393],[107.72785662,11.883973757],[107.727392179,11.883070943],[107.727564092,11.883131628],[107.727691664,11.883206143],[107.727786883,11.883275545],[107.727857542,11.883326339],[107.727935661,11.883367494],[107.728027276,11.883389874],[107.727902134,11.883026183],[107.727792247,11.882955356],[107.72765009,11.882482868],[107.727450684,11.882384465],[107.727290422,11.882324366],[107.727112475,11.882310033],[107.726996385,11.882337694],[107.726906867,11.882382621],[107.726748868,11.882509187],[107.726489951,11.88276559],[107.726284594,11.883019394],[107.726231034,11.883113942],[107.72618552,11.883212597],[107.726163476,11.883270097],[107.726144197,11.883328435],[107.726109748,11.883446536],[107.726082339,11.883566481],[107.726062138,11.883687683],[107.726048895,11.883814166],[107.726027353,11.883939643],[107.72597614,11.884124129],[107.725938841,11.884212558],[107.725919395,11.884251617],[107.725905984,11.884293024],[107.725898859,11.884335939],[107.725898188,11.884379441]]]},"properties":{"name":"Đập Thuỷ điện Đồng Nai 4","tags":{"name":"Đập Thuỷ điện Đồng Nai 4","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1205505956,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1216452916,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[73.914886223,17.726505939],[73.915270617,17.727023438],[73.9158975,17.728058351],[73.915813932,17.728289189],[73.915797169,17.728798641],[73.915705219,17.728933925],[73.915270617,17.734864457],[73.915279083,17.735596867],[73.915437836,17.736440673],[73.915721983,17.737276433],[73.91591418,17.737340136],[73.916424051,17.739003776],[73.91632372,17.739155069],[73.916306956,17.739433683],[73.917995239,17.744392753],[73.91852179,17.745268242],[73.918630336,17.745284252],[73.918713987,17.745140921],[73.918755729,17.744878233],[73.918530088,17.74438479],[73.917711092,17.740444541],[73.917535575,17.739210725],[73.917351676,17.738287374],[73.916499237,17.738438668],[73.916256832,17.737698378],[73.91676662,17.737491345],[73.916917075,17.737204768],[73.917100974,17.736448552],[73.917042468,17.735628635],[73.917084294,17.733240547],[73.917009024,17.730390783],[73.916892097,17.728766874],[73.916482557,17.728114091],[73.915596673,17.726808442],[73.914886223,17.726505939]]]},"properties":{"name":"KANHER","tags":{"name":"KANHER","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1216452916,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1221646063,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[116.269694385,24.402001232],[116.270955023,24.402091337],[116.271334975,24.402118494],[116.271653655,24.402141126],[116.27166254,24.402038363],[116.26970327,24.401898721],[116.269694385,24.402001232]]]},"properties":{"name":"坝头水电站","tags":{"name":"坝头水电站","power":"plant","name:en":"Batou Dam","operator":"广东梅雁吉祥水电股份有限公司","waterway":"dam","plant:source":"hydro","plant:output:electricity":"18 MW"},"osm_id":1221646063,"output":"18 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1226762940,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-7.739793525,41.620889936],[-7.739663521,41.620946262],[-7.739567884,41.620983645],[-7.739201678,41.620493388],[-7.739251467,41.620450975],[-7.739308967,41.620402193],[-7.739473923,41.620422142],[-7.739467217,41.620532448],[-7.739480628,41.620552564],[-7.739537457,41.620615596],[-7.739599483,41.62058869],[-7.739627228,41.620624565],[-7.739692606,41.620709054],[-7.73966327,41.620721795],[-7.739793525,41.620889936]]]},"properties":{"name":"Central Hidroelétrica de Canedo","tags":{"area":"yes","name":"Central Hidroelétrica de Canedo","power":"plant","ref:e2p":"4030","alt_name":"Minihídrica de Canedo","material":"metal","operator":"RP GLOBAL PORTUGAL","waterway":"dam","plant:type":"francis_turbine","start_date":"12/2008","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"10 MW"},"osm_id":1226762940,"output":"10 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1232262842,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.256099657,40.490785172],[22.256134023,40.490828087],[22.256283137,40.49075902],[22.256447339,40.49068308],[22.256412973,40.490640081],[22.256249861,40.490715686],[22.256099657,40.490785172]]]},"properties":{"name":null,"tags":{"power":"plant","building":"yes","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":1232262842,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1232262851,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.266878366,40.498599872],[22.266994958,40.498714117],[22.26718506,40.498601129],[22.267385219,40.498483782],[22.267400977,40.498474562],[22.267284217,40.498360233],[22.267080621,40.49848043],[22.266878366,40.498599872]]]},"properties":{"name":null,"tags":{"power":"plant","building":"yes","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":1232262851,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1232514946,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[44.747335293,42.042038158],[44.74735717,42.042224068],[44.747541488,42.042212082],[44.747771739,42.042197079],[44.747749778,42.042011168],[44.747539141,42.042024831],[44.747335293,42.042038158]]]},"properties":{"name":"ბოდორნა ჰესი","tags":{"name":"ბოდორნა ჰესი","power":"plant","name:en":"Bodorna HPP","name:ka":"ბოდორნა ჰესი","operator":"შპს \"ჯორჯიან უოთერ ენდ ფაუერი\"","waterway":"dam","start_date":"2018-10-02","operator:en":"Georgian Water and Power LTD.","operator:ru":"ООО \"Джорджиан Уотер энд Пауэр\"","plant:method":"water-storage","plant:source":"hydro","operator:type":"private","operator:twitter":"https://x.com/GeorgianWater","operator:website":"https://www.gwp.ge","operator:youtube":"https://www.youtube.com/channel/UCqEjIwurRPKAXFp33EuM0ww","operator:wikidata":"Q131340190","plant:output:electricity":"2.5 MW"},"osm_id":1232514946,"output":"2.5 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1232959296,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.242764971,40.474774563],[22.243428567,40.475520218],[22.243558738,40.475453246],[22.243705337,40.475377725],[22.243041826,40.474631987],[22.242907128,40.474698791],[22.242764971,40.474774563]]]},"properties":{"name":null,"tags":{"area":"yes","power":"plant","building":"yes","waterway":"dam","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":1232959296,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1247736392,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.838012678,29.121836073],[100.83832373,29.121957946],[100.839300138,29.122061043],[100.839310867,29.121934476],[100.839021188,29.121892399],[100.839026553,29.121826769],[100.838243348,29.121700118],[100.838232535,29.121775136],[100.838018042,29.121723671],[100.838012678,29.121836073]]]},"properties":{"name":"Huadian Sichuan Egongbao","tags":{"name":"Huadian Sichuan Egongbao","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1247736392,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1250224555,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.626759154,28.825063475],[101.626812798,28.82518099],[101.62717213,28.8250447],[101.627435071,28.82535483],[101.62778074,28.825583069],[101.627826673,28.825613328],[101.627891046,28.825566389],[101.62762819,28.825345442],[101.627451164,28.825166824],[101.627590639,28.8250447],[101.627429622,28.82479559],[101.627258045,28.824936573],[101.627043468,28.824701545],[101.626925451,28.824767343],[101.627113122,28.824950655],[101.626759154,28.825063475]]]},"properties":{"name":"Jiulong Xigu","tags":{"name":"Jiulong Xigu","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1250224555,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1255371187,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[88.291024554,27.294345649],[88.292024683,27.294793662],[88.291965674,27.294885779],[88.292126272,27.294936322],[88.292392565,27.294389068],[88.292754328,27.294369119],[88.292725997,27.294307763],[88.292823562,27.294184968],[88.292349817,27.294293262],[88.292240182,27.294405245],[88.291203424,27.293997046],[88.291024554,27.294345649]]]},"properties":{"name":"RANGIT-III","tags":{"name":"RANGIT-III","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1255371187,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1265809888,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[88.477457862,27.241718278],[88.477626255,27.242440965],[88.477949796,27.24238137],[88.4779389,27.242334683],[88.477713929,27.242376089],[88.477684341,27.242248517],[88.477985755,27.242193029],[88.477973266,27.242139468],[88.477671768,27.242194957],[88.477639247,27.24205523],[88.477902271,27.242006867],[88.477893637,27.24197007],[88.477630697,27.242018434],[88.477600858,27.241890777],[88.477950551,27.24182632],[88.477940995,27.241784662],[88.477591218,27.241849119],[88.477556517,27.241700089],[88.477457862,27.241718278]]]},"properties":{"name":"TEESTA -V","tags":{"name":"TEESTA -V","power":"plant","building":"yes","waterway":"dam","plant:source":"hydro"},"osm_id":1265809888,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1270868257,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.105316299,27.193485622],[99.109615042,27.194970141],[99.110223652,27.193990045],[99.108824628,27.193523676],[99.107408422,27.192903331],[99.105765736,27.192344426],[99.105316299,27.193485622]]]},"properties":{"name":"托巴水电站","tags":{"name":"托巴水电站","power":"plant","name:en":"Toba Hydropower Station","waterway":"dam","wikidata":"Q125259095","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"350 MW"},"osm_id":1270868257,"output":"350 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1290517244,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[13.521219454,43.358260064],[13.5212705,43.358362826],[13.521689343,43.358262662],[13.521697725,43.358284036],[13.521701246,43.358285126],[13.521704095,43.358285042],[13.521707281,43.358282527],[13.521695378,43.358248162],[13.521804343,43.358219328],[13.521853796,43.358206336],[13.521852623,43.358088151],[13.521774839,43.358104244],[13.521729995,43.357989999],[13.521682722,43.357998297],[13.521735025,43.358138694],[13.521427241,43.358211197],[13.521219454,43.358260064]]]},"properties":{"name":"Centrale di Montelupone","tags":{"name":"Centrale di Montelupone","power":"plant","waterway":"dam","plant:source":"hydro","plant:output:electricity":"yes"},"osm_id":1290517244,"output":"yes","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1311187720,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.41325109,48.140027195],[7.413331892,48.140115456],[7.413396852,48.140161892],[7.413411771,48.140170777],[7.413418896,48.140165329],[7.413499697,48.14021738],[7.413527945,48.140195755],[7.413567339,48.140224086],[7.413587624,48.140212519],[7.4135432,48.140180835],[7.413580667,48.140157282],[7.413630539,48.14012585],[7.413648225,48.140114786],[7.413688374,48.140111768],[7.413733134,48.140109338],[7.413523754,48.139949076],[7.413417722,48.139867939],[7.413385536,48.139891492],[7.413319822,48.139843631],[7.41329652,48.139873387],[7.413419147,48.139951087],[7.413430044,48.139957877],[7.413411268,48.139970282],[7.413353517,48.140008168],[7.413339016,48.140017723],[7.413328371,48.140011186],[7.413299538,48.139993332],[7.41325109,48.140027195]]]},"properties":{"name":"Centrale Hydroélectrique B2 - Colmar","tags":{"name":"Centrale Hydroélectrique B2 - Colmar","power":"plant","operator":"EDF","waterway":"dam","start_date":"2022-06-02","plant:method":"run-of-the-river","plant:source":"hydro","not:operator:wikidata":"Q274591","plant:output:electricity":"190 kW"},"osm_id":1311187720,"output":"190 kW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1316610168,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[105.322408576,20.808653944],[105.322466998,20.808740864],[105.322518462,20.808814038],[105.322576298,20.808883189],[105.32264922,20.808956028],[105.322729184,20.809022245],[105.322836472,20.808979665],[105.322919034,20.808940018],[105.322998746,20.808895678],[105.323076446,20.809000955],[105.323153643,20.808947897],[105.323214244,20.80891152],[105.323234445,20.808899618],[105.323304685,20.808858546],[105.323344331,20.808835328],[105.323371824,20.808816385],[105.323451033,20.808766681],[105.323527727,20.808718485],[105.323600063,20.808673558],[105.323681284,20.808617734],[105.323744735,20.808574735],[105.323773652,20.808554535],[105.323828554,20.808516229],[105.323907595,20.808460741],[105.323983535,20.80840299],[105.324030558,20.808366529],[105.324113371,20.808302323],[105.324311435,20.808573645],[105.324511763,20.808840777],[105.324718042,20.809103717],[105.324908897,20.809337153],[105.325104447,20.809567152],[105.325304523,20.809793631],[105.325509377,20.810016422],[105.325613648,20.810107366],[105.325714734,20.810201327],[105.325812383,20.810298473],[105.325906763,20.810398889],[105.325997539,20.81050207],[105.326084543,20.810608017],[105.326132823,20.810384388],[105.326202644,20.810408779],[105.326285793,20.810435098],[105.32630088,20.810440044],[105.326316973,20.810440044],[105.326332061,20.810435098],[105.326342371,20.810427974],[105.326348741,20.81041758],[105.326350417,20.810406181],[105.326347483,20.810394949],[105.326342287,20.810376425],[105.32633357,20.810359158],[105.326328121,20.810321356],[105.32633139,20.81028347],[105.326339185,20.810256396],[105.326351423,20.810230748],[105.326465836,20.81015749],[105.326584943,20.810090937],[105.326708241,20.810031426],[105.326818546,20.809985661],[105.326931283,20.809945428],[105.327046199,20.809911062],[105.327510137,20.809911062],[105.32786159,20.810021451],[105.328188736,20.810216917],[105.328438265,20.810422526],[105.32860188,20.810598043],[105.32877614,20.810783618],[105.328974623,20.81107447],[105.329100771,20.811264991],[105.329095323,20.81101412],[105.329081996,20.810763585],[105.329064477,20.810553283],[105.329041176,20.810343484],[105.329012258,20.810134272],[105.328840597,20.809818358],[105.328477996,20.80969439],[105.328116317,20.809567571],[105.327534361,20.809356934],[105.327233954,20.809005984],[105.327346774,20.808932223],[105.32746454,20.808865587],[105.327558836,20.808819067],[105.327656821,20.808779924],[105.32775799,20.808748576],[105.32786159,20.80872519],[105.327966364,20.808709851],[105.328072228,20.808703397],[105.328178259,20.808705744],[105.328283619,20.808716892],[105.328387555,20.808736757],[105.328489144,20.808765256],[105.328554271,20.808788557],[105.328616297,20.808818062],[105.328674635,20.808853433],[105.328728782,20.808894337],[105.328777984,20.808940354],[105.328821737,20.80899098],[105.328850739,20.809031465],[105.328876136,20.809073961],[105.328924583,20.809160127],[105.328966661,20.809249143],[105.329003792,20.809344697],[105.329033632,20.809442262],[105.329559345,20.809387025],[105.329575438,20.809141352],[105.329599662,20.808667439],[105.329610391,20.80838916],[105.329631848,20.807825058],[105.32972824,20.807780047],[105.329827985,20.807741741],[105.329930496,20.807710477],[105.330035186,20.807686337],[105.330141468,20.807669657],[105.330258647,20.807659766],[105.330374737,20.807642081],[105.330468027,20.807621964],[105.330559893,20.807596818],[105.330541369,20.807508054],[105.330516391,20.807420631],[105.330485126,20.807334968],[105.330447743,20.807251568],[105.330404241,20.807170682],[105.330353949,20.807091054],[105.330297791,20.807014947],[105.330185725,20.806862564],[105.330068378,20.806713869],[105.329945667,20.806568946],[105.329883725,20.806499543],[105.329827566,20.806426034],[105.329777274,20.806348921],[105.329733018,20.806268622],[105.329695299,20.806185557],[105.329663951,20.806100062],[105.329641069,20.806018841],[105.329624053,20.805936363],[105.32957812,20.80577409],[105.329533445,20.805634028],[105.329484243,20.805495224],[105.329430683,20.805357844],[105.329330854,20.805382571],[105.329268325,20.805397994],[105.329249466,20.805469827],[105.329240246,20.80554342],[105.329239659,20.805595471],[105.329236139,20.805647439],[105.329230691,20.805693959],[105.329232199,20.805740562],[105.329240665,20.805786663],[105.32925592,20.80583117],[105.329277797,20.805873164],[105.329295147,20.805898645],[105.329306128,20.805927059],[105.329310235,20.805957067],[105.329307217,20.805987241],[105.329300847,20.806007861],[105.329291124,20.806027391],[105.329090797,20.806049351],[105.328889631,20.806065864],[105.328688298,20.806076928],[105.328486545,20.80608246],[105.328252187,20.806082041],[105.328017829,20.806088243],[105.32778389,20.806100816],[105.327550454,20.806120178],[105.327376027,20.806138786],[105.327202438,20.806162926],[105.327029854,20.806192766],[105.326858444,20.80622797],[105.326744031,20.806254624],[105.326630289,20.806283793],[105.326404732,20.806348082],[105.326182025,20.806420586],[105.325962587,20.80650122],[105.325808527,20.806563581],[105.325657485,20.806631977],[105.325509628,20.806706241],[105.32536546,20.806786456],[105.325224979,20.806872287],[105.325103274,20.80695334],[105.324984921,20.807038751],[105.324747546,20.807208233],[105.324555433,20.807349049],[105.3243686,20.807495816],[105.324186964,20.807648283],[105.324106666,20.807719027],[105.324020164,20.807783148],[105.323928047,20.807840061],[105.323872559,20.807869649],[105.323815478,20.807896555],[105.323802738,20.807871577],[105.323785974,20.807848862],[105.323760661,20.807825058],[105.323730989,20.807806282],[105.323651863,20.807853891],[105.323658569,20.807892783],[105.323593861,20.807936034],[105.323486656,20.808013818],[105.323332094,20.808120939],[105.323182477,20.808209955],[105.323054569,20.808287739],[105.323009055,20.808312968],[105.32285399,20.808398799],[105.322732452,20.808465938],[105.322408576,20.808653944]]]},"properties":{"name":"Đập thuỷ điện Hoà Bình","tags":{"name":"Đập thuỷ điện Hoà Bình","power":"plant","height":"128","name:en":"Hoa Binh Dam","building":"dam","material":"concrete","name:ace":"ГЭС Хоабинь","waterway":"dam","wikidata":"Q1504264","wikipedia":"vi:Nhà máy Thủy điện Hòa Bình","start_date":"1979-11-6","opening_date":"1994-12-20","plant:method":"water-storage","plant:source":"hydro","plant:output:electricity":"1920 MW"},"osm_id":1316610168,"output":"1920 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1322139292,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.63727788,27.43994266],[102.637306965,27.439986749],[102.637280395,27.440000663],[102.637411069,27.440198476],[102.637487931,27.440158327],[102.637651043,27.440405174],[102.637688929,27.440385476],[102.637307636,27.439808717],[102.637281987,27.439822128],[102.637340242,27.439910138],[102.63727788,27.43994266]]]},"properties":{"name":"Sichuan Luxihe","tags":{"name":"Sichuan Luxihe","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1322139292,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1335567769,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.175849836,28.416565473],[102.176810486,28.417133598],[102.176841331,28.41709303],[102.176420057,28.41684392],[102.176506977,28.416730261],[102.175967769,28.41641133],[102.175849836,28.416565473]]]},"properties":{"name":"Sichuan Mianning Changxing","tags":{"name":"Sichuan Mianning Changxing","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1335567769,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1342050702,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.845012877,24.683877595],[109.845190824,24.68419485],[109.845324432,24.684552589],[109.847174988,24.68362664],[109.847587294,24.683420362],[109.84747087,24.683249287],[109.846988156,24.683367472],[109.847056971,24.68351273],[109.845762554,24.684063589],[109.845480922,24.683221375],[109.845371454,24.683580288],[109.845050763,24.683594454],[109.845012877,24.683877595]]]},"properties":{"name":"Guangxi Liuzhou Luzhai County Luoqingjiang Xian","tags":{"name":"Guangxi Liuzhou Luzhai County Luoqingjiang Xian","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1342050702,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1343840761,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-84.35485466,46.504878148],[-84.354689201,46.504887284],[-84.35467403,46.504755856],[-84.354802524,46.504748899],[-84.354839405,46.504746887],[-84.35485466,46.504878148]]]},"properties":{"name":"Unit No. 10","tags":{"name":"Unit No. 10","power":"plant","building":"yes","heritage":"2","historic":"maritime","nrhp:nhl":"yes","operator":"United States Army Corps of Engineers","ref:nrhp":"66000394","waterway":"dam","source_ref":"https://catalog.archives.gov/id/25338738","plant:source":"hydro","nrhp:criteria":"(A)","operator:short":"USACE","heritage:website":"https://www.nps.gov/subjects/nationalregister/index.htm","protection_title":"U.S Army Corps of Engineers","heritage:operator":"nrhp","operator:wikidata":"Q1049334","nrhp:inscription_date":"1966-11-13","plant:output:electricity":"23.7 MW"},"osm_id":1343840761,"output":"23.7 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1349901491,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.385795542,22.42754235],[98.386356375,22.427828257],[98.386435584,22.427770757],[98.386472464,22.427682161],[98.385929987,22.427417292],[98.385795542,22.42754235]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1349901491,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1375587192,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[88.23288298,27.340193151],[88.232963363,27.340219051],[88.233027736,27.340092736],[88.233203253,27.340151074],[88.233406598,27.340244281],[88.233577588,27.340214357],[88.233464936,27.339999864],[88.232939139,27.339854522],[88.23288298,27.340193151]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1375587192,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1384302429,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[94.717870019,30.946920387],[94.718494974,30.946931451],[94.718497321,30.946832377],[94.71787245,30.946821229],[94.717870019,30.946920387]]]},"properties":{"name":"边坝水电站","tags":{"name":"边坝水电站","layer":"1","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1384302429,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1433745760,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.911888226,27.34653163],[109.912000879,27.346811586],[109.912751898,27.348488721],[109.912858096,27.348553094],[109.913183649,27.348405321],[109.913438543,27.34812042],[109.913310048,27.347869466],[109.913023052,27.347941718],[109.912963122,27.347721022],[109.913348521,27.347614572],[109.9132844,27.347451041],[109.912946022,27.347644998],[109.912757597,27.347276027],[109.912684759,27.347276027],[109.912569172,27.347024989],[109.912385022,27.34707067],[109.912200871,27.346697843],[109.912090062,27.346537916],[109.911888226,27.34653163]]]},"properties":{"name":"Zhongfang County Pailou Hydro","tags":{"name":"Zhongfang County Pailou Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1433745760,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1436609524,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.488312039,31.118133583],[103.488455621,31.118178594],[103.488712024,31.117279635],[103.488564167,31.117244934],[103.488312039,31.118133583]]]},"properties":{"name":"Yingxiuwan","tags":{"name":"Yingxiuwan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1436609524,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1455212866,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[78.097183244,31.062886448],[78.097202522,31.063186939],[78.097825465,31.063157519],[78.097806187,31.062857028],[78.097183244,31.062886448]]]},"properties":{"name":"Naitwar Mori Hydro Power Station","tags":{"name":"Naitwar Mori Hydro Power Station","power":"plant","operator":"SJVN","waterway":"dam","start_date":"2025","plant:source":"hydro"},"osm_id":1455212866,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1459070928,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.754333676,24.145233195],[109.754384051,24.145448861],[109.754618745,24.14540318],[109.754568453,24.14518743],[109.754333676,24.145233195]]]},"properties":{"name":"Guangxi Changtang","tags":{"name":"Guangxi Changtang","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1459070928,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1460565426,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[114.381582685,26.022986958],[114.381928187,26.02315753],[114.382085683,26.022900121],[114.381740265,26.022729466],[114.381582685,26.022986958]]]},"properties":{"name":"双溪水电站","tags":{"name":"双溪水电站","power":"plant","building":"industrial","waterway":"dam"},"osm_id":1460565426,"output":"","source":"","construction":"","source_layer":"power_plant"}},{"id":1463419123,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[78.240235341,31.430098045],[78.240546561,31.430425358],[78.240809334,31.430324692],[78.241123236,31.43068855],[78.240954257,31.430889967],[78.241141928,31.43112332],[78.241396738,31.430960878],[78.241487933,31.430738842],[78.241302944,31.430606156],[78.240535832,31.429917247],[78.240235341,31.430098045]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463419123,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463785407,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.723286527,26.221341763],[99.723667484,26.221469252],[99.723911566,26.221596824],[99.724037546,26.221714758],[99.724080545,26.221700257],[99.724160927,26.221360957],[99.724225384,26.221182926],[99.724085825,26.221130036],[99.724032265,26.221259955],[99.723895472,26.221483752],[99.723549467,26.221320054],[99.723286527,26.221341763]]]},"properties":{"name":"Yunnan Eryuan Misha River Longdi","tags":{"name":"Yunnan Eryuan Misha River Longdi","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463785407,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463792323,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.939287644,23.68576157],[98.93933056,23.686056278],[98.939432484,23.685776239],[98.940086943,23.685579767],[98.940521461,23.685191685],[98.94089697,23.684636551],[98.941234844,23.684219049],[98.941208022,23.684110922],[98.941041725,23.68419449],[98.940553647,23.684174792],[98.939700621,23.684253331],[98.939394849,23.68463169],[98.939475399,23.685029579],[98.93933584,23.685609271],[98.939287644,23.68576157]]]},"properties":{"name":"Yunnan Lincang Nanpeng River Dayakou","tags":{"name":"Yunnan Lincang Nanpeng River Dayakou","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463792323,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463792329,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[97.767543178,24.686674552],[97.767945426,24.688214727],[97.768181544,24.688156221],[97.768422943,24.688024625],[97.769452827,24.687639644],[97.769404548,24.687464127],[97.768943291,24.687083924],[97.767768484,24.68666969],[97.767543178,24.686674552]]]},"properties":{"name":"Yunnan Yingjiang Zuanshui River","tags":{"name":"Yunnan Yingjiang Zuanshui River","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463792329,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463792334,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[97.720642071,24.473778654],[97.720974665,24.474122982],[97.721242886,24.474213255],[97.72125898,24.474086269],[97.721148925,24.473910585],[97.72132067,24.473551672],[97.72162376,24.473209858],[97.72144665,24.473068287],[97.721103328,24.472982876],[97.720934348,24.473021935],[97.720907526,24.473266016],[97.720813649,24.473439354],[97.720784145,24.473515043],[97.720695632,24.473532142],[97.720642071,24.473778654]]]},"properties":{"name":"Yunnan Dayingjiang 2nd Tier","tags":{"name":"Yunnan Dayingjiang 2nd Tier","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463792334,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463898481,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[96.83601995,39.839575752],[96.836290937,39.839738445],[96.836253386,39.839816648],[96.836422365,39.839917566],[96.836387496,39.84008445],[96.836556476,39.840111188],[96.836553793,39.839954698],[96.836725455,39.839979341],[96.836779099,39.839857887],[96.83601995,39.839575752]]]},"properties":{"name":"Gansu Yumen Yueliangwan Second Cascade","tags":{"name":"Gansu Yumen Yueliangwan Second Cascade","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463898481,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463901974,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.867447037,36.562623684],[102.867578549,36.562662492],[102.867736799,36.562145497],[102.867801172,36.562154046],[102.867905778,36.561880461],[102.868128318,36.561880461],[102.868190093,36.561789936],[102.867838639,36.561731766],[102.86771794,36.561589525],[102.867449719,36.561548621],[102.867540998,36.561701675],[102.867602689,36.561899823],[102.867624146,36.56206788],[102.867447037,36.562623684]]]},"properties":{"name":"Gansu Mingjiazhuang","tags":{"name":"Gansu Mingjiazhuang","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463901974,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463901976,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.404383826,38.136319132],[101.404595721,38.136336063],[101.404740644,38.136345535],[101.404944492,38.136481657],[101.405008865,38.136448967],[101.404863942,38.136304463],[101.404776854,38.136230535],[101.404676271,38.136286442],[101.404410648,38.136247466],[101.404383826,38.136319132]]]},"properties":{"name":"Xidahe","tags":{"name":"Xidahe","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463901976,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463901979,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.027663571,37.21709928],[102.02772258,37.217765641],[102.027980072,37.217744351],[102.02797739,37.217423995],[102.028795463,37.217351324],[102.029452521,37.217334225],[102.029382867,37.217235989],[102.029015321,37.217240264],[102.02896176,37.217069357],[102.028749866,37.21707799],[102.028336722,37.217109925],[102.028317946,37.217058628],[102.027663571,37.21709928]]]},"properties":{"name":"Qinghai Jiangyuan","tags":{"name":"Qinghai Jiangyuan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463901979,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463910409,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.522849179,11.229308144],[76.52326752,11.230260579],[76.523766494,11.231018219],[76.524072266,11.231775944],[76.524155331,11.231754821],[76.523211277,11.228124284],[76.523079849,11.228197961],[76.522849179,11.229308144]]]},"properties":{"name":"KUNDAH V/Avalanchi","tags":{"name":"KUNDAH V/Avalanchi","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463910409,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463999931,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[88.502742544,27.386953274],[88.503477553,27.387515365],[88.504051546,27.386334019],[88.503220061,27.385948284],[88.503032222,27.386505597],[88.502742544,27.386953274]]]},"properties":{"name":"DIKCHU","tags":{"name":"DIKCHU","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463999931,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1463999932,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[88.709194938,27.237834774],[88.70953013,27.238032671],[88.71014972,27.238154293],[88.710769394,27.238149599],[88.710664788,27.237868134],[88.710240999,27.237677362],[88.709653595,27.237655821],[88.709331647,27.237686834],[88.709194938,27.237834774]]]},"properties":{"name":"CHUZACHEN","tags":{"name":"CHUZACHEN","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1463999932,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1464012367,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[119.241903923,27.479988963],[119.242074243,27.479977061],[119.242200391,27.479929451],[119.242388146,27.479810428],[119.242535667,27.479636755],[119.242593251,27.479510691],[119.242575817,27.479263174],[119.242524938,27.479391668],[119.24239745,27.479615381],[119.242177592,27.479847393],[119.241903923,27.479988963]]]},"properties":{"name":"Qingyuan County Longjing Hydroelectric","tags":{"name":"Qingyuan County Longjing Hydroelectric","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1464012367,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1464012377,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[115.779333017,25.609869689],[115.779695199,25.610428426],[115.779912458,25.610031795],[115.779622779,25.609632649],[115.779333017,25.609869689]]]},"properties":{"name":"Jiangxi Laohutou","tags":{"name":"Jiangxi Laohutou","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1464012377,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1464012388,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[117.983175419,27.872045862],[117.983381949,27.872159688],[117.983620749,27.872408631],[117.983666347,27.872624381],[117.983647571,27.872840131],[117.983730636,27.872835437],[117.983789645,27.87265992],[117.983749495,27.872434782],[117.983593843,27.872233198],[117.983301566,27.872050556],[117.983175419,27.872045862]]]},"properties":{"name":"Fujian Wuyishan Kengkou","tags":{"name":"Fujian Wuyishan Kengkou","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1464012388,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1464107475,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.243613465,32.487623993],[104.244595153,32.487906798],[104.24488215,32.48718277],[104.244031889,32.486884122],[104.243613465,32.487623993]]]},"properties":{"name":"Sichuan Yetang Hydro","tags":{"name":"Sichuan Yetang Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1464107475,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1464107482,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.513558943,33.00712949],[104.513872845,33.007239628],[104.514130337,33.007622095],[104.514288587,33.0075568],[104.514028329,33.007120438],[104.513811154,33.006987752],[104.513558943,33.00712949]]]},"properties":{"name":"Liuyuan","tags":{"name":"Liuyuan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1464107482,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1464107487,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.845012922,32.861679749],[104.845401842,32.861941181],[104.845659334,32.862096581],[104.845801575,32.861742865],[104.845281143,32.861366518],[104.845012922,32.861679749]]]},"properties":{"name":"Gansu Baishuijiang Hengdan","tags":{"name":"Gansu Baishuijiang Hengdan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1464107487,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1464108131,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.270996196,34.622461539],[103.271162493,34.62288977],[103.272388262,34.62260948],[103.272374851,34.622492468],[103.271717626,34.622196755],[103.270996196,34.622461539]]]},"properties":{"name":"Gansu Lintan Shubu Small","tags":{"name":"Gansu Lintan Shubu Small","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1464108131,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1464112496,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.730983234,19.720257195],[76.733402586,19.719999536],[76.733348942,19.719731985],[76.733295298,19.71956024],[76.732758856,19.719565353],[76.731235362,19.719787557],[76.730983234,19.720257195]]]},"properties":{"name":"ELDARI","tags":{"name":"ELDARI","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1464112496,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1465688572,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.012331946,27.703361234],[104.012500841,27.70363197],[104.012683231,27.703786364],[104.012833435,27.703912261],[104.013144655,27.703867082],[104.01330827,27.703774462],[104.012967545,27.703591653],[104.012838883,27.70348713],[104.012777192,27.703425356],[104.012562532,27.702936188],[104.01248743,27.702900565],[104.012441832,27.703121428],[104.012331946,27.703361234]]]},"properties":{"name":"Yunnan Miaolin","tags":{"name":"Yunnan Miaolin","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1465688572,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1465696656,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.773104239,25.769515775],[104.773153944,25.769530276],[104.773324264,25.769170357],[104.773254443,25.769126855],[104.773104239,25.769515775]]]},"properties":{"name":"Guizhou Pan County Yingwudashan Smallscale Hydro","tags":{"name":"Guizhou Pan County Yingwudashan Smallscale Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1465696656,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1466065800,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[107.83563046,14.7472494],[107.835684021,14.747472358],[107.836038156,14.746917392],[107.835839673,14.746787641],[107.835705562,14.746930217],[107.83563046,14.7472494]]]},"properties":{"name":"Dak Ro Sa","tags":{"name":"Dak Ro Sa","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1466065800,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1466086931,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.738231618,30.095714986],[102.738540155,30.095784639],[102.738848526,30.095761337],[102.739084644,30.095728983],[102.73926167,30.095687158],[102.739232165,30.095615157],[102.738980038,30.095643069],[102.738566977,30.095684895],[102.738301439,30.095626724],[102.738231618,30.095714986]]]},"properties":{"name":"Sichuan Tianshengqiao","tags":{"name":"Sichuan Tianshengqiao","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1466086931,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1466209860,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.945652289,31.401045866],[102.945687158,31.401087021],[102.946169955,31.400723079],[102.946153862,31.400640517],[102.946062667,31.400658957],[102.945992846,31.400759624],[102.945652289,31.401045866]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1466209860,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1466209869,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.926753444,32.157607343],[102.927075225,32.157596027],[102.927069945,32.15730308],[102.926855368,32.157312132],[102.926753444,32.157607343]]]},"properties":{"name":"Sichuan Heishui Changde","tags":{"name":"Sichuan Heishui Changde","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1466209869,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1466223659,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.155458618,29.052736333],[103.155489882,29.052879496],[103.156004447,29.052787463],[103.156000591,29.052712445],[103.156228579,29.052673218],[103.156207122,29.05261027],[103.155458618,29.052736333]]]},"properties":{"name":"Sichuan Leshan Ebian Ejingxiang Hydro","tags":{"name":"Sichuan Leshan Ebian Ejingxiang Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1466223659,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1468695559,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[92.65763183,27.224288278],[92.657838444,27.224355081],[92.657916144,27.224178559],[92.658082441,27.223832721],[92.658251421,27.22374446],[92.65814958,27.223491662],[92.657996695,27.22347012],[92.657685558,27.224002036],[92.65763183,27.224288278]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1468695559,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470192168,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.736147422,31.759717477],[104.736254795,31.75992275],[104.738446159,31.759026473],[104.73770587,31.757817719],[104.737319548,31.758093735],[104.737708552,31.758709469],[104.737410743,31.758814327],[104.737571759,31.75915195],[104.736147422,31.759717477]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470192168,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470192174,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.050325496,32.372970873],[103.050344271,32.373018482],[103.050682146,32.372789656],[103.050805527,32.372658228],[103.050722463,32.372635597],[103.050325496,32.372970873]]]},"properties":{"name":"Reshuitang 2nd Cascade","tags":{"name":"Reshuitang 2nd Cascade","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470192174,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470229945,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.143395026,28.132112262],[109.143558641,28.132128774],[109.143572052,28.131967925],[109.143687471,28.131703057],[109.143829544,28.13142394],[109.14398243,28.13140265],[109.144127353,28.131180278],[109.143853768,28.131064356],[109.143660649,28.131102242],[109.143585547,28.131279687],[109.1436982,28.131308018],[109.143494352,28.13161555],[109.143437942,28.131842532],[109.143395026,28.132112262]]]},"properties":{"name":"Pingju","tags":{"name":"Pingju","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470229945,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470229946,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[109.218301161,29.147148247],[109.218547841,29.147167022],[109.218440552,29.147738584],[109.218714138,29.147841682],[109.218939443,29.147270036],[109.218864341,29.146717249],[109.218338628,29.146740635],[109.218301161,29.147148247]]]},"properties":{"name":"Hubei Laifeng Jinlongtan","tags":{"name":"Hubei Laifeng Jinlongtan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470229946,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470229960,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[108.500676055,30.31794187],[108.501346692,30.318367922],[108.502226456,30.3178262],[108.502854093,30.316010847],[108.502762898,30.315649671],[108.501239319,30.315807083],[108.500676055,30.31794187]]]},"properties":{"name":"Chongqing Shizhu County Yangdong River (Dukou)","tags":{"name":"Chongqing Shizhu County Yangdong River (Dukou)","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470229960,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470235365,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[102.732674164,26.003769517],[102.732845742,26.003870854],[102.733358044,26.003326031],[102.733189148,26.00315965],[102.732674164,26.003769517]]]},"properties":{"name":"Yunnan Lujichang","tags":{"name":"Yunnan Lujichang","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470235365,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470242797,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[110.58130314,27.738105229],[110.58143725,27.738309496],[110.581901356,27.738907628],[110.582024738,27.738855493],[110.581592818,27.738295163],[110.58161436,27.738257276],[110.581448063,27.738036497],[110.58130314,27.738105229]]]},"properties":{"name":"Hunan Tongxi Small","tags":{"name":"Hunan Tongxi Small","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470242797,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470242805,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[111.052907988,27.079405573],[111.055099353,27.079699358],[111.05526565,27.079751829],[111.05627416,27.07991427],[111.056300982,27.079844952],[111.056271478,27.079539348],[111.055769821,27.079470029],[111.055700168,27.079727941],[111.055193146,27.079677817],[111.055110082,27.079637248],[111.052956268,27.07932913],[111.052907988,27.079405573]]]},"properties":{"name":"Hunan Longhui Yuanmushan","tags":{"name":"Hunan Longhui Yuanmushan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470242805,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470242808,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[110.040124639,26.875033232],[110.040728053,26.875609823],[110.041213532,26.876085999],[110.041701778,26.875746196],[110.041020497,26.875105065],[110.040926536,26.875167258],[110.040757641,26.875002135],[110.040580531,26.874994926],[110.040462598,26.874846651],[110.040382048,26.874837095],[110.040124639,26.875033232]]]},"properties":{"name":"Hunan Huitong Changzhai","tags":{"name":"Hunan Huitong Changzhai","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470242808,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470265315,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.910714072,27.866442979],[98.911051946,27.866668117],[98.91176818,27.866646827],[98.912014943,27.866440548],[98.911786955,27.866291183],[98.911242467,27.866281627],[98.910714072,27.866442979]]]},"properties":{"name":"Yunnan Diqing Luoma Hydroelectric","tags":{"name":"Yunnan Diqing Luoma Hydroelectric","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470265315,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470265321,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[98.587604445,27.794427679],[98.587961179,27.794733786],[98.58802287,27.794738479],[98.58807643,27.794577128],[98.587963861,27.794387362],[98.587604445,27.794427679]]]},"properties":{"name":"Gongshan County Pula Hydro","tags":{"name":"Gongshan County Pula Hydro","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470265321,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470265325,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.523003085,23.111043471],[104.523078186,23.111267939],[104.523657544,23.111235836],[104.523644049,23.111008854],[104.523003085,23.111043471]]]},"properties":{"name":"Wenshan Panlong River Weilong","tags":{"name":"Wenshan Panlong River Weilong","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470265325,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470265326,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.680258376,22.563838131],[103.68059097,22.56465545],[103.680778641,22.564487058],[103.680531962,22.56382824],[103.680258376,22.563838131]]]},"properties":{"name":"Nam Ho","tags":{"name":"Nam Ho","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470265326,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470265337,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[104.656713886,24.236543469],[104.65679167,24.237127939],[104.656856043,24.237294236],[104.657333476,24.237536389],[104.656890828,24.236452944],[104.656713886,24.236543469]]]},"properties":{"name":null,"tags":{"power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470265337,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470277666,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[103.266878921,36.147359642],[103.267377896,36.147526442],[103.268002851,36.146666542],[103.267656846,36.146523631],[103.267420727,36.146445679],[103.266878921,36.147359642]]]},"properties":{"name":"Gansu Yongjing Fuchuan","tags":{"name":"Gansu Yongjing Fuchuan","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470277666,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470277669,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[105.777308543,22.201486278],[105.777879937,22.201846364],[105.777818246,22.201655173],[105.777501745,22.201463982],[105.777308543,22.201486278]]]},"properties":{"name":"Nam Cat","tags":{"name":"Nam Cat","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470277669,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470277674,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[100.072622877,24.140619963],[100.072826641,24.140742339],[100.07306804,24.140788858],[100.073199552,24.140796151],[100.073261243,24.14069825],[100.072778446,24.140384935],[100.072727484,24.140331039],[100.072681886,24.140443692],[100.072622877,24.140619963]]]},"properties":{"name":"Lincang Yun County Xintangfang","tags":{"name":"Lincang Yun County Xintangfang","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470277674,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470277680,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[99.911811036,25.803894822],[99.912146228,25.803894822],[99.912197274,25.803853834],[99.912875873,25.803909322],[99.912878555,25.803759622],[99.912615699,25.803733051],[99.912194592,25.80368234],[99.912336749,25.803175235],[99.912283021,25.803112455],[99.912183863,25.803211529],[99.911811036,25.803894822]]]},"properties":{"name":"Shaba","tags":{"name":"Shaba","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470277680,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":1470289163,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-46.70434808,-11.691958017],[-46.704305165,-11.691477483],[-46.70427566,-11.690983621],[-46.703940301,-11.691093927],[-46.704034262,-11.691288303],[-46.704168372,-11.691438004],[-46.704128139,-11.691774202],[-46.703744583,-11.69217603],[-46.703677528,-11.69229958],[-46.703688257,-11.692543828],[-46.703779452,-11.692604262],[-46.703926973,-11.692380968],[-46.704103915,-11.692247025],[-46.70434808,-11.691958017]]]},"properties":{"name":"Água Limpa","tags":{"name":"Água Limpa","power":"plant","waterway":"dam","plant:source":"hydro"},"osm_id":1470289163,"output":"","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":91095383,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-70.484526718,-30.19049017],[-70.484441977,-30.190300068],[-70.484200076,-30.190289339],[-70.484142073,-30.19063241],[-70.484526132,-30.190602319],[-70.484526718,-30.19049017]]]},"properties":{"id":98353,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":91095383,"source_layer":"osm_water_reservoir"}},{"id":91095396,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-70.485805042,-30.190241143],[-70.485395754,-30.190258745],[-70.485382846,-30.190457815],[-70.485790542,-30.190383636],[-70.485805042,-30.190241143]]]},"properties":{"id":98354,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":91095396,"source_layer":"osm_water_reservoir"}},{"id":219229690,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-70.489611516,-30.182356957],[-70.489606152,-30.182301302],[-70.489423761,-30.182278168],[-70.489337931,-30.182315216],[-70.489305744,-30.182435831],[-70.489380846,-30.182468269],[-70.48957933,-30.182435831],[-70.489611516,-30.182356957]]]},"properties":{"id":158630,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":219229690,"source_layer":"osm_water_reservoir"}},{"id":166332023,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-60.529980948,-22.736543808],[-60.529946415,-22.736464348],[-60.529900901,-22.736424869],[-60.529833007,-22.736400059],[-60.529770562,-22.736400143],[-60.529711805,-22.736420008],[-60.52968163,-22.736440376],[-60.529643241,-22.736485806],[-60.529624047,-22.736540623],[-60.529622873,-22.736576833],[-60.529638631,-22.736632657],[-60.529679702,-22.736685043],[-60.529732173,-22.736716308],[-60.529778357,-22.736728043],[-60.529840719,-22.736725612],[-60.529901571,-22.736701975],[-60.529946834,-22.736662245],[-60.529977428,-22.736601476],[-60.529980948,-22.736543808]]]},"properties":{"id":129470,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":166332023,"source_layer":"osm_water_reservoir"}},{"id":169358128,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-57.818542811,-22.569857761],[-57.818542476,-22.569804872],[-57.818530741,-22.569770171],[-57.818498639,-22.569726417],[-57.818473912,-22.569707139],[-57.818421944,-22.569685178],[-57.81835162,-22.569682412],[-57.818297808,-22.569700517],[-57.818266208,-22.569722478],[-57.818232429,-22.569765058],[-57.818216755,-22.569813002],[-57.818219437,-22.569865808],[-57.818239638,-22.569911741],[-57.818277524,-22.569951471],[-57.818301915,-22.569966307],[-57.818384728,-22.56998458],[-57.818440803,-22.569974018],[-57.818492436,-22.569944011],[-57.818527053,-22.56990185],[-57.818542811,-22.569857761]]]},"properties":{"id":130962,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":169358128,"source_layer":"osm_water_reservoir"}},{"id":313839180,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-48.99193986,-25.808415852],[-48.991935837,-25.808367907],[-48.991933574,-25.808339576],[-48.99190013,-25.807932719],[-48.991896945,-25.807894246],[-48.991730313,-25.807904723],[-48.991735258,-25.807975802],[-48.991766355,-25.808423479],[-48.99193986,-25.808415852]]]},"properties":{"id":206970,"name":"Barragem do Reservatório Salto do Meio","tags":{"name":"Barragem do Reservatório Salto do Meio","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":313839180,"source_layer":"osm_water_reservoir"}},{"id":681298533,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-45.10408443,-22.797787357],[-45.103892401,-22.797422325],[-45.103623761,-22.797264746],[-45.103065442,-22.796760826],[-45.103038872,-22.796458909],[-45.103055132,-22.796117347],[-45.103180023,-22.795499601],[-45.102960836,-22.795264907],[-45.10269865,-22.795129037],[-45.102737207,-22.794740368],[-45.102883136,-22.794351783],[-45.102737877,-22.793989014],[-45.102335043,-22.793515269],[-45.102279806,-22.793217041],[-45.102099009,-22.793065412],[-45.102011921,-22.792992406],[-45.101480508,-22.793188375],[-45.101349834,-22.79327672],[-45.101082032,-22.793457853],[-45.10144983,-22.794049783],[-45.101737916,-22.794641713],[-45.101745544,-22.795009511],[-45.101593664,-22.795328275],[-45.101854173,-22.795541678],[-45.102107977,-22.795418129],[-45.102331942,-22.795824483],[-45.102648862,-22.796144923],[-45.102868803,-22.796939109],[-45.102919849,-22.797220824],[-45.102955975,-22.797659869],[-45.103227967,-22.797941081],[-45.103526531,-22.798124142],[-45.103991475,-22.798047615],[-45.104037072,-22.797920127],[-45.10408443,-22.797787357]]]},"properties":{"id":409622,"name":"Represa do Rio Taboão","tags":{"name":"Represa do Rio Taboão","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":681298533,"source_layer":"osm_water_reservoir"}},{"id":1413160299,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-37.893562367,-10.488260227],[-37.893508723,-10.48797541],[-37.893390706,-10.487943727],[-37.893294146,-10.488845703],[-37.893331781,-10.489004037],[-37.893481901,-10.488650656],[-37.893562367,-10.488260227]]]},"properties":{"id":865880,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":1413160299,"source_layer":"osm_water_reservoir"}},{"id":442743675,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-39.491886864,-5.167334718],[-39.491830957,-5.166776567],[-39.491656864,-5.16629377],[-39.49138001,-5.165851876],[-39.489246648,-5.165027516],[-39.488768628,-5.165153077],[-39.48778124,-5.16500346],[-39.487757435,-5.165058948],[-39.487731032,-5.16511737],[-39.488985803,-5.166106434],[-39.490743321,-5.166850161],[-39.491038951,-5.166755948],[-39.491312955,-5.16679375],[-39.491457543,-5.166914115],[-39.491673461,-5.167357014],[-39.491886864,-5.167334718]]]},"properties":{"id":283928,"name":"Açude Fogareiro","tags":{"name":"Açude Fogareiro","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":442743675,"source_layer":"osm_water_reservoir"}},{"id":442745579,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-39.377279425,-4.976042505],[-39.37727733,-4.976011157],[-39.377274983,-4.975976456],[-39.376928643,-4.975903533],[-39.376411563,-4.97590588],[-39.376051644,-4.975994728],[-39.376048375,-4.976025825],[-39.376044771,-4.976060778],[-39.376320452,-4.976165551],[-39.376924033,-4.97618374],[-39.377279425,-4.976042505]]]},"properties":{"id":283929,"name":"Açude São José","tags":{"name":"Açude São José","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":442745579,"source_layer":"osm_water_reservoir"}},{"id":441596764,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-39.36140913,-4.985808177],[-39.36030297,-4.985450856],[-39.35731214,-4.984484674],[-39.351020264,-4.982835367],[-39.350441661,-4.982871745],[-39.349456033,-4.983069977],[-39.347949554,-4.983372982],[-39.347927928,-4.983442468],[-39.347917619,-4.983559731],[-39.350150055,-4.983391171],[-39.350696806,-4.983395781],[-39.35565378,-4.984798912],[-39.356920369,-4.985013069],[-39.357594526,-4.985199818],[-39.357949919,-4.985245416],[-39.358751815,-4.985509781],[-39.361376021,-4.985937929],[-39.36138851,-4.985889062],[-39.36140913,-4.985808177]]]},"properties":{"id":283205,"name":"Açude Pirabibu","tags":{"name":"Açude Pirabibu","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":441596764,"source_layer":"osm_water_reservoir"}},{"id":442746162,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-39.343097438,-4.992911169],[-39.342635343,-4.992646133],[-39.342507435,-4.992569439],[-39.341607638,-4.992216309],[-39.341142945,-4.992077421],[-39.338739602,-4.99158993],[-39.337582816,-4.991545422],[-39.337179227,-4.99153528],[-39.337170845,-4.99157216],[-39.337161038,-4.991614908],[-39.338682773,-4.991997625],[-39.340350269,-4.992264254],[-39.341099611,-4.992353018],[-39.341502864,-4.992450919],[-39.342395872,-4.992842857],[-39.343058713,-4.992999933],[-39.343076231,-4.9929597],[-39.343097438,-4.992911169]]]},"properties":{"id":283931,"name":"Açude Canafístula","tags":{"name":"Açude Canafístula","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":442746162,"source_layer":"osm_water_reservoir"}},{"id":442745580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-39.367373525,-4.980567811],[-39.367370423,-4.980520705],[-39.367366735,-4.980465216],[-39.366790479,-4.980235217],[-39.366271472,-4.980247874],[-39.365763026,-4.980260279],[-39.36534611,-4.980542749],[-39.365347619,-4.980609553],[-39.365348457,-4.980647523],[-39.365722038,-4.98086168],[-39.366272059,-4.980844749],[-39.366833646,-4.980827482],[-39.367373525,-4.980567811]]]},"properties":{"id":283935,"name":"Açude São José","tags":{"name":"Açude São José","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":442745580,"source_layer":"osm_water_reservoir"}},{"id":442745573,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-39.363614828,-4.983440373],[-39.363597226,-4.983428973],[-39.363564117,-4.983407348],[-39.362916112,-4.984660275],[-39.362780577,-4.984972333],[-39.36250037,-4.985685382],[-39.362462735,-4.985859642],[-39.362487881,-4.985866012],[-39.362507159,-4.985870957],[-39.362558457,-4.985725112],[-39.362967326,-4.985081633],[-39.363122223,-4.984749039],[-39.363286257,-4.984263811],[-39.363614828,-4.983440373]]]},"properties":{"id":283941,"name":"Açude São José","tags":{"name":"Açude São José","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":442745573,"source_layer":"osm_water_reservoir"}},{"id":442748668,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-39.346364033,-4.983101828],[-39.346359507,-4.983042232],[-39.345378908,-4.982840061],[-39.345371532,-4.982869062],[-39.345362982,-4.98290326],[-39.346329919,-4.983169805],[-39.346364033,-4.983101828]]]},"properties":{"id":283934,"name":"Açude Pirabibu","tags":{"name":"Açude Pirabibu","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":442748668,"source_layer":"osm_water_reservoir"}},{"id":1242124144,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-116.858417554,32.865481697],[-116.85804414,32.866206229],[-116.857441565,32.865987293],[-116.857815063,32.865262594],[-116.858417554,32.865481697]]]},"properties":{"id":740814,"name":"Blossom Valley Reservoir 1060 Dam","tags":{"name":"Blossom Valley Reservoir 1060 Dam","man_made":"reservoir_covered","waterway":"dam","ref:US:NID":"CA00892","gnis:feature_id":"1664213"},"type":"reservoir_covered","osm_id":1242124144,"source_layer":"osm_water_reservoir"}},{"id":534710606,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-99.858480757,19.891459495],[-99.858413618,19.891540129],[-99.858301049,19.891502327],[-99.858228629,19.891424124],[-99.858255451,19.891330833],[-99.858317142,19.891267717],[-99.858389562,19.891275345],[-99.858440524,19.891315662],[-99.858470028,19.891396463],[-99.858480757,19.891459495]]]},"properties":{"id":339292,"name":"Cuerpo Agua","tags":{"name":"Cuerpo Agua","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":534710606,"source_layer":"osm_water_reservoir"}},{"id":1514007490,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-98.876234861,32.709604198],[-98.876213571,32.709837131],[-98.876214325,32.710041985],[-98.876168811,32.710248096],[-98.876099577,32.710399641],[-98.876116843,32.710552946],[-98.87605532,32.710531656],[-98.875986756,32.71056929],[-98.875881144,32.71056007],[-98.875765809,32.710432581],[-98.875627759,32.710283551],[-98.875433215,32.710159499],[-98.875350151,32.710088337],[-98.875228026,32.709996974],[-98.875165078,32.709811985],[-98.875113278,32.709714252],[-98.875057203,32.70963563],[-98.874956872,32.709566898],[-98.874711617,32.709458856],[-98.874606508,32.709419545],[-98.874524869,32.709065828],[-98.874606508,32.708910679],[-98.874781606,32.708881259],[-98.875081175,32.708848569],[-98.875230876,32.708925264],[-98.875429108,32.709032385],[-98.875706801,32.709159622],[-98.875852143,32.709290547],[-98.875964125,32.709549296],[-98.876234861,32.709604198]]]},"properties":{"id":910758,"name":"T C Fambro Dam;T C Fambro Reservoir","tags":{"name":"T C Fambro Dam;T C Fambro Reservoir","water":"reservoir","alt_name":"TC Fambro Dam","waterway":"dam","ref:US:NID":"TX03634","gnis:feature_id":"1861413;1861414"},"type":"reservoir","osm_id":1514007490,"source_layer":"osm_water_reservoir"}},{"id":190679432,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-96.50416213,34.403664278],[-96.504161208,34.403680623],[-96.504133631,34.403711384],[-96.504073868,34.403726472],[-96.503991642,34.403721862],[-96.50392115,34.403706774],[-96.503855268,34.40366696],[-96.503807743,34.40361055],[-96.503794919,34.403534526],[-96.503735575,34.403485492],[-96.50365427,34.403439392],[-96.503564332,34.403405193],[-96.503482944,34.403408379],[-96.503414045,34.403392621],[-96.503283874,34.403402427],[-96.503095616,34.403326068],[-96.503028729,34.403250464],[-96.503009031,34.403178379],[-96.502993609,34.403102355],[-96.502939126,34.403039826],[-96.502813146,34.402895574],[-96.502729411,34.402795997],[-96.502732345,34.402707484],[-96.502807447,34.402610841],[-96.502857738,34.402540097],[-96.502892858,34.402460888],[-96.502850027,34.402350666],[-96.50280577,34.40232376],[-96.502741313,34.402312529],[-96.502691776,34.402327197],[-96.502607203,34.402332729],[-96.502493125,34.402329879],[-96.502395225,34.402315295],[-96.502273855,34.402275564],[-96.50215173,34.402218819],[-96.502086771,34.402167941],[-96.502026505,34.402112285],[-96.501974956,34.402127456],[-96.501890634,34.402149752],[-96.501846042,34.402171629],[-96.501832715,34.402201217],[-96.501799104,34.402206246],[-96.501728612,34.402210772],[-96.501691061,34.402228626],[-96.501660802,34.402287048],[-96.501610762,34.402327197],[-96.501572457,34.402356282],[-96.501523423,34.402411519],[-96.501524345,34.402464073],[-96.501557956,34.402509419],[-96.501632052,34.402529117],[-96.501687205,34.402551497],[-96.501745962,34.402567674],[-96.501829027,34.40259525],[-96.501844953,34.402631376],[-96.501831961,34.40266658],[-96.501781753,34.402670184],[-96.501719224,34.402694492],[-96.501690726,34.402748723],[-96.501685445,34.402797589],[-96.501726433,34.402821394],[-96.501784519,34.402806474],[-96.501872948,34.402807731],[-96.501946458,34.402804882],[-96.502010663,34.402833045],[-96.502035809,34.402872356],[-96.502086016,34.40291108],[-96.502150808,34.402933879],[-96.502213924,34.402962378],[-96.502243512,34.403027756],[-96.502247703,34.40307612],[-96.502249128,34.403124735],[-96.502234879,34.403187767],[-96.502202776,34.403248368],[-96.502159106,34.403304275],[-96.502144354,34.403377533],[-96.502160531,34.403419694],[-96.502152652,34.403461687],[-96.502117532,34.403493455],[-96.502087609,34.403478284],[-96.502057769,34.403446097],[-96.501986439,34.403480631],[-96.501902453,34.403524216],[-96.501916953,34.403546093],[-96.501901363,34.40357828],[-96.501825255,34.403605772],[-96.50173515,34.403620944],[-96.501692654,34.403591691],[-96.501631969,34.403527318],[-96.50165175,34.403481217],[-96.501698102,34.403406032],[-96.501777311,34.403343754],[-96.501859872,34.403293127],[-96.501883509,34.403228922],[-96.501889544,34.40317444],[-96.501881079,34.403139236],[-96.501805222,34.403123059],[-96.501753674,34.403116018],[-96.501622832,34.403141834],[-96.501592154,34.403145271],[-96.501492577,34.403109564],[-96.501325526,34.403044772],[-96.501199965,34.403005544],[-96.501104076,34.402944189],[-96.501018246,34.402890628],[-96.500930403,34.402832542],[-96.500843567,34.40280354],[-96.500767375,34.40275635],[-96.500689675,34.402734138],[-96.500587751,34.402740425],[-96.500500747,34.402739084],[-96.500372168,34.402732294],[-96.500257504,34.402718045],[-96.500179301,34.402699186],[-96.500114928,34.402681584],[-96.500090956,34.402650487],[-96.50006866,34.402602962],[-96.50007503,34.402522831],[-96.500059859,34.402461643],[-96.500029349,34.402415291],[-96.499970927,34.402379332],[-96.499884845,34.402349744],[-96.499817454,34.402343458],[-96.499708154,34.402353935],[-96.499635232,34.402376147],[-96.499543282,34.402418392],[-96.499505061,34.402422499],[-96.499443957,34.402445466],[-96.499383104,34.402491566],[-96.499363909,34.402520484],[-96.499362568,34.402543366],[-96.499370028,34.40256189],[-96.499404142,34.402560717],[-96.499419817,34.402522663],[-96.499452171,34.402504558],[-96.499473042,34.402515957],[-96.499494248,34.402545294],[-96.499513778,34.402580498],[-96.49951931,34.402612852],[-96.49952568,34.402675716],[-96.499527859,34.40272123],[-96.499550155,34.402740425],[-96.499592149,34.402781077],[-96.499614947,34.402813683],[-96.499634477,34.40285752],[-96.499624838,34.402920133],[-96.499592819,34.402965144],[-96.499520232,34.403071761],[-96.49948662,34.403107049],[-96.499461307,34.403120125],[-96.499436161,34.403099422],[-96.499418727,34.403038569],[-96.49943415,34.402948799],[-96.499409004,34.40292533],[-96.499314372,34.402846456],[-96.499247569,34.402794237],[-96.499108345,34.402685272],[-96.499024861,34.402545545],[-96.498891254,34.402394671],[-96.498874741,34.402304398],[-96.498894439,34.402125193],[-96.498951771,34.401922686],[-96.499004158,34.401754461],[-96.499026202,34.401637282],[-96.499057718,34.401536532],[-96.499090408,34.401418347],[-96.499110357,34.401284237],[-96.499149836,34.401172757],[-96.499170539,34.401065888],[-96.49919317,34.400974777],[-96.49921798,34.400910488],[-96.499226362,34.400805043],[-96.499261566,34.400714519],[-96.499325772,34.400485274],[-96.499389642,34.400253849],[-96.499449321,34.400052935],[-96.499523836,34.399736351],[-96.499604302,34.399461089],[-96.499710417,34.39896245],[-96.499767582,34.398883576],[-96.499820975,34.398829764],[-96.499898759,34.398824986],[-96.499982159,34.39882046],[-96.500097074,34.39881585],[-96.500255744,34.398812749],[-96.500425477,34.39883077],[-96.500510638,34.39888542],[-96.500556738,34.398921881],[-96.500604515,34.39895675],[-96.500657824,34.39902062],[-96.500736614,34.399102846],[-96.500824875,34.399175769],[-96.500945658,34.399249697],[-96.501093012,34.399345419],[-96.501257465,34.39943762],[-96.501439017,34.399468297],[-96.501650157,34.399517499],[-96.501696258,34.39954935],[-96.501839504,34.399626883],[-96.501970178,34.399686059],[-96.502019715,34.399715061],[-96.50204922,34.399778847],[-96.502079981,34.399810782],[-96.502110743,34.399845734],[-96.502139325,34.399994429],[-96.502163549,34.40007917],[-96.502160867,34.400159218],[-96.502125746,34.400244126],[-96.502061038,34.400336076],[-96.502050142,34.40041738],[-96.502093979,34.400480831],[-96.502237812,34.400545456],[-96.502341078,34.400597591],[-96.502462364,34.400653331],[-96.502523552,34.400691385],[-96.502561941,34.400697923],[-96.502598653,34.400684763],[-96.502654225,34.400642434],[-96.502702673,34.400617959],[-96.502745421,34.40058242],[-96.502793281,34.400531542],[-96.502805854,34.400508659],[-96.502800071,34.400492817],[-96.502804764,34.400474377],[-96.502788671,34.400467253],[-96.502798478,34.40045359],[-96.502788336,34.400427355],[-96.502821864,34.400427439],[-96.502836783,34.400423583],[-96.502857319,34.400399192],[-96.502914735,34.400361222],[-96.502936863,34.400344625],[-96.502939462,34.400325431],[-96.502947341,34.400297938],[-96.502962679,34.400285868],[-96.502968379,34.400313193],[-96.502997548,34.400305817],[-96.503036608,34.400293663],[-96.503073237,34.400269691],[-96.503104669,34.400234319],[-96.503122271,34.400174724],[-96.50313652,34.400143543],[-96.503162504,34.400117727],[-96.503201815,34.400113452],[-96.503245066,34.400121331],[-96.503286472,34.400134659],[-96.503312708,34.400150333],[-96.503320419,34.400186375],[-96.50324758,34.400239684],[-96.503207347,34.400228285],[-96.503174909,34.400257454],[-96.503143729,34.400292322],[-96.50312378,34.400316295],[-96.503122355,34.40033616],[-96.503097209,34.400346888],[-96.503054461,34.400398856],[-96.503034931,34.400431881],[-96.503030908,34.400456021],[-96.503009031,34.400458871],[-96.502962763,34.400494326],[-96.502947341,34.400526932],[-96.502954717,34.40054554],[-96.502959327,34.400561046],[-96.502938037,34.400580492],[-96.502882213,34.400592478],[-96.502846842,34.400584851],[-96.502815912,34.400633969],[-96.502777859,34.400676381],[-96.502741816,34.40070379],[-96.50270217,34.400728852],[-96.502703762,34.400753662],[-96.50272522,34.400758356],[-96.502750869,34.40075098],[-96.502778781,34.400751986],[-96.502796466,34.400759446],[-96.502807279,34.400761625],[-96.502831419,34.40073782],[-96.502848267,34.40071192],[-96.502874502,34.40069742],[-96.50291507,34.400699599],[-96.502946083,34.40070555],[-96.502953627,34.40072617],[-96.502921608,34.400747292],[-96.502906605,34.40076875],[-96.50291046,34.400788866],[-96.502943317,34.40080102],[-96.502971732,34.400797583],[-96.50300241,34.400795823],[-96.503046918,34.400806217],[-96.503081116,34.400809653],[-96.503129479,34.400801858],[-96.503110536,34.40084075],[-96.503070135,34.400865644],[-96.503012552,34.400841756],[-96.502960919,34.400845947],[-96.502935103,34.400869584],[-96.502949855,34.400896155],[-96.502970307,34.400910739],[-96.503001655,34.400931526],[-96.503028729,34.400944518],[-96.503004924,34.400957342],[-96.502966619,34.400948458],[-96.502933762,34.400948877],[-96.502901575,34.400965221],[-96.502867461,34.400989697],[-96.502862851,34.401033618],[-96.50286151,34.401075863],[-96.502864779,34.401115677],[-96.502876262,34.401143588],[-96.502902581,34.401164878],[-96.502888248,34.401181139],[-96.502867126,34.401198574],[-96.502855475,34.40122439],[-96.502849524,34.401276442],[-96.502863605,34.401301587],[-96.502854804,34.401334696],[-96.502847009,34.401387586],[-96.502837454,34.40142522],[-96.502824713,34.401446846],[-96.502824043,34.401467968],[-96.502842902,34.401487833],[-96.502881878,34.401497724],[-96.502905766,34.401508369],[-96.502884979,34.401515493],[-96.502841729,34.401509794],[-96.502797472,34.401487246],[-96.50278448,34.401468555],[-96.502780122,34.401432177],[-96.502802837,34.401398985],[-96.502818762,34.401369648],[-96.502823205,34.401303767],[-96.502813063,34.401253056],[-96.50280795,34.401207123],[-96.502813314,34.401163621],[-96.502801328,34.401112072],[-96.502797556,34.401061027],[-96.502793449,34.401018782],[-96.502794706,34.40097704],[-96.502806776,34.400952481],[-96.502801412,34.40092524],[-96.502763777,34.400906045],[-96.502734608,34.400894059],[-96.502712228,34.400901184],[-96.502685155,34.400885174],[-96.502658081,34.400884588],[-96.502627571,34.400887018],[-96.502599827,34.400912667],[-96.502574681,34.400926329],[-96.502554732,34.400931359],[-96.502530006,34.400934795],[-96.502486671,34.400951391],[-96.502457502,34.400969496],[-96.50242892,34.400978968],[-96.502384077,34.400989361],[-96.502353147,34.400996486],[-96.50232138,34.400996654],[-96.502292379,34.400999168],[-96.502256923,34.401013417],[-96.502225659,34.401018782],[-96.502196657,34.40103982],[-96.502147958,34.401063457],[-96.502124741,34.401072426],[-96.502093979,34.401091956],[-96.502090962,34.401111821],[-96.502045364,34.401127495],[-96.501988619,34.40114275],[-96.501955678,34.401154233],[-96.501915445,34.401169321],[-96.501904464,34.401199328],[-96.501931705,34.401221792],[-96.501977051,34.401237633],[-96.502028013,34.401259678],[-96.502054081,34.401285326],[-96.502065983,34.401336372],[-96.502108144,34.401386999],[-96.502146282,34.401436536],[-96.50214821,34.401501579],[-96.502199842,34.401556229],[-96.50223798,34.401607275],[-96.502259773,34.40164382],[-96.502258264,34.401683467],[-96.502243764,34.401736524],[-96.502231358,34.401840376],[-96.502221971,34.401969122],[-96.502229347,34.402056378],[-96.502253738,34.402118488],[-96.502351136,34.40217582],[-96.502486923,34.402231392],[-96.502597815,34.402261986],[-96.502685071,34.402277241],[-96.5027362,34.402263494],[-96.502794539,34.402280761],[-96.502865114,34.402322084],[-96.502937366,34.402390396],[-96.502968463,34.402479664],[-96.50293921,34.402574044],[-96.502910879,34.402618384],[-96.502863941,34.402647637],[-96.502806776,34.402703041],[-96.502788504,34.402764984],[-96.502807279,34.40280748],[-96.502889422,34.402905548],[-96.503009031,34.403016776],[-96.503058568,34.403082323],[-96.503072566,34.403206039],[-96.503107519,34.403268066],[-96.503218747,34.403338222],[-96.503277168,34.403361524],[-96.503360233,34.403358842],[-96.503397449,34.40334652],[-96.503437179,34.403349035],[-96.503482777,34.403351298],[-96.503540779,34.403356746],[-96.503626023,34.403363368],[-96.503761726,34.403428998],[-96.503827524,34.403492365],[-96.503841773,34.403549278],[-96.503843282,34.403589092],[-96.503868176,34.40363075],[-96.503910924,34.403658159],[-96.503956773,34.403678024],[-96.504029528,34.403692777],[-96.504076551,34.40369504],[-96.504128518,34.403670565],[-96.50416213,34.403664278]]]},"properties":{"id":143236,"name":"Delaware Creek Site 10 Reservoir;Delaware Creek Site 10 Dam","tags":{"name":"Delaware Creek Site 10 Reservoir;Delaware Creek Site 10 Dam","water":"reservoir","waterway":"dam","ref:US:NID":"OK02189","gnis:feature_id":"1750869"},"type":"reservoir","osm_id":190679432,"source_layer":"osm_water_reservoir"}},{"id":-18313580,"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[[[[-99.101887345,40.945681032],[-99.101864462,40.945822267],[-99.101784331,40.945951851],[-99.101631781,40.946035419],[-99.100453034,40.946026869],[-99.100403413,40.945969118],[-99.100418752,40.945897117],[-99.100529309,40.945836684],[-99.100628551,40.945810784],[-99.10063995,40.945689749],[-99.100552275,40.945649348],[-99.100399641,40.945609031],[-99.100243235,40.945560081],[-99.100117422,40.945502497],[-99.099949533,40.945398729],[-99.099861858,40.945269061],[-99.09980461,40.94515666],[-99.099663458,40.945081726],[-99.099529935,40.945047192],[-99.099449804,40.944978041],[-99.099346874,40.944929091],[-99.09924386,40.944848373],[-99.099167501,40.944741756],[-99.099026434,40.944649555],[-99.098946303,40.94448242],[-99.098847145,40.944349985],[-99.098786125,40.944226017],[-99.098683111,40.94420875],[-99.098652601,40.94413675],[-99.09869845,40.944073383],[-99.098713621,40.943992665],[-99.098664,40.943937931],[-99.098576325,40.94387163],[-99.09853056,40.943736263],[-99.098454201,40.943678595],[-99.098522933,40.943635344],[-99.098564842,40.943569044],[-99.098713621,40.943479776],[-99.09869845,40.943353042],[-99.098782353,40.943321358],[-99.098824262,40.943289675],[-99.098858628,40.943151373],[-99.098747903,40.943067722],[-99.098648829,40.943041822],[-99.098538104,40.943013072],[-99.098461913,40.942940988],[-99.098461913,40.94286312],[-99.098393265,40.94283722],[-99.098618319,40.942730686],[-99.09869845,40.942721968],[-99.098770869,40.942782486],[-99.098747903,40.942819953],[-99.098709849,40.94285742],[-99.098736504,40.942886254],[-99.098866256,40.942871837],[-99.098919648,40.942796986],[-99.098927276,40.942745019],[-99.099022662,40.942727752],[-99.099072283,40.942759436],[-99.099114108,40.94283437],[-99.099137075,40.942923637],[-99.09918284,40.942969737],[-99.099179068,40.943021621],[-99.099190468,40.943105273],[-99.099194239,40.943185823],[-99.099179068,40.943289675],[-99.099175212,40.943367459],[-99.09912182,40.943425042],[-99.099072283,40.943462426],[-99.099037833,40.943528727],[-99.099022662,40.943615144],[-99.099015034,40.943707429],[-99.099007323,40.943793846],[-99.099083682,40.94392343],[-99.09918284,40.944007082],[-99.099266743,40.944076233],[-99.099354418,40.944108],[-99.099423149,40.944130966],[-99.099388867,40.944194334],[-99.099369756,40.944257785],[-99.099385012,40.944326851],[-99.099461371,40.944378735],[-99.099552817,40.944450736],[-99.099648203,40.944511253],[-99.099747361,40.944551654],[-99.099911479,40.944597755],[-99.100033436,40.944640921],[-99.10015556,40.944678388],[-99.100208953,40.944710072],[-99.100262345,40.944787856],[-99.100304339,40.94486279],[-99.10035002,40.944957841],[-99.100365359,40.945032775],[-99.100407352,40.945099076],[-99.100464517,40.945127826],[-99.100575158,40.945133693],[-99.100662833,40.945127826],[-99.100765846,40.945116343],[-99.100845977,40.945073176],[-99.100922253,40.945024142],[-99.100971873,40.944966558],[-99.100987128,40.944903191],[-99.100979417,40.94479079],[-99.100979417,40.944681238],[-99.100952763,40.944594821],[-99.100918481,40.94447387],[-99.100849749,40.944390219],[-99.10086886,40.944243368],[-99.100895514,40.944179917],[-99.100907081,40.944125183],[-99.10092988,40.944076233],[-99.100975645,40.944015631],[-99.101055776,40.943966765],[-99.101101541,40.943989731],[-99.101200783,40.944027282],[-99.10126172,40.944024349],[-99.101372361,40.944024349],[-99.101452408,40.943989731],[-99.101460119,40.943937931],[-99.101471519,40.943857297],[-99.101475374,40.943770796],[-99.101528767,40.943713128],[-99.10161267,40.943739029],[-99.1017042,40.94380248],[-99.101738566,40.943880264],[-99.101753821,40.943960981],[-99.101723311,40.944050249],[-99.101673774,40.94413675],[-99.101605042,40.944179917],[-99.101490629,40.944223167],[-99.101406727,40.944300951],[-99.101341851,40.944338418],[-99.101334223,40.944430619],[-99.101379904,40.944551654],[-99.101425753,40.944606388],[-99.101528767,40.944675539],[-99.101563049,40.944770589],[-99.101574532,40.94485994],[-99.101627925,40.944937725],[-99.101730939,40.945052976],[-99.101730939,40.945107626],[-99.101711828,40.945145177],[-99.101608814,40.945214327],[-99.10152114,40.945352629],[-99.101502029,40.945401579],[-99.101528767,40.945436196],[-99.101608814,40.94544768],[-99.101711828,40.945476429],[-99.101837724,40.945571564],[-99.101887345,40.945681032]]],[[[-99.098877739,40.942399936],[-99.098869944,40.942488197],[-99.098414806,40.942617362],[-99.098158236,40.942673186],[-99.097963776,40.942670252],[-99.097913149,40.942643849],[-99.097897643,40.942549888],[-99.097827654,40.942435224],[-99.097967632,40.942238333],[-99.0979248,40.942153172],[-99.097917005,40.942070946],[-99.098119344,40.941947564],[-99.098173743,40.941879922],[-99.098208779,40.941771293],[-99.098484963,40.941242479],[-99.098434336,40.941101495],[-99.098426541,40.940907538],[-99.098574314,40.941013317],[-99.09870658,40.941130832],[-99.098741533,40.941233678],[-99.098757123,40.941398131],[-99.098683279,40.941462839],[-99.09862888,40.941536264],[-99.098667604,40.941621425],[-99.098710436,40.941818232],[-99.098659977,40.941962233],[-99.098644303,40.942067929],[-99.098764918,40.942223748],[-99.098877739,40.942399936]]]]},"properties":{"id":26249,"name":"Gunst Reservoir;Gunst Dam","tags":{"name":"Gunst Reservoir;Gunst Dam","type":"multipolygon","water":"reservoir","waterway":"dam","ref:US:NID":"NE00591","gnis:feature_id":"1767899"},"type":"reservoir","osm_id":-18313580,"source_layer":"osm_water_reservoir"}},{"id":1396794111,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-112.348222409,47.348391225],[-112.348210758,47.348416622],[-112.348138339,47.348430872],[-112.348084611,47.348430872],[-112.348012359,47.348440343],[-112.348012359,47.348471943],[-112.348042617,47.348506728],[-112.348044964,47.348535226],[-112.34799593,47.348541596],[-112.347923511,47.34850832],[-112.347855869,47.348465656],[-112.347757717,47.348411845],[-112.347659732,47.348384939],[-112.347430822,47.348346969],[-112.347330407,47.348290056],[-112.347265112,47.348195172],[-112.347227729,47.348044885],[-112.347136618,47.347905578],[-112.347078363,47.34787557],[-112.346898571,47.347850257],[-112.34660663,47.347820166],[-112.346422144,47.347775826],[-112.346139674,47.347692091],[-112.345938844,47.347660323],[-112.345810349,47.347676165],[-112.34570063,47.347729977],[-112.345658637,47.347783789],[-112.345611949,47.347834499],[-112.345600215,47.34789619],[-112.345588564,47.347970454],[-112.34554657,47.348082771],[-112.345462416,47.348196681],[-112.345427464,47.348236244],[-112.345362001,47.348282177],[-112.345296622,47.348312184],[-112.34526167,47.348346969],[-112.345271057,47.348391225],[-112.345275667,47.348460879],[-112.345264017,47.348517876],[-112.345205678,47.348606472],[-112.345147257,47.348731447],[-112.345049105,47.348842172],[-112.344801671,47.349062029],[-112.344677954,47.349188679],[-112.344526158,47.349296219],[-112.344425742,47.349353132],[-112.344266905,47.349399065],[-112.344210914,47.349444998],[-112.344273946,47.349588831],[-112.344178225,47.349701233],[-112.343984435,47.349859483],[-112.343788215,47.349966939],[-112.343627115,47.350147318],[-112.343554779,47.350205823],[-112.343426368,47.350297689],[-112.343216234,47.350368851],[-112.343041052,47.350462142],[-112.342926639,47.350506482],[-112.342872911,47.350525425],[-112.342730502,47.350525425],[-112.342548448,47.350528527],[-112.342212166,47.350527018],[-112.342002031,47.350527018],[-112.341665749,47.350527018],[-112.341366851,47.350470021],[-112.341236177,47.350389387],[-112.340855555,47.35025645],[-112.340720103,47.350253349],[-112.340577611,47.350296096],[-112.340514663,47.350308669],[-112.340465545,47.350299282],[-112.340425814,47.350250164],[-112.340365129,47.350174224],[-112.340101351,47.35007297],[-112.339909824,47.349770886],[-112.339830448,47.349680697],[-112.339624924,47.349598387],[-112.340176034,47.34850832],[-112.340486667,47.348574873],[-112.340678026,47.348631786],[-112.340853124,47.348709235],[-112.341002657,47.348786767],[-112.341135762,47.34887067],[-112.341292168,47.349052557],[-112.341443964,47.349209299],[-112.341612021,47.349337374],[-112.341784856,47.349451284],[-112.342032374,47.349549353],[-112.342235551,47.349622191],[-112.342464377,47.349645828],[-112.342597482,47.349649097],[-112.342718852,47.34961733],[-112.342886909,47.349565194],[-112.342985061,47.349493948],[-112.343029401,47.349418092],[-112.343045746,47.34931994],[-112.343066701,47.349161773],[-112.343099474,47.349046271],[-112.343146161,47.348943425],[-112.343328216,47.348706133],[-112.34344506,47.348647544],[-112.34357347,47.348579566],[-112.343715963,47.348453],[-112.343891061,47.348394494],[-112.344068422,47.348389716],[-112.344168837,47.348441936],[-112.344252908,47.348494155],[-112.344301942,47.348492562],[-112.34435106,47.348471943],[-112.344341672,47.348413437],[-112.344320633,47.348365996],[-112.344304372,47.348267843],[-112.344269252,47.348106576],[-112.344294901,47.348011525],[-112.344348629,47.347910355],[-112.344460779,47.347771132],[-112.344586843,47.347635094],[-112.344848358,47.347438873],[-112.34498842,47.34733762],[-112.345137953,47.347288586],[-112.345266363,47.347277522],[-112.34539008,47.347309121],[-112.345558221,47.347400987],[-112.345681938,47.347430994],[-112.345796351,47.347424624],[-112.345952841,47.347435772],[-112.346193318,47.34753384],[-112.346541251,47.347657222],[-112.346679049,47.347687229],[-112.346765467,47.347692091],[-112.346912569,47.34764775],[-112.347099318,47.347674656],[-112.347272153,47.347741041],[-112.347426212,47.347809018],[-112.347533668,47.347883449],[-112.347676077,47.348014794],[-112.34776241,47.348049579],[-112.347886211,47.348106576],[-112.347970282,47.348168266],[-112.347984279,47.348223587],[-112.348000624,47.348294749],[-112.348059046,47.348358033],[-112.348119731,47.348358033],[-112.348215452,47.34837706],[-112.348222409,47.348391225]]]},"properties":{"id":863857,"name":"Soap Creek Dam","tags":{"name":"Soap Creek Dam","water":"reservoir","waterway":"dam","wikidata":"Q48999684","ref:US:NID":"MT00870","gnis:feature_id":"795981"},"type":"reservoir","osm_id":1396794111,"source_layer":"osm_water_reservoir"}},{"id":433283252,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-85.83879057,11.501813287],[-85.838553362,11.501894843],[-85.838591751,11.502006742],[-85.837694217,11.50231528],[-85.837386601,11.501420428],[-85.838521343,11.501030334],[-85.83879057,11.501813287]]]},"properties":{"id":276032,"name":"Embalse","tags":{"name":"Embalse","note":"Approximate location based on satellite imagery and OpenTopo map","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":433283252,"source_layer":"osm_water_reservoir"}},{"id":1254389336,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-86.033119127,12.38953973],[-86.032914441,12.39001532],[-86.032427453,12.389815495],[-86.032632139,12.389339822],[-86.033119127,12.38953973]]]},"properties":{"id":746580,"name":"","tags":{"note":"Approximate location and elevation based on OpenSatellite imagery, OpenSRTM Contours and OpenTopo maps.","water":"reservoir","seasonal":"yes","waterway":"dam","intermittent":"yes"},"type":"reservoir","osm_id":1254389336,"source_layer":"osm_water_reservoir"}},{"id":1254389337,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-86.032366935,12.389805856],[-86.031923281,12.390085979],[-86.031676937,12.389713655],[-86.031636536,12.389652635],[-86.032080107,12.389372595],[-86.032366935,12.389805856]]]},"properties":{"id":746581,"name":"","tags":{"note":"Approximate location and elevation based on OpenSatellite imagery, OpenSRTM Contours and OpenTopo maps.","water":"reservoir","seasonal":"yes","waterway":"dam","intermittent":"yes"},"type":"reservoir","osm_id":1254389337,"source_layer":"osm_water_reservoir"}},{"id":1465772832,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-80.067177419,34.909372073],[-80.067101815,34.909496628],[-80.067039034,34.909475673],[-80.066526648,34.909345167],[-80.066561852,34.909281297],[-80.066690347,34.90932329],[-80.066834516,34.909350532],[-80.066992012,34.909386658],[-80.067119668,34.909407528],[-80.067177419,34.909372073]]]},"properties":{"id":890361,"name":"Nelson Lake;Nelson Lake Dam","tags":{"name":"Nelson Lake;Nelson Lake Dam","water":"reservoir","waterway":"dam","ref:US:NID":"NC00499","gnis:feature_id":"998200;998201"},"type":"reservoir","osm_id":1465772832,"source_layer":"osm_water_reservoir"}},{"id":1413546951,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-81.35875192,37.84575786],[-81.358741359,37.84580396],[-81.358713028,37.845855677],[-81.358670532,37.845907393],[-81.358594424,37.845911584],[-81.358548407,37.845900352],[-81.358472384,37.845883588],[-81.358398036,37.845866825],[-81.35836082,37.845841679],[-81.35833601,37.845798344],[-81.358341458,37.845763392],[-81.35835554,37.845727098],[-81.358369705,37.845683764],[-81.35837507,37.845633472],[-81.358373226,37.84559852],[-81.35838035,37.845557951],[-81.358378506,37.845531465],[-81.358415722,37.845521658],[-81.358468863,37.845524424],[-81.358537846,37.845527274],[-81.35861035,37.845541271],[-81.358652846,37.84557614],[-81.358698863,37.845623666],[-81.358728954,37.845683764],[-81.358744879,37.845727098],[-81.35875192,37.84575786]]]},"properties":{"id":866135,"name":"Wingrove Branch Dam Number Two","tags":{"name":"Wingrove Branch Dam Number Two","fixme":"This is on the correct stream but the two \"reservoirs\" look tiny. Can these be what these are?","water":"reservoir","waterway":"dam","wikidata":"Q31591120","gnis:feature_id":"1559276"},"type":"reservoir","osm_id":1413546951,"source_layer":"osm_water_reservoir"}},{"id":37660225,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-77.795521062,35.384845673],[-77.795378402,35.384883559],[-77.795259547,35.384950279],[-77.795131304,35.385026973],[-77.794950422,35.385174746],[-77.794856461,35.38521917],[-77.79460844,35.385229648],[-77.794427307,35.385273653],[-77.794272661,35.385334421],[-77.794132013,35.385421761],[-77.793989437,35.385466436],[-77.793789528,35.385472136],[-77.793648461,35.385444392],[-77.79350798,35.385394436],[-77.793346629,35.385367362],[-77.792743718,35.385301481],[-77.792401318,35.385279268],[-77.792200739,35.385301648],[-77.792099234,35.385301481],[-77.791998651,35.385284717],[-77.791845933,35.385218584],[-77.791696651,35.385202071],[-77.791389119,35.385196791],[-77.789959418,35.385069721],[-77.789148133,35.385041977],[-77.78902735,35.385025884],[-77.789000528,35.385004426],[-77.788981082,35.384976179],[-77.788987368,35.384960253],[-77.789087784,35.384899987],[-77.789148469,35.384844499],[-77.789196078,35.384717932],[-77.78927646,35.384663869],[-77.789335804,35.384647273],[-77.789470501,35.384658589],[-77.789671416,35.384713239],[-77.789812064,35.384724135],[-77.789893368,35.384718268],[-77.789979953,35.384707455],[-77.790470546,35.384580888],[-77.790724602,35.384614751],[-77.791033642,35.384554318],[-77.791375205,35.384449125],[-77.791764461,35.384361534],[-77.791925812,35.384361869],[-77.792562502,35.384482485],[-77.792805074,35.384510397],[-77.793072708,35.384521461],[-77.793260547,35.3845052],[-77.793481326,35.384449795],[-77.793615017,35.384432948],[-77.793903858,35.384235973],[-77.794144921,35.383989042],[-77.7943398,35.3839012],[-77.794641046,35.383922238],[-77.794754956,35.383917377],[-77.794876242,35.38387882],[-77.795151252,35.383758121],[-77.795346048,35.384279727],[-77.795359207,35.384323983],[-77.795439841,35.384543421],[-77.795521062,35.384845673]]]},"properties":{"id":36539,"name":"Bear Creek W/S Lake Number Fifteen;A G Phelps Dam","tags":{"name":"Bear Creek W/S Lake Number Fifteen;A G Phelps Dam","water":"reservoir","alt_name":"Phelps Lake Dam","waterway":"dam","ref:US:NID":"NC00980","gnis:feature_id":"998904;998905"},"type":"reservoir","osm_id":37660225,"source_layer":"osm_water_reservoir"}},{"id":1491996184,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-72.23959096,43.064827068],[-72.239102128,43.065244236],[-72.238865255,43.065112388],[-72.238850503,43.065035442],[-72.238903141,43.06498733],[-72.239055441,43.06487736],[-72.239162645,43.06473252],[-72.239279154,43.064699999],[-72.239419131,43.064761019],[-72.23959096,43.064827068]]]},"properties":{"id":903759,"name":"Spoons Pond Dam","tags":{"name":"Spoons Pond Dam","water":"reservoir","waterway":"dam","wikidata":"Q34942892","ref:US:NID":"NH00019","gnis:feature_id":"1774786"},"type":"reservoir","osm_id":1491996184,"source_layer":"osm_water_reservoir"}},{"id":1413802323,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-16.251378403,16.876396471],[-16.251260302,16.876822524],[-16.250785634,16.876683971],[-16.250911782,16.876270743],[-16.251378403,16.876396471]]]},"properties":{"id":866284,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":1413802323,"source_layer":"osm_water_reservoir"}},{"id":1413802324,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-16.252655134,16.876440141],[-16.252403006,16.877158722],[-16.251319478,16.876853369],[-16.251531205,16.876134621],[-16.252655134,16.876440141]]]},"properties":{"id":866285,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":1413802324,"source_layer":"osm_water_reservoir"}},{"id":635611199,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-3.439097428,41.031152891],[-3.439053758,41.031260682],[-3.43893834,41.031234028],[-3.438982177,41.031126153],[-3.439097428,41.031152891]]]},"properties":{"id":387566,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":635611199,"source_layer":"osm_water_reservoir"}},{"id":618480333,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[22.334961045,-34.037553056],[22.3350597,-34.037366643],[22.335121223,-34.037341162],[22.335207557,-34.037371756],[22.335253741,-34.037346275],[22.33532155,-34.037264551],[22.335318449,-34.037164974],[22.335395479,-34.037090878],[22.335669651,-34.037116443],[22.335731258,-34.037162376],[22.335709716,-34.037198166],[22.335876097,-34.037323224],[22.33590996,-34.037305371],[22.335950026,-34.037397237],[22.335996294,-34.037624554],[22.335937788,-34.0377419],[22.33581449,-34.037713821],[22.335638889,-34.037604018],[22.335404699,-34.037609131],[22.335339991,-34.037609131],[22.335238318,-34.037637211],[22.33511502,-34.037637211],[22.334961045,-34.037553056]]]},"properties":{"id":380726,"name":"","tags":{"area":"yes","water":"reservoir","waterway":"dam","reservoir_type":"water_storage"},"type":"reservoir","osm_id":618480333,"source_layer":"osm_water_reservoir"}},{"id":968263159,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.680689632,-33.564676254],[21.680759453,-33.564662759],[21.680853247,-33.564656054],[21.680933797,-33.56468296],[21.680965983,-33.564825955],[21.680979394,-33.565033826],[21.680901526,-33.565096355],[21.680794238,-33.565109766],[21.68073523,-33.565080765],[21.68069508,-33.564960065],[21.680689632,-33.564676254]]]},"properties":{"id":550038,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":968263159,"source_layer":"osm_water_reservoir"}},{"id":1493032702,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[33.288790774,-13.499245958],[33.288979199,-13.499226345],[33.288988586,-13.499411501],[33.288796138,-13.499426504],[33.288790774,-13.499245958]]]},"properties":{"id":904093,"name":"Dam","tags":{"name":"Dam","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":1493032702,"source_layer":"osm_water_reservoir"}},{"id":1195039586,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[30.680560435,-1.809643046],[30.680726732,-1.809653775],[30.680887665,-1.809937921],[30.681059326,-1.810130957],[30.681193437,-1.810173872],[30.681450929,-1.810077312],[30.681697692,-1.810029033],[30.681890727,-1.809975472],[30.682057024,-1.80979853],[30.682271685,-1.809675149],[30.682443346,-1.809836081],[30.682346787,-1.810104134],[30.682046379,-1.810436561],[30.681788887,-1.810822547],[30.681601133,-1.810935116],[30.681338276,-1.811138964],[30.681096877,-1.811219347],[30.680882217,-1.810533036],[30.680699826,-1.809927109],[30.680560435,-1.809643046]]]},"properties":{"id":684745,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":1195039586,"source_layer":"osm_water_reservoir"}},{"id":590455244,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[37.449674127,-1.653296077],[37.449684856,-1.653221059],[37.449684856,-1.653139251],[37.449727771,-1.653041351],[37.449773368,-1.653048056],[37.449802873,-1.653074962],[37.449806896,-1.653105724],[37.449845788,-1.653116452],[37.44988862,-1.653093654],[37.449919549,-1.65305342],[37.449963721,-1.65303607],[37.450018791,-1.653038752],[37.450076458,-1.653015953],[37.45013949,-1.652993155],[37.450178382,-1.652974379],[37.450244096,-1.652981001],[37.450288353,-1.652969015],[37.450313834,-1.652979744],[37.450363454,-1.652970356],[37.45037016,-1.652995837],[37.450379548,-1.65303607],[37.450411734,-1.653032047],[37.450429169,-1.653026682],[37.450473425,-1.653049481],[37.450502929,-1.653084266],[37.450497565,-1.653100359],[37.450441238,-1.653081667],[37.450419781,-1.653095079],[37.450411734,-1.653167414],[37.45038223,-1.65321033],[37.450368819,-1.653245115],[37.450351384,-1.653294735],[37.450343338,-1.653340333],[37.450350043,-1.6533778],[37.450388935,-1.653392636],[37.450403688,-1.653420715],[37.450392959,-1.653460949],[37.450405029,-1.653514677],[37.450402346,-1.653530602],[37.450437131,-1.653562873],[37.450461355,-1.653593718],[37.450449285,-1.653629844],[37.450414333,-1.653628503],[37.450395641,-1.653660773],[37.450413075,-1.653704946],[37.450437131,-1.653726403],[37.450473425,-1.653715675],[37.450509635,-1.653699665],[37.450547186,-1.653691535],[37.450592783,-1.653719782],[37.450623629,-1.65374652],[37.450653133,-1.653727745],[37.450679955,-1.653700923],[37.450713483,-1.65365675],[37.450714824,-1.653632526],[37.450797972,-1.653645937],[37.450827477,-1.653683572],[37.450856981,-1.653735707],[37.450840888,-1.653765212],[37.45072287,-1.653813575],[37.45064232,-1.653836374],[37.450557831,-1.653848444],[37.450439897,-1.653813575],[37.450356749,-1.653743838],[37.45029774,-1.653670161],[37.450177041,-1.653576284],[37.450111327,-1.653509228],[37.450038823,-1.653462374],[37.450004038,-1.653436809],[37.449962464,-1.653416776],[37.449883339,-1.653420715],[37.449814943,-1.65341401],[37.449723748,-1.653406047],[37.449682173,-1.653385931],[37.449674127,-1.653296077]]]},"properties":{"id":368732,"name":"Mutondo Earth Dam","tags":{"name":"Mutondo Earth Dam","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":590455244,"source_layer":"osm_water_reservoir"}},{"id":731256548,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[144.23452276,-38.381274048],[144.23462284,-38.381117055],[144.234841943,-38.381220069],[144.235079821,-38.381455517],[144.235136148,-38.381524248],[144.234985944,-38.381578228],[144.234810678,-38.381578228],[144.234560227,-38.381534055],[144.23452276,-38.381274048]]]},"properties":{"id":435545,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":731256548,"source_layer":"osm_water_reservoir"}},{"id":126393477,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[143.916158296,-37.522259731],[143.916520478,-37.52235914],[143.916534979,-37.522413874],[143.916498769,-37.522478247],[143.916229878,-37.522595175],[143.916158296,-37.522259731]]]},"properties":{"id":110963,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":126393477,"source_layer":"osm_water_reservoir"}},{"id":56630673,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[150.409016696,-34.436431651],[150.409087775,-34.436369709],[150.409147286,-34.436340037],[150.409303944,-34.436273652],[150.409421542,-34.436241382],[150.409534698,-34.436228725],[150.4096414,-34.436234174],[150.409767631,-34.436244902],[150.409885145,-34.436271808],[150.40990895,-34.436280358],[150.40998095,-34.436305923],[150.410078851,-34.436359818],[150.410159485,-34.436431651],[150.410242131,-34.436492671],[150.410066697,-34.436373481],[150.409959325,-34.436311538],[150.409904088,-34.436297625],[150.40976629,-34.43626284],[150.409532854,-34.436245154],[150.409377286,-34.436267366],[150.409235129,-34.43631816],[150.409119123,-34.436364931],[150.409016696,-34.436431651]]]},"properties":{"id":84967,"name":"Upper Welby Reservoir","tags":{"name":"Upper Welby Reservoir","note":"Dam Wall correct, rest is DR only","water":"reservoir","waterway":"dam","description":"Former Mittagong Water Supply (circa 1931) on Nattai Creek"},"type":"reservoir","osm_id":56630673,"source_layer":"osm_water_reservoir"}},{"id":921028352,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[37.899683756,7.746599044],[37.899757684,7.746719827],[37.899872265,7.746679594],[37.899923981,7.746756456],[37.899850053,7.746833318],[37.899901769,7.747016379],[37.90034886,7.747031047],[37.900784887,7.746858967],[37.901180261,7.746474573],[37.901420486,7.746415983],[37.901745536,7.74649645],[37.902203775,7.746298721],[37.901692144,7.745464218],[37.901597764,7.745463967],[37.901368686,7.745628755],[37.901013964,7.746016837],[37.900208463,7.746064446],[37.899809317,7.746218254],[37.899683756,7.746599044]]]},"properties":{"id":529984,"name":"","tags":{"area":"yes","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":921028352,"source_layer":"osm_water_reservoir"}},{"id":316221200,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[3.956569393,36.894157291],[3.956794698,36.894225939],[3.957395429,36.894277488],[3.958146448,36.894423249],[3.958371753,36.894260221],[3.95831702,36.894047488],[3.957406158,36.893805587],[3.956601579,36.893865517],[3.956569393,36.894157291]]]},"properties":{"id":208265,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":316221200,"source_layer":"osm_water_reservoir"}},{"id":123252241,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[30.387902766,39.878069726],[30.38799044,39.878510028],[30.38832119,39.878598038],[30.388867858,39.878287321],[30.390001846,39.878976145],[30.390886053,39.879152333],[30.391115465,39.878831138],[30.391500279,39.878504831],[30.391830945,39.878214733],[30.392330422,39.877945423],[30.392553129,39.877805529],[30.392748847,39.877494895],[30.393012122,39.877209995],[30.393120165,39.876997597],[30.393356367,39.876764496],[30.393558874,39.875946087],[30.393754591,39.875599077],[30.394240574,39.875153578],[30.394463281,39.875205379],[30.394618598,39.875350386],[30.394874917,39.875381482],[30.395023444,39.875562783],[30.395178761,39.87586319],[30.395367773,39.876168795],[30.395414963,39.876479596],[30.395624175,39.876966416],[30.395779492,39.877215191],[30.39624519,39.877468995],[30.396920185,39.877474025],[30.397088829,39.877209995],[30.397453358,39.87674899],[30.397453358,39.876458892],[30.397345399,39.876220595],[30.397156387,39.876023788],[30.397109029,39.87583729],[30.397257556,39.87563009],[30.397237356,39.875257179],[30.397237356,39.875127678],[30.397169882,39.874941181],[30.397210366,39.874433573],[30.397345399,39.874262582],[30.397385883,39.874050269],[30.397649075,39.873780874],[30.397669359,39.873661767],[30.397628875,39.87356328],[30.397669359,39.87348558],[30.397628875,39.873387176],[30.397554527,39.873288773],[30.397561317,39.873133372],[30.397811097,39.873102275],[30.397979741,39.872962465],[30.398060794,39.872739674],[30.397838087,39.872428873],[30.397865077,39.872257966],[30.398148553,39.871998965],[30.398310575,39.871843565],[30.39837126,39.871688164],[30.398890938,39.871227076],[30.399194782,39.871195979],[30.399417489,39.871076872],[30.399316152,39.871040579],[30.399181287,39.871102772],[30.398735789,39.871113166],[30.398539988,39.871071675],[30.398155258,39.871066479],[30.398067584,39.871102772],[30.397554527,39.871009566],[30.397412873,39.870942175],[30.397399378,39.870677977],[30.397561317,39.870330883],[30.397669359,39.870165173],[30.397736834,39.869952775],[30.397885277,39.869740378],[30.397932551,39.869413986],[30.398033721,39.869186083],[30.398175542,39.869077285],[30.398202532,39.868838988],[30.39831728,39.868605887],[30.398519787,39.868440093],[30.398600756,39.868217386],[30.398620957,39.867714891],[30.398769484,39.867471397],[30.399046255,39.867176018],[30.399235266,39.867046518],[30.399262172,39.866885921],[30.399208277,39.86679263],[30.399268962,39.866683833],[30.399228477,39.86654922],[30.39910694,39.866409326],[30.399210791,39.866321986],[30.399046255,39.866202125],[30.399086739,39.866103638],[30.399019265,39.865984531],[30.393687033,39.865176348],[30.393261819,39.865311045],[30.393194345,39.865274751],[30.393113376,39.865373239],[30.392816321,39.865393942],[30.392249453,39.865559736],[30.392053652,39.865580439],[30.391918619,39.865730643],[30.392789331,39.86582913],[30.392951354,39.865896437],[30.393430547,39.865989728],[30.393612853,39.866103638],[30.393700528,39.866207238],[30.393653338,39.866393735],[30.393504895,39.86667872],[30.393336083,39.866854824],[30.393032323,39.867233099],[30.392883879,39.867367796],[30.392661172,39.867486819],[30.39242497,39.8674921],[30.392411475,39.86761632],[30.392181979,39.86774582],[30.391851145,39.867782198],[30.391851145,39.867880517],[30.391641933,39.867958218],[30.391729775,39.868010018],[30.392343917,39.868056789],[30.392661172,39.868227696],[30.392796121,39.868362393],[30.392897374,39.868533384],[30.39285689,39.868823481],[30.392762342,39.868885591],[30.392802826,39.869004782],[30.392769131,39.869144676],[30.392654383,39.869243079],[30.392661172,39.86934668],[30.392553129,39.869419183],[30.392634182,39.869538374],[30.392337128,39.870113372],[30.392208969,39.870227283],[30.392154989,39.870341276],[30.391857934,39.87037757],[30.391749975,39.87045527],[30.391533974,39.870532971],[30.391574458,39.870610671],[30.391911998,39.870631374],[30.392175189,39.870781578],[30.392438465,39.870786775],[30.392505939,39.870936978],[30.392411475,39.871455064],[30.392276443,39.871662264],[30.392404686,39.871812468],[30.392370991,39.872014472],[30.392256159,39.872268276],[30.392060441,39.872418563],[30.391803955,39.872491067],[30.391176234,39.87243407],[30.391250498,39.87253767],[30.391196435,39.872755265],[30.391358457,39.873019378],[30.39186464,39.873231776],[30.391871429,39.873335376],[30.3921482,39.873340573],[30.392235958,39.873495973],[30.392154989,39.873760171],[30.391959188,39.874117575],[30.391830945,39.874215979],[30.391776965,39.874402476],[30.391628438,39.874537173],[30.391412436,39.87463038],[30.391311267,39.874987784],[30.391351751,39.875200182],[30.391223424,39.875676777],[30.391075065,39.875909878],[30.390899548,39.876013478],[30.390177363,39.87672309],[30.389893887,39.877059791],[30.38905687,39.877354918],[30.388415654,39.877681225],[30.387902766,39.878069726]]]},"properties":{"id":109873,"name":"Keskin 75.Yıl Baraj Gölü","tags":{"name":"Keskin 75.Yıl Baraj Gölü","water":"reservoir","man_made":"dam"},"type":"reservoir","osm_id":123252241,"source_layer":"osm_water_reservoir"}},{"id":175413336,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[27.179328839,42.434021632],[27.179642154,42.434260348],[27.17993946,42.434476517],[27.180003582,42.435020335],[27.180641445,42.435854921],[27.180776058,42.435878977],[27.180931459,42.435825166],[27.181007985,42.435740425],[27.180987869,42.435540097],[27.180604732,42.435199121],[27.180320669,42.434909862],[27.180289824,42.43437166],[27.180509597,42.434000425],[27.180565589,42.433775036],[27.180539856,42.433622821],[27.180480428,42.433576972],[27.180406416,42.43376766],[27.180378337,42.433921552],[27.180314551,42.434164878],[27.180208519,42.434217517],[27.180070889,42.434183486],[27.17991004,42.434230676],[27.179328839,42.434021632]]]},"properties":{"id":134922,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":175413336,"source_layer":"osm_water_reservoir"}},{"id":125530810,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[73.951962147,18.011199376],[73.953156987,18.010930987],[73.954887096,18.011654094],[73.955374671,18.011197699],[73.954982734,18.010406364],[73.953699967,18.007987263],[73.953176266,18.008871554],[73.952856831,18.009775961],[73.951962147,18.011199376]]]},"properties":{"id":110575,"name":"chandak pazar talav","tags":{"name":"chandak pazar talav","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":125530810,"source_layer":"osm_water_reservoir"}},{"id":363078276,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[79.337678259,13.699153344],[79.33777616,13.699732366],[79.338095426,13.70054298],[79.338657349,13.701320485],[79.33873396,13.7015934],[79.338670089,13.701849719],[79.338601945,13.70198626],[79.338678555,13.702073096],[79.338760195,13.702130177],[79.339618921,13.701485357],[79.339317172,13.700919244],[79.338985081,13.700646329],[79.33868283,13.700472656],[79.338274296,13.700059093],[79.338074136,13.699562716],[79.337878335,13.699091318],[79.337797952,13.699027951],[79.337678259,13.699153344]]]},"properties":{"id":233772,"name":"Akasaganga","tags":{"name":"Akasaganga","water":"reservoir","operator":"Andhra Pradesh Water Resources Department","waterway":"dam"},"type":"reservoir","osm_id":363078276,"source_layer":"osm_water_reservoir"}},{"id":134381273,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[82.887234964,20.951004644],[82.887236221,20.951227686],[82.887656071,20.951335226],[82.888020097,20.951390295],[82.888141551,20.951290131],[82.88820584,20.951064742],[82.888273398,20.950554284],[82.88751492,20.950430567],[82.887382485,20.95057373],[82.88729129,20.950814291],[82.887234964,20.951004644]]]},"properties":{"id":114538,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":134381273,"source_layer":"osm_water_reservoir"}},{"id":133478171,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[82.943090542,20.94707613],[82.94310806,20.947193392],[82.943131194,20.947283079],[82.943247535,20.947347452],[82.943412994,20.947333873],[82.943455322,20.947308644],[82.943419699,20.947081662],[82.943353566,20.94699273],[82.943102696,20.947026844],[82.943090542,20.94707613]]]},"properties":{"id":114284,"name":"","tags":{"water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":133478171,"source_layer":"osm_water_reservoir"}},{"id":303607660,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[53.612824324,36.592004769],[53.614530376,36.593114617],[53.614926337,36.592576918],[53.6151068,36.592144579],[53.615385666,36.592416656],[53.615967873,36.592707424],[53.617874085,36.593112019],[53.618550924,36.593366745],[53.619186859,36.593815763],[53.619542587,36.594360084],[53.618921572,36.596182897],[53.619018047,36.596980267],[53.619740567,36.597936894],[53.619966795,36.597849554],[53.619293057,36.596158254],[53.619772083,36.594222537],[53.618766171,36.593075138],[53.616408425,36.592241223],[53.616285295,36.592092779],[53.617961927,36.591106397],[53.619409985,36.590422853],[53.619366734,36.589838131],[53.619514172,36.589456922],[53.62009772,36.589149893],[53.620651848,36.588990721],[53.621842832,36.589378468],[53.622121782,36.589714498],[53.622636766,36.589679965],[53.622926445,36.58989538],[53.623205395,36.589860846],[53.623312683,36.589611065],[53.623881227,36.5895508],[53.624374838,36.589533533],[53.624900551,36.589206136],[53.625533552,36.589094153],[53.626209469,36.589361285],[53.62610218,36.588792656],[53.626509876,36.588680674],[53.628001184,36.588896089],[53.629320747,36.588663491],[53.629900188,36.58876684],[53.630629749,36.590636172],[53.630973072,36.590963569],[53.631799192,36.590894586],[53.63356945,36.591006568],[53.634459944,36.591626745],[53.63513586,36.592453788],[53.63574732,36.592307356],[53.635446997,36.59152348],[53.634942741,36.590730971],[53.634298927,36.590007362],[53.633161754,36.589903929],[53.632314176,36.589404284],[53.631852836,36.58876684],[53.6320996,36.588094863],[53.631702633,36.587603851],[53.630393631,36.586070466],[53.630500919,36.585381222],[53.630844242,36.585131357],[53.632432194,36.584648979],[53.631445141,36.583537622],[53.629342289,36.584166516],[53.628580541,36.584916026],[53.627808065,36.585148624],[53.627325268,36.585587919],[53.626778097,36.586216898],[53.625297434,36.586397779],[53.623441345,36.587957064],[53.622915632,36.588051864],[53.622196884,36.588120763],[53.621435137,36.587526318],[53.62127412,36.586785442],[53.619686336,36.585019459],[53.618377335,36.585157257],[53.617540569,36.585338139],[53.617240162,36.585725886],[53.617325992,36.586277163],[53.616939754,36.586552844],[53.616340448,36.586622582],[53.615459091,36.587762437],[53.615164467,36.588307177],[53.615173939,36.588663827],[53.615524219,36.58908443],[53.615630417,36.589326332],[53.615389689,36.589808543],[53.614814439,36.590401646],[53.614032491,36.590993576],[53.612824324,36.592004769]]]},"properties":{"id":201779,"name":"دریاچه سد گلورد بهشهر","tags":{"name":"دریاچه سد گلورد بهشهر","water":"reservoir","name:ar":"خزان جيلفارد في بهشهر","name:en":"Gelvard Reservoir of Behshahr","waterway":"dam"},"type":"reservoir","osm_id":303607660,"source_layer":"osm_water_reservoir"}},{"id":-19640604,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[45.766752332,41.330222633],[45.766752332,41.33026295],[45.766787117,41.330315253],[45.76693732,41.330426062],[45.767235129,41.33035557],[45.767294138,41.330228668],[45.767261951,41.330180388],[45.767160027,41.330113836],[45.767007141,41.330069579],[45.766950731,41.330063544],[45.766875629,41.33008978],[45.766771023,41.330168234],[45.766752332,41.330222633]]]},"properties":{"id":27569,"name":"","tags":{"type":"multipolygon","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":-19640604,"source_layer":"osm_water_reservoir"}},{"id":536721147,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[69.219693842,34.14009659],[69.219708092,34.14058274],[69.219807082,34.140881555],[69.219959046,34.14134717],[69.2201582,34.141957121],[69.220307565,34.141910769],[69.220550473,34.141775485],[69.220933358,34.141690493],[69.221180876,34.141790992],[69.221461083,34.141779341],[69.22174129,34.141771546],[69.221988724,34.141875984],[69.222166252,34.14190683],[69.222240935,34.141821837],[69.22218025,34.141609272],[69.222525836,34.141732989],[69.222801349,34.141848995],[69.223030175,34.141806331],[69.223347765,34.141794764],[69.223847495,34.141655624],[69.224365748,34.141373489],[69.224898166,34.141253628],[69.225477272,34.14141213],[69.225962919,34.141593849],[69.226542025,34.141624779],[69.227228587,34.141110633],[69.228092426,34.140785918],[69.228223267,34.140461371],[69.228615456,34.140302869],[69.228947044,34.140028445],[69.229096578,34.139630221],[69.229381395,34.139425451],[69.229773668,34.13888046],[69.233157945,34.138977438],[69.232771791,34.137876392],[69.231429848,34.137454279],[69.230431731,34.137600795],[69.229334624,34.137922995],[69.228471036,34.137963982],[69.228003829,34.138139751],[69.227055333,34.138274448],[69.226262572,34.138239328],[69.225370738,34.138479553],[69.225222043,34.138772417],[69.225066391,34.139000992],[69.224471779,34.139241133],[69.224103645,34.139112219],[69.223721431,34.138936535],[69.223374671,34.138760766],[69.22255358,34.13867284],[69.221980174,34.138573263],[69.221745397,34.138652891],[69.221392687,34.138772417],[69.220769743,34.13905363],[69.220210587,34.139528297],[69.219792916,34.13991487],[69.219693842,34.14009659]]]},"properties":{"id":339810,"name":"دسرخ آب داوبو بند","tags":{"name":"دسرخ آب داوبو بند","water":"reservoir","waterway":"dam"},"type":"reservoir","osm_id":536721147,"source_layer":"osm_water_reservoir"}},{"id":766735416,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[12.63999435,46.8008289],[12.640111026,46.801010368],[12.640176824,46.801007099],[12.640192163,46.800985976],[12.640075487,46.800804341],[12.63999435,46.8008289]]]},"properties":{"id":449987,"name":"","tags":{"water":"reservoir","waterway":"dam","reservoir_type":"water_storage"},"type":"reservoir","osm_id":766735416,"source_layer":"osm_water_reservoir"}},{"id":204364423,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[13.152885436,47.73483468],[13.15291779,47.734928222],[13.152941092,47.734954038],[13.152935141,47.734978262],[13.152983923,47.735118575],[13.152993479,47.735144978],[13.153045363,47.735134417],[13.153029018,47.735096028],[13.153222556,47.735041797],[13.153408383,47.734991673],[13.153364043,47.734917074],[13.153337221,47.734870638],[13.153323977,47.734847756],[13.15330956,47.734822945],[13.153264969,47.734745832],[13.153114765,47.734652541],[13.153001944,47.734677938],[13.15302642,47.734718339],[13.152885436,47.73483468]]]},"properties":{"id":150984,"name":"","tags":{"area":"yes","water":"reservoir","building":"dam","waterway":"dam"},"type":"reservoir","osm_id":204364423,"source_layer":"osm_water_reservoir"}},{"id":204364424,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[13.152125952,47.734895616],[13.152193677,47.734930485],[13.152319574,47.734966946],[13.152443961,47.734996618],[13.152557871,47.735012125],[13.152779321,47.735009359],[13.152752583,47.73495672],[13.152694329,47.734843732],[13.152544376,47.734827974],[13.152308342,47.734889246],[13.152262744,47.734863178],[13.152187559,47.734881032],[13.152125952,47.734895616]]]},"properties":{"id":150983,"name":"","tags":{"area":"yes","layer":"-1","water":"reservoir","building":"dam","location":"surface","waterway":"dam"},"type":"reservoir","osm_id":204364424,"source_layer":"osm_water_reservoir"}},{"id":9285335444,"type":"Feature","geometry":{"type":"Point","coordinates":[170.646956534,-43.173468518]},"properties":{"id":5623238,"tags":{"name":"Amethyst Hydro","power":"generator","operator":"West Power","waterway":"dam","start_date":"June 2013","generator:type":"pelton_turbine","generator:source":"hydro","ref:linz:topo50_id":"7599104","generator:output:electricity":"yes"},"type":"pelton_turbine","method":"","osm_id":9285335444,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":-1587872,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[13.272965084,43.189258852],[13.273445786,43.189320123],[13.273483253,43.189295648],[13.273486438,43.189150641],[13.27322727,43.189117365],[13.273005736,43.189089286],[13.272965084,43.189258852]]]},"properties":{"id":13,"tags":{"type":"multipolygon","layer":"1","power":"generator","waterway":"dam","generator:source":"hydro"},"type":"","method":"","osm_id":-1587872,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":1125793099,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[42.19546504,41.903346578],[42.195512984,41.903405922],[42.196035261,41.903153626],[42.196158894,41.9031864],[42.196302476,41.903284216],[42.196346229,41.903236775],[42.196273055,41.90317676],[42.196182782,41.903132923],[42.196214633,41.903109118],[42.196134921,41.903067628],[42.196166856,41.902990431],[42.196111033,41.902942989],[42.196015395,41.903011218],[42.196015395,41.903085482],[42.195863851,41.903150777],[42.19546504,41.903346578]]]},"properties":{"id":989682,"tags":{"area":"yes","power":"generator","waterway":"dam"},"type":"","method":"","osm_id":1125793099,"output":"","source":"","construction":"","source_layer":"osm_power_generator"}},{"id":1374619746,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[76.947095017,9.337554339],[76.947366591,9.33786162],[76.947436663,9.337879054],[76.947515956,9.337821387],[76.947177076,9.337458786],[76.947095017,9.337554339]]]},"properties":{"id":3386923,"tags":{"name":"Allunkal Dam","power":"generator","name:ml":"അള്ളുങ്കൽ അണക്കെട്ട്","waterway":"dam","generator:method":"run-of-the-river","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"run-of-the-river","osm_id":1374619746,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":1044109177,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[86.594031646,21.54196729],[86.594622487,21.542239032],[86.594849636,21.542235344],[86.595087766,21.54214373],[86.595634266,21.541783475],[86.595704674,21.541673253],[86.595614485,21.541553224],[86.595397897,21.541355663],[86.59518969,21.541109235],[86.594739917,21.541791187],[86.594613434,21.541953628],[86.594548894,21.541960753],[86.594444455,21.54197232],[86.594165422,21.541861595],[86.594031646,21.54196729]]]},"properties":{"id":745469,"tags":{"name":"Radho Dam","power":"generator","waterway":"dam","generator:source":"hydro"},"type":"","method":"","osm_id":1044109177,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":62424860,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.045295694,45.199365883],[4.04530022,45.199453474],[4.045341375,45.199452384],[4.045428295,45.199450037],[4.045429217,45.199468478],[4.045637256,45.199463029],[4.04563164,45.199357082],[4.045468361,45.199361357],[4.045319163,45.199365296],[4.045295694,45.199365883]]]},"properties":{"id":10883,"tags":{"name":"Usine Electrique de Vert","power":"generator","building":"yes","waterway":"dam","generator:source":"hydro"},"type":"","method":"","osm_id":62424860,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":584986830,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.559757129,48.314366087],[4.559763332,48.31446734],[4.559996349,48.314461054],[4.559990649,48.314366925],[4.559918145,48.314368937],[4.559899286,48.314057549],[4.559849498,48.31405889],[4.559867938,48.31436307],[4.559757129,48.314366087]]]},"properties":{"id":145808,"tags":{"area":"yes","power":"generator","waterway":"dam","generator:method":"water-storage","generator:source":"hydro"},"type":"","method":"water-storage","osm_id":584986830,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":122134438,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.931500234,49.360826666],[4.931613642,49.360827336],[4.93163577,49.360749887],[4.931517753,49.360742847],[4.931500234,49.360826666]]]},"properties":{"id":14979,"tags":{"power":"generator","waterway":"dam","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"","osm_id":122134438,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":1022558150,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[6.113064268,45.204825352],[6.113164851,45.205243274],[6.113202066,45.206297466],[6.113089665,45.206569123],[6.113198294,45.20662327],[6.113325532,45.206724859],[6.113428545,45.206790741],[6.113698191,45.206873889],[6.113715039,45.206964917],[6.113724343,45.207121826],[6.113911678,45.207099446],[6.113986445,45.207170692],[6.114211164,45.207282842],[6.11435156,45.20730254],[6.114490197,45.207442434],[6.115085564,45.20743313],[6.115607924,45.208021539],[6.115802636,45.20805842],[6.116036658,45.20804526],[6.116251989,45.208034783],[6.116328768,45.208070322],[6.116427925,45.20816269],[6.116491628,45.208194374],[6.116575866,45.208196972],[6.116641496,45.208228656],[6.116718191,45.208224633],[6.116779965,45.208232596],[6.116866048,45.208281378],[6.116911058,45.208357821],[6.117015078,45.208321444],[6.117148937,45.208323539],[6.117242144,45.208478269],[6.117484632,45.208336866],[6.117346247,45.208065041],[6.117337362,45.207757845],[6.117194618,45.206198056],[6.11714659,45.205144619],[6.117311378,45.205099524],[6.11724952,45.204125547],[6.117226889,45.203570916],[6.11703092,45.203543675],[6.116723555,45.20361643],[6.116583745,45.203795467],[6.116504033,45.203912144],[6.116400517,45.203980288],[6.11608016,45.204082883],[6.115624772,45.204226549],[6.115124288,45.204163098],[6.114668899,45.204260076],[6.114102283,45.204398126],[6.113781842,45.204302992],[6.113419074,45.204364599],[6.113278761,45.204480269],[6.113072231,45.204705994],[6.113064268,45.204825352]]]},"properties":{"id":686262,"tags":{"area":"yes","name":"Barrage de Grand'Maison","layer":"2","power":"generator","operator":"Électricité de France","waterway":"dam","wikidata":"Q505304","wikipedia":"fr:Barrage de Grand'Maison","operator:short":"EDF","generator:method":"dam","generator:source":"hydro","operator:wikidata":"Q274591"},"type":"","method":"dam","osm_id":1022558150,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":501257396,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[6.116056356,47.367185128],[6.116265987,47.367191331],[6.116271938,47.367099046],[6.116273028,47.367080774],[6.116063564,47.367074487],[6.116062391,47.367092676],[6.116056356,47.367185128]]]},"properties":{"id":101900,"tags":{"power":"generator","operator":"Nature Hydro Energie","waterway":"dam","start_date":"2009","description":"Barrage Hydroélectrique à Aux-les-Cromary de 2 DN 3550 de 202 kW","generator:source":"hydro","generator:output:electricity":"404 kW"},"type":"","method":"","osm_id":501257396,"output":"404 kW","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":568497356,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.950346347,48.448810221],[7.950938528,48.448984649],[7.950946323,48.448972244],[7.950925955,48.448959084],[7.950879268,48.448935447],[7.950764687,48.448906865],[7.95079503,48.44886353],[7.950845573,48.448875936],[7.950916651,48.44889337],[7.950945066,48.448883395],[7.950844651,48.448859423],[7.950704589,48.448821537],[7.950598893,48.448789686],[7.950398566,48.448693294],[7.95038549,48.448716093],[7.950496047,48.448772251],[7.950649185,48.448821369],[7.950606437,48.448858836],[7.950518846,48.44883763],[7.950366044,48.44880427],[7.950346347,48.448810221]]]},"properties":{"id":143850,"tags":{"area":"yes","name":"WKA Großer Deich Offenburg/Kinzig","layer":"1","power":"generator","website":"http://www.hydroenergie.de/wka-grosser-deich-offenburg-kinzig-187","operator":"Elektrizitätswerk Mittelbaden Wasserkraft GmbH & Co.KG","waterway":"dam","generator:type":"kaplan_turbine","generator:method":"run-of-the-river","generator:source":"hydro","generator:output:electricity":"465 kW"},"type":"kaplan_turbine","method":"run-of-the-river","osm_id":568497356,"output":"465 kW","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":691372848,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.320103074,47.685041984],[10.320296193,47.685156481],[10.320434997,47.685008289],[10.32048906,47.684950454],[10.320549159,47.684809973],[10.320301725,47.684843752],[10.320252774,47.684892619],[10.320103074,47.685041984]]]},"properties":{"id":221275,"tags":{"power":"generator","waterway":"dam","plant:source":"hydro","generator:method":"run-of-the-river","generator:source":"hydro","generator:output:electricity":"0,9 MW"},"type":"","method":"run-of-the-river","osm_id":691372848,"output":"0,9 MW","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":9571152801,"type":"Feature","geometry":{"type":"Point","coordinates":[10.620584244,52.529132924]},"properties":{"id":5678810,"tags":{"power":"generator","waterway":"dam"},"type":"","method":"","osm_id":9571152801,"output":"","source":"","construction":"","source_layer":"osm_power_generator"}},{"id":598321537,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[12.286740298,45.651654887],[12.286974991,45.651801235],[12.287082866,45.651716662],[12.287046573,45.651693947],[12.287084291,45.651664526],[12.286927885,45.651567045],[12.286885976,45.651599986],[12.286843898,45.65157375],[12.286740298,45.651654887]]]},"properties":{"id":148597,"tags":{"name":"Centrale Idroelettrica Silea","power":"generator","voltage":"15000","building":"yes","operator":"Sied Spa","waterway":"dam","generator:type":"kaplan_turbine","generator:method":"run-of-the-river","generator:source":"hydro"},"type":"kaplan_turbine","method":"run-of-the-river","osm_id":598321537,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":103465589,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[12.5815175,49.805362661],[12.581518589,49.805525856],[12.581531497,49.805541447],[12.581872725,49.806359436],[12.582127618,49.806928149],[12.582525843,49.806751626],[12.58250405,49.806413584],[12.582433725,49.806302691],[12.582369772,49.806285592],[12.582362563,49.80626908],[12.582314619,49.806158019],[12.582384272,49.806143183],[12.582402461,49.80603732],[12.582303219,49.805945873],[12.582055031,49.805725681],[12.581639624,49.805357045],[12.5815175,49.805362661]]]},"properties":{"id":13757,"tags":{"name":"Přehrada Lučina","power":"generator","height":"26.2","waterway":"dam","start_date":"1975","description":"Uvnitř sypanné kamenité hráze se nachází elektrárna vybavená dvěma turbínami o hltnostech 0,55 a 0,85 m3/s, které zde při spádu 15 m vyrobí průměrně kolem 571 MWh elektrické energie za rok, což stačí k zásobení asi 200 domácností.","plant:source":"hydro","generator:source":"hydro","plant:output:electricity":"150 kW","generator:output:electricity":"150 kW"},"type":"","method":"","osm_id":103465589,"output":"150 kW","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":768630684,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[18.413488232,49.510241633],[18.41352134,49.510258732],[18.413608344,49.510303995],[18.413788639,49.510397453],[18.413811438,49.510454953],[18.413842283,49.510522092],[18.413879834,49.510513291],[18.414070271,49.51067875],[18.414343856,49.510988796],[18.414424993,49.511195997],[18.41443765,49.51142256],[18.416025518,49.512042318],[18.415862657,49.512199143],[18.415876739,49.512230072],[18.415505924,49.512327135],[18.415491842,49.51231188],[18.415058665,49.512433333],[18.415152962,49.512549926],[18.415233679,49.512649754],[18.415454962,49.512553949],[18.415569626,49.51252126],[18.415621929,49.512590578],[18.415662162,49.512577418],[18.415687643,49.512611449],[18.415709771,49.51260533],[18.415675573,49.512556547],[18.415757381,49.512530899],[18.415733241,49.512496449],[18.415770792,49.512485134],[18.415745227,49.512452025],[18.415707089,49.512463844],[18.415686218,49.512434674],[18.415916972,49.51236192],[18.415928371,49.512377175],[18.415967934,49.512365859],[18.415960558,49.512353621],[18.417525627,49.511916757],[18.417567536,49.511928575],[18.417572817,49.511921199],[18.417545743,49.511912817],[18.417810528,49.511846433],[18.417880098,49.511820532],[18.417974226,49.51178558],[18.41806802,49.511728918],[18.418106996,49.511741072],[18.418144547,49.511717519],[18.418144547,49.511704527],[18.418136416,49.511691451],[18.418321572,49.511580894],[18.418377899,49.511533871],[18.418396674,49.511427673],[18.418228366,49.511302363],[18.417922761,49.511460865],[18.417861657,49.51147436],[18.417614475,49.511510151],[18.415473486,49.510646395],[18.414428598,49.510197461],[18.414108996,49.510032086],[18.414047891,49.509968635],[18.413973795,49.50993846],[18.41391068,49.50989412],[18.413879834,49.509886324],[18.413816719,49.509886324],[18.413769864,49.509907279],[18.413665258,49.510048263],[18.413488232,49.510241633]]]},"properties":{"id":312303,"tags":{"name":"Šance","power":"generator","waterway":"dam","generator:method":"water-storage","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"water-storage","osm_id":768630684,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":203164337,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[18.531263862,49.582374699],[18.531443654,49.582462374],[18.532383936,49.58274518],[18.533950597,49.583216243],[18.534477065,49.583374577],[18.53582169,49.58377892],[18.536114134,49.583624777],[18.53579755,49.583385473],[18.53577123,49.583432579],[18.535685567,49.583449595],[18.534130389,49.582976353],[18.532554591,49.582498835],[18.531874484,49.582291383],[18.531793598,49.58223908],[18.531939946,49.581845969],[18.531627637,49.581780171],[18.531263862,49.582374699]]]},"properties":{"id":22510,"tags":{"name":"Morávka","power":"generator","waterway":"dam","generator:method":"water-storage","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"water-storage","osm_id":203164337,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":9853726765,"type":"Feature","geometry":{"type":"Point","coordinates":[20.570611917,50.166982271]},"properties":{"id":5725373,"tags":{"power":"generator","waterway":"dam","generator:source":"hydro"},"type":"","method":"","osm_id":9853726765,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":452067563,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[26.25772115,49.265868252],[26.257776555,49.265881663],[26.257766497,49.265896248],[26.257786948,49.265900942],[26.257797677,49.265886944],[26.257855848,49.265901025],[26.257848388,49.265912592],[26.257868337,49.265918544],[26.257876383,49.265905468],[26.257937655,49.265922064],[26.257932374,49.265934553],[26.257957436,49.265941175],[26.257964561,49.265929691],[26.258017032,49.265943522],[26.25800865,49.265957184],[26.258040333,49.265965482],[26.258049134,49.265951149],[26.258105377,49.265966069],[26.258130271,49.265926087],[26.258058438,49.265905635],[26.258071095,49.265886692],[26.258042261,49.265878897],[26.258031029,49.265898846],[26.257979648,49.265886692],[26.257990545,49.26586733],[26.257960035,49.265859367],[26.257948551,49.265878562],[26.257902283,49.265867582],[26.25791427,49.265847549],[26.257904044,49.265845453],[26.257882921,49.265839837],[26.257871354,49.265859535],[26.257820392,49.265846292],[26.257830031,49.265830282],[26.257805556,49.26582366],[26.257795246,49.265840843],[26.257742273,49.265829025],[26.25772115,49.265868252]]]},"properties":{"id":94603,"tags":{"name":"Сатанівська гідроелектростанція","power":"generator","name:en":"Sataniv Hydroelectric Power Station","name:uk":"Сатанівська гідроелектростанція","waterway":"dam","wikidata":"Q25421634","wikipedia":"uk:Сатанівська ГЕС","generator:type":"francis_turbine","generator:source":"hydro","generator:output:electricity":"yes"},"type":"francis_turbine","method":"","osm_id":452067563,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":428977583,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[35.996858631,48.20206384],[35.997009254,48.202242374],[35.997168258,48.202182863],[35.997471851,48.202069036],[35.997321228,48.201890586],[35.997029119,48.20199997],[35.996858631,48.20206384]]]},"properties":{"id":91048,"tags":{"power":"generator","waterway":"dam","generator:method":"water-storage","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"water-storage","osm_id":428977583,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":-18113688,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[28.954778959,61.032465651],[28.954839979,61.032489791],[28.955101327,61.032335061],[28.95517777,61.032289798],[28.955116666,61.032265658],[28.95506277,61.032297594],[28.955044581,61.032308322],[28.955027985,61.032318129],[28.954778959,61.032465651]]]},"properties":{"id":6051,"tags":{"type":"multipolygon","power":"generator","waterway":"dam","generator:method":"water-storage","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"water-storage","osm_id":-18113688,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":92540979,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[34.320798688,63.879016965],[34.320848225,63.879065999],[34.32106188,63.879023922],[34.321153159,63.879114028],[34.320929948,63.879157865],[34.320962135,63.879189633],[34.321184172,63.879146047],[34.321264554,63.879225339],[34.321043942,63.879268674],[34.32107923,63.879303543],[34.321300596,63.879260124],[34.321378464,63.87933707],[34.321153745,63.879381243],[34.321189285,63.879416279],[34.321406543,63.879373532],[34.321489776,63.879455674],[34.321279222,63.879497081],[34.321316019,63.879533374],[34.321526237,63.879492052],[34.321610643,63.879575368],[34.321391037,63.879618451],[34.321450548,63.879677124],[34.321944829,63.879580062],[34.321886743,63.879522646],[34.321740395,63.879551479],[34.321645344,63.879457686],[34.321717177,63.87944352],[34.321696054,63.879422817],[34.321629251,63.879435977],[34.321529338,63.879337489],[34.321598741,63.879323827],[34.32157854,63.879303878],[34.321508719,63.87931754],[34.321414422,63.879224417],[34.32148877,63.879209833],[34.321465552,63.87918695],[34.321395144,63.879200864],[34.321298249,63.879105227],[34.321365891,63.879091983],[34.321343344,63.879069771],[34.321280396,63.879082177],[34.321191631,63.878994586],[34.32131736,63.878969859],[34.321270924,63.878924178],[34.320798688,63.879016965]]]},"properties":{"id":12709,"tags":{"power":"generator","building":"industrial","waterway":"dam","building:levels":"3","generator:source":"hydro"},"type":"","method":"","osm_id":92540979,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":84037746,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[32.811295878,68.832858785],[32.811700389,68.833420959],[32.812272789,68.833343594],[32.811894681,68.832796424],[32.811295878,68.832858785]]]},"properties":{"id":11926,"tags":{"name":"Туломская ГЭС","power":"generator","waterway":"dam","wikidata":"Q4318962","wikipedia":"ru:Нижнетуломская ГЭС","generator:method":"run-of-the-river","generator:source":"hydro"},"type":"","method":"run-of-the-river","osm_id":84037746,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":-19921964,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[87.775923244,68.088848286],[87.776749365,68.089028497],[87.77614855,68.089372742],[87.776695721,68.089492854],[87.777275162,68.089196554],[87.777693586,68.089284648],[87.779363597,68.088276473],[87.778639149,68.088120821],[87.778291048,68.088039684],[87.777692748,68.087901466],[87.776895797,68.088335481],[87.775923244,68.088848286]]]},"properties":{"id":8158,"tags":{"name":"Плотина Усть-Хантайской ГЭС","type":"multipolygon","power":"generator","waterway":"dam","start_date":"1970..1972","generator:type":"kaplan_turbine","generator:source":"hydro","generator:output:electricity":"yes"},"type":"kaplan_turbine","method":"","osm_id":-19921964,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":9779975257,"type":"Feature","geometry":{"type":"Point","coordinates":[109.617069786,30.142948686]},"properties":{"id":5716666,"tags":{"name":"Dongping Dam","power":"generator","name:zh":"洞坪电站","waterway":"dam","generator:source":"hydro"},"type":"","method":"","osm_id":9779975257,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":443375479,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[126.176503792,40.026904247],[126.176926659,40.027060821],[126.177899882,40.027421076],[126.178632628,40.02769223],[126.180748471,40.028475351],[126.180796835,40.028398741],[126.18082877,40.028348282],[126.180859951,40.028298828],[126.180701617,40.028240239],[126.178736479,40.027512857],[126.178012618,40.027244972],[126.176615271,40.026727725],[126.176560034,40.026815232],[126.176503792,40.026904247]]]},"properties":{"id":93262,"tags":{"power":"generator","waterway":"dam","generator:method":"run-of-the-river","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"run-of-the-river","osm_id":443375479,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":420411993,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-80.247276858,-0.688456962],[-80.247276858,-0.688377753],[-80.246909563,-0.688365516],[-80.246751816,-0.688376412],[-80.246753073,-0.68851572],[-80.246753827,-0.688540027],[-80.246810741,-0.688554025],[-80.246850303,-0.68857498],[-80.246848208,-0.688812523],[-80.246844939,-0.689068422],[-80.24675651,-0.68906507],[-80.246748463,-0.689189122],[-80.246900678,-0.689186272],[-80.247274176,-0.68918644],[-80.247182981,-0.689081833],[-80.247051469,-0.689072865],[-80.247052726,-0.688816546],[-80.247054235,-0.688577662],[-80.247115926,-0.688577662],[-80.247117854,-0.688550337],[-80.247177533,-0.688553438],[-80.247276858,-0.688456962]]]},"properties":{"id":90114,"tags":{"area":"yes","name":"La Represa Nuevo Simbocal","power":"generator","waterway":"dam","generator:source":"hydro"},"type":"","method":"","osm_id":420411993,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":-7603722,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-45.47131782,-21.923765577],[-45.471210532,-21.92387253],[-45.470802836,-21.924444847],[-45.470888667,-21.924457252],[-45.470987909,-21.924385168],[-45.471195612,-21.924094064],[-45.471250765,-21.924016867],[-45.47131782,-21.923765577]]]},"properties":{"id":267,"tags":{"type":"multipolygon","power":"generator","waterway":"dam","generator:source":"hydro"},"type":"","method":"","osm_id":-7603722,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":111556614,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-40.572925224,-20.393435415],[-40.572924302,-20.393411023],[-40.572901419,-20.393411778],[-40.572378556,-20.393429044],[-40.572285014,-20.393432146],[-40.572287277,-20.393489813],[-40.572491963,-20.393483024],[-40.572771919,-20.39347372],[-40.572770745,-20.393440527],[-40.572925224,-20.393435415]]]},"properties":{"id":14387,"tags":{"name":"Usina Jucu","power":"generator","waterway":"dam","generator:method":"run-of-the-river","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"run-of-the-river","osm_id":111556614,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":164091213,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-79.046197826,43.149570791],[-79.046047539,43.149610186],[-79.046036559,43.149587974],[-79.045636239,43.149660478],[-79.044903158,43.148153495],[-79.045276069,43.148071437],[-79.045235752,43.147989462],[-79.045260143,43.147983091],[-79.045237847,43.147937829],[-79.045472876,43.147876138],[-79.045493328,43.14791788],[-79.045513444,43.147912683],[-79.045569854,43.148027096],[-79.045452172,43.148057942],[-79.045515959,43.148187442],[-79.045645878,43.148450969],[-79.045737073,43.148635958],[-79.045895659,43.148957739],[-79.04598962,43.149148427],[-79.046137058,43.149447326],[-79.046197826,43.149570791]]]},"properties":{"id":17616,"tags":{"name":"Sir Adam Beck GS 1","power":"generator","waterway":"dam","generator:method":"water-storage","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"water-storage","osm_id":164091213,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":-13547486,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-77.62832045,43.176129271],[-77.628166558,43.176371675],[-77.627992215,43.176416519],[-77.628264375,43.17696964],[-77.62827502,43.176991433],[-77.628104113,43.177036863],[-77.628034124,43.176892862],[-77.627969584,43.17691038],[-77.62788501,43.176736288],[-77.627823319,43.176752884],[-77.627943264,43.176473264],[-77.627730364,43.17605945],[-77.62765434,43.175972026],[-77.627505059,43.175947635],[-77.627277825,43.175980324],[-77.627286207,43.17592492],[-77.627386538,43.175921567],[-77.627427107,43.175821823],[-77.627667919,43.175757282],[-77.627912251,43.176253826],[-77.62796271,43.176240331],[-77.628008643,43.176255754],[-77.628125319,43.176063473],[-77.628250126,43.176054672],[-77.62832045,43.176129271]]]},"properties":{"id":2790,"tags":{"name":"Station 5 Headgates Dam","type":"multipolygon","power":"generator","operator":"RG&E","waterway":"dam","ref:US:NID":"NY00484","generator:method":"run-of-the-river","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"run-of-the-river","osm_id":-13547486,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":652413844,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-5.865157578,41.474753298],[-5.864919951,41.474753298],[-5.864915928,41.474700995],[-5.864827331,41.474699067],[-5.864823559,41.47454308],[-5.864839904,41.474542074],[-5.864841161,41.474521287],[-5.864805873,41.474480635],[-5.864807131,41.474464625],[-5.864870079,41.474463619],[-5.864868905,41.474439647],[-5.864868319,41.47442808],[-5.864919364,41.474428164],[-5.864920873,41.474523131],[-5.865153052,41.474519191],[-5.865157578,41.474753298]]]},"properties":{"id":185398,"tags":{"power":"generator","waterway":"dam","generator:source":"hydro"},"type":"","method":"","osm_id":652413844,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":212753929,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-7.71471202,42.452996416],[-7.714337182,42.453035727],[-7.713936611,42.453037739],[-7.713939963,42.45271227],[-7.713942226,42.452399541],[-7.714111038,42.452389231],[-7.714123611,42.452711264],[-7.714123611,42.452961967],[-7.714412954,42.452973031],[-7.714681343,42.452939755],[-7.71471202,42.452996416]]]},"properties":{"id":23768,"tags":{"area":"yes","name":"Encoro de San Pedro","power":"generator","waterway":"dam","wikidata":"Q105729417","wikipedia":"es:Embalse de San Pedro (Galicia)","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"","osm_id":212753929,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":993362237,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-84.334078437,45.391610784],[-84.33342666,45.391898953],[-84.333353318,45.39185587],[-84.333276456,45.391814129],[-84.333084259,45.391990568],[-84.333193475,45.392057874],[-84.332858031,45.392011941],[-84.332326954,45.39187255],[-84.332631133,45.391748331],[-84.332971774,45.391580273],[-84.333694713,45.391223372],[-84.333764618,45.391181295],[-84.333995204,45.39126042],[-84.334078437,45.391610784]]]},"properties":{"id":615471,"tags":{"area":"yes","name":"Kleber Dam","power":"generator","height":"12.2","material":"soil","operator":"Tower Kleber Limited Partnership","waterway":"dam","wikidata":"Q33130945","ref:US:NID":"MI00546","gnis:feature_id":"629782","generator:method":"water-storage","generator:source":"hydro","generator:output:electricity":"1200 kW"},"type":"","method":"water-storage","osm_id":993362237,"output":"1200 kW","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":92705055,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-74.36156195,45.636834257],[-74.36153496,45.636923776],[-74.361518028,45.636975995],[-74.360369708,45.63679327],[-74.360385717,45.636742727],[-74.360409438,45.636664272],[-74.36156195,45.636834257]]]},"properties":{"id":12710,"tags":{"layer":"1","power":"generator","man_made":"dam"},"type":"","method":"","osm_id":92705055,"output":"","source":"","construction":"","source_layer":"osm_power_generator"}},{"id":12069881339,"type":"Feature","geometry":{"type":"Point","coordinates":[-3.69941386,52.366541856]},"properties":{"id":5961435,"tags":{"power":"generator","waterway":"dam","generator:source":"hydro"},"type":"","method":"","osm_id":12069881339,"output":"","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":305696476,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-3.577944073,52.268480127],[-3.5778279,52.268508541],[-3.57778423,52.268514325],[-3.577210405,52.268192627],[-3.577163215,52.268187598],[-3.577055508,52.268173097],[-3.576938748,52.26817075],[-3.576829364,52.268186425],[-3.576704809,52.267997664],[-3.576581176,52.267810161],[-3.576641358,52.267784429],[-3.57671252,52.267734137],[-3.576776306,52.267658281],[-3.576805308,52.267577396],[-3.576894827,52.267176657],[-3.577029021,52.267141117],[-3.577469322,52.267785434],[-3.577944073,52.268480127]]]},"properties":{"id":39137,"tags":{"name":"Caban-Coch Dam","power":"generator","waterway":"dam","wikidata":"Q17741532","dam_height":"122 feet","weir_length":"566 feet","construction":"masonary","base_thickness":"122.5 feet","foundation_depth":"25 feet","generator:method":"water-storage","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"water-storage","osm_id":305696476,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":158922359,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-3.598173964,52.293319897],[-3.598156614,52.2933406],[-3.597705164,52.293974775],[-3.597218511,52.294733085],[-3.597098817,52.294705844],[-3.597079371,52.294485735],[-3.597089933,52.294209719],[-3.597066128,52.294044344],[-3.597167046,52.293862373],[-3.597240472,52.293758019],[-3.597594104,52.293616448],[-3.59794782,52.293392149],[-3.598000878,52.293344036],[-3.598033651,52.293314281],[-3.598173964,52.293319897]]]},"properties":{"id":17142,"tags":{"ref":"73584","name":"Pen-y-Garreg Dam","power":"generator","operator":"Dŵr Cymru Welsh Water","waterway":"dam","generator:method":"water-storage","generator:source":"hydro","generator:output:electricity":"810 kW"},"type":"","method":"water-storage","osm_id":158922359,"output":"810 kW","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":158921673,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-3.625244326,52.30451485],[-3.625169224,52.304565142],[-3.62489874,52.304717776],[-3.624696569,52.304812492],[-3.624456763,52.304900921],[-3.624093659,52.30499857],[-3.623718568,52.305063027],[-3.623316237,52.30510963],[-3.623125046,52.305127148],[-3.6229906,52.305136368],[-3.62289731,52.30514869],[-3.622900159,52.305106529],[-3.622926311,52.305059674],[-3.623124962,52.305030337],[-3.623294109,52.304974179],[-3.623639862,52.304796482],[-3.623755616,52.304783155],[-3.624013109,52.30475692],[-3.624227769,52.304638819],[-3.62461451,52.304627838],[-3.624883066,52.304578385],[-3.625052129,52.304535637],[-3.625183055,52.304491884],[-3.625211469,52.304505546],[-3.625244326,52.30451485]]]},"properties":{"id":17143,"tags":{"name":"Craig Goch Dam","power":"generator","waterway":"dam","generator:method":"water-storage","generator:source":"hydro","generator:output:electricity":"yes"},"type":"","method":"water-storage","osm_id":158921673,"output":"yes","source":"hydro","construction":"","source_layer":"osm_power_generator"}},{"id":121546174,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-46.674955847,-23.703552327],[-46.674944867,-23.703511339],[-46.67486901,-23.703230126],[-46.674640352,-23.703281843],[-46.674553516,-23.702979759],[-46.674552342,-23.702975065],[-46.674458046,-23.702604836],[-46.674436001,-23.70252328],[-46.674062168,-23.702607602],[-46.674098462,-23.702742467],[-46.67418446,-23.703061734],[-46.674354362,-23.703692723],[-46.674503979,-23.703802443],[-46.674475312,-23.703688449],[-46.674470367,-23.703661962],[-46.674955847,-23.703552327]]]},"properties":{"id":1463,"name":"Usina Elevatória de Pedreira","tags":{"name":"Usina Elevatória de Pedreira","alt_name":"Usina de Pedreira","man_made":"pumping_station","operator":"EMAE","waterway":"dam","wikidata":"Q10388342","wikipedia":"pt:Usina Elevatória de Pedreira","start_date":"1939"},"osm_id":121546174,"substance":"","pumping_station":"","source_layer":"osm_pumping_station"}},{"id":86643657,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-46.694814755,-23.595078765],[-46.694757171,-23.594900649],[-46.694429606,-23.594989665],[-46.694151663,-23.59506527],[-46.69376182,-23.595171217],[-46.693778081,-23.595221257],[-46.69373416,-23.595233243],[-46.693746733,-23.595272219],[-46.69377213,-23.595350506],[-46.693775567,-23.595361235],[-46.693864247,-23.595337179],[-46.694218047,-23.595240955],[-46.69456313,-23.595147161],[-46.694814755,-23.595078765]]]},"properties":{"id":959,"name":"Usina Elevatória de Traição","tags":{"name":"Usina Elevatória de Traição","layer":"1","alt_name":"Usina de Traição","building":"yes","man_made":"pumping_station","operator":"EMAE","waterway":"dam","wikidata":"Q10388344","wikipedia":"pt:Usina Elevatória de Traição","start_date":"1940"},"osm_id":86643657,"substance":"","pumping_station":"","source_layer":"osm_pumping_station"}},{"id":787154263,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-43.896919634,-22.629916528],[-43.896916532,-22.629827345],[-43.89667111,-22.629699437],[-43.896031236,-22.629633136],[-43.8960366,-22.629538169],[-43.895953619,-22.629529871],[-43.895959403,-22.629429205],[-43.895662432,-22.629404813],[-43.895243421,-22.629355528],[-43.895242247,-22.629424008],[-43.895056001,-22.629458374],[-43.895008644,-22.62951931],[-43.895030604,-22.629778143],[-43.895162619,-22.630115934],[-43.895286336,-22.630374348],[-43.895446514,-22.630364038],[-43.89560116,-22.630386083],[-43.89576511,-22.630409468],[-43.895638879,-22.631136766],[-43.895965019,-22.631366011],[-43.896016819,-22.631150428],[-43.895922858,-22.630981952],[-43.89610265,-22.630282734],[-43.896441027,-22.630162118],[-43.896744536,-22.630029014],[-43.896919634,-22.629916528]]]},"properties":{"id":11716,"name":"Usina Elevatória de Vigário","tags":{"name":"Usina Elevatória de Vigário","man_made":"pumping_station","waterway":"dam"},"osm_id":787154263,"substance":"","pumping_station":"","source_layer":"osm_pumping_station"}},{"id":676431737,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[65.445178591,38.2425906],[65.445333991,38.242806434],[65.445524093,38.242722196],[65.446075874,38.243488972],[65.446522797,38.243290656],[65.445980487,38.242536956],[65.446093978,38.24248658],[65.445929023,38.242257419],[65.445178591,38.2425906]]]},"properties":{"id":10004,"name":"","tags":{"area":"yes","building":"yes","man_made":"pumping_station","waterway":"dam","substance":"water","pumping_station":"water"},"osm_id":676431737,"substance":"water","pumping_station":"water","source_layer":"osm_pumping_station"}},{"id":-17406482,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.826184548,48.186399571],[14.826409267,48.186509123],[14.826398454,48.18651147],[14.826403483,48.186526725],[14.826419576,48.186537035],[14.826463749,48.186558325],[14.826355622,48.186653794],[14.826478417,48.186716072],[14.826497528,48.186725795],[14.826526865,48.186699224],[14.826557291,48.18671406],[14.826527451,48.186740966],[14.826541784,48.186748259],[14.826653851,48.186646754],[14.826693497,48.186665864],[14.826714619,48.186661422],[14.826712356,48.186648346],[14.826690563,48.186637869],[14.826710177,48.186620183],[14.826732976,48.186631331],[14.826744627,48.186629655],[14.826745465,48.186620099],[14.826722917,48.186608532],[14.826744962,48.186588583],[14.826767342,48.186599731],[14.82677807,48.186597384],[14.826778825,48.186589086],[14.82675678,48.186577938],[14.826778825,48.186557989],[14.826800366,48.186568718],[14.826810341,48.186566371],[14.826811598,48.186558325],[14.826790224,48.18654768],[14.826810927,48.186529072],[14.826796427,48.186522199],[14.826775975,48.186540555],[14.826728366,48.18651675],[14.826747225,48.186499065],[14.826724929,48.186488336],[14.826607247,48.186431842],[14.82658646,48.186450198],[14.82653072,48.186422035],[14.826549747,48.186404349],[14.826531642,48.186395632],[14.826418319,48.186501244],[14.82619293,48.18639186],[14.826184548,48.186399571]],[[14.82669467,48.186546758],[14.826716966,48.186526976],[14.826764659,48.186550865],[14.826742531,48.186570897],[14.82669467,48.186546758]],[[14.826660891,48.186577352],[14.826682852,48.186557319],[14.82673088,48.186581459],[14.826708752,48.186601491],[14.826660891,48.186577352]],[[14.826628118,48.186607359],[14.826647564,48.186589338],[14.826695844,48.186613142],[14.826676063,48.186630828],[14.826628118,48.186607359]],[[14.82657615,48.186654549],[14.82660884,48.186625547],[14.82663935,48.186639881],[14.826606744,48.186669385],[14.82657615,48.186654549]],[[14.826537174,48.18668992],[14.826565841,48.186663937],[14.826596351,48.186678773],[14.826567685,48.186704673],[14.826537174,48.18668992]],[[14.826496271,48.186453551],[14.826520494,48.186432261],[14.82657548,48.186459753],[14.82655134,48.186481127],[14.826496271,48.186453551]],[[14.826466599,48.186481379],[14.826486464,48.186463777],[14.826540443,48.186490766],[14.826520494,48.186508452],[14.826466599,48.186481379]],[[14.826436256,48.186509961],[14.826456792,48.186491521],[14.826509933,48.186517924],[14.826489481,48.186536364],[14.826436256,48.186509961]]]},"properties":{"id":152,"name":"Pumpwerk Ardagger","tags":{"name":"Pumpwerk Ardagger","type":"multipolygon","layer":"1","man_made":"pumping_station","operator":"Verbund","waterway":"dam","pumping_station":"water"},"osm_id":-17406482,"substance":"","pumping_station":"water","source_layer":"osm_pumping_station"}},{"id":760461694,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[117.276191223,31.646233465],[117.276227181,31.646976269],[117.276800922,31.646956153],[117.276765048,31.646213349],[117.276191223,31.646233465]]]},"properties":{"id":11359,"name":"派河泵站枢纽","tags":{"name":"派河泵站枢纽","man_made":"pumping_station","waterway":"dam"},"osm_id":760461694,"substance":"","pumping_station":"","source_layer":"osm_pumping_station"}},{"id":514833328,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[117.746677171,32.050853161],[117.747245967,32.051010992],[117.747295169,32.050883587],[117.746726373,32.050725756],[117.746677171,32.050853161]]]},"properties":{"id":7513,"name":"滁河三级站","tags":{"name":"滁河三级站","man_made":"pumping_station","waterway":"dam","substance":"water","pumping_station":"water"},"osm_id":514833328,"substance":"water","pumping_station":"water","source_layer":"osm_pumping_station"}},{"id":514833338,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[117.786860098,32.015241136],[117.787100994,32.015316322],[117.787346416,32.015393017],[117.787464434,32.015121359],[117.787216246,32.015043826],[117.786978199,32.014969479],[117.786860098,32.015241136]]]},"properties":{"id":7512,"name":"滁河二级站","tags":{"name":"滁河二级站","man_made":"pumping_station","waterway":"dam","substance":"water","pumping_station":"water"},"osm_id":514833338,"substance":"water","pumping_station":"water","source_layer":"osm_pumping_station"}},{"id":426020034,"type":"Feature","geometry":{"type":"LineString","coordinates":[[-45.237219646,-20.63182923],[-45.23707464,-20.632024612],[-45.236927118,-20.632223347],[-45.237600017,-20.633131526],[-45.23801576,-20.634269118],[-45.23928763,-20.63543898]]},"properties":{"id":65305,"tags":{"layer":"-1","man_made":"pipeline","waterway":"dam","substance":"water"},"type":"","osm_id":426020034,"substance":"water","construction":"","source_layer":"osm_pipeline"}},{"id":422973329,"type":"Feature","geometry":{"type":"LineString","coordinates":[[-82.400310678,23.0387685],[-82.400298608,23.038626259]]},"properties":{"id":64939,"tags":{"name":"Acueducto de Albear","man_made":"pipeline","waterway":"dam"},"type":"","osm_id":422973329,"substance":"","construction":"","source_layer":"osm_pipeline"}},{"id":379870615,"type":"Feature","geometry":{"type":"LineString","coordinates":[[68.835025339,41.066041222],[68.83503808,41.066204166],[68.835054257,41.066335175]]},"properties":{"id":59373,"tags":{"man_made":"pipeline","waterway":"dam"},"type":"","osm_id":379870615,"substance":"","construction":"","source_layer":"osm_pipeline"}},{"id":-16305324,"type":"Feature","geometry":{"type":"LineString","coordinates":[[-119.451909237,37.144768736],[-119.451919631,37.144904355],[-119.451986602,37.144900248],[-119.451984842,37.144934446],[-119.451878057,37.145098732],[-119.451848971,37.145100073],[-119.451781162,37.14523192],[-119.451717375,37.145306687],[-119.451708574,37.145356643],[-119.451612937,37.1453693],[-119.451593323,37.145475247],[-119.451592569,37.145557222],[-119.451554347,37.145770122],[-119.451542361,37.145962487],[-119.451506822,37.146157618],[-119.451512941,37.146187122],[-119.451456112,37.146192654],[-119.451437168,37.14603893],[-119.451368018,37.145477594],[-119.451350667,37.145336443],[-119.451284115,37.144795391],[-119.451225525,37.144047725],[-119.451218401,37.143951836],[-119.451290234,37.143947394],[-119.45145477,37.144058538],[-119.451572117,37.144217458],[-119.4516411,37.144261128],[-119.451782251,37.144343019],[-119.451851318,37.14441963],[-119.451874033,37.144510071],[-119.45189943,37.144588274],[-119.451909237,37.144768736]]},"properties":{"id":182097,"role":"part","tags":{"name":"Big Creek Number 7 104-022 Dam","power":"plant","alt_name":"Big Creek Dam Number 7","waterway":"dam","nhd:fdate":"Thu Mar 27 00:00:00 PST 2003","nhd:com_id":"77258663","ref:US:NID":"CA00440","plant:source":"hydro","gnis:feature_id":"1663705"},"member":76455894,"osm_id":-16305324,"source_layer":"osm_power_plant_relation_member"}},{"id":-18480861,"type":"Feature","geometry":{"type":"LineString","coordinates":[[-84.183873474,9.972214194],[-84.183872636,9.97216516],[-84.183799127,9.972133895],[-84.183794936,9.972139679],[-84.183750177,9.972130878],[-84.183740621,9.972106738],[-84.18360517,9.972145546],[-84.183536857,9.972161137],[-84.183494109,9.972170357],[-84.183477429,9.972177398],[-84.183485811,9.972200364],[-84.183292525,9.972380742],[-84.183316245,9.972450899],[-84.183348264,9.97244126],[-84.183368549,9.972376468],[-84.183410458,9.97232165],[-84.183496456,9.972246967],[-84.183517076,9.972310921],[-84.183574911,9.972294577],[-84.183551525,9.972213943],[-84.183627382,9.972192066],[-84.183704244,9.972170357],[-84.183725031,9.972250488],[-84.183754368,9.972240932],[-84.183826452,9.972280327],[-84.183873474,9.972214194]]},"properties":{"id":224717,"role":"","tags":{"name":"Puente Mulas","power":"plant","waterway":"dam"},"member":25380234,"osm_id":-18480861,"source_layer":"osm_power_plant_relation_member"}},{"id":904985009,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[45.183470442,41.801235886],[45.183541353,41.801266732],[45.183609582,41.801296571],[45.183633135,41.80126648],[45.183601032,41.801252483],[45.183648976,41.801191546],[45.183613437,41.80117604],[45.183589633,41.80116573],[45.183538671,41.80123069],[45.183477818,41.801204119],[45.183470442,41.801235886]]]},"properties":{"id":22664,"name":"","tags":{"building":"yes","man_made":"water_works","waterway":"dam"},"type":"water_works","osm_id":904985009,"source_layer":"osm_water_treatment_plant"}},{"id":245329971,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[120.994965477,29.199022672],[120.994973021,29.199215372],[120.996145398,29.200258584],[120.996292165,29.200370231],[120.997210989,29.201069198],[120.998192929,29.201580745],[120.998375654,29.201580745],[120.998543041,29.201314955],[120.998299463,29.200085749],[120.997416429,29.199474457],[120.996746631,29.198856459],[120.995825628,29.199241859],[120.995559167,29.199221994],[120.995209055,29.198896357],[120.994965477,29.199022672]]]},"properties":{"id":6544,"name":"","tags":{"man_made":"water_works","waterway":"dam"},"type":"water_works","osm_id":245329971,"source_layer":"osm_water_treatment_plant"}},{"id":1415605139,"type":"Feature","geometry":{"type":"LineString","coordinates":[[169.362055788,-45.423612051],[169.360158461,-45.423276272],[169.358642426,-45.4229907],[169.357633077,-45.422794061],[169.356944755,-45.42275366],[169.356044036,-45.422764473]]},"properties":{"id":2377429,"line":"","tags":{"power":"minor_line","voltage":"11000","operator":"Aurora Energy","waterway":"dam","operator:wikidata":"Q4822522"},"type":"minor_line","osm_id":1415605139,"tunnel":false,"disused":"","voltage":"11000","circuits":null,"location":"","frequency":"","construction":"","source_layer":"osm_power_line"}},{"id":405007988,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[51.592068922,43.199413946],[51.59684602,43.199885177],[51.59724064,43.197759191],[51.593578084,43.197397847],[51.59272137,43.197675623],[51.592397577,43.197643772],[51.592068922,43.199413946]]]},"properties":{"id":33090,"name":"","tags":{"man_made":"wastewater_plant","waterway":"dam"},"type":"wastewater_plant","osm_id":405007988,"source_layer":"osm_wastewater_plant"}},{"id":-19953989,"type":"Feature","geometry":{"type":"MultiLineString","coordinates":[[[14.292237283,46.169084857],[14.292230326,46.169090389],[14.292224878,46.169096341],[14.292219346,46.169103549],[14.292216999,46.169113188],[14.292215322,46.169122744],[14.29221658,46.169131796],[14.292221273,46.169138334],[14.292231918,46.169148225],[14.29223963,46.169150823]],[[14.292171736,46.168911268],[14.292167462,46.168929289],[14.29216654,46.168975893],[14.292183555,46.169023083],[14.292186321,46.169030124],[14.292204593,46.169025933],[14.292237283,46.169084857]],[[14.293466824,46.169211843],[14.293464394,46.169201869],[14.293090644,46.16923657],[14.293067678,46.169224248],[14.293022583,46.169223662],[14.292636178,46.169247718],[14.292563842,46.169209496],[14.292558394,46.169207988],[14.292478766,46.169186446],[14.292457643,46.169185021],[14.292426882,46.169183429],[14.29241347,46.169186446],[14.292388492,46.169203545],[14.292448842,46.169220477],[14.292470384,46.169226428],[14.292521681,46.169242772],[14.292532661,46.169225673],[14.292546659,46.169229529],[14.29256468,46.169234558],[14.292535679,46.169293483],[14.292533499,46.169297925],[14.292576582,46.169300859],[14.292619162,46.169286191],[14.293023422,46.169262554],[14.293072791,46.169259536],[14.293092321,46.169248388],[14.293466824,46.169211843]],[[14.292535679,46.169293483],[14.29249905,46.169285185],[14.292510198,46.169267248],[14.292313977,46.169211927],[14.292318755,46.169203042],[14.29232546,46.169190721],[14.292333256,46.169176472],[14.292290089,46.169164653],[14.292276175,46.169192062],[14.292257986,46.169227769],[14.29231163,46.169241767],[14.292303919,46.169254842],[14.292294196,46.169271439],[14.292207024,46.169244868],[14.292170647,46.169314522],[14.29248589,46.169394736],[14.292518496,46.169328436],[14.292526123,46.169312845],[14.292533499,46.169297925],[14.292535679,46.169293483]],[[14.292426882,46.169183429],[14.292448172,46.169103549],[14.292476838,46.169107153],[14.292457643,46.169185021],[14.292426882,46.169183429]],[[14.292237283,46.169084857],[14.292246922,46.169084941],[14.292247257,46.169081086],[14.292319845,46.169084857],[14.292448172,46.169103549],[14.292476838,46.169107153],[14.292575074,46.169109333],[14.292575493,46.169095838],[14.292480777,46.169093156],[14.292326383,46.169069099],[14.292247341,46.169065747],[14.292242228,46.169055269],[14.292266117,46.169050324],[14.292263099,46.169041858],[14.292231835,46.169048899],[14.292217418,46.169022161],[14.292267374,46.16901336],[14.292264943,46.169006235],[14.292183555,46.169023083],[14.292186321,46.169030124],[14.292204593,46.169025933],[14.292237283,46.169084857]]]},"properties":{"name":"HE Podlubnik - Centrala","tags":{"name":"HE Podlubnik - Centrala","type":"site","power":"plant","alt_name":"HE Škofja Loka","operator":"Elektro Gorenjska","waterway":"dam","start_date":"1969","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"330KW"},"osm_id":-19953989,"output":"330KW","source":"hydro","construction":"","source_layer":"power_plant_relation_by_geom_type"}}]} \ No newline at end of file diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 33d20f98d..64104953e 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1,7 +1,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import * as Cesium from 'cesium'; import militaryAwarenessLayer, { _getAwarenessNavigationStateForTest, diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index 65d1c7ea8..c3f9cdb89 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -798,7 +798,6 @@ test('zoom-out aborts an active installation request and returns non-loading gui // by source probes against the shipped file, the same technique the HUD datum // tests use where a full boot is impractical. import { installationRetryDelayMs } from './militaryInstallations.js'; -import fs from 'node:fs'; const installationsSource = readLayerSource( new URL('./militaryInstallations.js', import.meta.url), 'utf8'); diff --git a/src/data/militaryNames.js b/src/data/militaryNames.js index b26ab04e6..d4b4b8846 100644 --- a/src/data/militaryNames.js +++ b/src/data/militaryNames.js @@ -7,7 +7,7 @@ import { createRetryableLoader } from './retryableLoad.js'; export const MILITARY_POINT_CAP = 512; export const MILITARY_LABEL_CAP = 24; -export const MILITARY_NAMES_TIMEOUT_MS = 10_000; +const MILITARY_NAMES_TIMEOUT_MS = 10_000; /** Share a bounded acquisition; failed assets become retryable after cooldown. */ export function createMilitaryNamesLoader({ diff --git a/src/data/overpassProxy.test.mjs b/src/data/overpassProxy.test.mjs index f161eb72e..4a3b5780f 100644 --- a/src/data/overpassProxy.test.mjs +++ b/src/data/overpassProxy.test.mjs @@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo const memoryHit = await resolveOverpassPreflight({ cacheKey: key, memoryCache: new Map([[key, fresh]]), - inFlight: new Map([[key, Promise.resolve(joined)]]), + inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, now: 1000, @@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo const inFlightHit = await resolveOverpassPreflight({ cacheKey: key, memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]), - inFlight: new Map([[key, Promise.resolve(joined)]]), + inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, now: 1000, @@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk'); assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota'); + const diskMemory = new Map(); const diskHit = await resolveOverpassPreflight({ cacheKey: key, - memoryCache: new Map(), + memoryCache: diskMemory, inFlight: new Map(), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, @@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo assert.equal(diskHit.payload, disk); assert.equal(diskReads, 1); assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota'); + const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream }); + assert.equal(publishedDisk.source, 'HIT'); const upstreamMiss = await resolveOverpassPreflight({ cacheKey: key, @@ -214,3 +217,66 @@ function pointSegDistDeg(p, a, b) { const t = c1 / c2; return Math.hypot(wx - t * vx, wy - t * vy); } + + +test('cold disk lookup is single-flight through upstream publication and quota admission', async () => { + const inFlight = new Map(); + let release; + const disk = new Promise((resolve) => { release = resolve; }); + let reads = 0, admissions = 0, upstreams = 0; + const payload = { status: 200, body: '{"elements":[]}' }; + const options = { + cacheKey: 'cold', memoryCache: new Map(), inFlight, + readDisk: () => { reads++; return disk; }, + allowUpstream: () => { admissions++; return true; }, + loadUpstream: async () => { upstreams++; return payload; }, + }; + const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options)); + await Promise.resolve(); + assert.equal(inFlight.size, 1); + release(null); + const results = await Promise.all(pending); + assert.equal(reads, 1); + assert.equal(admissions, 1); + assert.equal(upstreams, 1); + assert.ok(results.every((result) => result.payload === payload)); + assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']); + assert.equal(inFlight.size, 0); + const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } }; + const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]); + assert.ok(rejected.every((result) => result.status === 'rejected')); + assert.equal(inFlight.size, 0); + await resolveOverpassPreflight({ ...options, readDisk: async () => null }); + assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry'); +}); + +test('a joined client retries admission with its own quota after another client is rate limited', async () => { + const inFlight = new Map(); + let release; + const disk = new Promise((resolve) => { release = resolve; }); + let reads = 0, upstreams = 0; + const admissions = { a: 0, b: 0, c: 0 }; + const quota = { a: false, b: true, c: false }; + const payload = { status: 200, body: '{"elements":[]}' }; + const client = (name) => resolveOverpassPreflight({ + cacheKey: 'quota', memoryCache: new Map(), inFlight, + readDisk: () => { reads++; return reads === 1 ? disk : null; }, + allowUpstream: () => { admissions[name]++; return quota[name]; }, + loadUpstream: async () => { upstreams++; return payload; }, + }); + // A (quota exhausted) owns the cold reservation; B (quota available) and + // C (quota exhausted) join it while the disk lookup is pending. + const pending = [client('a'), client('b'), client('c')]; + await Promise.resolve(); + assert.equal(inFlight.size, 1); + release(null); + const [a, b, c] = await Promise.all(pending); + assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A"); + assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota'); + assert.equal(b.payload, payload); + assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch"); + assert.equal(c.payload, payload); + assert.deepEqual(admissions, { a: 1, b: 1, c: 0 }); + assert.equal(upstreams, 1); + assert.equal(inFlight.size, 0); +}); diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index e54ef6b29..ef3d8074e 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -11,6 +11,8 @@ * that belong to someone else and leave them alone. */ +import { isSurfacePick } from './scenePick.js'; + /** @type {Map boolean>} layerId -> ownership predicate */ const _owners = new Map(); @@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) { } return false; } + +/** + * Resolve a moving-contact click without letting map tiles hide a nearby touch + * target. Exact picks always win; only a coarse primary pointer may expand a + * surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers + * do not each choose their own contact under one tap. + * Unowned non-surface objects remain barriers, not permission to pick through. + * + * @param {object} scene Cesium scene with pick and optional drillPick. + * @param {{x: number, y: number}} position Screen position in CSS pixels. + * @returns {object|null|undefined} Original pick or the first registered touch hit. + */ +export function pickContact(scene, position) { + const picked = scene.pick(position); + if ( + !globalThis.matchMedia?.('(pointer: coarse)').matches || + findPickOwner(resolvePickId(picked)) || + !isSurfacePick(picked) || + typeof scene.drillPick !== 'function' + ) + return picked; + let hits; + try { + hits = scene.drillPick(position, 8, 14, 14) || []; + } catch { + return picked; + } + for (const hit of hits.slice(0, 8)) { + if (findPickOwner(resolvePickId(hit))) return hit; + if (!isSurfacePick(hit)) break; + } + return picked; +} diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index f522ec072..43f11260f 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -16,6 +16,7 @@ import { unregisterPickOwner, isOwnedByOtherLayer, resolvePickId, + pickContact, } from './pickRegistry.js'; // --------------------------------------------------------------------------- @@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => { unregisterPickOwner('bikeshare'); assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false); }); + +function primaryPointer(t, coarse) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }), + }); + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + }); +} + +function contactOwners(t) { + registerPickOwner('flights', (id) => id === 'civil'); + registerPickOwner('military', (id) => id === 'military'); + registerPickOwner('ais-live-vessels', (id) => id === '123456789'); + t.after(() => { + for (const id of ['flights', 'military', 'ais-live-vessels']) + unregisterPickOwner(id); + }); +} + +const mapTile = { primitive: { isCesium3DTileset: true } }; + +test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => { + primaryPointer(t, true); + contactOwners(t); + const position = { x: 30, y: 50 }; + const contact = { primitive: { id: 'civil' } }; + let drills = 0; + const scene = { + pick(point) { + assert.equal(point, position); + return mapTile; + }, + drillPick(...args) { + drills++; + assert.deepEqual(args, [position, 8, 14, 14]); + return [mapTile, contact]; + }, + }; + assert.equal(pickContact(scene, position), contact); + assert.equal(drills, 1); +}); + +test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) { + const scene = { + pick: () => mapTile, + drillPick: () => [mapTile, first, { id: 'civil' }], + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), first); + } +}); + +test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const exact of [ + { id: 'military' }, + { id: 'custom-annotation' }, + { primitive: { id: 'custom-gizmo' } }, + { primitive: { id: 'civil', isCesium3DTileset: true } }, + ]) { + const scene = { + pick: () => exact, + drillPick: () => assert.fail('must keep exact interactive hit'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), exact); + } +}); + +test('touch expansion stops at a non-surface object and respects the drill bound', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const hits of [ + [mapTile, { id: 'custom-annotation' }, { id: 'civil' }], + [...Array(8).fill(mapTile), { id: 'civil' }], + [mapTile], + ]) { + assert.equal( + pickContact( + { pick: () => mapTile, drillPick: () => hits }, + { x: 1, y: 1 }, + ), + mapTile, + ); + } +}); + +test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => { + primaryPointer(t, false); + contactOwners(t); + const scene = { + pick: () => mapTile, + drillPick: () => assert.fail('mouse must not expand'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile); +}); + +test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const drillPick of [ + undefined, + () => { + throw new Error('context lost'); + }, + () => [], + ]) { + assert.equal( + pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }), + undefined, + ); + } +}); diff --git a/src/data/placeProviderPayloads.js b/src/data/placeProviderPayloads.js index 37fd99ef7..8adf935a9 100644 --- a/src/data/placeProviderPayloads.js +++ b/src/data/placeProviderPayloads.js @@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) { return Number.MAX_SAFE_INTEGER; const latitudeScale = 111320; const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180); + // The short way round, so places either side of 180° stay close. + let dLon = lonB - lonA; + dLon -= 360 * Math.round(dLon / 360); return Math.round( - Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale), + Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale), ); } diff --git a/src/data/radio.js b/src/data/radio.js index e0a8009bc..954c8dd44 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -49,34 +49,12 @@ export const buildRadioTunerBand = layer.buildRadioTunerBand; export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay; export const getRadioAcceptedCatalogSnapshot = layer.getRadioAcceptedCatalogSnapshot; -export const radioStationResolutionMatches = - layer.radioStationResolutionMatches; export const getRadioUIState = layer.getRadioUIState; -export const subscribeToRadio = layer.subscribeToRadio; -export const subscribeToRadioPlaybackControls = - layer.subscribeToRadioPlaybackControls; -export const getRadioTunerStations = layer.getRadioTunerStations; -export const beginRadioTuning = layer.beginRadioTuning; -export const setRadioTuningStatic = layer.setRadioTuningStatic; export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed; -export const previewRadioTuningStation = layer.previewRadioTuningStation; -export const endRadioTuning = layer.endRadioTuning; -export const cancelRadioTuning = layer.cancelRadioTuning; -export const commitRadioTuningStation = layer.commitRadioTuningStation; -export const playSelectedRadio = layer.playSelectedRadio; export const confirmRadioPlayback = layer.confirmRadioPlayback; -export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice; -export const stopRadioPlayback = layer.stopRadioPlayback; -export const toggleRadioPlayback = layer.toggleRadioPlayback; -export const pauseRadioPlayback = layer.pauseRadioPlayback; export const setRadioVolume = layer.setRadioVolume; export const setRadioParams = layer.setRadioParams; -export const getRadioParams = layer.getRadioParams; export const setRadioVoiceDucking = layer.setRadioVoiceDucking; -export const selectRadioStation = layer.selectRadioStation; -export const cycleRadioStation = layer.cycleRadioStation; -export const selectRequestedRadioStation = layer.selectRequestedRadioStation; -export const setRadioFilter = layer.setRadioFilter; export const retainRadioClusterIdentitiesForStations = layer.retainRadioClusterIdentitiesForStations; export { @@ -86,10 +64,8 @@ export { RADIO_SINGLETON_MID_LIMIT, RADIO_SINGLETON_NEAR_LIMIT, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, RADIO_OVERLAY_SOURCE_OPTIONS, DEFAULT_RADIO_FILTER, - GLOBAL_RADIO_ALTITUDE_M, } from '../layers/radio/index.js'; export default layer; diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 58a4101a1..99963df16 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -712,6 +712,58 @@ test('unusable directory responses preserve warm client state atomically', async assert.equal(getRadioUIState().stale, true); assert.equal(getRadioUIState().degraded, true); } + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioUIState().stationCount, 1); + assert.equal(getRadioUIState().rejectedStationCount, 1); + assert.equal(getRadioUIState().degraded, true); + assert.equal(getRadioUIState().stale, false); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); + assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id); + // Server-side structural rejections are combined with client rejections + // after admission; the healthy generation is still accepted. + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now, + rejectedStationCount: 2, + })); + await radioLayer.update(); + assert.equal(getRadioUIState().rejectedStationCount, 3); + assert.equal(getRadioUIState().degraded, true); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3); + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement], stale: false, degraded: false, + acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now, + rejectedStationCount: 10_000, + })); + await radioLayer.update(); + assert.equal(getRadioUIState().rejectedStationCount, 10_000); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); + for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) { + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement], stale: false, degraded: false, + acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, + rejectedStationCount, + })); + await radioLayer.update(); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); + assert.equal(getRadioUIState().stale, true); + } + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [{ id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); + assert.equal(getRadioUIState().stale, true); + assert.equal(getRadioUIState().rejectedStationCount, 10_000); + radioLayer.destroy(); + radioLayer.init(viewer); + assert.equal(getRadioUIState().rejectedStationCount, 0); + assert.equal(getRadioUIState().degraded, false); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); @@ -1779,7 +1831,6 @@ test('voice playback confirmation waits for playing and requires a hard duck', a }); test('voice playback confirmation accepts fallback buffering but times out safely', async () => { - let listener = () => {}; const state = { audioState: 'loading', playingStationId: 'fallback', @@ -1789,9 +1840,8 @@ test('voice playback confirmation accepts fallback buffering but times out safel const confirmed = await confirmRadioPlayback({ startPlayback: async () => false, subscribe: (next) => { - listener = next; next(state); - return () => { listener = () => {}; }; + return () => {}; }, getState: () => state, timeoutMs: 10, diff --git a/src/data/radioCountry.js b/src/data/radioCountry.js index 55d9b36a5..1f77d8e98 100644 --- a/src/data/radioCountry.js +++ b/src/data/radioCountry.js @@ -117,8 +117,3 @@ export function normalizeRadioCountryInput(value) { name: canonicalCountryName(code), }); } - -/** Return whether a value is a recognized ISO 3166-1 alpha-2 code. */ -export function isRadioCountryCode(value) { - return typeof value === 'string' && ISO_ALPHA_2_SET.has(value.toUpperCase()); -} diff --git a/src/data/radioProxy.test.mjs b/src/data/radioProxy.test.mjs index 60463205b..e9df10678 100644 --- a/src/data/radioProxy.test.mjs +++ b/src/data/radioProxy.test.mjs @@ -11,7 +11,14 @@ import { publicRadioStation, publicRadioHttpsUrl, } from '../../vite.config.js'; -import { rankRadioStationsForRequest } from './radio.js'; +import { + getRadioAcceptedCatalogSnapshot, + getRadioUIState, + radioLayer, + rankRadioStationsForRequest, +} from './radio.js'; +import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js'; +import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js'; const UUID = '12345678-1234-4234-8234-123456789abc'; const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }]; @@ -533,3 +540,62 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing', const crashed = mockMirrorStatus(t, 200, 'OK\x7f'); await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError); }); + + +test('case expansion is capped after tag normalization in the portable and server station path', () => { + const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'})); + assert.equal(row.tags[0].length, 80); + assert.equal(row.tags[0], 'i\u0307'.repeat(40)); + assert.deepEqual(row.tags.slice(1), ['news radio']); +}); + +test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => { + const originalFetch = globalThis.fetch; + const middleware = createProxy({ + now: () => Date.now(), + fetchImpl: async (url) => { + if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]); + const rows = healthyRowsForQuery(url); + if (queryTag(url)) return responseJson(rows); + // 400 valid broad rows, one row missing its required name, and one + // schema-valid row that normalization filters by policy (not counted). + const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' }); + return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]); + }, + }); + const response = await invoke(middleware, '/stations'); + assert.equal(response.status, 200); + const body = JSON.parse(response.body); + assert.equal(body.degraded, false); + assert.equal(body.degradedReason, null); + assert.equal(body.acceptedGeneration, 1); + assert.equal(body.rejectedStationCount, 1); + assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX); + + const viewer = { + camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, + scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + dataSources: { add() {}, remove() {} }, + entities: { add(entity) { return entity; }, remove() {} }, + }; + radioLayer.destroy(); + try { + globalThis.fetch = async () => new Response(response.body, { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + radioLayer.init(viewer); + radioLayer.enable(); + await radioLayer.update(); + const state = getRadioUIState(); + assert.equal(state.stationCount, body.stations.length); + assert.equal(state.rejectedStationCount, 1); + assert.equal(state.degraded, true); + assert.equal(state.stale, false); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1); + assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance); + } finally { + globalThis.fetch = originalFetch; + radioLayer.destroy(); + } +}); diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 6aaba700f..6bee7c56d 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -28,4 +28,3 @@ export { createCableReferenceSweepGate, updateCableReferenceStem, } from '../layers/submarineCables/index.js'; -export default createTeleGeographySubmarineCableLayer(); diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 1b02df1d5..17fec219d 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -930,7 +930,6 @@ for (const fixture of LAYERS) { } test('both layers gate the tracked-model load and record its failures', async () => { - const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { const source = readLayerSource(new URL(`./${name}`, import.meta.url)); assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/, @@ -946,7 +945,6 @@ test('both layers gate the tracked-model load and record its failures', async () }); test('the tracked regime never consults the fleet models3d toggle', async () => { - const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { const source = readLayerSource(new URL(`./${name}`, import.meta.url)); const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index a9a918fef..764c60104 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -1,7 +1,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { bindTrackingClickGesture, isTrackingClickGesture, diff --git a/src/data/wind.js b/src/data/wind.js index 38bc10688..59b9aaf5d 100644 --- a/src/data/wind.js +++ b/src/data/wind.js @@ -1,5 +1,4 @@ import { createSourceSlot } from '../sources/sourceSlot.js'; -import { createWindLayer as createLayer } from '../layers/wind/index.js'; import { createWindSource } from '../layers/wind/source.js'; const sourceSlot = createSourceSlot( @@ -8,10 +7,3 @@ const sourceSlot = createSourceSlot( 'Wind source', ); export const configureWindSource = sourceSlot.configure; - -/** Create a wind layer using the configured application source by default. */ -export function createWindLayer(options = {}) { - return createLayer({ ...options, feed: options.feed ?? sourceSlot.source }); -} - -export default createWindLayer(); diff --git a/src/exclusiveSurfaces.js b/src/exclusiveSurfaces.js new file mode 100644 index 000000000..af941d69f --- /dev/null +++ b/src/exclusiveSurfaces.js @@ -0,0 +1,11 @@ +/** + * Body classes that mean "another surface owns the screen": cockpit, scene + * playback, recording and clean view. First run yields to them and the phone + * shell closes its sheets for them. + */ +export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([ + 'cockpit-mode', + 'scene-playback-mode', + 'recording-mode', + 'ui-clean-view', +]); diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 59ddf3e6a..408152295 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -22,6 +22,7 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js'; // is enthusiasm, not "never show me this again". import { t, msg } from './i18n/index.js'; +import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.js'; /** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */ export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1'; @@ -305,12 +306,7 @@ export async function runFirstRunChoice( * hides the launcher in CSS, and the observer below turns that into a yield — * see the ESC ARBITRATION note on initFirstRunExperience. */ -export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([ - 'cockpit-mode', - 'scene-playback-mode', - 'recording-mode', - 'ui-clean-view', -]); +export { EXCLUSIVE_SURFACE_CLASSES }; /** * Is some other surface currently claiming the screen? diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 749123acd..a02c4391f 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -247,6 +247,10 @@ test('no storage is touched from a default parameter position', () => { test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { const module = fs.readFileSync( + new URL('./exclusiveSurfaces.js', import.meta.url), + 'utf8', + ); + const firstRun = fs.readFileSync( new URL('./firstRunExperience.js', import.meta.url), 'utf8', ); @@ -279,6 +283,8 @@ test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/, ); + // First run keeps exporting the one shared list (the phone shell reads it too). + assert.match(firstRun, /export \{ EXCLUSIVE_SURFACE_CLASSES \};/); }); test('exclusiveSurfaceActive reads the live body classes', () => { @@ -373,10 +379,6 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh new URL('./ui/radioBindings.js', import.meta.url), 'utf8', ); - const module = fs.readFileSync( - new URL('./firstRunExperience.js', import.meta.url), - 'utf8', - ); // stopPropagation() does NOT stop later listeners on the SAME document, so the // disclosure's earlier capture handler closed the disclosure and the launcher @@ -986,6 +988,16 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping ]); // Cyber deliberately adds one layout; first-run missions still change no tools. hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera') + .parameters.properties.motion; + assert.deepEqual(cameraMotion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); const block = JSON.stringify(legacyTools); // Re-derived for the voice layer manifest (generated layer enums, alias and // field hints), point-and-ask's pointer/referent arguments and the prompt @@ -995,10 +1007,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Air quality (ECCC AQHI) extends the manifest. - assert.equal(block.length, 30655, 'serialized tool schema length drifted'); + // Globe rotation adds one camera motion and its descriptions; Globe View + // framing/default guidance updates only move_camera and mode descriptions. + // Radio markup and the complete production-request comparison pin locality. + // Restore the base camera/Globe View wording with the globe-rotation append. + assert.equal(block.length, 30878, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', + '7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/index.js b/src/i18n/index.js index d5698aa5b..48b63b2f6 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -210,7 +210,7 @@ export function formatRelativeTime(value, unit = 'second', options) { } /** Collapse markup whitespace so a message reads the same in HTML and JS. */ -export function normalizeMarkupText(text) { +function normalizeMarkupText(text) { return String(text ?? '') .replace(/\s+/g, ' ') .trim(); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3c5602ae7..fd55b2e25 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2943,5 +2943,9 @@ "hemisphere|E": "E", "hemisphere|W": "O", "OK": "OK", - "{category} ({count})": "{category} ({count})" + "{category} ({count})": "{category} ({count})", + "Layers": "Capas", + "Search": "Buscar", + "More": "Más", + "Panels": "Paneles" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7a790e04e..442312de2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3010,5 +3010,9 @@ "hemisphere|E": "В", "hemisphere|W": "З", "OK": "НОРМА", - "{category} ({count})": "{category} ({count})" + "{category} ({count})": "{category} ({count})", + "Layers": "Слои", + "Search": "Поиск", + "More": "Ещё", + "Panels": "Панели" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 46ccf96a7..fa08cca1f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2809,5 +2809,9 @@ "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 语音会话有时长限制。会话到期后,请重新启动语音以开始新对话。", "Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)", "OK": "正常", - "{category} ({count})": "{category}({count})" + "{category} ({count})": "{category}({count})", + "Layers": "图层", + "Search": "搜索", + "More": "更多", + "Panels": "面板" } diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs index fc59481aa..e07cd5e11 100644 --- a/src/keyboardFocusStyles.test.mjs +++ b/src/keyboardFocusStyles.test.mjs @@ -1,7 +1,6 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; const css = readStylesheet(new URL('../style.css', import.meta.url)); diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 1a0ca4c73..474ec1de0 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -3,16 +3,12 @@ import * as Cesium from 'cesium'; import { LAYER_ID, REQUEST_DEBOUNCE_MS, - QUERY_LIMIT, - MAX_RENDERED, - QUERY_SNAP_DEGREES, QUERY_REUSE_MS, ALPR_COLOR, } from './policy.js'; import { snapAlprBox, boxContains, - destinationPointDeg, alprRetryDelayMs, validateAlprSnapshot, alprCreditMarkup, @@ -84,9 +80,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { viewportBox, clearRendered, hideOnMapCredit, - presentOnMapCredit, renderRecords, - selectRecord, focusNearest, clearSelection, updateSelectedAnchor, diff --git a/src/layers/alpr/records.js b/src/layers/alpr/records.js index 0d751f12d..10cd88062 100644 --- a/src/layers/alpr/records.js +++ b/src/layers/alpr/records.js @@ -1,11 +1,11 @@ import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js'; -export function textTag(value) { +function textTag(value) { const t = String(value ?? '').trim(); return t || null; } -export function numTag(value) { +function numTag(value) { // `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only // tag value must read as missing, never as a due-north bearing. if (value == null) return null; @@ -102,7 +102,7 @@ export function normalizeAlprNode(el) { }; } -export function normalizeDirection(value) { +function normalizeDirection(value) { const degrees = numTag(value); return degrees != null && degrees >= 0 && degrees <= 360 ? degrees % 360 diff --git a/src/layers/alpr/tileRecords.js b/src/layers/alpr/tileRecords.js index 5db9192b3..0368f53fb 100644 --- a/src/layers/alpr/tileRecords.js +++ b/src/layers/alpr/tileRecords.js @@ -3,7 +3,7 @@ import { VectorTile } from '@mapbox/vector-tile'; import { normalizeAlprNode } from './records.js'; /** Map extract attributes without treating an OSM edit date as a field verification. */ -export function normalizeAlprTileFeature(feature) { +function normalizeAlprTileFeature(feature) { const p = feature?.properties || {}; if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null; const [lon, lat] = feature.geometry.coordinates; diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index eda5a46e6..22cc8eb98 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -1,9 +1,6 @@ export const AIRCRAFT_DEPENDENCIES = ['flights', 'military']; -export const DEFERRED_DEPENDENCIES = [ - 'ais-live-vessels', - 'military-installations', -]; +const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations']; export const DEPENDENCIES = [ ...AIRCRAFT_DEPENDENCIES, diff --git a/src/layers/bikeshare/failureReporting.test.mjs b/src/layers/bikeshare/failureReporting.test.mjs index 89a87bdad..e49ebd962 100644 --- a/src/layers/bikeshare/failureReporting.test.mjs +++ b/src/layers/bikeshare/failureReporting.test.mjs @@ -60,7 +60,7 @@ test('the layer reports failure when a failed city left it with nothing', () => }); test('a layer with nothing to draw and nothing failed is not an error', () => { - const { state, methods } = makeLayer(); + const { methods } = makeLayer(); // Out of range of every city is empty, not broken. assert.equal(methods.getStats().error, undefined); diff --git a/src/layers/bikeshare/policy.js b/src/layers/bikeshare/policy.js index 14f1edf6e..1b9c1579f 100644 --- a/src/layers/bikeshare/policy.js +++ b/src/layers/bikeshare/policy.js @@ -11,7 +11,7 @@ export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ // --- Activation / display thresholds --- /** Altitude (m) at which bikeshare layer becomes eligible for display. */ -export const ACTIVATION_ALTITUDE_M = 50000; +const ACTIVATION_ALTITUDE_M = 50000; /** Hysteresis enter threshold — layer activates when camera drops below this. */ diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 900692939..110c03bd3 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,4 +1,4 @@ -import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; +import { CAMERA_SEEDS } from './policy.js'; import { lookupModelSpec, horizontalFovRangeDeg, diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index 807770c10..0c1812127 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -5,9 +5,6 @@ import { PROJECTION_CANVAS_HEIGHT, PROJECTION_MATTE_COLOR, PROJECTION_TEXTURE_SWAP_MS, - ACTIVE_FRAME_REFRESH_MS, - FRAME_ENDPOINT, - MEDIA_ENDPOINT, PROJECTION_ACTIVE_REFRESH_MS, PROJECTION_IDLE_REFRESH_MS, PLACEHOLDER_REPAINT_MS, diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index 797d1706b..d9db40fe9 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -1,7 +1,6 @@ import * as Cesium from 'cesium'; import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js'; import { - PROJECTION_VERT_ASPECT, FRUSTUM_GROUND_CLEARANCE_M, PROBE_MIN_RANGE_M, PROBE_CLEARANCE_M, diff --git a/src/layers/cctv/health.js b/src/layers/cctv/health.js index 979e997cf..925acc34e 100644 --- a/src/layers/cctv/health.js +++ b/src/layers/cctv/health.js @@ -1,4 +1,4 @@ -import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js'; +import { HEALTH_SYNC_INTERVAL_MS } from './policy.js'; export function createHealth({ state: layerState, services, parts, source }) { /** diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index 96d7985f1..bc369c02a 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -1,10 +1,4 @@ -export { - FRAME_ENDPOINT, - SOURCE_ENDPOINT, - HEALTH_ENDPOINT, - MEDIA_ENDPOINT, - ACTIVE_FRAME_REFRESH_MS, -} from './sourcePolicy.js'; +export { ACTIVE_FRAME_REFRESH_MS } from './sourcePolicy.js'; import * as Cesium from 'cesium'; import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js'; import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; @@ -90,9 +84,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 = export const PROJECTION_TEXTURE_SWAP_MS = 1000; -export const PROJECTION_VERT_ASPECT = - PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT; - // V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink // below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury // the monitor plane in the 3D tiles. Exported for the unit suite. diff --git a/src/layers/cctv/sourcePolicy.js b/src/layers/cctv/sourcePolicy.js index 5ce0cd024..3341a4ce9 100644 --- a/src/layers/cctv/sourcePolicy.js +++ b/src/layers/cctv/sourcePolicy.js @@ -1,9 +1,5 @@ export const FRAME_ENDPOINT = '/api/cctv/frame'; -export const SOURCE_ENDPOINT = '/api/cctv/sources'; - -export const HEALTH_ENDPOINT = '/api/cctv/health'; - export const MEDIA_ENDPOINT = '/api/cctv/media'; export const ACTIVE_FRAME_REFRESH_MS = 10000; diff --git a/src/layers/cyclones/labels.js b/src/layers/cyclones/labels.js index e95b5a28c..cb418865f 100644 --- a/src/layers/cyclones/labels.js +++ b/src/layers/cyclones/labels.js @@ -64,7 +64,7 @@ const SHARED_CARD = Object.freeze({ * @param {boolean} selected * @returns {object} */ -export function cycloneStormEntry(storm, selected) { +function cycloneStormEntry(storm, selected) { return { ...SHARED_CARD, ...markerClearance( @@ -92,7 +92,7 @@ export function cycloneStormEntry(storm, selected) { * @param {{tauHours:number,position:object}} point * @returns {object} */ -export function cycloneLeadEntry(stormId, point) { +function cycloneLeadEntry(stormId, point) { return { ...SHARED_CARD, ...markerClearance(CYCLONE_MARKER_PX.forecast), diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 2c015e6c9..2ecb0a8a4 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -1,11 +1,3 @@ -export { - GROUND_FLOOR_CLAMP_RADIUS_KM, - GROUND_FLOOR_WARM_MAX_ALT_M, - POSITION_HISTORY_LIMIT, - LANDED_MISSING_POLL_LIMIT, - MISSING_POLL_LIMIT, - ERROR_BACKOFF_INTERVAL, -} from './recordPolicy.js'; import * as Cesium from 'cesium'; import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 552adbe01..371b3a854 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -580,7 +580,6 @@ export function createQueries({ const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - const now = Cesium.JulianDate.now(); const nearby = []; for (const [icao24, bb] of flightState._billboards) { diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index cef9e1563..dde8f4f95 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const flightsLayer = layer; function _emitAwarenessEvent(type, detail) { @@ -1174,7 +1174,9 @@ export function createTracking({ // first-person reference. A globe click must not fall through to the // normal empty-space deselection path; cockpit has explicit exit controls. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself — ignore (don't deselect) diff --git a/src/layers/installations/policy.js b/src/layers/installations/policy.js index fae021cf3..92da9dd7b 100644 --- a/src/layers/installations/policy.js +++ b/src/layers/installations/policy.js @@ -2,8 +2,6 @@ export const LAYER_ID = 'military-installations'; export const REQUEST_DEBOUNCE_MS = 180; -export const MAX_VIEWPORT_DEGREES = 10; - export const MAX_RENDERED = 700; export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']); diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs new file mode 100644 index 000000000..d09e5168b --- /dev/null +++ b/src/layers/launches/hover.test.mjs @@ -0,0 +1,232 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Event } from 'cesium'; +import { createLaunchHover } from './lifecycle.js'; + +function fakeTimers() { + const timers = new Map(); + let nextId = 1; + const api = { + setTimer(fn, ms) { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimer(id) { + timers.delete(id); + }, + pending: () => [...timers.values()], + flush() { + const due = [...timers.entries()]; + timers.clear(); + for (const [, timer] of due) timer.fn(); + }, + }; + return api; +} + +function hoverRig({ overMarker }) { + let at = 0, + picks = 0, + enabled = true, + free = true; + const timers = fakeTimers(); + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick(position) { + picks++; + return overMarker(position) ? [{ id: 'launch' }] : []; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: { hidden: false }, + pointerFree: () => free, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); + return { + hover, + timers, + viewer, + cursor: () => viewer.scene.canvas.style.cursor, + picks: () => picks, + setTime: (value) => (at = value), + setEnabled: (value) => (enabled = value), + setFree: (value) => (free = value), + }; +} + +const onMarker = (position) => position?.x === 1; +const marker = () => ({ endPosition: { x: 1, y: 1 } }); +const empty = () => ({ endPosition: { x: 9, y: 9 } }); + +test('mission hover runs one trailing check for a pointer that stops on empty space', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + assert.equal(rig.cursor(), 'pointer'); + rig.setTime(50); + const movement = empty(); + rig.hover.move(movement); + // Cesium reuses its movement object; the trailing check must use a copy. + movement.endPosition.x = 1; + assert.equal(rig.picks(), 1); + assert.deepEqual( + rig.timers.pending().map((timer) => timer.ms), + [70], + ); + rig.setTime(80); + rig.hover.move(empty()); + assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time'); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), ''); + assert.equal(rig.timers.pending().length, 0); + rig.hover.destroy(); +}); + +test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + assert.equal(rig.cursor(), ''); + rig.setTime(50); + rig.hover.move(marker()); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), 'pointer'); + rig.hover.destroy(); + assert.equal(rig.cursor(), ''); +}); + +test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => { + for (const cancel of [ + (rig) => rig.hover.reset(), + (rig) => rig.hover.destroy(), + (rig) => rig.viewer.camera.moveStart.raiseEvent(), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + rig.setTime(50); + rig.hover.move(marker()); + assert.equal(rig.timers.pending().length, 1); + cancel(rig); + assert.equal(rig.timers.pending().length, 0); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + +test('mission hover trailing check rechecks eligibility before picking', () => { + for (const block of [ + (rig) => rig.setEnabled(false), + (rig) => rig.setFree(false), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + rig.setTime(50); + rig.hover.move(marker()); + block(rig); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + +test('mission hover throttles, clears on motion/hidden, and releases listeners', () => { + let at = 0, + picks = 0, + enabled = true; + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_n, fn) => listeners.add(fn), + removeEventListener: (_n, fn) => listeners.delete(fn), + }; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: {} }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: doc, + pointerFree: () => true, + ...fakeTimers(), + }); + for (at = 0; at < 120; at++) hover.move({ endPosition: {} }); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + viewer.camera.moveStart.raiseEvent(); + at = 500; + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + assert.equal(viewer.scene.canvas.style.cursor, ''); + viewer.camera.moveEnd.raiseEvent(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + doc.hidden = true; + for (const fn of listeners) fn(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + assert.equal(viewer.scene.canvas.style.cursor, ''); + doc.hidden = false; + enabled = false; + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + hover.destroy(); + assert.equal(listeners.size, 0); + assert.equal(viewer.camera.moveStart.numberOfListeners, 0); + assert.equal(viewer.camera.moveEnd.numberOfListeners, 0); +}); + +test('mission hover preserves the cursor of a pointer-owning tool', () => { + let free = true, + picks = 0; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => true, + entityLaunchId: () => true, + documentRef: null, + pointerFree: () => free, + }); + hover.move({ endPosition: {} }); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + free = false; + viewer.scene.canvas.style.cursor = 'crosshair'; + hover.move({ endPosition: {} }); + viewer.camera.moveStart.raiseEvent(); + hover.destroy(); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'crosshair'); +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 75b0276a3..6ac777da1 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,6 +1,116 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +const HOVER_PICK_INTERVAL_MS = 120; + +/** + * Bound hover picking independently of precise click selection. A move inside + * the throttle window is not dropped: its position is kept and one trailing + * check runs when the window ends, so a pointer that stops is still resolved. + */ +export function createLaunchHover({ + viewer, + enabled, + entityLaunchId, + now = () => performance.now(), + documentRef = globalThis.document, + pointerFree = isPointerFree, + setTimer = globalThis.setTimeout, + clearTimer = globalThis.clearTimeout, +}) { + let moving = false; + let lastPick = -Infinity; + let ownsCursor = false; + let pendingPosition = null; + let trailingTimer = null; + const clearCursor = () => { + if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer') + viewer.scene.canvas.style.cursor = ''; + ownsCursor = false; + }; + const cancelTrailing = () => { + if (trailingTimer !== null) clearTimer(trailingTimer); + trailingTimer = null; + pendingPosition = null; + }; + const reset = () => { + cancelTrailing(); + clearCursor(); + lastPick = -Infinity; + }; + const start = () => { + moving = true; + reset(); + }; + const end = () => { + moving = false; + reset(); + }; + const visibility = () => { + if (documentRef?.hidden || documentRef?.visibilityState === 'hidden') + reset(); + }; + const eligible = () => + enabled() && + !moving && + !documentRef?.hidden && + documentRef?.visibilityState !== 'hidden' && + pointerFree(); + const pick = (position, at) => { + lastPick = at; + const found = viewer.scene + .drillPick(position, 12) + .some((picked) => entityLaunchId(picked?.id)); + if (found) { + viewer.scene.canvas.style.cursor = 'pointer'; + ownsCursor = true; + } else clearCursor(); + }; + const trailing = () => { + trailingTimer = null; + const position = pendingPosition; + pendingPosition = null; + if (!position) return; + // Eligibility may have changed while the check waited. + if (!eligible()) { + reset(); + return; + } + pick(position, now()); + }; + const removeStart = viewer.camera?.moveStart?.addEventListener(start); + const removeEnd = viewer.camera?.moveEnd?.addEventListener(end); + documentRef?.addEventListener?.('visibilitychange', visibility); + return { + move(movement) { + if (!eligible()) { + reset(); + return; + } + const at = now(); + const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick); + if (wait > 0) { + // Cesium reuses the movement object, so keep a copy of the position. + pendingPosition = Cesium.Cartesian2.clone( + movement.endPosition, + pendingPosition ?? undefined, + ); + if (trailingTimer === null) trailingTimer = setTimer(trailing, wait); + return; + } + cancelTrailing(); + pick(movement.endPosition, at); + }, + reset, + destroy() { + reset(); + removeStart?.(); + removeEnd?.(); + documentRef?.removeEventListener?.('visibilitychange', visibility); + }, + }; +} + export function createLifecycle({ state: layerState, services, @@ -8,6 +118,7 @@ export function createLifecycle({ source, }) { const { holdContinuousRender, releaseContinuousRender } = services.render; + let hover = null; const methods = { init(viewer) { @@ -45,14 +156,15 @@ export function createLifecycle({ layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( viewer.scene.canvas, ); - layerState._moveHandler.setInputAction((movement) => { - if (!layerState._enabled || !layerState._dataSource?.show) return; - const missionEntity = viewer.scene - .drillPick(movement.endPosition, 12) - .map((picked) => picked?.id) - .find((candidate) => parts.selection.entityLaunchId(candidate)); - viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + hover = createLaunchHover({ + viewer, + enabled: () => layerState._enabled && layerState._dataSource?.show, + entityLaunchId: parts.selection.entityLaunchId, + }); + layerState._moveHandler.setInputAction( + hover.move, + Cesium.ScreenSpaceEventType.MOUSE_MOVE, + ); // Apply horizon visibility before Cesium traverses and picks the scene. // A postRender write is one frame late and can remain visibly stale when // request-on-demand rendering stops after a globe camera move. @@ -100,6 +212,7 @@ export function createLifecycle({ }, async disable() { + hover?.reset(); layerState._sourceController.abort(); layerState._enabled = false; releaseContinuousRender('rocket-launches'); @@ -122,6 +235,8 @@ export function createLifecycle({ }, async destroy(viewer) { + hover?.destroy(); + hover = null; layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 60e6e0070..6379e78e1 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -109,7 +109,7 @@ export function createRendering({ ); }); if (satelliteTrack) layerState._orbitMatches++; - const entity = layerState._dataSource.entities.add({ + layerState._dataSource.entities.add({ id: `rocket-launch:${launch.id}`, position, point: { diff --git a/src/layers/localAdsb/feeds.js b/src/layers/localAdsb/feeds.js index 4bb4bced7..5899729af 100644 --- a/src/layers/localAdsb/feeds.js +++ b/src/layers/localAdsb/feeds.js @@ -5,12 +5,12 @@ import { /** Same-origin route served by `server/providers/local-receivers.js`. */ export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft'; -export const LOCAL_RECEIVER_POLL_MS = 1_000; +const LOCAL_RECEIVER_POLL_MS = 1_000; /** * Browser-side deadline for one route request, body included. The server * bounds each upstream read at 2 s; this bounds the browser-to-server leg. */ -export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; +const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; function abortError() { const error = new Error('Local receiver request aborted'); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index fcf54b5d5..4db1e2852 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -36,18 +36,8 @@ import { } from './policy.js'; import { localAdsbStatus } from './status.js'; -export { - LAYER_ID as LOCAL_ADSB_LAYER_ID, - HEARD_BY_RECEIVER, -} from './policy.js'; -export { - localAdsbCardModel, - localAdsbClassLine, - localAdsbIsUat, - localAdsbReceiverLine, - localAdsbTitle, -} from './card.js'; -export { localAdsbClass } from './enrichment.js'; +export { HEARD_BY_RECEIVER } from './policy.js'; +export { localAdsbCardModel, localAdsbReceiverLine } from './card.js'; export { localAdsbStatus } from './status.js'; const FEET_TO_METERS = 0.3048; diff --git a/src/layers/localAdsb/motion.js b/src/layers/localAdsb/motion.js index 279bef6d3..084f3aa7e 100644 --- a/src/layers/localAdsb/motion.js +++ b/src/layers/localAdsb/motion.js @@ -26,7 +26,7 @@ import { export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000; export const LOCAL_ADSB_HISTORY_POINTS = 600; /** A display correction larger than this snaps instead of sliding. */ -export const LOCAL_ADSB_SNAP_M = 3_000; +const LOCAL_ADSB_SNAP_M = 3_000; /** Chord course uses the newest fix at least this much older than the anchor. */ const CHORD_BASELINE_MS = 2_000; /** Turn rate is estimated over this window of heard fixes. */ diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index d264c5e15..2e084cffb 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -24,7 +24,7 @@ function plural(count, one, many) { * @param {object[]} feeds Feed statuses from the route. * @returns {string} */ -export function describeFeedProblems(feeds) { +function describeFeedProblems(feeds) { const byStatus = new Map(); for (const feed of feeds) { if (feed?.status === 'live') continue; @@ -41,7 +41,7 @@ export function describeFeedProblems(feeds) { } /** Whether the server reported at least one configured feed. */ -export function feedsConfigured(feedState) { +function feedsConfigured(feedState) { return Boolean( feedState?.configured === true && Array.isArray(feedState.feeds) && diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index f375e5419..bb1d89127 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -1,10 +1,3 @@ -export { - GROUND_FLOOR_WARM_MAX_ALT_M, - POSITION_HISTORY_LIMIT, - LANDED_MISSING_POLL_LIMIT, - MISSING_POLL_LIMIT, - ERROR_BACKOFF_INTERVAL, -} from './recordPolicy.js'; import * as Cesium from 'cesium'; import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 9c106e927..d23ad621a 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -366,7 +366,6 @@ export function createQueries({ const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - const now = Cesium.JulianDate.now(); const nearby = []; for (const [icao24, bb] of flightState._billboards) { diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 6d619bf72..9de25db1d 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -187,7 +187,6 @@ export function createMilitarySnapshotRenderer({ // If this is the tracked aircraft, update label text // (position updates automatically via dead-reckoning CallbackProperty) if (isTracked && flightState._trackedEntity) { - const info = flightState.records.data.get(icao24); tracking._updateTrackedLabelModel(icao24); } } diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 8a440a54f..8c9723176 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { ensureGeoidReady, geoidHeight } = services.geoid; const { clearFocusTarget } = services.focus; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; function _emitAwarenessEvent(type, detail) { if ( @@ -593,7 +593,7 @@ export function createTracking({ } /** - * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are + * Fire-and-forget optional source history backfill (PRD F2). Trace points are * stride-thinned to the remaining vertex budget, then spliced strictly older * than the oldest seeded fix AHEAD of the locally accumulated fine segment, * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ @@ -1079,7 +1079,9 @@ export function createTracking({ // first-person reference. Empty globe clicks are inert until the user // exits with C, Escape, or the dedicated button. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself -- ignore (don't deselect) diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js index 869b8d403..79f348469 100644 --- a/src/layers/radio/ingestion.js +++ b/src/layers/radio/ingestion.js @@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js'; import { RADIO_DIRECTORY_STALE_MS, RADIO_DIRECTORY_FUTURE_SKEW_MS, + RADIO_REJECTED_STATION_COUNT_MAX, } from './policy.js'; // Directory errors stay English in layer state and stats; the layer row and @@ -64,17 +65,24 @@ export function createIngestion({ typeof body.degraded !== 'boolean' ) throw new Error('Radio directory freshness metadata was malformed'); + // Rows the server skipped as structurally malformed. Older envelopes + // omit the field; a present value must be a bounded count. + const serverRejectedStationCount = + body.rejectedStationCount === undefined + ? 0 + : body.rejectedStationCount; + if ( + !Number.isSafeInteger(serverRejectedStationCount) || + serverRejectedStationCount < 0 || + serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX + ) + throw new Error('Radio directory diagnostic metadata was malformed'); const rows = body.stations; const acceptedRows = rows.filter( parts.catalogModel.isValidRadioDirectoryStation, ); if (!acceptedRows.length) throw new Error('Radio directory returned no usable stations'); - if (acceptedRows.length !== rows.length) { - throw new Error( - 'Radio directory response contained malformed stations', - ); - } const acceptedGeneration = body.acceptedGeneration; if ( acceptedGeneration !== null && @@ -147,7 +155,15 @@ export function createIngestion({ } if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt; } - layerState._degraded = body.degraded; + // Combine server-side structural rejections with rows this client + // refused. The count is diagnostic: it marks coverage degraded but does + // not undo the generation admitted above. + layerState._rejectedStationCount = Math.min( + RADIO_REJECTED_STATION_COUNT_MAX, + serverRejectedStationCount + rows.length - acceptedRows.length, + ); + layerState._degraded = + body.degraded || layerState._rejectedStationCount > 0; layerState._stale = body.stale; layerState._error = preservingWarmCatalog ? ERROR_REFRESH_DEGRADED diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index 414cfa5c0..45d66892e 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -108,6 +108,7 @@ export function createLifecycle({ layerState._loading = false; layerState._stale = false; layerState._degraded = false; + layerState._rejectedStationCount = 0; layerState._error = null; layerState._updatedAt = null; layerState._managerLifecyclePresentation = null; diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js index 85777ed74..e0438c780 100644 --- a/src/layers/radio/policy.js +++ b/src/layers/radio/policy.js @@ -10,7 +10,7 @@ export const MARKER_LIFT_M = 2.5; export const SELECTED_LIFT_M = 5; -export const RADIO_PICK_TOLERANCE_PX = 8; +const RADIO_PICK_TOLERANCE_PX = 8; export const RADIO_TUNER_DIRECTORY_LIMIT = 750; @@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000; export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000; +// Bound for the server's `rejectedStationCount` envelope field and the +// combined client counter; matches server/providers/radio/constants.js. +export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000; + export const RADIO_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/src/layers/radio/presentation.js b/src/layers/radio/presentation.js index 5d6763ca4..c30889018 100644 --- a/src/layers/radio/presentation.js +++ b/src/layers/radio/presentation.js @@ -20,6 +20,7 @@ export function createPresentation({ loading: layerState._loading, stale: layerState._stale, degraded: layerState._degraded, + rejectedStationCount: layerState._rejectedStationCount, error: layerState._error, updatedAt: layerState._updatedAt, filter: layerState._filter, diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index 36cbc5050..9b4e6b387 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -259,7 +259,7 @@ export function createRendering({ layerState._horizonScanCount += 1; const occluder = horizonOccluder(layerState._viewer.camera); let visibilityChanged = false; - for (const [id, record] of layerState._renderById) { + for (const record of layerState._renderById.values()) { const matches = parts.categories.stationMatchesRadioCategory( record.station, layerState._filter, diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js index dca461e03..918ebb989 100644 --- a/src/layers/radio/state.js +++ b/src/layers/radio/state.js @@ -25,6 +25,7 @@ export function createState({ services }) { layerState._stale = false; layerState._degraded = false; + layerState._rejectedStationCount = 0; layerState._error = null; diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 406dc3e19..3fbcf1123 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -300,7 +300,7 @@ export function hhmm(iso) { } /** Candidate key: `PRODUCT:YYYY-MM-DD`. */ -export function candidateKey(product, day) { +function candidateKey(product, day) { return `${product}:${day}`; } diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index 656374fc6..e7ea5456d 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -94,9 +94,9 @@ export const TRACK_VIEW_FROM_HIGH_SCALE = 4; * attribute, so classification costs nothing per frame. */ -export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); +const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); -export const _classColor = (group) => +const _classColor = (group) => Cesium.Color.fromCssColorString(satelliteClassColor(group)); export const POINT_STYLES = { diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 4d0cb0408..049d2f746 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -317,7 +317,7 @@ test('a rejected key stops coverage requests until the layer goes off', async () test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, source, state, coverage } = setup(); + const { viewer, source, coverage } = setup(); coverage.refresh(); source.calls[0].reject( Object.assign(new Error('rate limited'), { retryAfterSec: 30 }), @@ -593,7 +593,7 @@ test('a tile that fails before the terrain table loads is reported at once, neve }); test('the selected sequence is drawn and highlighted even past the per-tile cap', async () => { - const { state, source, coverage, centre, viewer } = setup(); + const { state, source, coverage, centre } = setup(); const line = (n) => [ [ [centre.lon - 0.001, centre.lat + n * 1e-6], diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index c083879db..0ca4e3eb2 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -17,18 +17,18 @@ const EXPANDED_SELECTORS = [ ]; /** A photo download (tens of kilobytes to a few megabytes). */ -export const IMAGE_TIMEOUT_MS = 15_000; +const IMAGE_TIMEOUT_MS = 15_000; /** A picture file or decoded side past these is refused (a photo is a few MB). */ export const MAX_IMAGE_BYTES = 48 * 1024 * 1024; export const MAX_IMAGE_SIDE = 16_384; /** 64 MP: 256 MB once decoded to RGBA. A 16,384 px square is four times that. */ -export const MAX_IMAGE_PIXELS = 8_192 * 8_192; +const MAX_IMAGE_PIXELS = 8_192 * 8_192; /** Where in a file to look for its dimensions (a JPEG's EXIF comes first). */ const HEADER_BYTES = 256 * 1024; /** Whether a picture this size is past what the viewer decodes. */ -export function tooManyPixels(width, height) { +function tooManyPixels(width, height) { return ( width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE || @@ -37,7 +37,7 @@ export function tooManyPixels(width, height) { } /** Panorama texture widths: enough for a docked viewer, and for an expanded one. */ -export const PANO_MID_SIZE = Object.freeze([2048, 1024]); +const PANO_MID_SIZE = Object.freeze([2048, 1024]); export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); /** @@ -155,7 +155,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { * A picture refused for its size (`tooLarge`): the viewer will not load * that file another way, an having no size limit at all. */ -export function oversized(failure) { +function oversized(failure) { return Object.assign(new Error(failure), { tooLarge: true }); } diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 939e0ec1c..76a0ea208 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -14,7 +14,7 @@ export const KARTAVIEW_LABEL = 'KARTAVIEW'; */ export const KARTAVIEW_API_HOST = 'https://api.kartaview.org'; /** KartaView's image proxy: resizes a photo given its base64url address. */ -export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; +const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; /** Stable id prefixes for picked primitives; every one starts with `kv:`. */ export const PICK_PREFIX = Object.freeze({ @@ -140,8 +140,6 @@ export const MASK_SLOW_MESSAGE = msg( export const FILTERED_OUT_MESSAGE = msg( 'None of KartaView’s photos here pass the imagery filter', ); -/** Under the SINCE slider while KartaView is lit. */ -export const SINCE_NOTE = '(by upload date)'; /** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ function utcDay(ms) { @@ -224,9 +222,32 @@ export function kartaViewTimeMs(value) { String(value ?? ''), ); if (!match) return null; - const [, y, mo, d, h = '0', mi = '0', s = '0'] = match; - if (Number(y) < 1990) return null; - const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hours = Number(match[4] ?? 0); + const minutes = Number(match[5] ?? 0); + const seconds = Number(match[6] ?? 0); + if (year < 1990) return null; + if ( + month < 1 || + month > 12 || + day < 1 || + hours > 23 || + minutes > 59 || + seconds > 59 + ) + return null; + // Date.UTC rolls 31 February into March. A photo would then pass or fail + // the day filter on the wrong calendar day, so an impossible date is unknown. + const ms = Date.UTC(year, month - 1, day, hours, minutes, seconds); + const parsed = new Date(ms); + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day + ) + return null; return Number.isFinite(ms) ? ms : null; } diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 82a68555e..540071f0b 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -158,6 +158,10 @@ test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => kartaViewTimeMs('2022-03-21T06:42:59'), Date.UTC(2022, 2, 21, 6, 42, 59), ); + assert.equal(kartaViewTimeMs('2022-02-31 12:00:00'), null); + assert.equal(kartaViewTimeMs('2022-13-01 00:00:00'), null); + assert.equal(kartaViewTimeMs('2022-03-21 25:00:00'), null); + assert.equal(kartaViewTimeMs('2022-04-31'), null); assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null); assert.equal(kartaViewTimeMs(''), null); assert.equal(kartaViewTimeMs(null), null); diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 008697b1b..38a8b9c39 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -36,16 +36,29 @@ async function readJsonOrThrow(response, label) { return payload; } +/** `endpoint` with exactly one trailing slash, or '' when unset. */ +function endpointBase(endpoint) { + const text = String(endpoint || '').trim(); + return text ? text.replace(/\/*$/, '/') : ''; +} + /** * Mapillary network access: tiles via the local proxy, which adds the token * and caches; Graph API lookups direct with the client token, as MapillaryJS. - * @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options + * With `graphEndpoint` (a Graph API base that adds the token itself, such as + * a same-origin proxy), the source's Graph lookups go there through + * `fetchImpl` instead, and the photo viewer sends its own lookups there too; + * no token is sent or needed. + * @param {{token?: string, fetchImpl?: Function, endpoints?: object, + * graphEndpoint?: string}} options */ export function createMapillarySource({ token = '', fetchImpl = (...args) => globalThis.fetch(...args), endpoints = {}, + graphEndpoint = '', } = {}) { + const graphBase = endpointBase(graphEndpoint); const urls = { status: '/api/mapillary/status', tiles: '/api/mapillary/tiles', @@ -54,6 +67,13 @@ export function createMapillarySource({ }; async function graph(path, params = {}, { signal } = {}) { + if (graphBase) { + const search = new URLSearchParams(params); + const response = await fetchImpl(`${graphBase}${path}?${search}`, { + signal, + }); + return readJsonOrThrow(response, 'Mapillary graph'); + } if (!token) throw new MapillarySourceError('Mapillary token not configured', { status: 503, @@ -70,6 +90,10 @@ export function createMapillarySource({ get token() { return token; }, + /** The Graph API base that adds the token itself, or '' (direct). */ + get graphEndpoint() { + return graphBase; + }, hasToken: () => Boolean(token), async getStatus({ signal } = {}) { diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index d744e499d..b502a7fd8 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -125,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as 'the Retry-After header wins over the body', ); }); + +test('with a Graph endpoint, sequence lookups go there without a token', async () => { + const { calls, fetchImpl } = fakeFetch(() => + jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph', + fetchImpl, + }); + assert.equal(source.graphEndpoint, '/api/mapillary/graph/'); + assert.equal(source.hasToken(), false); + const images = await source.getSequenceImages('seq-1'); + assert.equal(images.length, 1); + const url = new URL(calls[0].url, 'http://localhost'); + assert.equal(url.pathname, '/api/mapillary/graph/images'); + assert.equal(url.searchParams.get('sequence_ids'), 'seq-1'); + assert.equal(url.searchParams.has('access_token'), false); +}); + +test('Graph endpoint errors keep their status', async () => { + const { fetchImpl } = fakeFetch(() => + jsonResponse({ error: 'no_key', keyRequired: true }, 503), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph/', + fetchImpl, + }); + await assert.rejects( + () => source.getSequenceImages('1'), + (error) => error.status === 503 && error.keyRequired === true, + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/providers/mapillary/viewer.js index d36580321..4ec2654e4 100644 --- a/src/layers/streetLevel/providers/mapillary/viewer.js +++ b/src/layers/streetLevel/providers/mapillary/viewer.js @@ -13,6 +13,20 @@ import { mapillaryImageUrl } from './policy.js'; * @property {string} externalUrl Deep link to the image on mapillary.com. */ +/** + * The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS + * resolves its lookup paths against it; '' when the source has none. + */ +export function graphEndpointUrl(source) { + if (!source?.graphEndpoint) return ''; + const url = new URL( + source.graphEndpoint, + globalThis.location?.href || 'http://localhost/', + ); + url.pathname = url.pathname.replace(/\/*$/, '/'); + return url.href; +} + /** * The panel's photo viewer: loads MapillaryJS on the first `mount` and emits * a pose on every image, view or position change. @@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) { async function buildViewer(host) { const built = generation; destroyViewer(); - const { Viewer } = await ensureLibrary(); + const { Viewer, GraphDataProvider } = await ensureLibrary(); // Unmounted (layer off) while the library loaded: a viewer built now // would hold a WebGL context nobody releases. if (built !== generation) throw new Error('Mapillary viewer was unmounted'); + const endpoint = graphEndpointUrl(source); viewer = new Viewer({ - accessToken: source.token, + // A source with its own Graph endpoint (which adds the token itself) + // answers the viewer's lookups, sent without a token; otherwise + // MapillaryJS calls the Graph API with the client token. + ...(endpoint + ? { dataProvider: new GraphDataProvider({ endpoint }) } + : { accessToken: source.token }), container: host, component: { cover: false, diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index 4dfa023ef..e855c9c72 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -41,8 +41,6 @@ import { normalizeItem, } from './policy.js'; -export { PANORAMAX_PROVIDER_ID } from './policy.js'; - /** * A tile's bytes decoded by `decode`; a body that is not a vector tile * (an HTML error page, a cut-off answer) reads as such, not as a parser's diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index e9a51c10e..670772c6a 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -15,7 +15,7 @@ export const PANORAMAX_LABEL = 'PANORAMAX'; */ export const PANORAMAX_API = 'https://api.panoramax.xyz/api'; /** The meta-catalogue's web viewer, where the caption link goes. */ -export const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/'; +const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/'; /** Stable id prefixes for picked primitives; every one starts with `pnx:`. */ export const PICK_PREFIX = Object.freeze({ diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 6770cd133..5e51929a6 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,6 +1,6 @@ import { encodeIdSwitches } from '../../data/idSwitches.js'; -export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); +const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); export function filtersOf(provider) { return provider?.filters ?? IMAGERY_FILTERS; diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js index b18f2cc57..b6c068d49 100644 --- a/src/layers/streetLevel/sequenceCones.js +++ b/src/layers/streetLevel/sequenceCones.js @@ -5,8 +5,8 @@ import { createHorizonCull, metresBetween } from './view.js'; import { msg } from '../../i18n/index.js'; /** Cones closer than this to the previous kept one are dropped. */ -export const IMAGE_CONE_MIN_SPACING_M = 3; -export const IMAGE_CONE_SIZE_PX = 26; +const IMAGE_CONE_MIN_SPACING_M = 3; +const IMAGE_CONE_SIZE_PX = 26; /** * @typedef {object} ConeImage One image of a sequence, as the cones draw it. diff --git a/src/layers/streetLevel/tileCache.js b/src/layers/streetLevel/tileCache.js index d6c0a91e5..6ee0897ef 100644 --- a/src/layers/streetLevel/tileCache.js +++ b/src/layers/streetLevel/tileCache.js @@ -1,6 +1,6 @@ /** Decoded coverage tiles kept, and for how long. */ -export const TILE_CACHE_SIZE = 24; -export const TILE_CACHE_TTL_MS = 10 * 60_000; +const TILE_CACHE_SIZE = 24; +const TILE_CACHE_TTL_MS = 10 * 60_000; /** Least-recently-used map: reading an entry renews it. */ export function createLru(limit) { diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 577bb8ae1..04c6ed6c4 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -64,7 +64,7 @@ export function keyAlternatives(providers) { * The filter controls a switched-on provider honours (both when none is on). * @param {Array} providers */ -export function filterControls(providers) { +function filterControls(providers) { const active = providers.filter((p) => p.on); const honoured = (key) => active.length === 0 || @@ -78,7 +78,7 @@ export function filterControls(providers) { * note needs no provider name. * @param {Array} providers */ -export function sinceNote(providers) { +function sinceNote(providers) { return providers.some((p) => p.on && p.sinceBasis === 'upload') ? t('(by upload date)') : null; diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 4700acf14..fdcb74e21 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -22,13 +22,16 @@ registerHooks({ const source = url.includes('/library?') ? 'const lib = await globalThis.__gevMapillaryLibrary();\n' + 'export const Viewer = lib.Viewer;\n' + - 'export const RenderMode = lib.RenderMode;\n' + 'export const RenderMode = lib.RenderMode;\n' + + 'export const GraphDataProvider = lib.GraphDataProvider;\n' : 'export default {};\n'; return { format: 'module', shortCircuit: true, source }; }, }); -const { createMapillaryViewer } = +const { createMapillaryViewer, graphEndpointUrl } = await import('./providers/mapillary/viewer.js'); +const { createMapillarySource } = + await import('./providers/mapillary/source.js'); /** * A viewer host over `adapter`. `marker` records where the position marker @@ -345,7 +348,7 @@ function fakeLibrary() { } globalThis.__gevMapillaryLibrary = async () => { await gate.promise; - return { Viewer, RenderMode: {} }; + return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider }; }; return { gate, viewers }; } @@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets host.close(); assert.equal(state.street.license, null); }); + +/** MapillaryJS 4's Graph provider, recording what it was built with. */ +class FakeGraphDataProvider { + constructor(options = {}) { + this.options = options; + } +} + +test('without a Graph endpoint the viewer gets the client token, as before', async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const adapter = createMapillaryViewer({ source: { token: 't' } }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, 't'); + assert.equal(viewer.options.dataProvider, undefined); +}); + +test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const source = createMapillarySource({ + graphEndpoint: 'https://app.example/api/mapillary/graph', + fetchImpl: async () => { + throw new Error('the viewer does not use the source transport'); + }, + }); + const adapter = createMapillaryViewer({ source }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, undefined); + const provider = viewer.options.dataProvider; + assert.ok(provider instanceof FakeGraphDataProvider); + assert.deepEqual(provider.options, { + endpoint: 'https://app.example/api/mapillary/graph/', + }); +}); + +test('a relative Graph endpoint resolves against the page, with one trailing slash', () => { + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal(graphEndpointUrl({ token: 't' }), ''); +}); diff --git a/src/layers/submarineCables/geometry.js b/src/layers/submarineCables/geometry.js index 08366dba4..b4b434710 100644 --- a/src/layers/submarineCables/geometry.js +++ b/src/layers/submarineCables/geometry.js @@ -33,7 +33,7 @@ export function featureReference(feature) { }; } -export function coordsFromProperty(value) { +function coordsFromProperty(value) { if (!Array.isArray(value) || value.length < 2) return null; const lon = Number(value[0]); const lat = Number(value[1]); @@ -41,7 +41,7 @@ export function coordsFromProperty(value) { return [lon, lat]; } -export function coordsFromPoint(value) { +function coordsFromPoint(value) { if (!Array.isArray(value) || value.length < 2) return null; const lon = Number(value[0]); const lat = Number(value[1]); @@ -49,7 +49,7 @@ export function coordsFromPoint(value) { return [lon, lat]; } -export function collectLonLat(value, out) { +function collectLonLat(value, out) { if (!Array.isArray(value)) return; if (typeof value[0] === 'number' && typeof value[1] === 'number') { const lon = Number(value[0]); diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 05afde1c1..6c58a57a4 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -20,6 +20,23 @@ export function createAnimation({ parts, source, }) { + function syncRenderDemand() { + const needed = Boolean( + layerState._enabled && + (layerState._dots.length || layerState._heatJamPrim), + ); + // Height easing only runs in a held frame. Settle it before releasing, so + // a population emptied mid-correction cannot report loading forever. + if (!needed && layerState._heightPending) parts.retention.finishHeights(); + if (needed === Boolean(layerState._renderHeld)) return; + layerState._renderHeld = needed; + if (needed) { + layerState._lastAnimTime = 0; + services.render.holdContinuousRender('traffic'); + layerState._viewer?.scene?.requestRender?.(); + } else services.render.releaseContinuousRender('traffic'); + } + /** * Spawn animated dot primitives along a single road. * @@ -188,6 +205,7 @@ export function createAnimation({ : null, }); } + syncRenderDemand(); } /** @@ -206,6 +224,8 @@ export function createAnimation({ */ function animate() { + syncRenderDemand(); + if (!layerState._renderHeld) return; const now = Date.now(); // Delta time in seconds, capped to avoid jumps when returning from background tab const dt = layerState._lastAnimTime @@ -339,6 +359,13 @@ export function createAnimation({ layerState._count = 0; layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; layerState._closedRoads = 0; + syncRenderDemand(); } - return { spawnDotsForRoad, animate, maybeStopLight, clearDots }; + return { + spawnDotsForRoad, + animate, + maybeStopLight, + clearDots, + syncRenderDemand, + }; } diff --git a/src/layers/traffic/footprint.js b/src/layers/traffic/footprint.js index d75947941..9a241d997 100644 --- a/src/layers/traffic/footprint.js +++ b/src/layers/traffic/footprint.js @@ -1,9 +1,9 @@ import * as Cesium from 'cesium'; import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js'; -export const RETICLE_RANGE_KM = 10; -export const RETICLE_TILE_CAP = 16; -export const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`; +const RETICLE_RANGE_KM = 10; +const RETICLE_TILE_CAP = 16; +const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`; const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 94c40d935..fc13e205d 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -12,7 +12,6 @@ export function createLifecycle({ parts, source, }) { - const { holdContinuousRender, releaseContinuousRender } = services.render; const { resetFlowTileCache } = source; const methods = { @@ -81,7 +80,7 @@ export function createLifecycle({ layerState._enabled = true; layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); if (layerState._roadMode !== 'tomtom') source.prefetch?.(); - holdContinuousRender('traffic'); // per-frame animator (perf wave 2) + parts.animation.syncRenderDemand(); layerState._lastAnimTime = 0; layerState._pointCollection.show = true; @@ -142,7 +141,7 @@ export function createLifecycle({ disable(viewer) { layerState._enabled = false; services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic'); - releaseContinuousRender('traffic'); + parts.animation.syncRenderDemand(); clearTimeout(layerState._fetchTimeout); clearInterval(layerState._enableKickTimer); layerState._enableKickTimer = null; diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index 3c230d870..ef60e478f 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -37,7 +37,14 @@ function setup(t) { visibleRoadsForAltitude: (roads) => roads, }, }; - const ctx = { state, services: {}, parts, source: {} }; + const ctx = { + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts, + source: {}, + }; parts.style = createStyle(ctx); parts.model = createModel(ctx); parts.animation = createAnimation(ctx); @@ -558,6 +565,37 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling assert.notEqual(forward.waypoints, reverse.waypoints); }); +test('a closure that empties the population mid-correction settles heights and clears loading before release', (t) => { + const h = setup(t); + h.parts.retention.reconcile(h.roads(), 450, true); + h.settle(); + assert.equal(h.state._renderHeld, true); + const road = h.state._roads[0]; + // Republishing the sole road starts a height correction. + const corrected = h.roads(0, 120); + h.parts.retention.reconcile(corrected, 450, true); + assert.equal(h.state._heightPending, true); + assert.equal(road.heightMoving, true); + // The closure arrives before the next population pass, which then removes + // the final points after their fade, long before the correction finishes. + corrected[0].flow = { level: 0, closure: true }; + for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame(); + assert.equal(h.state._dots.length, 0); + assert.equal(h.state._heatJamPrim, null); + assert.equal(h.state._renderHeld, false); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._populationPending, false); + assert.equal(road.heightMoving, false); + assert.ok( + Math.abs(C.Cartographic.fromCartesian(road.waypoints[0]).height - 120) < + 0.01, + ); + // Frames driven by other layers stay idle and cannot revive the work. + h.frame(); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._renderHeld, false); +}); + test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => { const h = setup(t); h.state._viewer.scene = { diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js index 81b01f299..c20b15e41 100644 --- a/src/layers/traffic/policy.js +++ b/src/layers/traffic/policy.js @@ -175,13 +175,3 @@ export const STYLED_MIN_BASE_PX = 5; /** @const {number} Maximum tile cache entries before LRU eviction */ export const TILE_CACHE_MAX_ENTRIES = 64; - -/** - * Milliseconds the first dot paint will wait for flow data. Cached flow - * settles within this window (decode cache, 120 s TTL) and renders fully - * colored; a cold tile fetch loses the race, dots paint immediately in - * white, and recolorDotsInPlace applies the colors when flow arrives — - * field-test round 1's "takes forever to load" was the sequential wait. - */ - -export const FLOW_RENDER_RACE_MS = 250; diff --git a/src/layers/traffic/renderDemand.test.mjs b/src/layers/traffic/renderDemand.test.mjs new file mode 100644 index 000000000..df5965145 --- /dev/null +++ b/src/layers/traffic/renderDemand.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAnimation } from './animation.js'; + +test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => { + const calls = []; + const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } }; + const animation = createAnimation({ + state, + services: { + render: { + holdContinuousRender: (id) => calls.push(['hold', id]), + releaseContinuousRender: (id) => calls.push(['release', id]), + }, + }, + parts: { + retention: { clear() {} }, + rendering: { + removeHeatLines() { + state._heatJamPrim = null; + }, + }, + }, + }); + animation.syncRenderDemand(); + assert.deepEqual(calls, []); + state._dots.push({}); + animation.syncRenderDemand(); + animation.syncRenderDemand(); + assert.deepEqual(calls, [['hold', 'traffic']]); + animation.clearDots(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._heatJamPrim = {}; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['hold', 'traffic']); + state._enabled = false; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._enabled = true; + animation.clearDots(); + assert.equal(state._renderHeld, false); + state._dots.push({}); + animation.syncRenderDemand(); + assert.equal(state._renderHeld, true); +}); diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index 7e148148d..bf0132b98 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -50,6 +50,7 @@ export function createRendering({ layerState._heatSlowPrim = null; } layerState._heatLineCount = 0; + parts.animation.syncRenderDemand?.(); } /** @@ -152,6 +153,7 @@ export function createRendering({ } layerState._heatLineCount = kept.length; + parts.animation.syncRenderDemand?.(); if (candidates.length > kept.length) { console.log( `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index 4bfe7bfb6..a987f64b0 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -125,6 +125,7 @@ export function createRetention({ state, parts }) { count++; } state._count = count; + parts.animation.syncRenderDemand?.(); } function reconcile(roads, height, replace) { altitude = height; @@ -241,6 +242,29 @@ export function createRetention({ state, parts }) { road.heightMoving = moving; } } + /** + * Settle every retained road on its corrected heights at once. Easing only + * runs inside the held animation frame, so once nothing is left to animate + * the remaining correction would otherwise stay pending indefinitely. + * Published roads whose record was just dropped are settled too, so a later + * held frame cannot revive their correction. + */ + function finishHeights() { + const roads = new Set(state._roads); + for (const record of records.values()) roads.add(record.road); + for (const road of roads) { + if (!road.heightMoving) continue; + for (let i = 0; i < road.waypoints.length; i++) + Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]); + for (let i = 0; i < road.segmentDist.length; i++) + road.segmentDist[i] = Cesium.Cartesian3.distance( + road.waypoints[i], + road.waypoints[i + 1], + ); + road.heightMoving = false; + } + state._heightPending = false; + } function clear() { if (timer !== null) { if (typeof cancelAnimationFrame === 'function') @@ -252,5 +276,5 @@ export function createRetention({ state, parts }) { state._populationPending = false; state._heightPending = false; } - return { reconcile, easeHeights, clear, refreshCounts }; + return { reconcile, easeHeights, finishHeights, clear, refreshCounts }; } diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 4edf4c234..595bc2a0d 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -1,6 +1,6 @@ import { normalizeRoadMode } from './roadModes.js'; import * as Cesium from 'cesium'; -import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js'; +import { FLOW_BUCKET_COLORS } from './policy.js'; export function createState({ services }) { const layerState = {}; diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 6fd46211f..22b179120 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -9,7 +9,6 @@ import { FEED_STALE_AFTER_MS, MAX_VEHICLES_TOTAL, MISSED_POLLS_TO_DROP, - TRANSIT_POLL_MS, isStaleVehicleFix, transitVehicleKey, vehicleReportTimeMs, diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 8a2d39edf..ea547f85d 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -6,7 +6,6 @@ import { FEED_EVICT_AFTER_MS, FEED_STALE_AFTER_MS, MISSED_POLLS_TO_DROP, - SELECTED_CARD_REFRESH_MS, TRANSIT_POLL_MS, VEHICLE_MAX_FIX_AGE_MS, } from './policy.js'; @@ -1407,7 +1406,6 @@ test('the selected card lands where the vehicle stopped, not short of it', async // segment ends left it a few metres behind, and then the render hold was // released with no frame scheduled to close the gap. const app = harness(t); - const start = Date.now(); app.serve('mbta', () => ({ status: 200, body: snapshot('mbta', 'MBTA', [vehicle('b', 42.36, -71.06, reported())], { @@ -3576,7 +3574,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve source: {}, parts: { flow: { ensureFlowStatus: noop }, - animation: { animate: noop, clearDots: noop }, + animation: { animate: noop, clearDots: noop, syncRenderDemand: noop }, viewport: { onCameraChanged: noop }, ingestion: { cancelActiveFetch: noop }, }, diff --git a/src/layers/transit/movement.js b/src/layers/transit/movement.js index 84128cc39..2c9a61dcd 100644 --- a/src/layers/transit/movement.js +++ b/src/layers/transit/movement.js @@ -9,10 +9,7 @@ import { correctHeight, setTimeOffsetMs, } from '../../data/contactPlayback.js'; -export const FIX_HISTORY_MAX = 128; -export const DISPLAY_LAG_MIN_MS = 25_000; -export const DISPLAY_LAG_RESET_MS = 90_000; -export const MODE_MAX_SPEED_MPS = Object.freeze({ +const MODE_MAX_SPEED_MPS = Object.freeze({ bus: 36, // ~130 km/h — a coach on a motorway tram: 28, // ~100 km/h — interurban light rail subway: 42, // ~150 km/h @@ -32,22 +29,19 @@ export const MODE_MAX_SPEED_MPS = Object.freeze({ * @param {boolean} [inferred] Whether the mode came from a feed default. * @returns {number} m/s */ -export function speedCeilingMps(mode, inferred = false) { +function speedCeilingMps(mode, inferred = false) { if (inferred) return MODE_MAX_SPEED_MPS.unknown; return MODE_MAX_SPEED_MPS[mode] ?? MODE_MAX_SPEED_MPS.unknown; } -/** Position deadband is disabled: even a short reported crawl is played. */ -export const FIX_NOISE_M = 0; - /** * Displacement (m) a glide must cover before its direction is worth believing. * Below this the course derived from it is noise; the last good one is held. */ -export const COURSE_MIN_TRAVEL_M = 12; +const COURSE_MIN_TRAVEL_M = 12; /** Maximum display-course change per second (deg). A bus is not a gyroscope. */ -export const COURSE_MAX_RATE_DEG_PER_S = 120; +const COURSE_MAX_RATE_DEG_PER_S = 120; /** * Longest stretch of elapsed time (s) one evaluation may spend. The limiter * used to bank the time since the course last CHANGED, so a vehicle that held @@ -56,15 +50,7 @@ export const COURSE_MAX_RATE_DEG_PER_S = 120; * EVALUATED, and capped here so that even a first look after a long idle * sweeps visibly instead of snapping. */ -export const COURSE_STEP_MAX_S = 0.25; - -/** - * Consecutive fixes at the same place before a vehicle is called STOPPED. - * One is not enough: a fifteen-second gap between identical fixes is also what - * a vehicle waiting at a light looks like, and so is a feed that repeated - * itself. - */ -export const STOPPED_FIXES = 2; +const COURSE_STEP_MAX_S = 0.25; /** * How long after its newest fix a vehicle stops being described as travelling. @@ -75,7 +61,7 @@ export const STOPPED_FIXES = 2; * otherwise is the same overreach as reading STOPPED off a stale status. Ninety * seconds matches the threshold at which a whole feed reads as gone quiet. */ -export const MOTION_UNKNOWN_AFTER_MS = 90_000; +const MOTION_UNKNOWN_AFTER_MS = 90_000; const EARTH_M_PER_DEG = 111_320; @@ -94,29 +80,13 @@ export function fixDistanceM(a, b) { return Math.hypot(dLat, dLon); } -/** - * Course in degrees clockwise from north, from one point to another. - * @param {{lat:number, lon:number}} from - * @param {{lat:number, lon:number}} to - * @returns {number} 0-360 - */ -export function courseBetween(from, to) { - const dLat = (to.lat - from.lat) * EARTH_M_PER_DEG; - const dLon = - (to.lon - from.lon) * - EARTH_M_PER_DEG * - Math.cos((from.lat * Math.PI) / 180); - const deg = (Math.atan2(dLon, dLat) * 180) / Math.PI; - return (deg + 360) % 360; -} - /** * Shortest signed difference between two courses, in degrees (-180, 180]. * @param {number} from * @param {number} to * @returns {number} */ -export function courseDelta(from, to) { +function courseDelta(from, to) { let delta = ((to - from + 540) % 360) - 180; if (delta === -180) delta = 180; return delta; @@ -259,7 +229,7 @@ export function attachFloorToPlace(entry, fix, heightM) { correctHeight(entry.track, scratch.seq, heightM); } } -export function displayCourse(entry, nowMs) { +function displayCourse(entry, nowMs) { if (!entry) return null; const target = Number.isFinite(entry.sample?.segmentCourseDeg) ? entry.sample.segmentCourseDeg diff --git a/src/layers/transit/movement.test.mjs b/src/layers/transit/movement.test.mjs index e758a52c8..692d2d3ac 100644 --- a/src/layers/transit/movement.test.mjs +++ b/src/layers/transit/movement.test.mjs @@ -9,7 +9,7 @@ import { displacementPlausible, } from './movement.js'; import { buildTransitSelectionCopy } from './policy.js'; -import { seek, sampleAt } from '../../data/contactPlayback.js'; +import { sampleAt } from '../../data/contactPlayback.js'; const entry = () => { const e = { mode: 'bus', modeInferred: false, courseDeg: null }; initializePlayback(e); diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 2fb5101ce..010a2efc2 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -99,7 +99,7 @@ export const VISIBILITY_REFRESH_MS = 250; * on screen. Generous on purpose: a vehicle that crosses the edge mid-glide * should already have been moving, not pop into motion at the border. */ -export const VIEW_MARGIN_RATIO = 0.6; +const VIEW_MARGIN_RATIO = 0.6; /** * Whether a vehicle is close enough to the camera's look-at area to be worth @@ -516,10 +516,10 @@ export function createTransitSelectedOverlayEntry(key, position, copy, mode) { } /** Modes that ordinarily run below the street, drawn on the surface above themselves. */ -export const UNDERGROUND_MODES = new Set(['subway']); +const UNDERGROUND_MODES = new Set(['subway']); /** The mode in plain words, for a card a person reads rather than scans. */ -export const TRANSIT_MODE_WORD = Object.freeze({ +const TRANSIT_MODE_WORD = Object.freeze({ bus: msg('Bus'), tram: msg('Tram'), subway: msg('Subway'), @@ -529,7 +529,7 @@ export const TRANSIT_MODE_WORD = Object.freeze({ }); /** Short, fixed-width mode word for the detection card's class field. */ -export const TRANSIT_MODE_ABBR = Object.freeze({ +const TRANSIT_MODE_ABBR = Object.freeze({ bus: 'BUS', tram: 'TRAM', subway: 'METRO', @@ -588,7 +588,7 @@ export function transitDetectionId(record, feed = null) { } /** Longest route text the single-line detection label can carry. */ -export const DETECTION_ID_MAX_CHARS = 14; +const DETECTION_ID_MAX_CHARS = 14; /** * Cut long route text with an ellipsis, so a reader can see it was cut. @@ -600,7 +600,7 @@ export const DETECTION_ID_MAX_CHARS = 14; * @param {string} text * @returns {string} Uppercase, at most DETECTION_ID_MAX_CHARS characters. */ -export function elideRouteText(text) { +function elideRouteText(text) { const value = String(text || '').toUpperCase(); if (value.length <= DETECTION_ID_MAX_CHARS) return value; return `${value.slice(0, DETECTION_ID_MAX_CHARS - 1)}\u2026`; @@ -659,6 +659,6 @@ export function transitDetectionMetric( * @param {number} [nowMs] * @returns {string} */ -export function transitVehicleState(entry, nowMs = Date.now()) { +function transitVehicleState(entry, nowMs = Date.now()) { return displayMotion(entry, nowMs).word; } diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 96d77f2a2..2309978a5 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -22,7 +22,6 @@ import { import { MARKER_PIXEL_SIZE, ROTATION_REFRESH_MS, - SELECTED_CARD_REFRESH_MS, VISIBILITY_REFRESH_MS, SELECTED_PIXEL_SIZE, TRANSIT_MODE_COLORS, diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index da0a2ce82..64e115637 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -12,8 +12,6 @@ export function createCards({ layer, options, }) { - const { state } = vesselState; - function updateSelectedVesselHud(record) { const el = document.getElementById('hud-ais-vessel'); if (!el) return; diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js index 75e5cdd67..9ab06fbb8 100644 --- a/src/layers/vessels/policy.js +++ b/src/layers/vessels/policy.js @@ -1,8 +1,3 @@ -export { - PARTIAL_RETENTION_MS, - SELECTED_PIN_REFRESHES, - AIS_FIRST_CONNECT_LABEL, -} from './recordPolicy.js'; import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js'; import { msg } from '../../i18n/index.js'; diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 88e5aa2ec..96c5df6eb 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -10,7 +10,7 @@ export function createSelection({ options, }) { const { state } = vesselState; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const { requestWorldFocus } = services.worldFocus; const { selectEntityContext, @@ -37,7 +37,9 @@ export function createSelection({ // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!state.feed.enabled) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); const pickedId = resolvePickId(picked); let record = pickedId ? state.records.byMmsi.get(pickedId) : null; const rawId = picked?.id ?? picked?.primitive?.id; diff --git a/src/layers/weather/source.js b/src/layers/weather/source.js index cf004476f..5386bfad7 100644 --- a/src/layers/weather/source.js +++ b/src/layers/weather/source.js @@ -4,7 +4,7 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; // the weather layer translates them where it shows them (t / translateKnown). const msg = (text) => text; -export const WEATHER_PRODUCTS = Object.freeze([ +const WEATHER_PRODUCTS = Object.freeze([ 'radar', 'clouds', 'clouds-regional', diff --git a/src/layers/wind/gpuRendering.js b/src/layers/wind/gpuRendering.js index 27d71dcd5..0873fa63b 100644 --- a/src/layers/wind/gpuRendering.js +++ b/src/layers/wind/gpuRendering.js @@ -6,8 +6,8 @@ import { WIND_NARROW_PATH_LIMIT, } from './streamlines.js'; -export const WIND_FADE_LOW_METERS = 15000; -export const WIND_FADE_HIGH_METERS = 60000; +const WIND_FADE_LOW_METERS = 15000; +const WIND_FADE_HIGH_METERS = 60000; // Original material: a persistent fine curve plus a moving, tapered highlight. // The integer part of s identifies a path; its fractional part follows the wind. diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index 3b5e09697..175381031 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -97,7 +97,7 @@ const complete = (model, extra = {}) => ({ }); test('local appearance and units reuse the loaded field; optional scalar requests remain separate', async () => { const requests = []; - const { layer, calls } = harness({ + const { layer } = harness({ getSnapshot: async (args) => { requests.push(args); return complete( diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 01d00b1d6..b9903f97e 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -93,7 +93,6 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d }); test('share-follow failures use the universal top-center status instead of the bottom toast', () => { - const ui = readShellSource(); const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); assert.match(handler, /this\.showStatus\(message\)/); assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); @@ -629,7 +628,7 @@ test('the loading ticker never runs hidden and stops after loading and notices s // safety-net poll, so its `if (document.hidden) return;` is correct there // and must not be confused with this self-stopping ticker's leak. const armBody = ui.slice(ui.indexOf('_armLoadingFeedbackTicker() {')); - assert.ok(armBody.startsWith('_armLoadingFeedbackTicker() {'), 'ticker function not found in ui.js'); + assert.ok(armBody.startsWith('_armLoadingFeedbackTicker() {'), 'ticker function not found in the UI shell source'); const arm = armBody.slice(0, armBody.indexOf('\n }\n') + 5); // 1. The ARM guard itself refuses while hidden. Previously the only hidden diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 79cc92489..9d38592e3 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -1,5 +1,4 @@ import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/src/locationStatus.js b/src/locationStatus.js index 1d90582c9..2c9e406f3 100644 --- a/src/locationStatus.js +++ b/src/locationStatus.js @@ -64,5 +64,3 @@ export function locationMiniStatus({ return { ...EMPTY }; } - -export default locationMiniStatus; diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index b7beec325..0fa66e0da 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -1,6 +1,5 @@ -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { shellMethod } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; -import { StyleManager } from './ui/applicationShell.js'; import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -8,7 +7,6 @@ import { readFileSync } from 'node:fs'; import { msg, t } from './i18n/index.js'; // Exercise the installed event routes and central close method, without WebGL. -const source = readShellSource(); const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const locationMarkup = markup.slice(markup.indexOf('
]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/); diff --git a/src/maps/availability.js b/src/maps/availability.js index 30ef3e6c6..ceace9968 100644 --- a/src/maps/availability.js +++ b/src/maps/availability.js @@ -1,7 +1,4 @@ -import { - keySetupRequirement, - keySetupRequirementEnvVars, -} from '../keySetupCore.mjs'; +import { keySetupRequirementEnvVars } from '../keySetupCore.mjs'; import { t } from '../i18n/index.js'; /** * Why Google 3D is unavailable, phrased so the tooltip and toast recommend the diff --git a/src/maps/imageryComparison.js b/src/maps/imageryComparison.js index 5586bd86e..cf7d266ae 100644 --- a/src/maps/imageryComparison.js +++ b/src/maps/imageryComparison.js @@ -1,5 +1,5 @@ /** Basemap that before/after imagery comparisons are read against. */ -export const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery'; +const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery'; const SWITCH_POLICIES = new Set(['preserve', 'esri', 'esri-if-photoreal']); const leases = new WeakMap(); diff --git a/src/maps/terrainRetry.js b/src/maps/terrainRetry.js index 26fc95576..6cf0fe2af 100644 --- a/src/maps/terrainRetry.js +++ b/src/maps/terrainRetry.js @@ -24,7 +24,7 @@ */ export const TERRAIN_RETRY_ATTEMPTS = 3; -export const TERRAIN_RETRY_STATUSES = Object.freeze([429, 502, 503, 504]); +const TERRAIN_RETRY_STATUSES = Object.freeze([429, 502, 503, 504]); export const TERRAIN_RETRY_BASE_MS = 750; export const TERRAIN_RETRY_MAX_MS = 15_000; export const TERRAIN_RETRY_SPREAD_MS = 1_500; diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 717c826a2..ccf3cbd59 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1155,11 +1155,7 @@ export function isOverlayPointVisible( * Return true when a rectangle intersects any cached UI exclusion box. * `count` lets pooled buffers expose a live prefix without truncating. */ -export function overlayRectIntersectsAny( - rect, - exclusions, - count = exclusions.length, -) { +function overlayRectIntersectsAny(rect, exclusions, count = exclusions.length) { for (let i = 0; i < count; i++) { const other = exclusions[i]; if ( @@ -1177,7 +1173,7 @@ export function overlayRectIntersectsAny( * Like `overlayRectIntersectsAny`, but only against exclusions that composite * BELOW the host — the ones a placement may never overlap at any cost. */ -export function overlayRectIntersectsAnyHard( +function overlayRectIntersectsAnyHard( rect, exclusions, count = exclusions.length, diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index a9549c85d..d3d5cfc6d 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -2,11 +2,8 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); -const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import test from 'node:test'; const source = readShellSource(); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 78f3f59f0..4f9ef7602 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { providerCooldownMs } from '../server/providers/space/cooldown.js'; import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js'; @@ -23,7 +24,7 @@ function extract(name) { function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { - readResponseTextCapped, coalesceProxyRequest, + readResponseTextCapped, coalesceProxyRequest, providerCooldownMs, // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, @@ -117,7 +118,7 @@ test('Launch Library retains single-flight, fresh cache, stale fallback, and met test('CelesTrak unexpected failures hide details', async () => { const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } }); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 500); assert.equal(res.body, 'celestrak proxy error'); assert.equal(res.headers['x-tle-cache'], 'ERROR'); @@ -126,7 +127,7 @@ test('CelesTrak unexpected failures hide details', async () => { test('CelesTrak retains invalid-group and unavailable responses', async () => { const app = fixture('celestrakProxy'); assert.equal((await app.request('/../')).status, 400); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 502); assert.equal(res.headers['x-tle-cache'], 'NONE'); }); @@ -135,13 +136,13 @@ test('CelesTrak retains fresh and stale TLE caches', async () => { let now = Date.now(); let calls = 0; const app = fixture('celestrakProxy', { - Date: { now: () => now }, + Date: class extends Date { static now() { return now; } }, fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); }, }); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS'); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'MISS'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'HIT'); now += 7 * 3600_000; - const stale = await app.request('/active'); + const stale = await app.request('/stations'); assert.equal(stale.status, 200); assert.equal(stale.body, '1 valid-fixture-TLE'); assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR'); diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 23ad778d5..14ea0f3e0 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -209,6 +209,35 @@ test('no unchanged Realtime tool definition drifts silently', () => { const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout; assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + // Globe rotation adds one motion and clarifies the two affected descriptions. + // Check those edits explicitly, then keep the legacy pin for every other field. + const camera = unchanged.find((tool) => tool.name === 'move_camera'); + assert.deepEqual(camera.parameters.properties.motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + assert.equal( + camera.description, + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', + ); + assert.equal( + camera.parameters.properties.direction.description, + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', + ); + assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.'); + camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.'; + camera.parameters.properties.motion.enum = + camera.parameters.properties.motion.enum.filter( + (motion) => motion !== 'rotate_globe', + ); + camera.description = + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").'; + camera.parameters.properties.direction.description = + 'Required except for orbit (defaults right/clockwise) and stop.'; assert.equal(unchanged.length, 14); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index f72b43dc7..1077b0658 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -12,7 +12,6 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; // cancellable — an AbortSignal for the data manager, a liveness predicate for // the visual commit. Several of these assert exactly that plumbing. import assert from 'node:assert/strict'; -import fs from 'node:fs'; import test from 'node:test'; import { SceneDirector } from './director.js'; @@ -1265,7 +1264,7 @@ test('applyVisualState gates the map-stack switch on both sides of its await', ( // switch would otherwise stand on the globe. const source = readShellSource(); const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/); - assert.ok(method, 'applyVisualState is missing from ui.js'); + assert.ok(method, 'applyVisualState is missing from the UI shell source'); assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/); const mapStackBlock = method[0].match(/if \(state\.mapStack\) \{[\s\S]*?\n {4}\}/); diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index 191bb1ac1..ba3940a57 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -994,7 +994,7 @@ const PUBLIC_SCENE_RECIPES = [ ]; /** Build the recipe list without mutating stored user-authored projects. */ -export function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) { +function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) { return [...localDemoRecipes, ...PUBLIC_SCENE_RECIPES]; } @@ -1004,7 +1004,7 @@ export const SCENE_APPEND_RECIPES = BHOTE_KOSHI_NEPAL_APPEND_RECIPE ? [BHOTE_KOSHI_NEPAL_APPEND_RECIPE] : []; -export function getSceneRecipeById(id) { +function getSceneRecipeById(id) { return SCENE_RECIPES.find((recipe) => recipe.id === id) || null; } diff --git a/src/sdr/controller.js b/src/sdr/controller.js index 50fec4a38..019b768a1 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -38,12 +38,12 @@ const READ_PIPELINE_DEPTH = 4; const RATE_WINDOW_MS = 1_000; // A bulk read that has not completed in this long is treated as a stalled // USB transfer: the session is torn down instead of waiting forever. -export const SDR_READ_STALL_MS = 5_000; +const SDR_READ_STALL_MS = 5_000; // Decoder admission. Every block sent to the worker is numbered and must be // acknowledged; past these bounds the receiver stops with an overload error // rather than queueing raw IQ the decoder cannot keep up with. Eight 16 ms // blocks is 128 ms of input, about forty times the measured decode cost. -export const SDR_WORKER_ADMISSION = Object.freeze({ +const SDR_WORKER_ADMISSION = Object.freeze({ maxBlocks: 8, maxBytes: 8 * 65_536, maxAgeMs: 1_000, @@ -66,7 +66,7 @@ const DECODER_WINDOW_SAMPLES = 256; // raw USB device is closed directly, which aborts any pending transfer. The // raw close is bounded by the same deadline, so a teardown settles within // twice this even when `USBDevice.close()` itself never settles. -export const SDR_CLOSE_TIMEOUT_MS = 2_000; +const SDR_CLOSE_TIMEOUT_MS = 2_000; /** Tuning presets per receiver mode; gain is a separate per-mode setting. */ export const SDR_RECEIVER_PRESETS = Object.freeze({ @@ -1912,9 +1912,3 @@ function formatMhz(hz) { function positiveModulo(value, divisor) { return ((value % divisor) + divisor) % divisor; } - -export const SDR_FM_BAND = Object.freeze({ - minHz: FM_MIN_HZ, - maxHz: FM_MAX_HZ, -}); -export const SDR_ADSB_FREQUENCY_HZ = ADSB_FREQUENCY_HZ; diff --git a/src/sdr/gain.js b/src/sdr/gain.js index 761edf013..94d525fe2 100644 --- a/src/sdr/gain.js +++ b/src/sdr/gain.js @@ -10,7 +10,7 @@ * about 1 msg/s and 28.0 dB about 9 msg/s on the same antenna. */ -export const SDR_GAIN_AUTO = 'auto'; +const SDR_GAIN_AUTO = 'auto'; /** R820T tuner gain steps in dB, as listed by librtlsdr. */ export const R820T_GAIN_STEPS_DB = Object.freeze([ diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 26d695ba1..d4a2a7ca7 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -13,6 +13,7 @@ import { npmProcessSpec, readDoctorDotenvValue, resolveOpenSkyAuthMode, + resolveOpenSkyCredentialsFile, resolveCredential, } from '../scripts/setup-doctor.mjs'; @@ -237,7 +238,7 @@ test('doctor retains OAuth capability wording for a complete client pair', () => ); }); -test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => { +test('doctor reports retired Basic mode as anonymous, whatever OAuth credentials exist', () => { const oauthCredentials = { OPENSKY_CLIENT_ID: { configured: true }, OPENSKY_CLIENT_SECRET: { configured: true }, @@ -245,7 +246,7 @@ test('doctor reports Basic mode without inferring runtime auth from OAuth creden for (const credentials of [{}, oauthCredentials]) { assert.equal( buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights, - 'OpenSky Basic mode selected (credential presence and validity not verified)', + 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)', ); } }); @@ -393,3 +394,39 @@ test('doctor never calls a dependency-missing setup ready', () => { assert.match(output, /Setup needs attention/); assert.doesNotMatch(output, /Ready\. Run/); }); + +test('doctor counts OPENSKY_CREDENTIALS_FILE as OAuth credentials, from the environment or .env', () => { + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: true }).flights, + 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)', + ); + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: false }).flights, + 'OpenSky keyless anonymous access (rate-limited)', + ); + const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-file-')); + try { + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + false, + ); + writeFileSync( + path.join(root, '.env'), + 'OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json\n', + ); + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + true, + ); + assert.equal( + resolveOpenSkyCredentialsFile({ + authoritativeEnvironment: true, + environment: { OPENSKY_CREDENTIALS_FILE: '' }, + rootDir: root, + }), + false, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 13dedd709..230843584 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -2,12 +2,10 @@ import { DisplayBindings } from './ui/displayBindings.js'; import { LocationNavigation } from './ui/locationNavigation.js'; import { VisualSettings } from './ui/visualSettings.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js'; import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; diff --git a/src/sky/stars.js b/src/sky/stars.js index 9fd5aa698..f4139c3c5 100644 --- a/src/sky/stars.js +++ b/src/sky/stars.js @@ -18,7 +18,7 @@ export const STAR_MAX_SIGMA_PX = 1.35; /** Peak opacity of the faintest star. */ export const STAR_MIN_ALPHA = 0.22; /** B−V for stars the catalogue gives no colour (a solar-type white). */ -export const STAR_DEFAULT_BV = 0.6; +const STAR_DEFAULT_BV = 0.6; /** Floats per star in the vertex buffer: xyz, σ, rgb, alpha. */ export const STAR_VERTEX_FLOATS = 8; @@ -76,7 +76,7 @@ export function bvToTemperature(bv) { } /** Black-body colour of a temperature, sRGB 0..1 (Helland's fit). */ -export function temperatureToRgb(kelvin) { +function temperatureToRgb(kelvin) { const t = clamp(kelvin, 1000, 40000) / 100; const red = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592; const green = diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index 9c0082700..814da60b2 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -41,12 +41,12 @@ const CATEGORY_PATTERN = /^[A-D][0-7]$/; /** A previous accepted fix older than this is no reference for a new one. */ export const LOCAL_ADSB_REFERENCE_MAX_AGE_MS = 10 * 60_000; /** Speed limit (kt) for an aircraft that has not reported a ground speed. */ -export const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; +const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; /** Tighter limit (kt) for light aircraft and rotorcraft without a speed. */ -export const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; +const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; /** Reported ground speed is multiplied by this, plus the margin below. */ -export const LOCAL_ADSB_SPEED_FACTOR = 1.5; -export const LOCAL_ADSB_SPEED_MARGIN_KT = 50; +const LOCAL_ADSB_SPEED_FACTOR = 1.5; +const LOCAL_ADSB_SPEED_MARGIN_KT = 50; /** Distance always allowed between two fixes (CPR and reception error). */ export const LOCAL_ADSB_POSITION_MARGIN_M = 500; /** diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js index 429dbe826..b2ccc9630 100644 --- a/src/sources/featureGeometry.js +++ b/src/sources/featureGeometry.js @@ -77,7 +77,7 @@ export function simplifyRing(ring, tolM) { return out.length >= 4 ? out : ring; } -export function douglasPeucker(points, tol) { +function douglasPeucker(points, tol) { const n = points.length; if (n < 3) return points.slice(); // Iterative, index-based (no per-call array slicing / recursion), so a large @@ -113,7 +113,7 @@ export function douglasPeucker(points, tol) { return out; } -export function perpDistance(p, a, b) { +function perpDistance(p, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const len2 = dx * dx + dy * dy; @@ -149,7 +149,7 @@ export function stitchRing(ways) { return endpointGapM(largest) <= allow ? largest : []; } -export function largestByArea(components) { +function largestByArea(components) { let best = components[0]; let bestArea = approximateAreaM2(best); for (let i = 1; i < components.length; i += 1) { @@ -162,7 +162,7 @@ export function largestByArea(components) { return best; } -export function ringSpanM(chain) { +function ringSpanM(chain) { let minLat = Infinity; let maxLat = -Infinity; let minLon = Infinity; @@ -176,7 +176,7 @@ export function ringSpanM(chain) { return approximateDistanceM(minLat, minLon, maxLat, maxLon); } -export function buildRingComponents(ways) { +function buildRingComponents(ways) { const same = (a, b) => Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7; const remaining = ways.map((w) => w.slice()); @@ -207,7 +207,7 @@ export function buildRingComponents(ways) { return components; } -export function endpointGapM(chain) { +function endpointGapM(chain) { if (!chain || chain.length < 2) return Infinity; const a = chain[0]; const b = chain[chain.length - 1]; @@ -250,10 +250,13 @@ export function approximateAreaM2(coords) { return Math.abs(area) / 2; } +/** Local equirectangular distance in metres, the short way round 180°. */ export function approximateDistanceM(latA, lonA, latB, lonB) { const latScale = 111_320; const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180)); - return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale); + let dLon = lonB - lonA; + dLon -= 360 * Math.round(dLon / 360); + return Math.hypot((latB - latA) * latScale, dLon * lonScale); } export function pointInPolygon(lon, lat, coords) { diff --git a/src/sources/featureGeometry.test.mjs b/src/sources/featureGeometry.test.mjs new file mode 100644 index 000000000..6da3ff92f --- /dev/null +++ b/src/sources/featureGeometry.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { approximateDistanceM } from './featureGeometry.js'; + +test('approximate distance takes the short way round 180°', () => { + // Lau Islands (-179.0) to Savusavu (179.33): about 178 km apart. + const across = approximateDistanceM(-17.5, -179.0, -16.78, 179.33); + assert.ok(across > 150_000 && across < 200_000, String(across)); + assert.equal( + Math.round(approximateDistanceM(0, 179.9, 0, -179.9)), + Math.round(approximateDistanceM(0, -0.1, 0, 0.1)), + ); + // Away from 180° nothing changes. + assert.equal(Math.round(approximateDistanceM(0, 0, 0, 1)), 111_320); +}); diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 658db1596..b8b1787da 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -214,11 +214,10 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo assert.equal(mil.observedAtMs, now - 60000); assert.equal(mil.records[0].positionTimeMs, now - 75000); assert.equal(mil.stale, true); - const track = await military.getTrack('abc123'); - assert.equal(track.complete, false); - assert.equal(track.records[0].observedAtMs, now - 50000); - assert.equal(track.records[0].baroAltitudeM, 304.8); - assert.equal(track.records[0].ellipsoidAltitudeM, null); + assert.equal(military.getTrack, undefined); + assert.ok( + requests.every(({ url }) => !url.startsWith('/api/military/track')), + ); }); test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => { diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 55670c629..d76ecf3fa 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -1,5 +1,4 @@ import { - epoch, finite, httpError, LiveSourceError, @@ -32,9 +31,7 @@ function openSkyError(response) { const reasons = { oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid', oauth_invalid_credentials: 'OpenSky OAuth rejected credentials', - basic_invalid_credentials: 'OpenSky username/password rejected', - missing_basic_creds: 'OpenSky auth missing', - missing_oauth_and_basic_creds: 'OpenSky auth missing', + missing_oauth_creds: 'OpenSky auth missing', auth_required: 'OpenSky auth required', forced_anonymous: 'OpenSky auth required', }; @@ -159,26 +156,8 @@ export function createMilitarySource({ status: response.status, }; }, - async getTrack(reference, { signal } = {}) { - const { response, payload } = await readResponse( - fetchImpl, - '/api/military/track?hex=' + encodeURIComponent(reference), - { signal }, - 'Military aircraft', - ); - if (!response.ok) throw httpError(response, 'Military aircraft'); - const baseTimeMs = epoch(payload?.timestamp, 1000); - return { - records: - baseTimeMs == null - ? [] - : normalizeAircraftTrack(payload?.trace, { - baseTimeMs, - readsb: true, - }), - complete: false, - }; - }, + // ADSB.lol does not expose a supported live-history endpoint. Omitting + // optional getTrack keeps the tracking layer on its accumulated trail. }; } diff --git a/src/sources/openFreeMap.js b/src/sources/openFreeMap.js index 274c2ace7..00c288a8b 100644 --- a/src/sources/openFreeMap.js +++ b/src/sources/openFreeMap.js @@ -24,7 +24,7 @@ const ROAD_TYPES = Object.freeze({ * OpenMapTiles folds private access into `no`. Tunnels cannot be placed on * the surface above them; ramps/bridges remain valid only on a motor road. */ -export function isDrivableOpenMapRoad(properties = {}) { +function isDrivableOpenMapRoad(properties = {}) { return ( Object.hasOwn(ROAD_TYPES, properties.class) && !properties.subclass && diff --git a/src/sources/radioBrowser.js b/src/sources/radioBrowser.js index 8511b6beb..8329f6257 100644 --- a/src/sources/radioBrowser.js +++ b/src/sources/radioBrowser.js @@ -11,6 +11,16 @@ export function cleanRadioText(value, maxLength) { .trim(); } +/** Normalize a station/filter tag and cap its final, case-folded representation. */ +export function normalizeRadioTag(value) { + return cleanRadioText( + String(value ?? '') + .toLowerCase() + .replace(/[_-]+/g, ' '), + 80, + ); +} + export function isNonGlobalIpv4(hostname) { const pieces = hostname.split('.'); if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) @@ -99,13 +109,7 @@ export function normalizeRadioBrowserStation( if (!name) return null; const tags = String(raw?.tags ?? '') .split(',') - .map((tag) => - cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(), - ) + .map(normalizeRadioTag) .filter(Boolean) .filter((tag, index, all) => all.indexOf(tag) === index) .slice(0, 24); diff --git a/src/sources/transitHistoryStore.js b/src/sources/transitHistoryStore.js index 9fe29f59d..cf82835c1 100644 --- a/src/sources/transitHistoryStore.js +++ b/src/sources/transitHistoryStore.js @@ -1,7 +1,7 @@ import { validTransitIdentifier } from './transitHistory.js'; export { validTransitIdentifier } from './transitHistory.js'; /** Bounded, process-local operator position history. No upstream or scene access. */ -export const TRANSIT_HISTORY_LIMITS = Object.freeze({ +const TRANSIT_HISTORY_LIMITS = Object.freeze({ retentionMs: 900000, fixes: 128, feedBytes: 16 * 1024 * 1024, diff --git a/src/splitFlap.js b/src/splitFlap.js index 17d677999..9b78feeba 100644 --- a/src/splitFlap.js +++ b/src/splitFlap.js @@ -72,7 +72,7 @@ export const FLAP_STAGGER_MS = 26; */ export const FLAP_MAX_TOTAL_MS = 620; /** Slack after the last character lands before the cells are stripped. */ -export const FLAP_SETTLE_SLACK_MS = 60; +const FLAP_SETTLE_SLACK_MS = 60; /** * Fraction of a character's flip at which the outgoing glyph has rotated * away and the incoming one takes over as what the eye reads. MUST track the diff --git a/src/standalone/ui.js b/src/standalone/ui.js deleted file mode 100644 index 780d515cf..000000000 --- a/src/standalone/ui.js +++ /dev/null @@ -1 +0,0 @@ -export { StyleManager } from '../ui/composition.js'; diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs index a2e6452f6..326ba9d77 100644 --- a/src/testSupport/kartaViewFakes.mjs +++ b/src/testSupport/kartaViewFakes.mjs @@ -1,7 +1,7 @@ // DOM stand-ins for the KartaView viewer's tests (no browser in Node). /** A DOM element with what the viewer touches: children, events, attributes. */ -export function fakeElement(tag) { +function fakeElement(tag) { const listeners = new Map(); const element = { tagName: tag.toUpperCase(), diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index b34fee3b9..4352bd582 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -156,13 +156,18 @@ test('self package imports cannot evade portable ownership', (t) => { assert.throws(() => checkImportDirections(root), /Source imports rendering/); }); -test('compatibility exceptions are limited to the two existing composition entries', (t) => { +test('the compatibility exception is limited to the existing composition entry', (t) => { const { root, write } = fixture(t); - write('src/ui.js', "import './standalone/catalog.js';"); write('src/standalone/catalog.js'); write('server/providers/local.js', "import '../standalone/key-setup.js';"); write('server/standalone/key-setup.js'); assert.doesNotThrow(() => checkImportDirections(root)); + write('src/ui.js', "import './standalone/catalog.js';"); + assert.throws( + () => checkImportDirections(root), + /Reusable module imports standalone/, + ); + write('src/ui.js'); write('src/ui/new.js', "import '../standalone/catalog.js';"); assert.throws( () => checkImportDirections(root), diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 60b2c9f25..1f1befc95 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -584,6 +584,87 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(callCount, 1); }); +test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'basic', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return Response.json({ time: Math.floor(Date.now() / 1000), states: [] }); + } + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?basic=${Math.random()}` + ); + const flights = install(fresh.openSkyProxy()); + const first = await flights('/api/flights'); + assert.equal(first.statusCode, 200); + assert.equal(first.headers['x-opensky-auth'], 'anon'); + assert.equal(first.headers['x-opensky-auth-reason'], 'basic_retired'); + assert.deepEqual(authorizations, [undefined]); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /retired username\/password sign-in/); + assert.ok(!warnings[0].includes('fixture-password')); +}); + +test('OpenSky auto mode never falls back to Basic, even when OAuth is rejected', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: 'fixture-client', + OPENSKY_CLIENT_SECRET: 'fixture-secret', + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'auto', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'error', () => {}); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/token')) + return Response.json({ access_token: 'fixture-token', expires_in: 1800 }); + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return new Response('', { status: 401 }); + } + if (url.includes('/lat/')) return regionalFallback(); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?auto=${Math.random()}` + ); + await install(fresh.openSkyProxy())('/api/flights', '?lat=30&lon=-97'); + assert.deepEqual(authorizations, ['Bearer fixture-token']); +}); + +test('retired military history refuses without upstream I/O while validating identifiers', async (t) => { + t.mock.method(globalThis, 'fetch', async () => { + assert.fail('retired history must not contact the upstream'); + }); + const route = install(providers.trackBackfillProxies()); + for (const hex of ['abc123', 'abc456']) { + const response = await route('/api/military/track', '?hex=' + hex); + assert.equal(response.statusCode, 503); + assert.equal(JSON.parse(response.body).code, 'history_unavailable'); + assert.equal(response.headers['retry-after'], '1800'); + assert.equal(response.headers['cache-control'], 'no-store'); + } + assert.equal( + (await route('/api/military/track', '?hex=invalid')).statusCode, + 400, + ); +}); + test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => { environment(t, { OPENSKY_AUTH_MODE: 'anon' }); t.mock.method(console, 'warn', () => {}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 352cc8824..d533b7fb9 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -81,6 +81,24 @@ test('nearby labels rank landmarks, deduplicate names/addresses and bound the pr assert.deepEqual(projectNearbyPlaces({}, 0, 0), []); }); +test('nearby place distances take the short way round 180°', () => { + const at = (name, longitude) => ({ + id: name, + displayName: { text: name }, + location: { latitude: -16.8, longitude }, + types: ['point_of_interest'], + }); + // From Taveuni at 179.98: 2 km east across 180°, 4 km west on this side. + const result = projectNearbyPlaces( + { places: [at('West', 179.942), at('East', -179.999)] }, + -16.8, + 179.98, + ); + const byName = Object.fromEntries(result.map((p) => [p.name, p.distanceM])); + assert.ok(byName.East > 1500 && byName.East < 2500, String(byName.East)); + assert.ok(byName.West > 3500 && byName.West < 4500, String(byName.West)); +}); + test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => { const viewport = { low: { latitude: 1, longitude: 2 }, diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index a5b2cecba..7aef775b1 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -12,6 +12,7 @@ import { launchLibraryRecentUrl, } from 'gods-eye-view/sources/space'; import * as compatibility from '../../server/providers/local.js'; +import { createLaunchSource } from 'gods-eye-view/layers/launches/source'; function install(plugin, preview = false) { const routes = new Map(); @@ -22,17 +23,23 @@ function install(plugin, preview = false) { }, }, }); - return async (route, url = '/', method = 'GET') => { + return async (route, url = '/', method = 'GET', headers = {}) => { const res = { headersSent: false, + headers: {}, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, + // The same-site gates answer 403 through statusCode + setHeader. + setHeader(name, value) { + this.headers[name] = value; + }, end(body) { this.body = body; + this.status ??= this.statusCode; }, }; - await routes.get(route)({ url, method }, res); + await routes.get(route)({ url, method, headers, socket: {} }, res); return res; }; } @@ -161,7 +168,11 @@ test('CelesTrak answers 502 to an oversized TLE body without caching it', async assert.ok(bodies[0].pulled <= 17, 'reading stops at the 16 MiB cap'); assert.equal(fsp.writeFile.mock.callCount(), 0); assert.equal((await request('/api/celestrak', '/stations')).status, 502); - assert.equal(bodies.length, 2, 'nothing was cached in memory'); + assert.equal( + bodies.length, + 1, + 'failed bodies are not cached but a cooldown bounds retry work', + ); }); for (const preview of [false, true]) @@ -190,3 +201,194 @@ for (const preview of [false, true]) assert.equal(hit.body, first.body); assert.equal(calls, 1); }); + +test('space providers honor Retry-After, gate groups, preserve stale dates and recover', async (t) => { + isolateDisk(t); + t.mock.method(console, 'warn', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + for (const kind of ['tle', 'launches']) { + for (const retryAfter of ['90', new Date(now + 90000).toUTCString()]) { + let calls = 0; + let fail = false; + let cancelled = false; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + if (fail) + return new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { + status: kind === 'tle' ? 403 : 429, + headers: { 'Retry-After': retryAfter }, + }, + ); + return new Response( + kind === 'tle' + ? 'ISS\n1 25544U fixture\n2 25544 fixture\n' + : '{"results":[]}', + ); + }); + const request = install( + kind === 'tle' ? celestrakProxy() : rocketLaunchesProxy(), + ); + const route = kind === 'tle' ? '/api/celestrak' : '/api/launches'; + const url = kind === 'tle' ? '/stations' : '/'; + if (kind === 'tle') { + assert.equal( + (await request(route, '/not-a-supported-group')).status, + 400, + ); + assert.equal(calls, 0); + } + const first = await request(route, url); + now += 7 * 3600000; + // Recompute the HTTP-date relative to the clock at the failed refresh. + const originalFetch = globalThis.fetch; + t.mock.method(globalThis, 'fetch', async (...args) => { + const response = await originalFetch(...args); + if (fail && !/^\d+$/.test(retryAfter)) + response.headers.set( + 'Retry-After', + new Date(now + 90000).toUTCString(), + ); + return response; + }); + fail = true; + const stale = await request(route, url); + assert.equal(stale.status, 200); + assert.equal(stale.body, first.body); + assert.equal(stale.headers['Retry-After'], '90'); + assert.ok( + stale.headers[kind === 'tle' ? 'X-TLE-Cached-At' : 'X-GEV-Cached-At'], + ); + assert.equal(cancelled, true); + await request(route, url); + if (kind === 'tle') + assert.equal((await request(route, '/visual')).status, 502); + assert.equal(calls, 2, 'cooldown applies across TLE groups'); + now += 91000; + fail = false; + assert.equal((await request(route, url)).status, 200); + assert.equal(calls, 3); + } + } +}); + +test('Space Missions reads the active catalog through the real proxy while unknown groups stay rejected', async (t) => { + isolateDisk(t); + const tle = 'ISS\n1 25544U fixture\n2 25544 fixture\n'; + const fetched = []; + t.mock.method(globalThis, 'fetch', async (url) => { + fetched.push(new URL(url).searchParams.get('GROUP')); + return new Response(tle); + }); + const request = install(celestrakProxy()); + // Route the browser source's real request into the middleware. + const source = createLaunchSource({ + fetchImpl: async (url) => { + const prefix = '/api/celestrak'; + assert.ok(url.startsWith(`${prefix}/`)); + const res = await request(prefix, url.slice(prefix.length)); + return new Response(res.body, { status: res.status }); + }, + }); + assert.equal(await source.getActiveTle(), tle); + assert.deepEqual(fetched, ['active']); + for (const group of ['/not-a-group', '/active-extra', '/../active']) + assert.equal((await request('/api/celestrak', group)).status, 400); + assert.deepEqual(fetched, ['active'], 'rejected groups never reach upstream'); +}); + +test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => { + isolateDisk(t); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'); + }); + const request = install(celestrakProxy()); + const proxied = { + host: 'gev.example.test', + 'x-forwarded-for': '203.0.113.7', + 'x-forwarded-host': 'gev.example.test', + 'x-forwarded-proto': 'https', + via: '1.1 proxy', + }; + const app = await request('/api/celestrak', '/stations', 'GET', { + ...proxied, + origin: 'https://gev.example.test', + 'sec-fetch-site': 'same-origin', + }); + assert.equal(app.status, 200); + assert.equal(app.headers['x-tle-cache'], 'MISS'); + assert.equal(calls, 1); + for (const headers of [ + { ...proxied, origin: 'https://unrelated.example' }, + { ...proxied, origin: 'null' }, + { ...proxied, 'sec-fetch-site': 'cross-site' }, + ]) { + const refused = await request('/api/celestrak', '/visual', 'GET', headers); + assert.equal(refused.status, 403); + } + assert.equal(calls, 1, 'refused requests do no provider or cache work'); +}); + +for (const longFirst of [true, false]) + test(`CelesTrak keeps the longest cooldown when concurrent failures finish ${longFirst ? 'long then short' : 'short then long'}`, async (t) => { + isolateDisk(t); + t.mock.method(console, 'warn', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + const pending = new Map(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + calls++; + const group = new URL(url).searchParams.get('GROUP'); + await new Promise((resolve) => pending.set(group, resolve)); + return group === 'stations' + ? new Response('slow down', { + status: 429, + headers: { 'Retry-After': '1800' }, + }) + : new Response('unavailable', { status: 503 }); + }); + const request = install(celestrakProxy()); + const long = request('/api/celestrak', '/stations'); + const short = request('/api/celestrak', '/visual'); + while (pending.size < 2) await new Promise(setImmediate); + const order = longFirst + ? [ + ['stations', long], + ['visual', short], + ] + : [ + ['visual', short], + ['stations', long], + ]; + for (const [group, response] of order) { + pending.get(group)(); + assert.equal((await response).status, 502); + } + assert.equal(calls, 2); + const cooling = await request('/api/celestrak', '/geo'); + assert.equal(cooling.status, 502); + assert.equal(cooling.headers['Retry-After'], '1800'); + now += 61_000; + assert.equal((await request('/api/celestrak', '/geo')).status, 502); + assert.equal( + calls, + 2, + 'the 60 s fallback did not replace the 30 min deadline', + ); + now += 1800_000; + t.mock.method( + globalThis, + 'fetch', + async () => new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'), + ); + assert.equal((await request('/api/celestrak', '/geo')).status, 200); + }); diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs new file mode 100644 index 000000000..b5f6c6de4 --- /dev/null +++ b/src/tooling/trafficFixtures.test.mjs @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + trafficFixtureResponse, + trafficFixtureTile, +} from '../../scripts/traffic-fixtures.mjs'; +import { decodeOpenFreeMapTile } from '../sources/openFreeMap.js'; +import { isDrivableFlowSegment } from '../layers/traffic/roadModes.js'; +import { decodeFlowTile } from '../layers/traffic/flowDecode.js'; + +test('traffic fixtures provide aligned current road tiles and partial flow coverage', () => { + for (const z of [12, 14]) { + const roads = decodeOpenFreeMapTile( + trafficFixtureTile(z), + z, + 935, + 1686, + ).roads; + const flow = decodeFlowTile(trafficFixtureTile(z, true), z, 935, 1686); + assert.ok(roads.length > flow.length && flow.length > 0); + for (const segment of flow) { + assert.equal(segment.trafficLevel, 0.6); + assert.equal(isDrivableFlowSegment(segment), true); + assert.ok( + roads.some( + (road) => + JSON.stringify(road.coordinates) === JSON.stringify(segment.coords), + ), + ); + } + } +}); + +test('fixture TileJSON and every road/flow request stay on intercepted sources', () => { + const request = (url) => trafficFixtureResponse({ url: () => url }); + const tileJson = request('https://tiles.openfreemap.org/planet'); + assert.equal(tileJson.headers['access-control-allow-origin'], '*'); + const metadata = JSON.parse(tileJson.body); + const tileUrl = metadata.tiles[0] + .replace('{z}', '14') + .replace('{x}', '935') + .replace('{y}', '1686'); + assert.ok( + decodeOpenFreeMapTile(request(tileUrl).body, 14, 935, 1686).roads.length, + ); + assert.ok( + decodeFlowTile( + request('http://localhost/api/tomtom/flow/12/935/1686.pbf').body, + 12, + 935, + 1686, + ).length, + ); + assert.throws( + () => request('https://tiles.openfreemap.org/unexpected'), + /Unrecognized/, + ); + assert.equal(request('http://localhost/api/unrelated'), null); +}); + +test('flat terrain fixture decodes through Cesium and advertises no network children', async () => { + const { createServer } = await import('node:http'); + const { CesiumTerrainProvider } = await import('cesium'); + const server = createServer((req, res) => { + const response = trafficFixtureResponse({ + url: () => `https://terrain.reearth.land${req.url}`, + }); + res.writeHead(response.status, { 'content-type': response.contentType }); + res.end(response.body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const provider = await CesiumTerrainProvider.fromUrl( + `http://127.0.0.1:${server.address().port}`, + ); + for (const x of [0, 1]) { + const data = await provider.requestTileGeometry(x, 0, 0); + const rect = provider.tilingScheme.tileXYToRectangle(x, 0, 0); + assert.equal( + data.interpolateHeight(rect, (rect.west + rect.east) / 2, 0), + 0, + ); + for (const dx of [0, 1]) + for (const dy of [0, 1]) { + assert.equal(data.isChildAvailable(x, 0, x * 2 + dx, dy), false); + } + } + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } +}); + +test('fixture interception targets provider URLs and leaves app workers alone', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { origin: 'http://localhost:4173', fixtures: true }, + ); + const patterns = sent[0].args.patterns.map((p) => p.urlPattern); + assert.deepEqual(patterns, [ + 'http://localhost:4173/api/tomtom/status*', + 'http://localhost:4173/api/tomtom/flow/*', + 'https://tiles.openfreemap.org/*', + 'https://terrain.reearth.land/*', + ]); + await paused({ + requestId: 'terrain', + request: { url: 'https://terrain.reearth.land/layer.json' }, + }); + const reply = sent.at(-1); + assert.equal(reply.name, 'Fetch.fulfillRequest'); + assert.equal( + JSON.parse(Buffer.from(reply.args.body, 'base64')).format, + 'heightmap-1.0', + ); + assert.ok( + reply.args.responseHeaders.some( + (h) => h.name === 'access-control-allow-origin' && h.value === '*', + ), + ); +}); + +test('keyless interception refuses and records unexpected flow requests', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = [], + flows = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { + origin: 'http://localhost:4173', + keyless: true, + onFlow: (url) => flows.push(url), + }, + ); + await paused({ + requestId: 'status', + request: { url: 'http://localhost:4173/api/tomtom/status' }, + }); + assert.deepEqual(JSON.parse(Buffer.from(sent.at(-1).args.body, 'base64')), { + hasKey: false, + }); + await paused({ + requestId: 'flow', + request: { url: 'http://localhost:4173/api/tomtom/flow/12/1/1.pbf' }, + }); + assert.equal(sent.at(-1).args.responseCode, 503); + assert.equal(flows.length, 1); +}); diff --git a/src/tooling/voiceBench.test.mjs b/src/tooling/voiceBench.test.mjs index 84c31f3dc..1cc69c8da 100644 --- a/src/tooling/voiceBench.test.mjs +++ b/src/tooling/voiceBench.test.mjs @@ -234,6 +234,19 @@ test('typed argument checks feed full-call accuracy without changing the legacy const inMeters = gradeCore(altitude, [{ name: 'analyst_query', args: { layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }] } }]); assert.equal(inMeters.fullCallOk, true); assert.equal(where('set_hud', (a) => a.visible === 'off')({ name: 'set_hud', args: { visible: 'off' } }), true); + const globe = byPhrase('Keep spinning the Earth slowly to the left'); + const globeArgs = { motion: 'rotate_globe', direction: 'left', speed: 'slow' }; + for (const mode of [undefined, 'continuous', 'once', null]) { + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, mode } }]).fullCallOk, mode === undefined || mode === 'continuous'); + } + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, direction: 'right' } }]).fullCallOk, false); + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, speed: 'normal' } }]).fullCallOk, false); + const right = byPhrase('Rotate the globe to the right'); + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe' } }]).fullCallOk, true); + for (const direction of ['right', 'left', null]) { + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe', direction } }]).fullCallOk, direction === 'right'); + } + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate', direction: 'right' } }]).fullCallOk, false); }); test('gradeDialogue scores turn 2 in the context of turn 1', () => { diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index a5f4babf0..153e36e03 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -333,13 +333,20 @@ export const getAircraftTrack = defineTool({ requires: ['aircraft'], async run(args, { services, signal }) { const icao24 = args.icao24.toLowerCase(); + const source = feedFor(services, args.military); + // History is optional: the standalone military source has none. + if (typeof source.getTrack !== 'function') + throw new ToolError( + 'unsupported', + args.military + ? 'Track history is not available for the military aircraft feed' + : 'Track history is not available for the aircraft feed', + ); // A track server answers 404 for an aircraft it has no positions for. - const track = await feedFor(services, args.military) - .getTrack(icao24, { signal }) - .catch((error) => { - if (error?.status === 404) return { records: [] }; - throw error; - }); + const track = await source.getTrack(icao24, { signal }).catch((error) => { + if (error?.status === 404) return { records: [] }; + throw error; + }); const points = [...track.records].sort( (a, b) => a.observedAtMs - b.observedAtMs, ); diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 1af1352c0..484719790 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -3,6 +3,8 @@ import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; import { airlineDesignator } from './aviation.js'; import { LiveSourceError } from '../../sources/live/contract.js'; +import { createMcpServer } from '../mcp/index.js'; +import { createToolServices } from '../services.js'; const record = (id, latitude, longitude, extra = {}) => ({ id, @@ -242,6 +244,61 @@ test('tracks are ordered and thinned to 200 points', async () => { ); }); +test('military track history is unsupported by the default composition and kept for custom sources', async () => { + const requests = []; + const services = createToolServices({ + fetchImpl: async (url) => { + requests.push(String(url)); + throw new Error('no request expected'); + }, + appUrl: 'http://localhost:5173', + }); + const catalog = composeCatalog({ tools: coreTools, services }); + await assert.rejects( + catalog.call('get_aircraft_track', { icao24: 'AE0001', military: true }), + (error) => + error.name === 'ToolError' && + error.code === 'unsupported' && + /military/.test(error.message), + ); + const server = createMcpServer({ catalog, name: 'test', version: '1.0.0' }); + const { result } = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'get_aircraft_track', + arguments: { icao24: 'ae0001', military: true }, + }, + }); + assert.equal(result.isError, true); + assert.equal(result.structuredContent.error, 'unsupported'); + assert.doesNotMatch(result.content[0].text, /try again|later/i); + assert.deepEqual(requests, []); + + const track = [ + { + latitude: 37.6, + longitude: -122.3, + observedAtMs: Date.UTC(2026, 0, 1), + baroAltitudeM: 3000, + onGround: false, + }, + ]; + const custom = feed(military, { track }); + const withHistory = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil), military: custom }, + }); + const kept = await withHistory.call('get_aircraft_track', { + icao24: 'AE0001', + military: true, + }); + assert.equal(custom.calls[0], 'ae0001'); + assert.equal(kept.data.total, 1); + assert.equal(kept.data.view.follow.kind, 'military_aircraft'); +}); + test('aircraft info combines type and route lookups', async () => { const aircraft = feed(civil, { enrichment: { diff --git a/src/tools/queries/panelRequest.js b/src/tools/queries/panelRequest.js index 3864c9889..51ecd6406 100644 --- a/src/tools/queries/panelRequest.js +++ b/src/tools/queries/panelRequest.js @@ -55,7 +55,7 @@ const COMPRESS_MIN_BYTES = 1024; * rather than fail; past `PANEL_QUEUED_REQUESTS` waiting, they are refused. */ export const PANEL_CONCURRENT_REQUESTS = 6; -export const PANEL_QUEUED_REQUESTS = 256; +const PANEL_QUEUED_REQUESTS = 256; /** * Each server's panel state: bodies too large for one call, kept for the diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 0c59ca7f0..5284e8a06 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -7,7 +7,7 @@ import { AREA_SCHEMA, resolveArea } from '../area.js'; import { cameraForArea, cameraLookingAt } from '../views.js'; /** Tool arguments that describe a view: an area to frame, or a camera. */ -export const VIEW_ARGUMENTS = Object.freeze({ +const VIEW_ARGUMENTS = Object.freeze({ view: Object.freeze({ type: 'object', description: diff --git a/src/ui.js b/src/ui.js deleted file mode 100644 index 5142301a3..000000000 --- a/src/ui.js +++ /dev/null @@ -1,16 +0,0 @@ -import { StyleManager as ApplicationControls } from './ui/composition.js'; -import { catalogControlServices } from './app/catalog.js'; -import { getStandaloneCatalog } from './standalone/catalog.js'; -/** Compatibility entry for direct standalone control construction. */ -export class StyleManager extends ApplicationControls { - constructor(viewer, options = {}) { - super(viewer, { - ...options, - services: { - ...catalogControlServices(getStandaloneCatalog()), - ...getStandaloneCatalog().surface.controlServices, - ...options.services, - }, - }); - } -} diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 82413f43e..4552da8d4 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -3,6 +3,7 @@ import { ShellFacade } from './shellFacade.js'; import { AircraftDisplay } from './aircraftDisplay.js'; import { LayerBindings } from './layerBindings.js'; import { PanelChrome } from './panelChrome.js'; +import { initMobileShell } from './mobileShell.js'; import { VisualSettings } from './visualSettings.js'; import { NavigationController } from './navigationController.js'; import { ShareRestoration } from './shareRestoration.js'; @@ -124,6 +125,8 @@ export class StyleManager extends ShellFacade { readInitialShare: () => this._initialShareState, readScrollRestoreOwner: () => this._displayPortalScrollRestoreOwner, readDisplayScrollTop: () => this._standardDisplayScrollTop, + readSharedCollapsed: (panelId) => + this._mobileShell?.sharedCollapsed(panelId), }); this._feedback = new ShellFeedback({ readLayers: () => this._dataManager?.getAll?.() || [], @@ -144,6 +147,7 @@ export class StyleManager extends ShellFacade { }, searchInput: this._locationSearch, interruptCameraMotion: services.interruptCameraMotion, + interruptGlobeRotation: services.interruptGlobeRotation, isCockpitActive: () => !!this.cockpitView?.active, clearLocation: () => this.clearSearchedLocation(), cancelShareSelection: () => this._shareRestoration.cancelSelection(), @@ -319,6 +323,7 @@ export class StyleManager extends ShellFacade { GLOBE_VIEW: services.GLOBE_VIEW, flyToGlobeView: services.flyToGlobeView, interruptCameraMotion: services.interruptCameraMotion, + interruptGlobeRotation: services.interruptGlobeRotation, flightsLayer: services.flightsLayer, militaryFlightsLayer: services.militaryFlightsLayer, satellitesLayer: services.satellitesLayer, @@ -587,6 +592,7 @@ export class StyleManager extends ShellFacade { this._initStreetLevelPanel(); this._initGlobalContextPanel(); this._initLocationBar(); + this._initMobileShell(); this._initShareButton(); this._initCameraOrientationControls(); this._initClearSelectedLayersButton(); @@ -848,6 +854,48 @@ export class StyleManager extends ShellFacade { } } + /** + * Phone layout: a bottom tab bar presenting one panel sheet at a time + * (src/ui/mobileShell.js). Inactive above 720px. + * @returns {void} + */ + _initMobileShell() { + this._mobileShell = initMobileShell({ + setPanelCollapsed: (panelId, collapsed, options) => + this._panelChrome.setPanelCollapsed(panelId, collapsed, options), + onSheetsClosed: () => this._syncShareState(), + onTabOpened: (sheet) => { + void this._onMobileTabOpened(sheet); + }, + }); + } + + /** Reveal a tracked aircraft through Contacts when its phone tab is opened. */ + async _onMobileTabOpened(sheet) { + const context = this.getContextModeState(); + if ( + sheet !== 'context' || + this._disposed || + !context || + context.mode || + context.changing || + context.entering || + !this.getAircraftTrackingTarget() + ) + return false; + try { + const result = await this.setContextMode('contacts'); + if (result?.ok) return true; + if (result?.cancelled || this._disposed) return false; + } catch { + if (this._disposed) return false; + } + this._showToast( + t('Contacts could not complete the requested transition; try again'), + ); + return false; + } + /** * Renders the owner-approved map stack chip row from the matching controller * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable, @@ -1707,6 +1755,7 @@ export class StyleManager extends ShellFacade { this._localePicker?.destroy(); this._localePicker = null; this._recording.destroy(); + this._mobileShell?.destroy(); this._panelChrome.destroy(); this._feedback.destroy(); diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 0fb67f1d4..d10bacf2f 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -107,7 +107,7 @@ export function readCameraTargetFrame(viewer) { } /** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */ -export function setCameraTargetFrame(viewer, frame) { +function setCameraTargetFrame(viewer, frame) { const camera = viewer?.camera; if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false; try { @@ -156,7 +156,7 @@ export function setCameraTargetFrame(viewer, frame) { * * @param {{pitch: number}|null} frame From {@link readCameraTargetFrame}. */ -export function frameIsTilted(frame) { +function frameIsTilted(frame) { const pitch = Number(frame?.pitch); return Number.isFinite(pitch) ? pitch > OBLIQUE_THRESHOLD : false; } diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index ac6687f54..33818984d 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -46,7 +46,7 @@ const SOURCE_LABELS = Object.freeze({ }); /** "SNAPSHOT" / "СНИМОК"; unknown codes (HLS, MJPEG…) stay as uppercase codes. */ -export function cctvSourceKindLabel(kind) { +function cctvSourceKindLabel(kind) { const key = String(kind || 'unknown').toLowerCase(); return SOURCE_KIND_LABELS[key] ? t(SOURCE_KIND_LABELS[key]) diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index aed87f483..95fb2aee2 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -64,7 +64,8 @@ export function onKeyDown(event) { event.preventDefault(); event.stopImmediatePropagation(); if (!this.active && !this.isEntryAllowed()) return; - const changed = this.active ? this.exit() : this.enter(); + if (this.active) this.exit(); + else this.enter(); return; } } diff --git a/src/ui/composition.js b/src/ui/composition.js index 797157567..c56c6f37c 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -9,7 +9,10 @@ import { flyToPOI, searchAndFlyTo, } from '../locations.js'; -import { interruptCameraMotion } from '../cameraVerbs.js'; +import { + interruptCameraMotion, + interruptGlobeRotation, +} from '../cameraVerbs.js'; import { IntelHUD } from '../hud.js'; import { ShareLinkManager } from '../sharelink.js'; import { OrbitController } from '../orbit.js'; @@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell { flyToPOI, searchAndFlyTo, interruptCameraMotion, + interruptGlobeRotation, IntelHUD, ShareLinkManager, OrbitController, diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 07ce43d8d..af8fb95b8 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -283,6 +283,7 @@ export class LocationNavigation { return; } + this.services.interruptGlobeRotation?.('orbit-intent'); const isActive = this.orbitController.toggle(this._currentTarget, { radius: this._currentPoi?.alt || 500, pitch: this._currentPoi?.pitch || -30, diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js new file mode 100644 index 000000000..2363fd9ed --- /dev/null +++ b/src/ui/mobileShell.js @@ -0,0 +1,520 @@ +/** + * Phone shell for narrow viewports (≤720px). The globe stays full screen and a + * bottom tab bar opens one sheet at a time; each sheet shows existing panels, + * which stay where they are in the rails and dock while CSS presents them + * (styles/mobile-shell.css). Desktop never sees the class or the sheet state. + * + * Sheets borrow panels: the panels a sheet expands are put back exactly as + * they were when the sheets close, and nothing a sheet does is persisted or + * treated as the user's own panel choice. + * + * Adapted from the phone tab-shell proposed in public PR #203 by blearymoth. + */ + +import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js'; +import { msg, t } from '../i18n/index.js'; + +export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)'; +export const MOBILE_SHELL_CLASS = 'mobile-shell'; +export const MOBILE_SHEET_ATTR = 'data-mobile-sheet'; + +/** + * Panels each sheet expands. A sheet also shows its rail's other panels + * collapsed (Scenes under Layers), per styles/mobile-shell.css. + */ +export const MOBILE_SHEET_PANELS = Object.freeze({ + layers: Object.freeze(['data-panel']), + cameras: Object.freeze(['cctv-panel', 'street-level-panel']), + context: Object.freeze(['global-context-panel']), + search: Object.freeze(['location-bar']), + more: Object.freeze(['control-panel', 'pp-toggles']), +}); + +export const MOBILE_TABS = Object.freeze([ + Object.freeze({ sheet: 'layers', label: msg('Layers'), icon: 'layers' }), + Object.freeze({ sheet: 'cameras', label: msg('Cameras'), icon: 'videocam' }), + Object.freeze({ sheet: 'context', label: msg('Context'), icon: 'radar' }), + Object.freeze({ sheet: 'search', label: msg('Search'), icon: 'search' }), + Object.freeze({ sheet: 'more', label: msg('More'), icon: 'more_horiz' }), +]); + +/** + * Surfaces that own the screen close the open sheet and keep sheets closed. + * Scene playback is the exception: its STOP control lives in the Scenes panel + * (Layers sheet), and a phone has no Escape key to stop it otherwise. + */ +const SHEET_BLOCKING_CLASSES = Object.freeze( + EXCLUSIVE_SURFACE_CLASSES.filter((name) => name !== 'scene-playback-mode'), +); + +/** Every panel any sheet borrows; the dock trays close each other. */ +const BORROWED_PANELS = Object.freeze([ + ...new Set(Object.values(MOBILE_SHEET_PANELS).flat()), +]); + +const BORROW_OPTIONS = Object.freeze({ persist: false, syncShare: false }); + +/** + * @param {string} panelId + * @returns {string|null} The sheet that presents `panelId`. + */ +export function sheetForPanelId(panelId) { + for (const [sheet, ids] of Object.entries(MOBILE_SHEET_PANELS)) { + if (ids.includes(panelId)) return sheet; + } + return null; +} + +/** + * The sheet after a tab press: the same tab closes its sheet, another opens. + * @param {string|null} current + * @param {string|null} pressed + * @returns {string|null} + */ +export function nextSheet(current, pressed) { + if (!pressed || !Object.hasOwn(MOBILE_SHEET_PANELS, pressed)) return null; + return current === pressed ? null : pressed; +} + +function sheetBlocked(body) { + return SHEET_BLOCKING_CLASSES.some((name) => body.classList.contains(name)); +} + +function currentSheetOf(body) { + const value = body.getAttribute(MOBILE_SHEET_ATTR); + return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null; +} + +function buildChrome(documentRef) { + const backdrop = documentRef.createElement('div'); + backdrop.id = 'mobile-sheet-backdrop'; + backdrop.hidden = true; + + const tabBar = documentRef.createElement('nav'); + tabBar.id = 'mobile-tab-bar'; + tabBar.setAttribute('aria-label', t('Panels')); + for (const tab of MOBILE_TABS) { + const button = documentRef.createElement('button'); + button.type = 'button'; + button.className = 'mobile-tab'; + button.dataset.mobileSheet = tab.sheet; + button.setAttribute('aria-expanded', 'false'); + const icon = documentRef.createElement('span'); + icon.className = 'material-symbols-outlined mobile-tab-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = tab.icon; + const label = documentRef.createElement('span'); + label.className = 'mobile-tab-label'; + label.textContent = t(tab.label); + button.append(icon, label); + tabBar.append(button); + } + documentRef.body.append(backdrop, tabBar); + return { backdrop, tabBar }; +} + +/** + * Wires the phone shell: the media gate, tab bar, backdrop, Escape, focus, + * and closing for full-screen modes. + * @param {object} input + * @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed + * @param {Document} [input.documentRef] + * @param {(query: string) => MediaQueryList} [input.matchMedia] + * @param {typeof MutationObserver} [input.MutationObserverImpl] + * @param {typeof ResizeObserver} [input.ResizeObserverImpl] + * @param {(callback: () => void) => number} [input.requestFrame] + * @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes + * and its panels are put back (e.g. to refresh the share link). + * @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user + * opens a sheet from its tab, never on close or programmatic presentation. + * @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }} + */ +export function initMobileShell({ + setPanelCollapsed, + documentRef = globalThis.document, + matchMedia = globalThis.matchMedia?.bind(globalThis), + MutationObserverImpl = globalThis.MutationObserver, + ResizeObserverImpl = globalThis.ResizeObserver, + requestFrame = globalThis.requestAnimationFrame?.bind(globalThis), + onSheetsClosed = () => {}, + onTabOpened = () => {}, +} = {}) { + const body = documentRef.body; + const { backdrop, tabBar } = buildChrome(documentRef); + const viewport = documentRef.querySelector('meta[name="viewport"]'); + const viewportContent = viewport?.getAttribute('content') ?? null; + const removers = []; + const listen = (target, type, handler, options) => { + target.addEventListener(type, handler, options); + removers.push(() => target.removeEventListener(type, handler, options)); + }; + const element = (id) => documentRef.getElementById(id); + const rails = ['left-panel-stack', 'right-context-rail'] + .map(element) + .filter(Boolean); + const dock = element('command-dock'); + const shown = (node) => + node && globalThis.getComputedStyle?.(node).display !== 'none'; + + /** + * Panel states from before the first sheet opened (`before`) and right after + * the open sheet was set up (`applied`); null when closed. + */ + let borrowed = null; + let returnFocus = null; + let swipe = null; + const written = new Map(); + + const writeVar = (name, value) => { + if (written.get(name) === value) return; + written.set(name, value); + body.style.setProperty(name, value); + }; + + // The dock (Presets, Search) sits on top of whichever rail is the sheet, + // and the required map attribution sits above everything the sheet covers. + const measureSheet = () => { + if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; + const height = Math.max( + 0, + ...rails.map((rail) => + shown(rail) ? rail.getBoundingClientRect().height : 0, + ), + ); + writeVar('--mobile-sheet-height', `${Math.round(height)}px`); + const viewportHeight = globalThis.innerHeight || 0; + const cover = Math.max( + 0, + ...[...rails, currentSheetOf(body) ? dock : null] + .filter(shown) + .map((node) => viewportHeight - node.getBoundingClientRect().top), + ); + writeVar('--mobile-sheet-cover', `${Math.round(cover)}px`); + }; + + const resize = + typeof ResizeObserverImpl === 'function' + ? new ResizeObserverImpl(measureSheet) + : null; + removers.push(() => resize?.disconnect()); + + const isCollapsed = (panelId) => + element(panelId)?.classList.contains('collapsed') ?? true; + // The user's preference, as share links record it: a collapse that layout + // applies only for space (Tactical, Cyber) still counts as expanded. + const prefersCollapsed = (panelId) => { + const classes = element(panelId)?.classList; + if (!classes) return true; + return ( + classes.contains('collapsed') && + !classes.contains('layout-auto-collapsed') && + !classes.contains('cyber-accordion-collapsed') + ); + }; + + const panelStates = () => + new Map( + BORROWED_PANELS.map((panelId) => [panelId, prefersCollapsed(panelId)]), + ); + + // Undo only what opening the sheet did (including side effects, such as dock + // trays closing each other). A panel the user changed since stays as they + // left it, and becomes the state later sheets put back. + const restoreBorrowed = () => { + if (!borrowed) return; + for (const [panelId, before] of borrowed.before) { + const now = prefersCollapsed(panelId); + if (now !== borrowed.applied.get(panelId)) + borrowed.before.set(panelId, now); + else if (now !== before) + setPanelCollapsed(panelId, before, BORROW_OPTIONS); + } + }; + + const present = (sheet) => { + if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet); + else body.removeAttribute(MOBILE_SHEET_ATTR); + backdrop.hidden = !sheet; + for (const button of tabBar.querySelectorAll('[data-mobile-sheet]')) + button.setAttribute( + 'aria-expanded', + String(button.dataset.mobileSheet === sheet), + ); + measureSheet(); + }; + + /** The first visible control of the sheet's first panel, for focus on open. */ + const sheetFocusTarget = (sheet) => { + if (sheet === 'search') return element('location-search'); + const panel = element(MOBILE_SHEET_PANELS[sheet][0]); + const controls = + panel?.querySelectorAll?.( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? []; + // Sheets hide some panel chrome (tray toggles, pins) that stays in the + // DOM, and a panel's own collapse control is a poor first stop. + return ( + [...controls].find( + (control) => + control.getClientRects().length > 0 && + !control.matches?.('.panel-collapse-btn'), + ) ?? null + ); + }; + + const setSheet = (requested) => { + swipe = null; + if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; + const sheet = sheetBlocked(body) ? null : requested; + const previous = currentSheetOf(body); + if (previous === sheet) return; + if (!previous) { + borrowed = { before: panelStates(), applied: panelStates() }; + returnFocus = documentRef.activeElement ?? null; + } else { + if (previous === 'search') element('location-search')?.blur?.(); + restoreBorrowed(); + } + present(sheet); + if (!sheet) { + borrowed = null; + onSheetsClosed(); + const focus = returnFocus; + returnFocus = null; + if (focus && focus !== body && body.contains?.(focus)) focus.focus?.(); + return; + } + for (const panelId of MOBILE_SHEET_PANELS[sheet]) + if (isCollapsed(panelId)) + setPanelCollapsed(panelId, false, BORROW_OPTIONS); + borrowed.applied = panelStates(); + // Rail layout settles on a later frame; what it does is part of opening. + const opening = (borrowed.opening ?? 0) + 1; + borrowed.opening = opening; + const opened = borrowed; + requestFrame?.(() => { + if (borrowed === opened && opened.opening === opening) + opened.applied = panelStates(); + }); + measureSheet(); + sheetFocusTarget(sheet)?.focus?.(); + }; + + const tabFor = (sheet) => + [...tabBar.querySelectorAll('[data-mobile-sheet]')].find( + (button) => button.dataset.mobileSheet === sheet, + ); + + const onMedia = (matches) => { + if (matches) { + body.classList.add(MOBILE_SHELL_CLASS); + // Lets the page draw under the notch and system bars; the shell's CSS + // keeps its chrome clear of them. + if (viewport && !/viewport-fit\s*=/.test(viewportContent || '')) + viewport.setAttribute( + 'content', + `${viewportContent || ''}, viewport-fit=cover`, + ); + for (const node of [...rails, dock].filter(Boolean)) + resize?.observe(node); + measureSheet(); + } else { + // Crossing to desktop closes the sheet, which puts its panels back. + setSheet(null); + body.classList.remove(MOBILE_SHELL_CLASS); + present(null); + resize?.disconnect(); + if (viewport && viewportContent !== null) + viewport.setAttribute('content', viewportContent); + } + tabBar.setAttribute('aria-hidden', String(!matches)); + }; + + const media = + typeof matchMedia === 'function' + ? matchMedia(MOBILE_SHELL_MEDIA_QUERY) + : null; + onMedia(Boolean(media?.matches)); + const onChange = (event) => onMedia(event.matches); + if (media?.addEventListener) { + media.addEventListener('change', onChange); + removers.push(() => media.removeEventListener('change', onChange)); + } else if (media?.addListener) { + // Safari before 14 only offers the legacy listener API. + media.addListener(onChange); + removers.push(() => media.removeListener(onChange)); + } + + listen(tabBar, 'click', (event) => { + const button = event.target?.closest?.('[data-mobile-sheet]'); + if (!button) return; + event.preventDefault(); + const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet); + // Closing from the tab bar returns focus to the tab that was pressed. + if (!sheet) returnFocus = button; + setSheet(sheet); + if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet); + }); + listen(backdrop, 'click', () => setSheet(null)); + // Touch events let native scrolling continue until a downward pull at the + // top is intentional. Pointer capture/touch-action:none would also seize + // ordinary vertical scrolling inside the borrowed panels. + const sheetRoot = (target, sheet) => { + const roots = + sheet === 'layers' + ? [element('left-panel-stack')] + : sheet === 'search' + ? [dock] + : sheet === 'more' + ? [element('right-context-rail'), dock] + : [element('right-context-rail')]; + return roots.find((root) => root?.contains(target)); + }; + const scrolled = (target, root) => { + for (let node = target; node; node = node.parentElement) { + if (node.scrollTop > 0) return true; + if (node === root) break; + } + return false; + }; + listen( + documentRef, + 'touchstart', + (event) => { + swipe = null; + const sheet = currentSheetOf(body); + const target = event.target; + if ( + !sheet || + !body.classList.contains(MOBILE_SHELL_CLASS) || + event.defaultPrevented || + event.touches.length !== 1 || + target?.closest?.( + 'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]', + ) + ) + return; + const root = sheetRoot(target, sheet); + if (!root || scrolled(target, root)) return; + const touch = event.touches[0]; + swipe = { + sheet, + target, + root, + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + claimed: false, + }; + }, + { passive: true }, + ); + listen( + documentRef, + 'touchmove', + (event) => { + if (!swipe) return; + const touch = event.touches[0]; + if ( + event.defaultPrevented || + event.touches.length !== 1 || + touch.identifier !== swipe.id || + currentSheetOf(body) !== swipe.sheet || + scrolled(swipe.target, swipe.root) + ) { + swipe = null; + return; + } + const dx = touch.clientX - swipe.x; + const dy = touch.clientY - swipe.y; + if (!swipe.claimed) { + // Claim direction before the browser crosses its native scroll slop. + // Android can make the next move non-cancelable after a ~9px first move. + if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return; + if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) { + swipe = null; + return; + } + swipe.claimed = true; + } + // Cancelling only the claimed pull leaves taps, sliders and normal scroll + // native, and suppresses the synthetic click at the end of a dismissal. + event.preventDefault(); + }, + { passive: false }, + ); + listen( + documentRef, + 'touchend', + (event) => { + const pull = swipe; + swipe = null; + if (!pull?.claimed || event.touches.length) return; + const touch = [...event.changedTouches].find( + (item) => item.identifier === pull.id, + ); + if (!touch || currentSheetOf(body) !== pull.sheet) return; + const dx = Math.abs(touch.clientX - pull.x); + const dy = touch.clientY - pull.y; + if (dy < 64 || dy <= dx * 1.25) return; + returnFocus = tabFor(pull.sheet) ?? returnFocus; + setSheet(null); + }, + { passive: true }, + ); + listen( + documentRef, + 'touchcancel', + () => { + swipe = null; + }, + { passive: true }, + ); + // Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools + // take Escape first; panels yield it to the sheet (PanelChrome), and an open + // dialog keeps its own native close. + listen(documentRef, 'keydown', (event) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + const sheet = currentSheetOf(body); + if (!sheet || documentRef.querySelector('dialog[open]')) return; + event.preventDefault(); + returnFocus = tabFor(sheet) ?? returnFocus; + setSheet(null); + }); + + if (typeof MutationObserverImpl === 'function') { + const observer = new MutationObserverImpl(() => { + if (currentSheetOf(body) && sheetBlocked(body)) setSheet(null); + }); + observer.observe(body, { attributes: true, attributeFilter: ['class'] }); + removers.push(() => observer.disconnect()); + } + + return { + setSheet, + getSheet: () => currentSheetOf(body), + /** + * The collapsed state to share for a panel a sheet borrowed: its state + * before the sheets, or the user's own change since. Undefined otherwise. + */ + sharedCollapsed(panelId) { + if (!borrowed?.before.has(panelId)) return undefined; + const now = prefersCollapsed(panelId); + return now !== borrowed.applied.get(panelId) + ? now + : borrowed.before.get(panelId); + }, + destroy() { + setSheet(null); + for (const remove of removers.splice(0)) remove(); + body.classList.remove(MOBILE_SHELL_CLASS); + body.removeAttribute(MOBILE_SHEET_ATTR); + body.style.removeProperty('--mobile-sheet-height'); + body.style.removeProperty('--mobile-sheet-cover'); + if (viewport && viewportContent !== null) + viewport.setAttribute('content', viewportContent); + backdrop.remove(); + tabBar.remove(); + }, + }; +} diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs new file mode 100644 index 000000000..bb7599547 --- /dev/null +++ b/src/ui/mobileShell.test.mjs @@ -0,0 +1,865 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; +import { ContextControls } from './contextControls.js'; +import { + MOBILE_SHEET_PANELS, + MOBILE_TABS, + initMobileShell, + nextSheet, + sheetForPanelId, +} from './mobileShell.js'; + +function element(tag, document) { + const node = new EventTarget(); + const attributes = new Map(); + const classes = new Set(); + Object.assign(node, { + tagName: tag.toUpperCase(), + children: [], + dataset: {}, + hidden: false, + textContent: '', + className: '', + parent: null, + scrollTop: 0, + parentElement: null, + classList: { + add: (...names) => names.forEach((name) => classes.add(name)), + remove: (...names) => names.forEach((name) => classes.delete(name)), + contains: (name) => classes.has(name), + }, + style: { + values: new Map(), + setProperty(name, value) { + this.values.set(name, value); + }, + removeProperty(name) { + this.values.delete(name); + }, + }, + setAttribute: (key, value) => attributes.set(key, String(value)), + getAttribute: (key) => attributes.get(key) ?? null, + removeAttribute: (key) => attributes.delete(key), + append(...children) { + for (const child of children) { + child.parent = node; + child.parentElement = node; + node.children.push(child); + if (child.id) document.ids.set(child.id, child); + } + }, + remove() { + const siblings = node.parent?.children; + siblings?.splice(siblings.indexOf(node), 1); + document.ids.delete(node.id); + }, + contains(other) { + for (let current = other; current; current = current.parent) + if (current === node) return true; + return false; + }, + closest(selector) { + for (let current = node; current; current = current.parent) { + if ( + selector.startsWith('button, a') && + (['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes( + current.tagName, + ) || + ['slider', 'button'].includes(current.getAttribute('role')) || + (current.getAttribute('contenteditable') !== null && + current.getAttribute('contenteditable') !== 'false')) + ) + return current; + if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet) + return current; + } + return null; + }, + querySelectorAll(selector) { + const found = []; + const matches = (child) => + selector === '[data-mobile-sheet]' + ? Boolean(child.dataset.mobileSheet) + : child.tagName === 'BUTTON'; + const walk = (parent) => { + for (const child of parent.children) { + if (matches(child)) found.push(child); + walk(child); + } + }; + walk(node); + return found; + }, + getBoundingClientRect: () => ({ top: 0, height: 0 }), + getClientRects: () => (node.hidden ? [] : [{}]), + matches: (selector) => + selector === '.panel-collapse-btn' && + node.className.split(' ').includes('panel-collapse-btn'), + focus() { + document.activeElement = node; + }, + blur() { + if (document.activeElement === node) + document.activeElement = document.body; + }, + }); + return node; +} + +const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())]; + +function fixture({ + mobile = true, + expanded = [], + onTabOpened = () => {}, +} = {}) { + const document = new EventTarget(); + document.ids = new Map(); + document.createElement = (tag) => element(tag, document); + document.getElementById = (id) => document.ids.get(id) ?? null; + const viewport = element('meta', document); + viewport.setAttribute('content', 'width=device-width, initial-scale=1.0'); + document.querySelector = (selector) => + selector === 'meta[name="viewport"]' + ? viewport + : selector === 'dialog[open]' && document.openDialog + ? {} + : null; + document.body = element('body', document); + document.activeElement = document.body; + const search = element('input', document); + search.id = 'location-search'; + document.body.append(search); + const rails = {}; + for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) { + rails[id] = element('div', document); + rails[id].id = id; + document.body.append(rails[id]); + } + for (const id of PANEL_IDS) { + const panel = element('section', document); + panel.id = id; + if (!expanded.includes(id)) panel.classList.add('collapsed'); + // A hidden tray toggle and a collapse control first, like the panels' + // own chrome in a sheet. + const toggle = element('button', document); + toggle.hidden = true; + const collapse = element('button', document); + collapse.className = 'panel-collapse-btn'; + panel.append(toggle, collapse, element('button', document)); + const root = + id === 'data-panel' + ? 'left-panel-stack' + : ['location-bar', 'control-panel'].includes(id) + ? 'command-dock' + : 'right-context-rail'; + rails[root].append(panel); + } + + const media = new EventTarget(); + media.matches = mobile; + const calls = []; + const frames = []; + let closedCount = 0; + let observed = null; + class FakeMutationObserver { + constructor(callback) { + observed = callback; + } + observe() {} + disconnect() { + observed = null; + } + } + const shell = initMobileShell({ + documentRef: document, + onTabOpened, + matchMedia: () => media, + MutationObserverImpl: FakeMutationObserver, + ResizeObserverImpl: null, + requestFrame: (callback) => frames.push(callback), + onSheetsClosed: () => { + closedCount += 1; + }, + setPanelCollapsed: (panelId, collapsed, options) => { + calls.push({ panelId, collapsed, ...options }); + const panel = document.getElementById(panelId); + if (collapsed) panel.classList.add('collapsed'); + else panel.classList.remove('collapsed'); + }, + }); + const tab = (sheet) => + document + .getElementById('mobile-tab-bar') + .children.find((button) => button.dataset.mobileSheet === sheet); + const press = (sheet) => { + const event = new Event('click', { cancelable: true }); + Object.defineProperty(event, 'target', { value: tab(sheet) }); + document.getElementById('mobile-tab-bar').dispatchEvent(event); + }; + const resize = (matches) => { + media.matches = matches; + const event = new Event('change'); + event.matches = matches; + media.dispatchEvent(event); + }; + const setBodyClass = (name) => { + document.body.classList.add(name); + observed?.(); + }; + const collapsed = (id) => + document.getElementById(id).classList.contains('collapsed'); + const runFrames = () => frames.splice(0).forEach((callback) => callback()); + return { + document, + shell, + calls, + runFrames, + closedCount: () => closedCount, + press, + tab, + resize, + viewport, + search, + setBodyClass, + collapsed, + }; +} + +test('every tab names a sheet and every sheet panel maps back to its sheet', () => { + assert.deepEqual( + MOBILE_TABS.map(({ sheet }) => sheet), + Object.keys(MOBILE_SHEET_PANELS), + ); + for (const [sheet, panels] of Object.entries(MOBILE_SHEET_PANELS)) + for (const panelId of panels) assert.equal(sheetForPanelId(panelId), sheet); + assert.equal(sheetForPanelId('unknown-panel'), null); +}); + +test('pressing a tab opens its sheet, pressing it again closes it', () => { + assert.equal(nextSheet(null, 'layers'), 'layers'); + assert.equal(nextSheet('layers', 'layers'), null); + assert.equal(nextSheet('layers', 'more'), 'more'); + assert.equal(nextSheet('layers', 'nonsense'), null); + assert.equal(nextSheet(null, 'constructor'), null); +}); + +test('one sheet at a time, presented with aria-expanded and a backdrop', () => { + const { document, shell, press, tab } = fixture(); + assert.equal(document.body.classList.contains('mobile-shell'), true); + press('layers'); + assert.equal(shell.getSheet(), 'layers'); + assert.equal(document.body.getAttribute('data-mobile-sheet'), 'layers'); + assert.equal(tab('layers').getAttribute('aria-expanded'), 'true'); + assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, false); + press('more'); + assert.equal(shell.getSheet(), 'more'); + assert.equal(tab('layers').getAttribute('aria-expanded'), 'false'); + assert.equal(tab('more').getAttribute('aria-expanded'), 'true'); + press('more'); + assert.equal(shell.getSheet(), null); + assert.equal(document.body.getAttribute('data-mobile-sheet'), null); + assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, true); +}); + +test('sheets borrow panels and put them back exactly as they were', () => { + // The user left Data Layers and Presets open on desktop. + const kept = ['data-panel', 'control-panel']; + const { calls, press, collapsed } = fixture({ expanded: kept }); + press('cameras'); + assert.equal(collapsed('cctv-panel'), false); + assert.equal(collapsed('street-level-panel'), false); + press('search'); + assert.equal(collapsed('cctv-panel'), true, 'Cameras put back on switch'); + assert.equal(collapsed('location-bar'), false); + press('search'); + for (const id of PANEL_IDS) + assert.equal(collapsed(id), !kept.includes(id), `${id} is back as it was`); + // Nothing a sheet does is the user's choice, persisted or shared. + assert.ok(calls.length > 0); + for (const call of calls) { + assert.equal(call.explicit, undefined, `${call.panelId} is not explicit`); + assert.equal(call.persist, false); + assert.equal(call.syncShare, false); + } + // Already-open panels are not touched. + assert.ok(!calls.some(({ panelId }) => kept.includes(panelId))); +}); + +test('a panel the user changes inside a sheet stays as they left it', () => { + const { press, collapsed, document } = fixture(); + press('cameras'); + // The user collapses CCTV inside the sheet (their own, persisted choice). + document.getElementById('cctv-panel').classList.add('collapsed'); + press('cameras'); + assert.equal(collapsed('cctv-panel'), true, 'their collapse is kept'); + assert.equal( + collapsed('street-level-panel'), + true, + 'the borrowed one is put back', + ); + + // Expanding one in a sheet is kept too, and later sheets respect it. + press('layers'); + document.getElementById('cctv-panel').classList.remove('collapsed'); + press('more'); + press('more'); + assert.equal(collapsed('cctv-panel'), false); +}); + +test('focus moves into the sheet and returns to the tab on close', () => { + const { document, press, search, tab } = fixture(); + press('search'); + assert.equal(document.activeElement, search); + press('search'); + assert.equal(document.activeElement, tab('search')); + + press('layers'); + const dataPanel = document.getElementById('data-panel'); + // The first visible control that is not the panel's own collapse button. + assert.equal(document.activeElement, dataPanel.children[2]); + const escape = new Event('keydown', { cancelable: true }); + escape.key = 'Escape'; + document.dispatchEvent(escape); + assert.equal(escape.defaultPrevented, true); + assert.equal(document.activeElement, tab('layers')); +}); + +test('the share link keeps the state from before the sheets', () => { + const { shell, press, document, closedCount } = fixture({ + expanded: ['street-level-panel'], + }); + assert.equal(shell.sharedCollapsed('data-panel'), undefined); + press('layers'); + // Data Layers is open only because the sheet borrowed it. + assert.equal(shell.sharedCollapsed('data-panel'), true); + assert.equal(shell.sharedCollapsed('street-level-panel'), false); + assert.equal(shell.sharedCollapsed('unrelated-panel'), undefined); + // A user change inside the sheet is theirs to share. + document.getElementById('cctv-panel').classList.remove('collapsed'); + assert.equal(shell.sharedCollapsed('cctv-panel'), false); + press('more'); + assert.equal(closedCount(), 0, 'switching sheets is not closing'); + press('more'); + assert.equal(closedCount(), 1, 'closing refreshes the share link once'); + assert.equal(shell.sharedCollapsed('data-panel'), undefined); +}); + +test('Escape closes the sheet only when nothing else took it', () => { + const { document, shell, press } = fixture(); + const escape = () => { + const event = new Event('keydown', { cancelable: true, bubbles: true }); + event.key = 'Escape'; + return event; + }; + press('layers'); + // A field cancelling its edit (or a lightbox closing) handles it first. + const handled = escape(); + handled.preventDefault(); + document.dispatchEvent(handled); + assert.equal(shell.getSheet(), 'layers'); + // An open dialog keeps Escape for its native close. + document.openDialog = true; + document.dispatchEvent(escape()); + assert.equal(shell.getSheet(), 'layers'); + document.openDialog = false; + const plain = escape(); + document.dispatchEvent(plain); + assert.equal(shell.getSheet(), null); + assert.equal(plain.defaultPrevented, true); +}); + +test('share state and restores follow the preference, not layout collapse', () => { + const { shell, press, collapsed, document } = fixture(); + // Layout collapsed Street Level only for space; the user left it open. + const street = document.getElementById('street-level-panel'); + street.classList.add('layout-auto-collapsed'); + press('cameras'); + assert.equal(shell.sharedCollapsed('street-level-panel'), false); + press('cameras'); + assert.equal( + collapsed('street-level-panel'), + false, + 'not turned into a real collapse', + ); +}); + +test('layout settling after a sheet opens is part of opening', () => { + const { press, runFrames, collapsed, document } = fixture({ + expanded: ['street-level-panel'], + }); + press('cameras'); + // A rail layout pass on the next frame collapses Street Level. + document.getElementById('street-level-panel').classList.add('collapsed'); + runFrames(); + press('cameras'); + assert.equal(collapsed('street-level-panel'), false, 'put back as it was'); +}); + +test('the backdrop closes the open sheet', () => { + const { document, shell, press } = fixture(); + press('context'); + document + .getElementById('mobile-sheet-backdrop') + .dispatchEvent(new Event('click')); + assert.equal(shell.getSheet(), null); +}); + +test('surfaces that own the screen close sheets; scene playback does not', () => { + for (const mode of ['cockpit-mode', 'recording-mode', 'ui-clean-view']) { + const { shell, press, setBodyClass, collapsed } = fixture(); + press('layers'); + setBodyClass(mode); + assert.equal(shell.getSheet(), null, `${mode} closes the sheet`); + assert.equal(collapsed('data-panel'), true, `${mode} puts panels back`); + press('layers'); + assert.equal(shell.getSheet(), null, `${mode} keeps sheets closed`); + } + // Scene STOP lives in the Scenes panel of the Layers sheet. + const playback = fixture(); + playback.setBodyClass('scene-playback-mode'); + playback.press('layers'); + assert.equal(playback.shell.getSheet(), 'layers'); +}); + +test('desktop widths never see the shell; crossing to desktop restores it', () => { + const desktop = fixture({ mobile: false }); + assert.equal(desktop.document.body.classList.contains('mobile-shell'), false); + desktop.press('layers'); + assert.equal(desktop.shell.getSheet(), null); + assert.equal( + desktop.viewport.getAttribute('content'), + 'width=device-width, initial-scale=1.0', + ); + + const phone = fixture({ expanded: ['street-level-panel'] }); + assert.match(phone.viewport.getAttribute('content'), /viewport-fit=cover$/); + phone.press('cameras'); + phone.resize(false); + assert.equal(phone.shell.getSheet(), null); + assert.equal(phone.document.body.classList.contains('mobile-shell'), false); + assert.equal(phone.collapsed('cctv-panel'), true); + assert.equal(phone.collapsed('street-level-panel'), false); + assert.equal( + phone.viewport.getAttribute('content'), + 'width=device-width, initial-scale=1.0', + ); +}); + +test('destroy puts panels back and removes the shell', () => { + const { document, shell, press, viewport, collapsed } = fixture(); + press('layers'); + shell.destroy(); + assert.equal(collapsed('data-panel'), true); + assert.equal(document.getElementById('mobile-tab-bar'), null); + assert.equal(document.getElementById('mobile-sheet-backdrop'), null); + assert.equal(document.body.classList.contains('mobile-shell'), false); + assert.equal(document.body.getAttribute('data-mobile-sheet'), null); + assert.equal(document.body.style.values.size, 0); + assert.equal( + viewport.getAttribute('content'), + 'width=device-width, initial-scale=1.0', + ); +}); + +test('the phone shell keeps the required attribution visible', () => { + // Whitespace-normalised, so formatter line wrapping cannot hide a rule. + const css = readFileSync( + new URL('./styles/mobile-shell.css', import.meta.url), + 'utf8', + ) + .replace(/\s+/g, ' ') + .replace(/\( /g, '(') + .replace(/ \)/g, ')'); + /** The declaration block of the rule whose selector list ends with `selector`. */ + const rule = (selector) => { + const end = css.indexOf(`${selector} {`); + assert.notEqual(end, -1, `missing rule ending in ${selector}`); + const open = css.indexOf('{', end); + return css.slice(open, css.indexOf('}', open)); + }; + const lift = (block) => + Number( + /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1], + ); + const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]); + const idleCredit = rule( + 'body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits', + ); + const dock = rule('body.mobile-shell #command-dock'); + const voice = rule('body.mobile-shell #command-dock #gev-voice-control'); + const voiceHeightPx = Number(/height: ([\d.]+)rem/.exec(voice)[1]) * 16; + // Idle: the credit line clears the voice bar, which clears the tab bar. + assert.ok(lift(idleCredit) > lift(dock) + voiceHeightPx); + // It must beat the dock's :has() placements, so the offset is !important. + assert.match(idleCredit, /\) !important;/); + assert.doesNotMatch( + idleCredit, + /display:\s*none|visibility:\s*hidden|opacity:\s*0/, + ); + // Open sheet: above everything the sheet covers, drawn above the sheets. + const sheetCredit = rule( + 'body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits', + ); + assert.match( + sheetCredit, + /bottom: calc\(var\(--mobile-sheet-cover, 0px\) \+ 6px\) !important;/, + ); + const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock'); + const railSheet = rule( + "body.mobile-shell[data-mobile-sheet='more'] #right-context-rail", + ); + assert.ok(z(sheetCredit) > z(openDock)); + assert.ok(z(sheetCredit) > z(railSheet)); +}); + +function touchEvent( + document, + type, + target, + { x = 10, y = 10, count = 1, id = 1, cancelable = true } = {}, +) { + const event = new Event(type, { cancelable }); + const touch = { identifier: id, clientX: x, clientY: y }; + Object.defineProperty(event, 'target', { value: target }); + event.touches = + type === 'touchend' || type === 'touchcancel' + ? [] + : Array.from({ length: count }, (_, i) => ({ + ...touch, + identifier: id + i, + })); + event.changedTouches = [touch]; + document.dispatchEvent(event); + return event; +} + +test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => { + for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) { + const phone = fixture(); + const { document, shell, press, tab } = phone; + press(sheet); + const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]); + touchEvent(document, 'touchstart', target); + const move = touchEvent(document, 'touchmove', target, { y: 90 }); + assert.equal(move.defaultPrevented, true); + assert.equal(shell.getSheet(), sheet, 'waits until finger lifts'); + touchEvent(document, 'touchend', target, { y: 90 }); + assert.equal(shell.getSheet(), null); + assert.equal(phone.closedCount(), 1); + assert.equal(document.activeElement, tab(sheet)); + for (const id of MOBILE_SHEET_PANELS[sheet]) + assert.equal(phone.collapsed(id), true); + assert.ok( + phone.calls.every( + (call) => call.persist === false && call.syncShare === false, + ), + ); + shell.destroy(); + } +}); + +test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => { + for (const nested of [false, true]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + const scroller = nested + ? panel + : document.getElementById('left-panel-stack'); + scroller.scrollTop = 40; + touchEvent(document, 'touchstart', panel); + scroller.scrollTop = 0; + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('taps, short pulls, upward and horizontal gestures never dismiss', () => { + for (const path of [ + [], + [{ x: 12, y: 14 }], + [{ y: 40 }], + [{ y: -30 }, { y: 120 }], + [{ x: 60, y: 12 }, { y: 120 }], + ]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + for (const point of path) touchEvent(document, 'touchmove', panel, point); + touchEvent(document, 'touchend', panel, path.at(-1) ?? {}); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('native controls and custom sliders keep touch gestures', () => { + for (const tag of [ + 'button', + 'a', + 'input', + 'select', + 'textarea', + 'label', + 'slider', + 'editable', + ]) { + const { document, shell, press } = fixture(); + press('more'); + const control = document.createElement( + ['slider', 'editable'].includes(tag) ? 'div' : tag, + ); + if (tag === 'slider') control.setAttribute('role', 'slider'); + if (tag === 'editable') control.setAttribute('contenteditable', 'true'); + const child = document.createElement('span'); + control.append(child); + document.getElementById('control-panel').append(control); + touchEvent(document, 'touchstart', child); + assert.equal( + touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', child, { y: 120 }); + assert.equal(shell.getSheet(), 'more'); + shell.destroy(); + } +}); + +test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => { + for (const cancel of [ + 'multitouch', + 'cancel', + 'sheet', + 'desktop', + 'noncancelable', + ]) { + const { document, shell, press, resize } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + if (cancel === 'multitouch') + touchEvent(document, 'touchstart', document.body, { count: 2 }); + if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel); + if (cancel === 'sheet') press('context'); + if (cancel === 'desktop') { + resize(false); + resize(true); + press('layers'); + } + if (cancel === 'noncancelable') + touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers'); + shell.destroy(); + } +}); + +test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => { + const { document, shell, press } = fixture(); + press('layers'); + const hiddenPanel = document.getElementById('global-context-panel'); + touchEvent(document, 'touchstart', hiddenPanel); + assert.equal( + touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', hiddenPanel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + shell.destroy(); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), null); +}); + +test('the tab-open callback reports only a successful manual opening', () => { + const opened = []; + const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) }); + phone.shell.setSheet('context'); + assert.deepEqual(opened, []); + phone.press('context'); + assert.deepEqual(opened, [], 'closing is not a new context request'); + phone.press('context'); + assert.deepEqual(opened, ['context']); + phone.setBodyClass('cockpit-mode'); + phone.press('context'); + assert.deepEqual( + opened, + ['context'], + 'blocked sheets cannot activate a mode', + ); + phone.shell.destroy(); +}); + +test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => { + const calls = []; + const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' }; + const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } }; + const entity = viewer.trackedEntity; + const enabled = new Set(['flights', 'weather']); + const controls = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => null, + claimVisualAuthority: () => calls.push('claim'), + syncDetection() {}, + scheduleLayout() {}, + showToast() {}, + }, + }); + controls.connect({ + layers: new Map( + ['flights', 'weather', 'military-awareness'].map((id) => [id, {}]), + ), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => ({}), + isEffectivelyEnabled: (id) => enabled.has(id), + isEnabled: (id) => enabled.has(id), + setLayerParams: (id, params) => calls.push({ id, params }), + setEnabled: async (id, value) => { + calls.push({ id, value }); + if (!value) enabled.delete(id); + if (id === 'flights' && !value) viewer.trackedEntity = null; + return true; + }, + _setEnabledWithIntent: (id, value) => { + calls.push({ id, value }); + enabled.add(id); + return { intentEpoch: 1, promise: Promise.resolve(true) }; + }, + }); + const shell = { + cockpitView: { + readAircraftInfo: () => (viewer.trackedEntity ? tracked : null), + }, + getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget, + getContextModeState: () => controls.getContextModeState(), + setContextMode: (mode) => controls.setContextMode(mode), + _showToast: () => assert.fail('successful entry must not report a failure'), + }; + try { + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + true, + ); + assert.equal(controls.getContextModeState().mode, 'flights'); + assert.equal(viewer.trackedEntity, entity); + assert.deepEqual(shell.getAircraftTrackingTarget(), { + layerId: 'flights', + id: 'abc123', + }); + assert.ok(enabled.has('flights')); + assert.ok( + calls.some( + (call) => call.id === 'military-awareness' && call.value === true, + ), + ); + assert.ok(!calls.some((call) => call.id === 'flights')); + const count = calls.length; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + calls.length, + count, + 'an already active Contacts session is not restarted', + ); + } finally { + controls.stop(); + controls.disconnect(); + } +}); + +test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => { + for (const state of [ + { mode: 'space-missions' }, + { mode: 'flights' }, + { changing: true }, + { entering: 'space-missions' }, + { noTrack: true }, + { disposed: true }, + { sheet: 'layers' }, + ]) { + const shell = { + _disposed: state.disposed, + getContextModeState: () => state, + getAircraftTrackingTarget: () => + state.noTrack ? null : { layerId: 'flights', id: 'abc123' }, + setContextMode: () => + assert.fail('guarded tab must not mutate Context or tracking'), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call( + shell, + state.sheet || 'context', + ), + false, + ); + } +}); + +test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => { + for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) { + const notices = []; + const shell = { + getContextModeState: () => ({ mode: null }), + getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }), + setContextMode: async () => { + if (outcome === 'throw') throw new Error('source unavailable'); + if (outcome === 'disposed') shell._disposed = true; + return { ok: false, cancelled: outcome === 'cancelled' }; + }, + _showToast: (message) => notices.push(message), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + notices.length, + ['failure', 'throw'].includes(outcome) ? 1 : 0, + ); + } +}); + +test('a slow downward pull is claimed before Android native scroll takes ownership', () => { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel, { y: 100 }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented, + false, + 'tap jitter is left native', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented, + true, + 'cancel the first directional move while Android still allows it', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented, + true, + ); + touchEvent(document, 'touchend', panel, { y: 175 }); + assert.equal(shell.getSheet(), null); + shell.destroy(); +}); diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index f94eb46fe..437b643f3 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -14,6 +14,7 @@ export class NavigationController { tracking, searchInput, interruptCameraMotion, + interruptGlobeRotation = () => {}, isCockpitActive, clearLocation, cancelShareSelection, @@ -27,6 +28,7 @@ export class NavigationController { tracking, searchInput, interruptCameraMotion, + interruptGlobeRotation, isCockpitActive, clearLocation, cancelShareSelection, @@ -46,6 +48,7 @@ export class NavigationController { cancelPendingSelection = true, clearSearchedLocation = true, } = {}) { + this.interruptGlobeRotation('navigation-intent'); cancelCameraArrival(this.viewer); this.cancelOrientation(); const { flightsLayer, militaryFlightsLayer, satellitesLayer } = diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index e0bd37355..d920e8545 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -46,6 +46,7 @@ export class PanelChrome { readInitialShare, readScrollRestoreOwner, readDisplayScrollTop, + readSharedCollapsed = () => undefined, }) { Object.assign(this, elements, operations, { readHud, @@ -54,6 +55,7 @@ export class PanelChrome { readInitialShare, readScrollRestoreOwner, readDisplayScrollTop, + readSharedCollapsed, }); this._disposed = false; this._lifetime = new UiLifetime(); @@ -149,6 +151,9 @@ export class PanelChrome { } _collapsePanelOnEscape(event, panelId) { + // In a phone sheet, Escape closes the sheet (mobileShell.js), not a panel. + if (globalThis.document?.body?.hasAttribute?.('data-mobile-sheet')) + return false; return collapsePanelOnEscape(event, { panel: document.getElementById(panelId), onChange: (collapsed, options) => @@ -250,7 +255,10 @@ export class PanelChrome { disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`), openDelayMs, closeDelayMs, - isActive: () => !this._disposed, + // A phone sheet holds its trays open; the tab bar closes them. + isActive: () => + !this._disposed && + !globalThis.document?.body?.hasAttribute?.('data-mobile-sheet'), onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), onEscape: (event) => this._collapsePanelOnEscape(event, panelId), @@ -380,7 +388,11 @@ export class PanelChrome { panelEl.classList.contains('cyber-accordion-collapsed') ? false : panelEl.classList.contains('collapsed'); - const entry = { id: spec.id, collapsed }; + // A panel the phone shell only borrowed for a sheet shares its own state. + const entry = { + id: spec.id, + collapsed: this.readSharedCollapsed(spec.id) ?? collapsed, + }; if (spec.pinnable) entry.pinned = panelEl.classList.contains('dock-pinned'); specs.push(entry); @@ -439,7 +451,9 @@ export class PanelChrome { !restore && !nextCollapsed && document.documentElement?.dataset.uiTheme === 'cyber' && - panelEl.parentElement === this._rightPanelStack + panelEl.parentElement === this._rightPanelStack && + // Phone sheets show several rail panels at once. + !document.body?.classList?.contains('mobile-shell') ) { for (const peer of this._rightPanelStack.children) { if ( diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 9be9ffbaf..270ba61f5 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -21,7 +21,7 @@ test('Display uses an inner scroll body only in Cyber', () => { }); test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => { - for (const mobile of [false, true]) { + for (const mobile of [false]) { const f = fixture('right', { mobile, hud: { visible: true, variant: 'cyber' }, @@ -50,6 +50,22 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the } }); +test('Cyber keeps no accordion on phones, where the rail is presented as tab sheets', () => { + // A sheet (for example Cameras: CCTV with Street Level) shows several of the + // rail's panels at once; the accordion would collapse all but one. + const f = fixture('right', { + mobile: true, + hud: { visible: true, variant: 'cyber' }, + }); + f.expand(f.first, 250); + f.expand(f.second, 250); + f.options.preferredPanelId = f.second.id; + f.run(); + assert.equal(f.first.classList.contains('collapsed'), false); + assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false); + assert.equal(f.second.classList.contains('collapsed'), false); +}); + test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => { const f = fixture('right', { hud: { visible: true, variant: 'cyber' } }); f.expand(f.first, 250); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 836fd75d4..5d3e5e40c 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -91,7 +91,9 @@ export function layoutRightPanelRail({ } // Normalize restored state and theme entry without overwriting saved panel // preferences. This marker is distinct from Tactical's space-based collapse. - if (hud.variant === 'cyber') { + // Phones present the rail as tab sheets that show several panels at once. + const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; + if (hud.variant === 'cyber' && !isMobile) { const expanded = panels.filter( (panel) => !panel.classList.contains('collapsed'), ); @@ -111,16 +113,19 @@ export function layoutRightPanelRail({ onCollapse(panel); } } - const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; const hasExpandedPanel = panels.some( (panel) => !panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles'), ); - const exclusive = shouldHideCollapsedRightPanels({ - hudVariant: hud.variant, - hasExpandedPanel, - }); + // Phones present the rail as tab sheets (mobileShell.js), which choose what + // shows; hiding collapsed siblings would empty a sheet and its a11y tree. + const exclusive = + !isMobile && + shouldHideCollapsedRightPanels({ + hudVariant: hud.variant, + hasExpandedPanel, + }); stack.classList.toggle('layout-exclusive', exclusive); for (const panel of panels) { if (exclusive && panel.classList.contains('collapsed')) diff --git a/src/ui/statusText.js b/src/ui/statusText.js index b0899d55e..677c9b886 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -218,7 +218,7 @@ function statusPatternText(piece) { } /** One piece of a status line in the UI language, or as it came. */ -export function translateStatusPiece(piece) { +function translateStatusPiece(piece) { const known = translateKnown(piece); if (known !== piece) return known; return statusPatternText(piece) ?? piece; diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css new file mode 100644 index 000000000..aecfdf8d4 --- /dev/null +++ b/src/ui/styles/mobile-shell.css @@ -0,0 +1,419 @@ +/* ── Phone shell (≤720px, body.mobile-shell; src/ui/mobileShell.js) ───────── + * The globe is full screen. A bottom tab bar opens one sheet; the sheet shows + * the panels of that tab from the rails or dock, everything else stays hidden. + */ +:root { + --mobile-tab-height: 58px; + --mobile-bottom-inset: env(safe-area-inset-bottom, 0px); + --mobile-top-inset: env(safe-area-inset-top, 0px); + --mobile-tab-space: calc( + var(--mobile-tab-height) + var(--mobile-bottom-inset) + ); + --mobile-sheet-max-height: min(62vh, calc(100vh - 168px)); + --mobile-dock-sheet-max-height: 34vh; + --mobile-rail-under-dock-max-height: 40vh; +} + +/* Dynamic viewport units keep sheets inside the area a mobile browser's + toolbars leave visible. */ +@supports (height: 1dvh) { + :root { + --mobile-sheet-max-height: min(62dvh, calc(100dvh - 168px)); + --mobile-dock-sheet-max-height: 34dvh; + --mobile-rail-under-dock-max-height: 40dvh; + } +} + +#mobile-tab-bar, +#mobile-sheet-backdrop { + display: none; +} + +body.mobile-shell #mobile-tab-bar { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 170; + display: grid; + grid-template-columns: repeat(5, 1fr); + height: var(--mobile-tab-space); + padding: 0 max(4px, env(safe-area-inset-right, 0px)) + var(--mobile-bottom-inset) max(4px, env(safe-area-inset-left, 0px)); + background: rgba(8, 14, 22, 0.94); + border-top: 1px solid var(--glass-border); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +body.mobile-shell .mobile-tab { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 0; + padding: 6px 0 4px; + color: var(--text-secondary); + background: none; + border: 0; + font: 500 10px/1.2 var(--font-sans); + letter-spacing: 0.04em; + -webkit-tap-highlight-color: transparent; +} + +body.mobile-shell .mobile-tab-icon { + font-size: 24px; + line-height: 1; +} + +body.mobile-shell .mobile-tab[aria-expanded='true'] { + color: var(--accent); +} + +body.mobile-shell .mobile-tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -4px; +} + +body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) { + position: fixed; + inset: 0 0 var(--mobile-tab-space); + z-index: 155; + display: block; + background: rgba(0, 0, 0, 0.18); +} + +/* ── Top bar: logo left, globe actions right ───────────────────────────── */ +body.mobile-shell #title-bar { + top: calc(var(--mobile-top-inset) + 10px); + left: 14px; +} + +body.mobile-shell #title-bar .title-logo { + width: 58px; +} + +body.mobile-shell #title-bar h1 > :not(.title-logo), +body.mobile-shell #title-bar .subtitle { + display: none; +} + +body.mobile-shell #top-center-actions { + top: calc(var(--mobile-top-inset) + 10px); + right: 12px; +} + +/* ── HUD: one compact readout under the top bar ────────────────────────── */ +body.mobile-shell #intel-hud .hud-edge, +body.mobile-shell #intel-hud .hud-corner, +body.mobile-shell #intel-hud .hud-top-bar, +body.mobile-shell #intel-hud .hud-bottom-bar { + display: none; +} + +body.mobile-shell #intel-hud .hud-bottom-left { + top: calc(var(--mobile-top-inset) + 62px); + bottom: auto; + left: 16px; + font-size: 9px; + opacity: 0.8; +} + +/* ── Panels: hidden unless their tab's sheet is open ───────────────────── */ +body.mobile-shell #left-panel-stack, +body.mobile-shell #right-context-rail { + display: none; +} + +body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack, +body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail, +body.mobile-shell[data-mobile-sheet='context'] #right-context-rail, +body.mobile-shell[data-mobile-sheet='more'] #right-context-rail { + position: fixed; + top: auto; + right: 0; + bottom: var(--mobile-tab-space); + left: 0; + z-index: 160; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + width: auto; + max-width: none; + max-height: var(--mobile-sheet-max-height); + padding: 14px 10px 10px; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; + background: rgba(8, 14, 22, 0.96); + border: 1px solid var(--glass-border); + border-bottom: 0; + border-radius: 18px 18px 0 0; + transform: none; +} + +body.mobile-shell #left-panel-stack > *, +body.mobile-shell #right-context-rail > *, +/* Rail rules show expanded Weather and Recent Imagery by id; on phones they + belong to the Context sheet only. */ +body.mobile-shell #right-context-rail > #weather-panel, +body.mobile-shell #right-context-rail > #recent-imagery-panel { + display: none; +} + +/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD + hiding collapsed siblings) never take a panel out of an open sheet. */ +body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel, +body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel, +body.mobile-shell[data-mobile-sheet='cameras'] + #right-context-rail + > #cctv-panel, +body.mobile-shell[data-mobile-sheet='cameras'] + #right-context-rail + > #street-level-panel, +body.mobile-shell[data-mobile-sheet='context'] + #right-context-rail + > #global-context-panel, +body.mobile-shell[data-mobile-sheet='context'] + #right-context-rail + > #weather-panel:not([hidden]), +body.mobile-shell[data-mobile-sheet='context'] + #right-context-rail + > #recent-imagery-panel:not([hidden]), +body.mobile-shell[data-mobile-sheet='more'] #right-context-rail > #pp-toggles { + position: relative; + inset: auto; + display: block; + width: 100%; + max-width: none; + margin: 0; + transform: none; +} + +/* ── Dock: voice floats above the tab bar; search and presets are sheets ─ */ +body.mobile-shell #command-dock { + /* The dock's tray rules size voice by this basis; a column makes it a height. */ + --dock-voice-width: auto; + top: auto; + bottom: calc(var(--mobile-tab-space) + 10px); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + height: auto; + min-height: 0; + max-height: none; + padding: 0 10px; + pointer-events: none; + background: none; + border: 0; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +/* The desktop dock centres itself with left: 50% and a translate under + :has() selectors that outrank any phone selector; on phones it spans the + width, so these few geometry properties must win. */ +body.mobile-shell #command-dock { + right: 0 !important; + left: 0 !important; + width: auto !important; + max-width: none !important; + transform: none !important; + translate: none !important; +} + +body.mobile-shell #command-dock > * { + pointer-events: auto; +} + +body.mobile-shell #location-bar, +body.mobile-shell #control-panel { + display: none; +} + +body.mobile-shell[data-mobile-sheet] #command-dock { + z-index: 165; + bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px) + 8px); +} + +body.mobile-shell[data-mobile-sheet='search'] #command-dock > #location-bar, +body.mobile-shell[data-mobile-sheet='more'] #command-dock > #control-panel { + position: relative; + inset: auto; + display: flex; + flex: none; + width: 100%; + max-width: none; + margin: 0; + transform: none; +} + +/* Search and More: the dock becomes the top of the sheet, flush with the rail + sheet below it (More) or alone above the tab bar (Search). */ +body.mobile-shell[data-mobile-sheet='search'] #command-dock, +body.mobile-shell[data-mobile-sheet='more'] #command-dock { + bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px)); + align-items: stretch; + max-height: var(--mobile-dock-sheet-max-height); + padding: 14px 10px 8px; + overflow-x: hidden; + overflow-y: auto; + pointer-events: auto; + background: rgba(8, 14, 22, 0.96); + border: 1px solid var(--glass-border); + border-bottom: 0; + border-radius: 18px 18px 0 0; +} + +body.mobile-shell[data-mobile-sheet='more'] #right-context-rail { + max-height: var(--mobile-rail-under-dock-max-height); + padding-top: 4px; + border-top: 0; + border-radius: 0; +} + +/* Presets and Location show their popover inline instead of as a floating + tray; the dock scrolls, so a tray above it would be clipped. */ +body.mobile-shell[data-mobile-sheet='more'] + #command-dock + #control-panel + .panel-inner, +body.mobile-shell[data-mobile-sheet='more'] + #command-dock + #control-panel-popover, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar + .location-inner, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar-popover { + position: static; + inset: auto; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: none; + max-height: none; + margin: 0; + opacity: 1; + visibility: visible; + transform: none; + pointer-events: auto; +} + +/* The tab names the sheet; the tray toggle and pin have nothing to do here. */ +body.mobile-shell[data-mobile-sheet='more'] #command-dock #control-panel-toggle, +body.mobile-shell[data-mobile-sheet='more'] + #command-dock + #control-panel + .dock-pin-btn, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar-toggle, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar + .panel-collapse-btn, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar + .dock-pin-btn { + display: none; +} + +body.mobile-shell[data-mobile-sheet='more'] #style-buttons { + flex-wrap: wrap; +} + +/* Search: the field takes the full width; the place pills scroll beneath it. */ +body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-city-row { + flex-wrap: wrap; + row-gap: 8px; +} + +body.mobile-shell[data-mobile-sheet='search'] + #location-bar + .location-search-wrap { + flex: 1 1 100%; +} + +body.mobile-shell[data-mobile-sheet='search'] #location-search { + flex: 1 1 auto; + width: 100%; + min-width: 0; +} + +body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-pills { + flex: 1 1 100%; + max-width: 100%; + overflow-x: auto; +} + +/* With a sheet open, voice steps aside; Search and More own the dock. */ +body.mobile-shell[data-mobile-sheet] #command-dock > #gev-voice-control { + display: none; +} + +body.mobile-shell #command-dock #gev-voice-control { + position: relative; + inset: auto; + flex: none; + align-self: center; + width: min(280px, 80vw); + height: 5.35rem; + margin: 0; + transform: none; +} + +/* ── Credits and corner chips clear the tab bar ────────────────────────── */ +/* While the tab bar shows, the credit line sits above the voice bar. The + dock's placements (including the Minimal HUD's :has() variant) outrank any + phone selector, so the offset must win. Clean view keeps its own corner. */ +body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) + #cesium-credits { + right: auto; + bottom: calc(var(--mobile-tab-space) + 106px) !important; + left: 10px; + max-width: calc(100vw - 20px); + transform-origin: left bottom; + scale: 0.8; +} + +/* An open sheet lifts the required attribution above everything it covers. */ +body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not( + .cockpit-mode + ):not(.ui-clean-view) + #cesium-credits { + bottom: calc(var(--mobile-sheet-cover, 0px) + 6px) !important; + z-index: 166; +} + +/* The view switcher clears the tab bar and the voice bar, opposite the credit. */ +body.mobile-shell:not(.cockpit-mode) #view-switcher { + right: 12px; + bottom: calc(var(--mobile-tab-space) + 106px); +} + +body.mobile-shell #corner-actions { + top: calc(var(--mobile-top-inset) + 60px); + right: 12px; + bottom: auto; +} + +/* Full-screen modes hide the phone chrome like the desktop chrome. Scene + playback keeps it: the Scenes panel (Layers tab) holds its STOP control. */ +body.mobile-shell.ui-clean-view #mobile-tab-bar, +body.mobile-shell.recording-mode #mobile-tab-bar, +body.mobile-shell.cockpit-mode #mobile-tab-bar { + display: none; +} + +body.mobile-shell.ui-clean-view #command-dock, +body.mobile-shell.cockpit-mode #command-dock { + bottom: calc(var(--mobile-bottom-inset) + 10px); +} diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index a15c2060a..c804ea342 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -10,7 +10,6 @@ import { MILITARY_DETECTION_PRESET, } from './effects.js'; import { createStyleParameters } from './visualInput.js'; -import * as Cesium from 'cesium'; import { BLOOM_SCALE_VERSION, clampBloomIntensity, diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index d0810bf71..3f464cad1 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -729,7 +729,7 @@ const schemas = [ properties: { motion: { type: 'string', - enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'], + enum: ['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe', 'stop'], }, direction: { type: 'string', diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 550d725e4..d65d7d8db 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -1,3 +1,4 @@ +import { realtimeInstructions } from '../../server/providers/openai/instructions.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; @@ -38,7 +39,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Air quality (ECCC AQHI) extends the manifest. - '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', + // Globe rotation adds one motion; visual keyhole fit and explicit + // reset guidance/defaults update only move_camera and mode descriptions. + // Restore base descriptions; append only globe-rotation defaults and fit guidance. + '967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6', ); }); @@ -106,6 +110,17 @@ test('all legacy action arguments are byte-identical after removing the delibera delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; delete property('analyst_query').layers.items.enum; + // Earth-centered rotation is additive; local camera yaw keeps its contract. + const motion = property('move_camera').motion; + assert.deepEqual(motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), @@ -145,6 +160,24 @@ test('all legacy action arguments are byte-identical after removing the delibera ); }); +test('globe rotation is an additive camera motion with the existing control arguments', () => { + const schema = GEV_ACTION_SCHEMAS.find( + (tool) => tool.name === 'move_camera', + ).parameters; + for (const motion of ['rotate_globe', 'rotate', 'orbit', 'pan', 'tilt']) { + assert.deepEqual( + validateArgs(schema, { + motion, + direction: 'right', + speed: 'slow', + mode: 'continuous', + }), + [], + ); + } + assert.ok(validateArgs(schema, { motion: 'spin_camera' }).length > 0); +}); + test('track_entity accepts a referent alone but rejects an empty target', () => { const schema = GEV_ACTION_SCHEMAS.find( (tool) => tool.name === 'track_entity', @@ -183,3 +216,15 @@ test('the benchmark still rejects an empty track_entity although the model-facin [], ); }); + +test('globe reset guidance requires a separate explicit command after refusal', () => { + const paragraph = realtimeInstructions() + .split('\n') + .find((line) => line.includes('globe-view-required')); + assert.ok(paragraph); + assert.match(paragraph, /calls only move_camera motion rotate_globe/); + assert.match(paragraph, /explicit user Reset\/Show Globe View/); + assert.match(paragraph, /never from your own plan/); + assert.match(paragraph, /bare yes\/ok/); + assert.match(paragraph, /Do not ask a question/); +}); diff --git a/src/voice/cloudVoiceAuth.js b/src/voice/cloudVoiceAuth.js index 77feae8aa..5cf9ca882 100644 --- a/src/voice/cloudVoiceAuth.js +++ b/src/voice/cloudVoiceAuth.js @@ -1,5 +1,5 @@ -export const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']); -export const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key'; +const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']); +const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key'; export const CLOUD_VOICE_AUTH_STORAGE_KEY = 'godsEyeView.voice.cloudAuth'; function storageRef(storage) { @@ -11,7 +11,7 @@ function storageRef(storage) { } } -export function normalizeCloudVoiceAuthMode(mode) { +function normalizeCloudVoiceAuthMode(mode) { const normalized = String(mode || '') .trim() .toLowerCase(); diff --git a/src/voice/deixis.js b/src/voice/deixis.js index d68fcd61a..25d7939eb 100644 --- a/src/voice/deixis.js +++ b/src/voice/deixis.js @@ -18,7 +18,7 @@ import { finiteOrNull } from './pointerContext.js'; -export const POINTER_SENTINEL = 'pointer'; +const POINTER_SENTINEL = 'pointer'; const TRACKABLE_LAYERS = new Set([ 'flights', diff --git a/src/voice/geminiSession.js b/src/voice/geminiSession.js index 04a573f76..041f1f643 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -21,6 +21,7 @@ import { shouldSendViewportImage, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, } from './realtimeProtocol.js'; const SOCKET_PATH = @@ -182,6 +183,8 @@ export class GeminiSessionController { this.model = null; this.epoch = 0; this.turn = 0; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.inputGeneration = 0; this.turnAbort = new AbortController(); this.disposed = false; @@ -659,6 +662,8 @@ export class GeminiSessionController { return; if (!pushToTalk || activityCaptureId === metadata.captureId) return; this.captureOwner.admitted = true; + this.globeToolsBlocked = false; + this.globeResetFailed = false; if ( !this.interrupt('push-to-talk', { keepPlayback: !this.pttPlaybackSuppressed, @@ -1294,6 +1299,8 @@ export class GeminiSessionController { this.replyPending = false; if (!this.interrupt()) return; } else if (!this.retiredReplyPending && !this.interrupt()) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; // Local activity ownership is only for expiry liveness, not tools. // Assign after retiring observed output so its boundary cannot clear // a newer detected activity that has not received a reply yet. @@ -1573,6 +1580,7 @@ export class GeminiSessionController { .then(async () => { if (!this.owns(epoch) || turn !== this.turn) return; if (this.cancelledCalls.has(call.id)) { + if (call.name === 'zoom_to_globe') this.globeResetFailed = true; if (this.tools.get(call.id) === action) this.tools.delete(call.id); this.pendingTools--; if (!this.pendingTools) { @@ -1618,8 +1626,28 @@ export class GeminiSessionController { (authority !== 'playback' || handoffEpoch === this.radio.radioHandoffEpoch); let result; + let skippedGlobeRotation = false; + let blockedGlobeTool = false; try { if (!actionCurrent()) throw cancelled(); + // Live has no response-scoped tool_choice. Recheck here so queued siblings + // cannot reset after a rotation refusal. + if (this.globeToolsBlocked) { + blockedGlobeTool = true; + throw new Error( + 'Wait for fresh user input before calling another tool.', + ); + } + if ( + this.globeResetFailed && + call.name === 'move_camera' && + String(args.motion || '').toLowerCase() === 'rotate_globe' + ) { + skippedGlobeRotation = true; + throw new Error( + 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + ); + } if (stronger) reservation = this.radio.reserveRadioToolHandoff({ abortScope }); handoffEpoch = this.radio.radioHandoffEpoch; @@ -1689,7 +1717,12 @@ export class GeminiSessionController { } catch (error) { result = { ok: false, - tool: call.name, + ...(skippedGlobeRotation + ? { action: call.name, reason: 'globe-reset-failed', skipped: true } + : { + tool: call.name, + ...(blockedGlobeTool ? { skipped: true } : {}), + }), ...(radioVisibility ? { layerId: 'radio' } : {}), error: error?.name === 'AbortError' @@ -1716,6 +1749,11 @@ export class GeminiSessionController { this.noteRadioStrongerCommit(action, abortScope, turn); } if (!this.owns(epoch) || turn !== this.turn) return; + if ( + call.name === 'zoom_to_globe' && + (result?.ok !== true || result.cancelled) + ) + this.globeResetFailed = true; const playbackObsolete = authority === 'playback' && handoffEpoch !== this.radio.radioHandoffEpoch; if (radioControl || radioVisibility) { @@ -1744,6 +1782,8 @@ export class GeminiSessionController { this.stopAfterReply = true; // Native call cancellation suppresses its output, without canceling siblings. if (!this.cancelledCalls.has(call.id)) { + if (isGlobeViewRefusal(result) && !skippedGlobeRotation) + this.globeToolsBlocked = true; this.postToolOutput = false; this.modelComplete = false; this.replyPending = true; @@ -1751,7 +1791,9 @@ export class GeminiSessionController { const mapEvents = mapSnapshot.map((text) => JSON.parse(text)); const response = { ...(result ?? { ok: false, error: 'Command returned no result' }), - responseGuidance: responseInstructionForToolResult(result), + responseGuidance: blockedGlobeTool + ? 'That action did not run. Wait for fresh user input; do not claim any change or call another tool.' + : responseInstructionForToolResult(result), }; this.send( { @@ -1902,6 +1944,8 @@ export class GeminiSessionController { { onSent: () => { if (!owned() || this.typedIntent !== typedIntent) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = true; this.bindTextIntent(typedIntent); if (!owned() || this.typedIntent !== typedIntent) return; @@ -2455,6 +2499,8 @@ export class GeminiSessionController { this.cancelledCalls.clear(); this.pendingMapEvents = []; this.userTurnPending = false; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = false; this.unrequestedReply = false; this.automaticTurnOpen = false; diff --git a/src/voice/geminiSession.test.mjs b/src/voice/geminiSession.test.mjs index b2f380b37..9ce8d351a 100644 --- a/src/voice/geminiSession.test.mjs +++ b/src/voice/geminiSession.test.mjs @@ -3398,3 +3398,215 @@ test('a narration callback that submits a successor keeps the old packet tools f assert.deepEqual(h.calls, []); h.c.stop(); }); + +for (const reply of ['typed', 'push-to-talk', 'open-mic']) { + test(`globe refusal blocks queued and later tools until fresh ${reply} input`, async () => { + const names = []; + const h = harness({ + runAction: async (name) => { + names.push(name); + return name === 'move_camera' + ? { ok: false, action: 'move_camera', reason: 'globe-view-required' } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start({ pushToTalk: reply === 'push-to-talk' }); + establishToolInput(h, 'rotate the globe'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'rotate', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'queued-reset', name: 'zoom_to_globe', args: {} }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const blocked = ws.sent + .flatMap((entry) => entry.toolResponse?.functionResponses || []) + .find((call) => call.id === 'queued-reset'); + assert.equal(blocked.response.ok, false); + assert.match(blocked.response.error, /fresh user input/i); + assert.equal(blocked.response.skipped, true); + assert.match(blocked.response.responseGuidance, /did not run/i); + assert.doesNotMatch( + blocked.response.responseGuidance, + /confirm.*completed/i, + ); + requestTool(ws, 'repeated-rotation', 'move_camera', { + motion: 'rotate_globe', + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const repeated = toolResponses(ws).find( + (call) => call.id === 'repeated-rotation', + ).response; + assert.equal(repeated.ok, false); + assert.equal(repeated.skipped, true); + assert.match(repeated.responseGuidance, /did not run/i); + assert.doesNotMatch( + repeated.responseGuidance, + /confirm.*completed/i, + ); + + if (reply === 'typed') { + const stimuli = [ + () => {}, + () => ws.message({ serverContent: { interrupted: true } }), + () => + ws.message({ + serverContent: { + inputTranscription: { text: 'reset to globe view' }, + }, + }), + () => { + h.c.input.setMicrophoneEnabled(false); + h.c.input.setMicrophoneEnabled(true); + }, + ]; + for (const [i, stimulus] of stimuli.entries()) { + stimulus(); + requestTool(ws, `unrequested-reset-${i}`, 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + } + } + // Finish the refusal reply before admitting the actual next user input. + ws.message({ serverContent: { turnComplete: true } }); + if (reply === 'typed') + assert.equal(h.c.sendTextCommand('reset to globe view'), true); + else if (reply === 'push-to-talk') { + h.c.input.pushToTalkKeyHeld = true; + h.c.input.spaceKeyHeld = true; + h.c.input.setMicrophoneEnabled(true); + h.c.input.setMicrophoneEnabled(false); + } else ws.message({ voiceActivity: { type: 'ACTIVITY_START' } }); + requestTool(ws, 'accepted-reset', 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera', 'zoom_to_globe']); + } finally { + h.c.stop(); + } + }); +} + +test('a cancelled globe reset skips queued rotation, preserves Stop and recovers on fresh input', async () => { + const calls = []; + let resets = 0; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return name === 'zoom_to_globe' && resets++ === 0 + ? { ok: false, action: name, cancelled: true } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-failed', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-skipped', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'stop-allowed', name: 'move_camera', args: { motion: 'stop' } }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, ['zoom_to_globe', 'stop']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-skipped', + ).response; + assert.equal(skipped.ok, false); + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + assert.equal( + toolResponses(ws).find((call) => call.id === 'stop-allowed').response.ok, + true, + ); + + ws.message({ serverContent: { turnComplete: true } }); + establishToolInput(h, 'reset to globe view and rotate'); + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-success', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-success', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'stop', + 'zoom_to_globe', + 'rotate_globe', + ]); + } finally { + h.c.stop(); + } +}); + +test('cancelling a queued globe reset also skips its dependent rotation', async () => { + const blocker = deferred(); + const calls = []; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return args.motion === 'pan' + ? blocker.promise + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'pan, reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'blocker', + name: 'move_camera', + args: { motion: 'pan', direction: 'left' }, + }, + { id: 'reset-cancelled', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-after-cancel', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await tick(); + ws.message({ toolCallCancellation: { ids: ['reset-cancelled'] } }); + blocker.resolve({ ok: true, action: 'move_camera' }); + await h.c.toolQueue; + assert.deepEqual(calls, ['pan']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-after-cancel', + ).response; + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + } finally { + blocker.resolve({ ok: true }); + h.c.stop(); + } +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d8b63565f..f7613aed1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { interruptGlobeRotation } from '../cameraVerbs.js'; import { layerSnapshot, feedProvenanceEnvelope, @@ -262,7 +263,9 @@ export function createGevActionRunner({ }), )); installViewTargetPrewarm(viewer); - initCameraVerbs(viewer, getViewTargetCartesian); + initCameraVerbs(viewer, getViewTargetCartesian, { + isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active), + }); // Nested calls (select_nearest_aircraft composing layer + flight) use the // raw runner; only the outer call gets a voice envelope. const runGevAction = async function runGevAction( @@ -298,6 +301,7 @@ export function createGevActionRunner({ // Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) — // a straight camera move would be snapped back by the per-frame lookAt. if (name === 'adjust_camera_zoom') { + interruptGlobeRotation('voice-zoom-intent'); const zoomOut = String(args.direction || '').toLowerCase() === 'out'; const amt = String(args.amount || 'medium').toLowerCase(); const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6; @@ -4369,46 +4373,6 @@ function dominantValue(values) { }; } -function summarizeEntity(viewer, entity, { includeProperties = false } = {}) { - const now = Cesium.JulianDate.now(); - if (entity.__gevContextId) { - const store = window.__gevContextStore; - const record = store?.entities?.get(entity.__gevContextId); - if (record) return summarizeContextRecord(record, { includeProperties }); - } - const props = propertyObject(entity); - const layerId = entity.__localLayerId || props.layerId || null; - const tags = props.tags || {}; - const label = cleanText( - props.name || - tags.name || - tags['name:en'] || - tags.official_name || - tags.operator || - props.operator || - entity.name || - layerTitle(layerId), - ); - const position = - entity.__localBaseCartesian || - entity.position?.getValue?.(now) || - polygonCenter(entity, now); - const carto = position ? Cesium.Cartographic.fromCartesian(position) : null; - return { - id: String(entity.id || ''), - name: label || layerTitle(layerId), - layerId, - layerName: layerTitle(layerId), - latitude: carto - ? Number(Cesium.Math.toDegrees(carto.latitude).toFixed(6)) - : null, - longitude: carto - ? Number(Cesium.Math.toDegrees(carto.longitude).toFixed(6)) - : null, - properties: includeProperties ? compactProperties(props) : undefined, - }; -} - function summarizeContextRecord(record, { includeProperties = false } = {}) { return { id: String(record.id || ''), @@ -4427,31 +4391,6 @@ function summarizeContextRecord(record, { includeProperties = false } = {}) { }; } -function polygonCenter(entity, now) { - const hierarchy = entity.polygon?.hierarchy?.getValue?.(now); - const positions = hierarchy?.positions; - if (!positions?.length) return null; - return Cesium.BoundingSphere.fromPoints(positions).center; -} - -function propertyObject(entity) { - const raw = entity?.properties?.getValue?.(Cesium.JulianDate.now()) || {}; - return unwrapProperties(raw); -} - -function unwrapProperties(value) { - if (!value || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map(unwrapProperties); - const out = {}; - for (const [key, entry] of Object.entries(value)) { - out[key] = - entry && typeof entry.getValue === 'function' - ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) - : unwrapProperties(entry); - } - return out; -} - function compactProperties(props) { const preferredKeys = [ 'name', diff --git a/src/voice/globeResetConfirmation.test.mjs b/src/voice/globeResetConfirmation.test.mjs new file mode 100644 index 000000000..be2778e6d --- /dev/null +++ b/src/voice/globeResetConfirmation.test.mjs @@ -0,0 +1,1131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { GevRealtimeController } from './realtimeController.js'; +import { + CALL_DEDUPE_MS, + responseInstructionForToolResult, +} from './realtimeProtocol.js'; + +const refusal = { + ok: false, + action: 'move_camera', + reason: 'globe-view-required', +}; + +function harness(result) { + const sent = []; + const calls = []; + const controller = new GevRealtimeController({ + ui: {}, + runner: async (name, args, context) => { + calls.push(name); + return typeof result === 'function' + ? result(name, args, context) + : result; + }, + }); + controller.dc = { readyState: 'open' }; + controller.sendRealtimeEvent = (event) => { + sent.push(event); + return true; + }; + controller.debugLog = () => {}; + controller.setStatus = () => {}; + controller.setVoiceSpeaker = () => {}; + controller.recordUsage = () => {}; + controller.captureViewportScreenshot = async () => null; + controller.cancelRadioHandoff = () => {}; + return { controller, sent, calls }; +} + +async function invoke( + controller, + { + responseId = 'rotation-response', + callId = 'rotation-call', + name = 'move_camera', + args = { motion: 'rotate_globe' }, + } = {}, +) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.function_call_arguments.done', + response_id: responseId, + item_id: `${callId}-item`, + call_id: callId, + name, + arguments: JSON.stringify(args), + }), + }); +} +/** A real user input boundary: the provider detected new speech. */ +async function speak(controller) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ type: 'input_audio_buffer.speech_started' }), + }); +} +function begin(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.created', + response: { id }, + }); +} +function complete(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.done', + response: { id, status: 'completed' }, + }); +} + +test('close-up rotation refusal instructs an explicit reset in a response with tools disabled', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(response?.tool_choice, 'none'); + assert.equal( + response.instructions, + responseInstructionForToolResult(refusal), + ); + assert.match(response.instructions, /do not ask a question/i); + assert.doesNotMatch(response.instructions, /confirm the completed/); +}); + +test('normal camera success retains the existing unrestricted followup', async () => { + const { controller, sent } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate', + }); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(typeof response?.instructions, 'string'); + assert.equal(Object.hasOwn(response, 'tool_choice'), false); +}); + +test('a newer typed request drops the old refusal followup and its tool restriction', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + controller.sendTextCommand('turn the camera right'); + complete(controller); + const responses = sent.filter((event) => event.type === 'response.create'); + assert.equal(responses.length, 1); + assert.equal(responses[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('refusal instructions do not interpolate untrusted failure text or alter other tool results', () => { + const instruction = responseInstructionForToolResult({ + ...refusal, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.doesNotMatch(instruction, /UNTRUSTED/); + const unavailable = responseInstructionForToolResult({ + ...refusal, + reason: 'globe-reset-unavailable', + }); + assert.doesNotMatch(unavailable, /Reset to Globe View and rotate the globe/); + assert.match(unavailable, /do not offer or perform a reset/i); + const completedReset = responseInstructionForToolResult({ + action: 'zoom_to_globe', + ok: true, + }); + assert.equal( + completedReset, + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); + for (const outcome of [ + { action: 'zoom_to_globe', ok: false }, + { action: 'zoom_to_globe', ok: true, cancelled: true }, + { tool: 'zoom_to_globe', ok: false }, + ]) { + const resetFailure = responseInstructionForToolResult({ + ...outcome, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.notEqual(resetFailure, completedReset); + assert.match(resetFailure, /did not finish/i); + assert.doesNotMatch(resetFailure, /UNTRUSTED/); + } + assert.equal( + responseInstructionForToolResult({ + ok: true, + action: 'get_current_view_state', + }), + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); +}); + +test('globe refusal blocks same-response tools and preserves the reset instruction, but not the next response', async () => { + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : { ok: true, action: name }, + ); + begin(controller); + await invoke(controller); + await invoke(controller, { + callId: 'reset-call', + name: 'zoom_to_globe', + args: {}, + }); + // The second delivery of the original call must not produce another output. + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'reset-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, false); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); + // A fresh explicit reset request is a separate user input. + await speak(controller); + begin(controller, 'fresh-response'); + await invoke(controller, { + responseId: 'fresh-response', + callId: 'fresh-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'fresh-response'); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('a reset arriving during pending globe admission cannot bypass the refusal', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller, { args: { motion: 'ROTATE_GLOBE' } }); + const reset = invoke(controller, { + callId: 'overlap-reset', + name: 'zoom_to_globe', + args: {}, + }); + finishRotation(refusal); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.equal(outputs.length, 2); + assert.equal( + JSON.parse( + outputs.find((event) => event.item.call_id === 'overlap-reset').item + .output, + ).ok, + false, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('successful pending globe admission releases a queued ordinary tool', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller); + const reset = invoke(controller, { + callId: 'allowed-reset', + name: 'zoom_to_globe', + args: {}, + }); + assert.deepEqual(calls, ['move_camera']); + finishRotation({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('rotation waits for an earlier reset before evaluating the globe', async () => { + let finishReset; + let atGlobe = false; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name === 'zoom_to_globe') { + await pending; + atGlobe = true; + return { ok: true, action: name }; + } + return atGlobe + ? { ok: true, action: name, motion: 'rotate_globe' } + : refusal; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['first-reset', 'rotation-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, undefined); +}); + +for (const outcome of [ + 'cancelled', + 'thrown', + 'retry-refused', + 'settled-failure', +]) { + test(`a ${outcome} reset sequence cannot suggest another reset`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name !== 'zoom_to_globe') return refusal; + await pending; + if (outcome === 'thrown') throw new Error('reset failed'); + return { + ok: outcome === 'retry-refused', + action: name, + cancelled: outcome === 'cancelled', + }; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (outcome === 'settled-failure') { + finishReset(); + await reset; + } + const rotation = invoke(controller); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual( + calls, + outcome === 'retry-refused' + ? ['zoom_to_globe', 'move_camera'] + : ['zoom_to_globe'], + ); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.ok, false); + assert.equal(result.reason, 'globe-reset-failed'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.doesNotMatch( + replies[0].response.instructions, + /Reset to Globe View and rotate the globe/, + ); + }); +} + +test( + 'Stop releases a rotation waiting for reset and isolates a reused response id', + { timeout: 1000 }, + async () => { + let finishOld, finishNew; + const oldReset = new Promise((resolve) => { + finishOld = resolve; + }); + const newReset = new Promise((resolve) => { + finishNew = resolve; + }); + let resets = 0; + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? ++resets === 1 + ? oldReset + : newReset + : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const old = invoke(controller, { + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + const waiter = invoke(controller); + controller.stop(); + await waiter; + assert.deepEqual(calls, ['zoom_to_globe']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); + const replacement = invoke(controller, { + callId: 'new-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller, { callId: 'new-rotation' }); + finishOld({ ok: false, action: 'zoom_to_globe' }); + await old; + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe']); + assert.equal(sent.length, 0); + finishNew({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([replacement, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); + assert.deepEqual( + sent + .filter((event) => event.item?.type === 'function_call_output') + .map((event) => event.item.call_id), + ['new-reset', 'new-rotation'], + ); + }, +); + +test( + 'a typed turn releases rotation without waiting for its reset runner', + { timeout: 1000 }, + async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(() => pending); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'stale-first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + // The tool response can finish while its reset and rotation still wait. + complete(controller); + controller.sendTextCommand('pan left'); + await rotation; + assert.deepEqual(calls, ['zoom_to_globe']); + const output = sent.find( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(JSON.parse(output.item.output).superseded, true); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'typed-response'); + complete(controller, 'typed-response'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + finishReset({ ok: false, action: 'zoom_to_globe' }); + await reset; + // This protects rotation's cancellation only. Ordinary reset-result + // follow-ups after supersession remain the inherited sibling-tool limit. + }, +); + +test('an aborted reset still blocks a late same-response rotation', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'aborted-reset', + name: 'zoom_to_globe', + args: {}, + }); + try { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.skipped, true); + assert.equal(result.reason, 'globe-reset-failed'); + } finally { + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + } + complete(controller); + assert.equal( + sent.find((event) => event.type === 'response.create').response.tool_choice, + 'none', + ); +}); + +test('an earlier tool finishing late cannot replace the globe refusal followup', async () => { + let finishLookup; + const pending = new Promise((resolve) => { + finishLookup = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : pending, + ); + begin(controller); + const lookup = invoke(controller, { + callId: 'lookup-call', + name: 'get_current_view_state', + args: {}, + }); + await invoke(controller); + finishLookup({ ok: true, action: 'get_current_view_state' }); + await lookup; + complete(controller); + assert.deepEqual(calls, ['get_current_view_state', 'move_camera']); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('a duplicate queued behind successive admissions cannot outlive the dedupe window', async (t) => { + let clock = 0; + t.mock.method(performance, 'now', () => clock); + let finishFirst, finishSecond, secondStarted; + const first = new Promise((resolve) => { + finishFirst = resolve; + }); + const second = new Promise((resolve) => { + finishSecond = resolve; + }); + const enteredSecond = new Promise((resolve) => { + secondStarted = resolve; + }); + let executions = 0; + const { controller, sent, calls } = harness(() => { + if (++executions === 1) return first; + secondStarted(); + return second; + }); + begin(controller); + const original = invoke(controller); + const queued = invoke(controller, { callId: 'queued-rotation' }); + const duplicate = controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.output_item.done', + response_id: 'rotation-response', + item: { + type: 'function_call', + id: 'queued-rotation-item', + call_id: 'queued-rotation', + name: 'move_camera', + arguments: JSON.stringify({ motion: 'rotate_globe' }), + }, + }), + }); + finishFirst({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await enteredSecond; + clock += CALL_DEDUPE_MS + 1; + finishSecond({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([original, queued, duplicate]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'queued-rotation'], + ); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); +}); + +test( + 'Stop releases an admission waiter and stale completion cannot affect its replacement', + { timeout: 1000 }, + async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const rotation = invoke(controller); + const queued = invoke(controller, { + callId: 'stopped-reset', + name: 'zoom_to_globe', + args: {}, + }); + controller.stop(); + await queued; // Must settle even though the original runner has not completed. + assert.equal(oldChannel.readyState, 'closed'); + assert.deepEqual(calls, ['move_camera']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); // Reuse the id to expose any leaked old refusal state. + finishRotation(refusal); + await rotation; + assert.equal(sent.length, 0); + await invoke(controller, { + callId: 'replacement-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['replacement-reset'], + ); + assert.equal(JSON.parse(outputs[0].item.output).ok, true); + assert.equal( + sent.find((event) => event.type === 'response.create').response + .tool_choice, + undefined, + ); + }, +); + +for (const completedFirst of [false, true]) { + test(`a newer typed turn retires its queued sibling after response completion: ${completedFirst}`, async () => { + let finishRotation; + let oldSignal; + let abortObserved = false; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name, args, context) => { + if (args.motion === 'rotate_globe') { + oldSignal = context.signal; + oldSignal.addEventListener('abort', () => { + abortObserved = true; + }); + return pending; + } + return { ok: true, action: name }; + }); + // Use the actual cancellation owner rather than the lightweight harness stub. + delete controller.cancelRadioHandoff; + begin(controller); + const rotation = invoke(controller); + const sibling = invoke(controller, { + callId: 'stale-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (completedFirst) complete(controller); + controller.sendTextCommand('pan left'); + assert.equal(oldSignal.aborted, true); + assert.equal(abortObserved, true); + finishRotation(refusal); + await Promise.all([rotation, sibling]); + if (!completedFirst) complete(controller); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'stale-reset'], + ); + assert.equal(JSON.parse(outputs[1].item.output).superseded, true); + assert.deepEqual(calls, ['move_camera']); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'new-turn-response'); + await invoke(controller, { + responseId: 'new-turn-response', + callId: 'new-pan', + args: { motion: 'pan', direction: 'left' }, + }); + complete(controller, 'new-turn-response'); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const finalOutput = sent + .filter((event) => event.item?.type === 'function_call_output') + .at(-1); + assert.equal(finalOutput.item.call_id, 'new-pan'); + assert.equal(JSON.parse(finalOutput.item.output).ok, true); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + ?.tool_choice, + undefined, + ); + }); +} + +test('a newer typed turn during visual context cannot revive the old refusal followup', async () => { + let finishContext; + let enterContext; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pending = new Promise((resolve) => { + finishContext = resolve; + }); + const { controller, sent } = harness(refusal); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async () => { + enterContext(); + await pending; + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + finishContext(); + await rotation; + complete(controller); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 1, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('stale globe completion preserves a newer tool status until that tool finishes', async () => { + let finishContext; + let enterContext; + let finishPan; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pendingContext = new Promise((resolve) => { + finishContext = resolve; + }); + const pendingPan = new Promise((resolve) => { + finishPan = resolve; + }); + const { controller, sent } = harness((name, args) => + args.motion === 'rotate_globe' ? refusal : pendingPan, + ); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async (result) => { + if (result === refusal) { + enterContext(); + await pendingContext; + } + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + complete(controller); + begin(controller, 'new-pan-response'); + const pan = invoke(controller, { + responseId: 'new-pan-response', + callId: 'new-pan-call', + args: { motion: 'pan', direction: 'left' }, + }); + assert.equal(controller.status, 'executing'); + finishContext(); + await rotation; + assert.equal(controller.status, 'executing'); + assert.equal(controller.pendingResponseInstructions, null); + finishPan({ ok: true, action: 'move_camera', motion: 'pan' }); + await pan; + complete(controller, 'new-pan-response'); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + assert.equal( + sent + .filter((event) => event.type === 'response.create') + .every((event) => event.response?.tool_choice === undefined), + true, + ); +}); + +const rotated = { + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', +}; +/** A close-up camera that fits only after an executed reset. */ +function closeUpCamera() { + const state = { atGlobeView: false, resets: 0 }; + const result = (name) => { + if (name === 'zoom_to_globe') { + state.resets += 1; + state.atGlobeView = true; + return { ok: true, action: 'zoom_to_globe', cancelled: false }; + } + return state.atGlobeView ? rotated : refusal; + }; + return { state, result }; +} +test('OpenAI: explicit reset and reset-then-rotate stay direct', async () => { + const camera = closeUpCamera(); + const { controller, calls } = harness(camera.result); + await speak(controller); + begin(controller, 'reset'); + await invoke(controller, { + responseId: 'reset', + callId: 'explicit-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset'); + assert.equal(camera.state.resets, 1); + // A fresh "Reset Globe View and rotate" resets, then rotates in order. + camera.state.atGlobeView = false; + await speak(controller); + begin(controller, 'both'); + await invoke(controller, { + responseId: 'both', + callId: 'reset-first', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { responseId: 'both', callId: 'then-rotate' }); + complete(controller, 'both'); + assert.equal(camera.state.resets, 2); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); +}); + +test('a failed reset survives real tool followups and a rejected typed send', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? { ok: false, action: name } : rotated, + ); + begin(controller, 'reset-response'); + await invoke(controller, { + responseId: 'reset-response', + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + for (const id of ['followup', 'after-rejected-text']) { + if (id === 'after-rejected-text') { + const send = controller.sendRealtimeEvent; + controller.sendRealtimeEvent = (event, source) => + source === 'client.user_text' ? false : send(event, source); + controller.sendTextCommand('rotate the globe'); + controller.sendRealtimeEvent = send; + } + begin(controller, id); + await invoke(controller, { responseId: id, callId: `${id}-rotation` }); + complete(controller, id); + const output = sent.find( + (event) => event.item?.call_id === `${id}-rotation`, + ); + assert.equal(JSON.parse(output.item.output).reason, 'globe-reset-failed'); + assert.equal(JSON.parse(output.item.output).skipped, true); + } + assert.deepEqual(calls, ['zoom_to_globe']); + const reply = sent.filter((event) => event.type === 'response.create').at(-1); + assert.equal(reply.response.tool_choice, 'none'); + assert.doesNotMatch( + reply.response.instructions, + /Reset to Globe View and rotate the globe/, + ); +}); + +test('failed reset skips waiting rotation without blocking queued Stop or lookup', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name, args) => + name === 'zoom_to_globe' + ? pending + : { ok: true, action: name, motion: args.motion }, + ); + begin(controller); + const reset = invoke(controller, { + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + const stop = invoke(controller, { + callId: 'queued-stop', + args: { motion: 'stop' }, + }); + const lookup = invoke(controller, { + callId: 'queued-lookup', + name: 'get_current_view_state', + args: {}, + }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await Promise.all([reset, rotation, stop, lookup]); + complete(controller); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'move_camera', + 'get_current_view_state', + ]); + for (const id of ['queued-stop', 'queued-lookup']) { + const output = sent.find((event) => event.item?.call_id === id); + assert.equal(JSON.parse(output.item.output).ok, true); + } + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).skipped, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); +}); + +for (const input of ['text', 'speech']) { + test(`aborted reset stays failed across followups; fresh ${input} isolates late settlement`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : rotated, + ); + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset'); + const reset = invoke(controller, { + responseId: 'old-reset', + callId: 'old-reset-call', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + complete(controller, 'old-reset'); + begin(controller, 'old-followup'); + await invoke(controller, { + responseId: 'old-followup', + callId: 'blocked-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe']); + const skipped = sent.find( + (event) => event.item?.call_id === 'blocked-rotation', + ); + assert.equal(JSON.parse(skipped.item.output).reason, 'globe-reset-failed'); + if (input === 'text') controller.sendTextCommand('rotate the globe'); + else await speak(controller); + begin(controller, 'fresh'); + await invoke(controller, { responseId: 'fresh', callId: 'fresh-rotation' }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + await invoke(controller, { + responseId: 'fresh', + callId: 'after-old-settlement', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera', 'move_camera']); + }); +} + +test('a real tool followup waits for a reset still pending in the previous response', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + begin(controller, 'reset-response'); + const reset = invoke(controller, { + responseId: 'reset-response', + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { + responseId: 'reset-response', + callId: 'fast-lookup', + name: 'get_current_view_state', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + assert.equal( + JSON.parse( + sent.find((event) => event.item?.call_id === 'fast-lookup').item.output, + ).ok, + true, + ); + begin(controller, 'followup'); + const rotation = invoke(controller, { + responseId: 'followup', + callId: 'waiting-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'get_current_view_state']); + finishReset({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([reset, rotation]); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'get_current_view_state', + 'move_camera', + ]); + assert.equal( + sent.filter((event) => event.item?.call_id === 'waiting-rotation').length, + 1, + ); +}); + +test('a recoverable collision keeps a completed reset successful for its rotation', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: true, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + await invoke(controller, { + callId: 'done-reset', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).ok, true); +}); + +test('an old response reset cannot poison a fresh spoken rotation request', async () => { + for (const lateReset of [false, true]) { + const { controller, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: false, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + // Use the production speech-start cancellation wiring, not the harness stub. + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset-response'); + if (lateReset) await speak(controller); + await invoke(controller, { + responseId: 'old-reset-response', + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (!lateReset) await speak(controller); + begin(controller, 'fresh-rotation-response'); + await invoke(controller, { + responseId: 'fresh-rotation-response', + callId: 'fresh-rotation', + }); + assert.deepEqual( + calls, + ['zoom_to_globe', 'move_camera'], + lateReset + ? 'late old reset must retain its original request' + : 'fresh speech clears prior reset failure', + ); + } +}); + +test('a late rotation without a reset retains existing arrival-time policy', async () => { + const { controller, calls } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + }); + delete controller.cancelRadioHandoff; + begin(controller, 'old-rotation-response'); + await speak(controller); + await invoke(controller, { + responseId: 'old-rotation-response', + callId: 'late-rotation', + }); + assert.deepEqual(calls, ['move_camera']); +}); diff --git a/src/voice/narration.js b/src/voice/narration.js index a91dbbe2b..ae97931f7 100644 --- a/src/voice/narration.js +++ b/src/voice/narration.js @@ -4,7 +4,7 @@ import { progressLine } from './speech.js'; * Timing policy for filling silence while tools run. Tuned against the turn * spans' `max_silence_ms`. */ -export const NARRATION_TIMING = Object.freeze({ +const NARRATION_TIMING = Object.freeze({ /** Start the earcon when nothing is audible this long after end of speech. */ earconAfterMs: 700, /** First spoken progress line when a tool is still running after this. */ diff --git a/src/voice/pointerContext.js b/src/voice/pointerContext.js index 3f9d09b6d..590e33a52 100644 --- a/src/voice/pointerContext.js +++ b/src/voice/pointerContext.js @@ -20,15 +20,15 @@ */ /** Movement below this many CSS pixels is still the same dwell. */ -export const POINTER_DWELL_PX = 12; +const POINTER_DWELL_PX = 12; /** Stillness that makes a dwell point. */ export const POINTER_DWELL_MS = 250; /** A resting pointer stays a referent this long after it last moved. */ export const POINTER_FRESH_MS = 30000; /** Pixels from the canvas edge that count as "parked", never a referent. */ -export const POINTER_EDGE_PX = 8; +const POINTER_EDGE_PX = 8; /** A dwell pick is reused by a snapshot only while this recent (contacts move). */ -export const POINTER_DWELL_REUSE_MS = 1000; +const POINTER_DWELL_REUSE_MS = 1000; /** * Open mic has no gesture, so the pointer only counts when it moved or * settled on the map this recently: a cursor resting there for minutes is @@ -76,7 +76,7 @@ export function cameraPoseKey(viewer) { } /** Bound and de-control a label before it goes to the model or the card. */ -export function pointerLabel(value, max = LABEL_MAX) { +function pointerLabel(value, max = LABEL_MAX) { const clean = String(value ?? '') .replace(/[\p{Cc}\p{Cf}]+/gu, ' ') .replace(/\s+/g, ' ') diff --git a/src/voice/pointerPick.js b/src/voice/pointerPick.js index 33ccab9cb..e74671eda 100644 --- a/src/voice/pointerPick.js +++ b/src/voice/pointerPick.js @@ -4,7 +4,7 @@ import { cameraPoseKey } from './pointerContext.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; /** "Around here" radius as a share of camera height, clamped to sane bounds. */ -export function pointerRadiusKm(cameraHeightM) { +function pointerRadiusKm(cameraHeightM) { const heightKm = Number(cameraHeightM) / 1000; if (!Number.isFinite(heightKm) || heightKm <= 0) return 50; return Math.round(Math.min(400, Math.max(5, heightKm * 0.75))); diff --git a/src/voice/pointerReticle.js b/src/voice/pointerReticle.js index 96729a933..0702d7345 100644 --- a/src/voice/pointerReticle.js +++ b/src/voice/pointerReticle.js @@ -14,7 +14,7 @@ export const RETICLE_MAX_MS = 10000; export const RETICLE_LINGER_MS = 1500; /** Tools that move the camera away from the pointed spot. */ -export const CAMERA_TOOLS = new Set([ +const CAMERA_TOOLS = new Set([ 'fly_to_location', 'track_entity', 'zoom_to_globe', diff --git a/src/voice/realtimeDiagnostics.js b/src/voice/realtimeDiagnostics.js index 630e29433..55e078280 100644 --- a/src/voice/realtimeDiagnostics.js +++ b/src/voice/realtimeDiagnostics.js @@ -1,10 +1,10 @@ -export const ERROR_LOG_LIMIT = 30; +const ERROR_LOG_LIMIT = 30; -export const ERROR_STORAGE_KEY = 'gev-realtime-errors'; +const ERROR_STORAGE_KEY = 'gev-realtime-errors'; -export const DEBUG_LOG_URL = '/api/realtime/debug-log'; +const DEBUG_LOG_URL = '/api/realtime/debug-log'; -export function createDebugSessionId() { +function createDebugSessionId() { const randomPart = Math.random().toString(36).slice(2, 10); return `gev-${Date.now().toString(36)}-${randomPart}`; } @@ -47,7 +47,7 @@ export function sanitizeDebugValue(value, depth = 0) { return output; } -export function sanitizeDebugString(value) { +function sanitizeDebugString(value) { if (value.startsWith('data:image/')) { return `[Redacted image data URL, ${value.length} chars]`; } @@ -65,13 +65,13 @@ export function sanitizeDebugString(value) { : redacted; } -export function isSecretLikeKey(key) { +function isSecretLikeKey(key) { return /(?:api[_-]?key|authorization|bearer|client[_-]?secret|token|secret|password)/i.test( key, ); } -export function createErrorRecord(source, error, extra = {}) { +function createErrorRecord(source, error, extra = {}) { const rtcError = error?.error || error; return { timestamp: new Date().toISOString(), @@ -90,7 +90,7 @@ export function createErrorRecord(source, error, extra = {}) { }; } -export function formatErrorForDisplay(record) { +function formatErrorForDisplay(record) { const primary = [record.source, record.message].filter(Boolean).join(': '); const state = [ record.errorDetail && `detail=${record.errorDetail}`, @@ -105,7 +105,7 @@ export function formatErrorForDisplay(record) { return state ? `${primary}\n${state}` : primary; } -export function removeEmptyValues(value) { +function removeEmptyValues(value) { return Object.fromEntries( Object.entries(value || {}).filter( ([, item]) => item !== null && item !== undefined && item !== '', @@ -113,14 +113,7 @@ export function removeEmptyValues(value) { ); } -export function compactText(value, maxLength) { - const text = String(value || '') - .replace(/\s+/g, ' ') - .trim(); - return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; -} - -export function loadStoredErrors() { +function loadStoredErrors() { try { const value = JSON.parse(localStorage.getItem(ERROR_STORAGE_KEY) || '[]'); return Array.isArray(value) ? value.slice(0, ERROR_LOG_LIMIT) : []; @@ -129,7 +122,7 @@ export function loadStoredErrors() { } } -export function storeErrors(errors) { +function storeErrors(errors) { try { localStorage.setItem( ERROR_STORAGE_KEY, diff --git a/src/voice/realtimeNarration.test.mjs b/src/voice/realtimeNarration.test.mjs index c9cd3e55f..e47a888c8 100644 --- a/src/voice/realtimeNarration.test.mjs +++ b/src/voice/realtimeNarration.test.mjs @@ -135,7 +135,7 @@ test('an old progress completion leaves the active in-band response alone', asyn }); test('user speech stops an audible progress line: cancel and clear its audio', async () => { - const { controller, sent, event, progressLine } = controllerFixture(); + const { sent, event, progressLine } = controllerFixture(); await progressLine('resp_p'); await event({ type: 'output_audio_buffer.started', response_id: 'resp_p' }); sent.length = 0; diff --git a/src/voice/realtimePreferences.js b/src/voice/realtimePreferences.js index 1d4d1dd23..d3b78a922 100644 --- a/src/voice/realtimePreferences.js +++ b/src/voice/realtimePreferences.js @@ -8,12 +8,12 @@ import { // Voice cost control (repo-wide `godsEyeView..` convention; // the neighbouring ERROR_STORAGE_KEY predates it). -export const VOICE_TIER_STORAGE_KEY = 'godsEyeView.voiceCost.tier'; +const VOICE_TIER_STORAGE_KEY = 'godsEyeView.voiceCost.tier'; -export const VOICE_LIMITS_STORAGE_KEY = 'godsEyeView.voiceCost.limits'; +const VOICE_LIMITS_STORAGE_KEY = 'godsEyeView.voiceCost.limits'; /** Best-effort localStorage handle; absent in tests and locked-down browsers. */ -export function voiceStorage(storage) { +function voiceStorage(storage) { if (storage) return storage; try { return typeof localStorage === 'undefined' ? null : localStorage; diff --git a/src/voice/realtimeProtocol.js b/src/voice/realtimeProtocol.js index 14105229c..a721bb7c9 100644 --- a/src/voice/realtimeProtocol.js +++ b/src/voice/realtimeProtocol.js @@ -99,6 +99,18 @@ export function hasStructuredViewIdentity(result) { ); } +export function isGlobeViewRefusal(result) { + return ( + result?.action === 'move_camera' && + result.ok === false && + [ + 'globe-view-required', + 'globe-reset-unavailable', + 'globe-reset-failed', + ].includes(result.reason) + ); +} + export function responseInstructionForToolResult(result) { if (result?.action === 'control_radio' && result.radioPlaybackSuppressed) { if (result.audioState === 'paused') { @@ -123,6 +135,20 @@ export function responseInstructionForToolResult(result) { : 'Be concise: useful names, place, layer and one or two notable properties.', ].join(' '); } + if (isGlobeViewRefusal(result)) { + // A refusal is not a completed action. Keep tool text out of instructions. + if (result.reason === 'globe-reset-failed') + return 'The globe did not rotate after the Globe View reset attempt. Say so briefly. Do not retry, offer or perform another reset for this request.'; + if (result.reason === 'globe-reset-unavailable') + return 'The globe did not rotate. Ask the user to zoom out further; the Globe View preset does not fit this viewport. Do not offer or perform a reset.'; + return 'The globe did not rotate. Say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" The suggested command is not a user request. Do not ask a question, perform a reset, or call tools in this reply.'; + } + if ( + (result?.action ?? result?.tool) === 'zoom_to_globe' && + (result.ok !== true || result.cancelled) + ) { + return 'The Globe View reset did not finish. Say so briefly; do not claim Globe View was reached. Do not retry the reset or rotate the globe for this request.'; + } if (result?.action === 'adjust_camera_zoom') { return result.ok ? `Confirm once that the camera zoomed ${result.direction}. Do not claim any other change.` diff --git a/src/voice/realtimeTurns.js b/src/voice/realtimeTurns.js index cbc8d5dbc..29c4b3bc0 100644 --- a/src/voice/realtimeTurns.js +++ b/src/voice/realtimeTurns.js @@ -6,6 +6,7 @@ import { SUPERSEDED_RESPONSE_MEMORY, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, extractFunctionCalls, callDedupeKeys, parseArguments, @@ -13,7 +14,7 @@ import { import { narrationLabel } from './speech.js'; /** Out-of-band progress responses carry this metadata tag. */ -export const PROGRESS_RESPONSE_TAG = 'progress'; +const PROGRESS_RESPONSE_TAG = 'progress'; /** * Speaks one code-authored progress line out of band. The line travels as @@ -66,6 +67,11 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds = new Set(); + this.globeRefusals = new Map(); + this.globeAdmissionGates = new Map(); + this.globeResets = new Map(); + this.globeResponseRequests = new Map(); + this.globeRequest = { failed: false, pending: new Set() }; this.activeToolAbortControllers = new Set(); this.narration = null; this.narrationSequence = 0; @@ -138,6 +144,7 @@ export class RealtimeTurns { }, }; if (!this.sendRealtimeEvent(itemEvent, 'client.user_text')) return; + this.globeRequest = { failed: false, pending: new Set() }; this.requestUserTextResponse(); } @@ -330,6 +337,7 @@ export class RealtimeTurns { this.userTurnPending = true; this.pendingResponseInstructions = null; this.cancelRadioHandoff({ abortTools: true }); + this.globeRequest = { failed: false, pending: new Set() }; this.setVoiceSpeaker('user'); // Point-and-ask: read the pointer as speech starts (open mic), or reuse // the push-to-talk keydown snapshot, before the user audio commits. @@ -384,6 +392,27 @@ export class RealtimeTurns { } const toolResponseId = payload.response_id || payload.response?.id || null; + // Follow-up responses retain reset ownership until fresh admitted input. + const arrivalRequest = this.globeRequest; + const globeRequest = + this.globeResponseRequests.get(toolResponseId) ?? arrivalRequest; + // Later siblings must wait for globe admission before they can mutate state. + // Other tool responses keep their existing concurrency. + let heldForAdmission = false; + while (toolResponseId && this.globeAdmissionGates.has(toolResponseId)) { + // Drop claimed duplicates before every wait, before their keys can expire. + if ( + calls.every((call) => + callDedupeKeys(call).some((key) => this.processedCalls.has(key)), + ) + ) + return; + heldForAdmission = true; + await this.globeAdmissionGates.get(toolResponseId).promise; + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + } + // A newer typed command superseded the response these calls belong to. // They are stale intent — dispatching one would let the old turn mutate // the map after the operator asked for something else. @@ -394,7 +423,10 @@ export class RealtimeTurns { // hazard `callDedupeKeys` is written to avoid). So each refused call gets // a terminal output saying plainly that the turn moved on. No // `response.create` follows — the deferred typed turn is the single answer. - if (this.isSupersededResponse(toolResponseId)) { + if ( + this.isSupersededResponse(toolResponseId) || + (heldForAdmission && arrivalRequest !== this.globeRequest) + ) { this.pruneProcessedCalls(); for (const call of calls) { const keys = callDedupeKeys(call); @@ -429,7 +461,19 @@ export class RealtimeTurns { if (keys.some((key) => this.processedCalls.has(key))) continue; keys.forEach((key) => this.processedCalls.set(key, performance.now())); + if (this.globeRefusals.get(toolResponseId)?.blocking) { + this.sendToolOutput(call.call_id || call.id, { + ok: false, + action: call.name, + skipped: true, + error: 'Not run: this response must wait for the Globe View reply.', + }); + continue; + } + let result; + let globeAdmissionGate = null; + let globeResetGate = null; const resultChannel = this.dc; let radioHandoffEpochAtStart = this.radio.radioHandoffEpoch; let toolController = null; @@ -443,6 +487,80 @@ export class RealtimeTurns { this.narration?.preamble(); try { const parsedArguments = parseArguments(call.arguments); + if ( + toolResponseId && + call.name === 'move_camera' && + String(parsedArguments.motion || '').toLowerCase() === 'rotate_globe' + ) { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + globeAdmissionGate = { promise, resolve }; + this.globeAdmissionGates.set(toolResponseId, globeAdmissionGate); + // OpenAI delivers sibling calls concurrently. A reset dispatched + // first must finish before rotation evaluates the camera's fit. + // Hold later siblings at admission while this rotation waits. + const resets = this.globeResets.get(toolResponseId); + if ( + resets || + globeRequest.failed || + globeRequest.pending.size || + arrivalRequest !== this.globeRequest + ) { + while (resets?.pending.size || globeRequest.pending.size) + await Promise.all( + [ + ...new Set([ + ...(resets?.pending || []), + ...globeRequest.pending, + ]), + ].map((gate) => gate.promise), + ); + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + keys.forEach((key) => + this.processedCalls.set(key, performance.now()), + ); + const superseded = + this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest; + if (superseded || resets?.failed || globeRequest.failed) { + result = { + ok: false, + action: call.name, + reason: 'globe-reset-failed', + skipped: true, + ...(superseded ? { superseded: true } : {}), + error: superseded + ? 'Superseded by a newer command from the operator — this call was not run.' + : 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + }; + } + } + } + if (toolResponseId && call.name === 'zoom_to_globe') { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + let resets = this.globeResets.get(toolResponseId); + if (!resets) { + resets = { pending: new Set(), failed: false }; + this.globeResets.set(toolResponseId, resets); + // Retain settled outcomes for later siblings, without evicting an + // active reset that another response still needs to await. + for (const [id, record] of this.globeResets) { + if (this.globeResets.size <= SUPERSEDED_RESPONSE_MEMORY) break; + if (!record.pending.size && id !== toolResponseId) + this.globeResets.delete(id); + } + } + globeResetGate = { promise, resolve, resets, request: globeRequest }; + resets.pending.add(globeResetGate); + globeRequest.pending.add(globeResetGate); + } + const isRadioControlCall = call.name === 'control_radio'; const isRadioVisibilityCall = call.name === 'set_layer_visibility' && @@ -499,7 +617,7 @@ export class RealtimeTurns { authorityDomain: radioAuthorityDomain, }); } - result = await (this.actionExecutor || this.runner)( + result ??= await (this.actionExecutor || this.runner)( call.name, parsedArguments, { @@ -515,7 +633,28 @@ export class RealtimeTurns { radioHandoffEpochAtStart === this.radio.radioHandoffEpoch), }, ); + if ( + globeAdmissionGate && + this.globeResets.has(toolResponseId) && + result?.reason === 'globe-view-required' + ) + result = { ...result, reason: 'globe-reset-failed' }; if (!this.ownsConversation(eventChannel)) return; + if ( + isGlobeViewRefusal(result) && + toolResponseId && + arrivalRequest === this.globeRequest && + !this.isSupersededResponse(toolResponseId) + ) { + this.globeRefusals.set(toolResponseId, { + result, + followupQueued: false, + blocking: !result.skipped, + }); + while (this.globeRefusals.size > SUPERSEDED_RESPONSE_MEMORY) { + this.globeRefusals.delete(this.globeRefusals.keys().next().value); + } + } if (result?.ok && result.radioPlaybackRequested) { const sessionIsCurrent = this.activeToolAbortControllers.has(toolController) && @@ -595,6 +734,23 @@ export class RealtimeTurns { : {}), }; } finally { + if (globeResetGate) { + const { resets, request } = globeResetGate; + if (result?.ok !== true || result.cancelled) { + resets.failed = true; + request.failed = true; + } + resets.pending.delete(globeResetGate); + request.pending.delete(globeResetGate); + globeResetGate.resolve(); + } + if (globeAdmissionGate) { + if ( + this.globeAdmissionGates.get(toolResponseId) === globeAdmissionGate + ) + this.globeAdmissionGates.delete(toolResponseId); + globeAdmissionGate.resolve(); + } if (toolController) { this.activeToolAbortControllers.delete(toolController); this.radio.releaseTool(toolController); @@ -671,13 +827,38 @@ export class RealtimeTurns { }); } if (!this.ownsConversation(eventChannel)) return; - // Keep the Radio handoff wording authoritative even when another tool - // result follows Radio in the same multi-intent response. - this.queueResponseCreate( - responseInstructionForToolResult( - this.radio.pendingRadioPlaybackResult || lastResult, - ), - ); + const refusal = this.globeRefusals.get(toolResponseId); + // A newer turn may arrive during admission or the viewport-image await. + // Preserve its reply; neither the refusal map nor lastResult may revive + // an old refusal follow-up after supersession. + if ( + (this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest) && + (refusal || isGlobeViewRefusal(lastResult) || lastResult?.superseded) + ) { + // Complete an idle turn below, but leave a newer running tool's status + // authoritative while suppressing this obsolete refusal follow-up. + if (this.activeToolAbortControllers.size) return; + } else if (refusal) { + // Even an earlier sibling finishing late cannot replace the reset instruction. + if (!refusal.followupQueued) { + refusal.followupQueued = true; + this.queueResponseCreate({ + instructions: responseInstructionForToolResult(refusal.result), + tool_choice: 'none', + }); + } + } else { + // Keep Radio handoff wording authoritative for ordinary multi-tool replies. + const followupResult = + this.radio.pendingRadioPlaybackResult || lastResult; + const instructions = responseInstructionForToolResult(followupResult); + this.queueResponseCreate( + isGlobeViewRefusal(followupResult) + ? { instructions, tool_choice: 'none' } + : instructions, + ); + } } this.setStatus('listening', t('Ask or command')); } @@ -966,6 +1147,22 @@ export class RealtimeTurns { this.userTurnPending = false; this.activeResponseId = payload.response?.id || payload.response_id || null; + // Late calls retain only their globe reset/request ownership. Ordinary + // tool admission and concurrency continue to use their existing policy. + if ( + this.activeResponseId && + !this.globeResponseRequests.has(this.activeResponseId) + ) { + this.globeResponseRequests.set( + this.activeResponseId, + this.globeRequest, + ); + while (this.globeResponseRequests.size > SUPERSEDED_RESPONSE_MEMORY) + this.globeResponseRequests.delete( + this.globeResponseRequests.keys().next().value, + ); + } + this.setVoiceSpeaker('ai'); return; } @@ -1045,7 +1242,8 @@ export class RealtimeTurns { const sent = this.sendRealtimeEvent( { type: 'response.create', - response: { instructions }, + response: + typeof instructions === 'string' ? { instructions } : instructions, }, 'client.response_create.tool_followup', ); @@ -1057,6 +1255,20 @@ export class RealtimeTurns { controller.abort(); this.activeToolAbortControllers.clear(); this.radio.clearTools(); + this.releaseGlobeResets(); + } + + releaseGlobeResets() { + for (const resets of this.globeResets.values()) { + if (!resets.pending.size) continue; + resets.failed = true; + for (const gate of resets.pending) { + gate.request.failed = true; + gate.request.pending.delete(gate); + gate.resolve(); + } + resets.pending.clear(); + } } reset() { @@ -1068,6 +1280,13 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds.clear(); + this.globeRefusals.clear(); + for (const gate of this.globeAdmissionGates.values()) gate.resolve(); + this.globeAdmissionGates.clear(); + this.releaseGlobeResets(); + this.globeResets.clear(); + this.globeResponseRequests.clear(); + this.globeRequest = { failed: false, pending: new Set() }; this.narration?.cancel(); this.assistantAudioPlaying = false; this.speechResponseIds.clear(); diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index 755913906..e229b6cc2 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -5,17 +5,17 @@ import { import { cameraPoseKey } from './pointerContext.js'; /** The live viewer's camera pose identity (Cesium-free read). */ -export function readViewerCameraKey() { +function readViewerCameraKey() { return cameraPoseKey(globalThis.window?.__godsEyeView?.viewer); } // Viewport-screenshot size guards (M13). The old code clamped WIDTH only, so a // tall portrait window produced an oversized capture whose dc.send could throw. // Cap total pixels (clamps both dimensions) and drop the image entirely if the // encoded data URL is still too big for the data channel. -export const VIEWPORT_MAX_PIXELS = 1200 * 900; +const VIEWPORT_MAX_PIXELS = 1200 * 900; // ~1.08 MP, matches the old 1200px-wide landscape budget -export const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; +const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; export async function captureViewportImage() { const viewer = window.__godsEyeView?.viewer; @@ -66,11 +66,11 @@ export async function captureViewportImage() { } /** Side of the point-and-ask crop, in CSS pixels, and its output size. */ -export const POINTER_CROP_SIZE = 512; +const POINTER_CROP_SIZE = 512; /** A pointer snapshot older than this no longer matches the frame. */ -export const POINTER_CROP_MAX_AGE_MS = 20000; +const POINTER_CROP_MAX_AGE_MS = 20000; /** View scales where a crop shows buildings and terrain, not a smear. */ -export const POINTER_CROP_SCALES = new Set(['local', 'city']); +const POINTER_CROP_SCALES = new Set(['local', 'city']); /** * Square source rectangle around a canvas point, clamped inside the canvas, @@ -128,7 +128,7 @@ export function pointerCropRect({ * @returns {Promise<{dataUrl: string, rect: object}|null>} JPEG data URL and * the crop's normalized bounds in the full canvas, or null. */ -export async function capturePointerCrop(screenPx) { +async function capturePointerCrop(screenPx) { const viewer = window.__godsEyeView?.viewer; const source = viewer?.scene?.canvas; if (!source?.width || !source?.height || !screenPx) return null; @@ -259,7 +259,7 @@ export async function renderFreshCesiumFrame(viewer) { } } -export function isNearlyBlackFrame(ctx, width, height) { +function isNearlyBlackFrame(ctx, width, height) { const sampleWidth = Math.min(48, width); const sampleHeight = Math.min(32, height); if (!sampleWidth || !sampleHeight) return true; diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 642db2bb4..ac99f86d7 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -59,7 +59,7 @@ function notesOf(...values) { ]; } -export function analystNounFor(layerKeys) { +function analystNounFor(layerKeys) { const keys = [...new Set(layerKeys.filter(Boolean))]; if (keys.length !== 1) return 'results'; const key = keys[0]; diff --git a/src/voice/speechLint.js b/src/voice/speechLint.js index 9b6c02ab4..f1b1c1878 100644 --- a/src/voice/speechLint.js +++ b/src/voice/speechLint.js @@ -7,7 +7,7 @@ * Phrases that add length without adding an answer. Tool numbers are spoken * verbatim, so approximations are hedges too. */ -export const HEDGE_PHRASES = Object.freeze([ +const HEDGE_PHRASES = Object.freeze([ 'keep in mind', "it's worth noting", 'it is worth noting', diff --git a/src/voice/turnMetrics.js b/src/voice/turnMetrics.js index 13e9c6940..b2c447cee 100644 --- a/src/voice/turnMetrics.js +++ b/src/voice/turnMetrics.js @@ -1,7 +1,7 @@ import { countWords, findHedges } from './speechLint.js'; /** Quiet period after the last audio or response before a turn is closed. */ -export const TURN_SETTLE_MS = 2500; +const TURN_SETTLE_MS = 2500; /** * Per-turn latency and speech spans for one voice session. diff --git a/src/voice/voiceCard.js b/src/voice/voiceCard.js index 72b754287..09619bb54 100644 --- a/src/voice/voiceCard.js +++ b/src/voice/voiceCard.js @@ -5,7 +5,7 @@ import { } from './voiceCardPresentation.js'; /** Hide an idle card this long after its last update. */ -export const VOICE_CARD_IDLE_MS = 14000; +const VOICE_CARD_IDLE_MS = 14000; const ELEMENT_IDS = Object.freeze({ card: 'gev-voice-card', diff --git a/style.css b/style.css index 0117150ce..1b55c459f 100644 --- a/style.css +++ b/style.css @@ -33,4 +33,5 @@ @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/cjk.css'; +@import './src/ui/styles/mobile-shell.css'; @import './src/ui/styles/cyber.css'; diff --git a/tools/pano-pinhole.mjs b/tools/pano-pinhole.mjs index 36ceaa546..42fee8fbd 100644 --- a/tools/pano-pinhole.mjs +++ b/tools/pano-pinhole.mjs @@ -34,13 +34,11 @@ * Dependencies: sharp (devDependency) */ -import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { resolve, join, basename, extname } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- diff --git a/tools/sat-ortho.mjs b/tools/sat-ortho.mjs index d42039e46..514bb22d4 100644 --- a/tools/sat-ortho.mjs +++ b/tools/sat-ortho.mjs @@ -32,11 +32,9 @@ import { readFileSync, mkdirSync } from 'node:fs'; import { resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); const TILE_SIZE = 256; const MAX_CONCURRENT = 8; diff --git a/tools/streetview-headings.mjs b/tools/streetview-headings.mjs index 708ae7b93..e9a124d22 100644 --- a/tools/streetview-headings.mjs +++ b/tools/streetview-headings.mjs @@ -28,7 +28,6 @@ import { projectRoot } from '../scripts/project-root.mjs'; import { parseEnv } from 'node:util'; import { resolveGoogleServerKey } from '../scripts/google-server-key.mjs'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); function parseArgs() { diff --git a/tools/streetview-panorama.mjs b/tools/streetview-panorama.mjs index c93f487bd..73010aa6c 100755 --- a/tools/streetview-panorama.mjs +++ b/tools/streetview-panorama.mjs @@ -24,11 +24,9 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- @@ -159,7 +157,7 @@ async function fetchTile(apiKey, session, panoId, z, x, y) { // --------------------------------------------------------------------------- async function stitchPanorama(apiKey, session, panoId, metadata, zoom) { - const { imageWidth, imageHeight, tileWidth, tileHeight } = metadata; + const { tileWidth, tileHeight } = metadata; // Street View tile grids follow a fixed power-of-2 scheme: // zoom 0: 1×1 (360° FOV, entire panorama in one tile)