← Plugin catalog
Developer Tools
Vaisala Xweather API & Maps
Vaisala Xweather v0.14.1
Publisher description
From the marketplace listing
Reusable Xweather skills for building Weather API requests, Raster Maps imagery and tiles, MapsGL integrations for the web and for Apple and Android platforms, and webhook receivers.
Language: English · Automatically detected from descriptions.
Files & skills
File archives
Plugin package56 files · 203 KBBrowse files →
Skill instructions
mapsgl35.8 KB
---
name: mapsgl
description: This skill should be used when working with the Xweather MapsGL JavaScript SDK for the web (@xweather/mapsgl) — setting up a MapsGL map controller for Mapbox GL, MapLibre GL, Google Maps, or Leaflet, and adding, removing, styling, filtering, masking, or animating MapsGL weather layers and custom data layers. Use it whenever a task mentions MapsGL, aerisweather.mapsgl, addWeatherLayer, weather map layers, or client-side WebGL weather rendering. Also use it for questions about how MapsGL usage or cost is measured — sessions, the 5-minute clock intervals, the 150x access multiplier, or how many accesses a MapsGL map consumes. Also covers Xweather's attribution requirement — the 'Powered by Vaisala Xweather' credit and logo rules that apply wherever Xweather data or imagery is displayed.
license: MIT
---
# MapsGL JavaScript SDK
The MapsGL JavaScript SDK (`@xweather/mapsgl`) renders weather and custom map data client-side in
WebGL, layered on top of Mapbox GL, MapLibre GL, Google Maps, or Leaflet. It requires an active
Xweather account with Weather API + Maps access (client id + secret).
**This skill is the web-based JavaScript SDK.** For a native Swift app on iOS, iPadOS, Mac Catalyst, or visionOS, use
the `mapsgl-apple` skill instead — a separate SDK with a Swift API (`MapboxMapController`,
`WeatherService.LayerCode`), its own install channels, and a smaller layer set. The concepts below
transfer; none of the code does. MapsGL ships for Android as well.
**Always qualify the name as the *MapsGL* JavaScript SDK.** Xweather ships an unrelated product simply
called the JavaScript SDK (<https://www.xweather.com/docs/javascript-sdk>), so the bare term is
ambiguous. Xweather's own naming for the family is MapsGL, MapsGL iOS, and MapsGL Android; "MapsGL
JavaScript SDK" is the unambiguous way to name this one.
## How to write MapsGL code examples
**Default to a single self-contained HTML file using vanilla JavaScript and the CDN build.** One
file the user can save and open in a browser — CDN `<script>`/`<link>` tags, a `<div>` for the map,
and a plain `<script>` block. No build step, no bundler, no package installs, no framework.
In that form, MapsGL lives on the global `aerisweather.mapsgl` namespace:
```javascript
const account = new aerisweather.mapsgl.Account('CLIENT_ID', 'CLIENT_SECRET');
const controller = new aerisweather.mapsgl.MapboxMapController(map, { account });
```
**Look up the current released version before writing any example.** Don't reuse a version from
memory or from an earlier turn — MapsGL ships often, and a stale pin is the most common thing to go
wrong in otherwise-correct example code:
```bash
curl -s https://www.xweather.com/docs/api/releases/versions \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["products"]["mapsgl"]["version"])'
```
That endpoint is the release source of truth for every Xweather product, keyed by product id —
`mapsgl` here, alongside `weather-api`, `maps`, `maps-ui-sdk`, `mapsgl-apple-sdk`,
`mapsgl-android-sdk`, `mcp-server`, `phrases-api`. It's a small public JSON document, no auth needed.
**MapsGL's own script and stylesheet always come from `cdn.aerisapi.com`**, with that version
substituted:
```html
<link href="https://cdn.aerisapi.com/sdk/js/mapsgl/1.9.4/aerisweather.mapsgl.css" rel="stylesheet" />
<script defer src="https://cdn.aerisapi.com/sdk/js/mapsgl/1.9.4/aerisweather.mapsgl.js"></script>
```
`1.9.4` above is the version at the time this file was last regenerated — use it only as a fallback
if the endpoint can't be reached, and say so when you do.
Note that **npm may be ahead of the released version.** `@xweather/mapsgl` on npm has carried a
higher version than the releases endpoint reports, and `cdn.aerisapi.com` serves those newer paths
too — so a version that resolves is not evidence it's the current release. Trust the releases
endpoint, not npm and not a 200 from the CDN.
Never substitute `unpkg.com`, `cdn.jsdelivr.net`, or another npm mirror for those two tags — the npm
build exposes a different global and the page breaks at runtime even though the files load. See
"MapsGL's own assets come from `cdn.aerisapi.com`" under Setup for why.
**Every generated example must include the Xweather attribution** — a link to
`https://www.xweather.com/` reading "Powered by Vaisala Xweather", or the logo. It's a requirement of
using the product, not a nicety, so build it into the markup rather than mentioning it afterwards. The
complete example below shows it positioned over the map. See "Attribution is required" for the rules.
Only produce **npm / ES-module / bundler** code when the user explicitly asks for it, or when they're
plainly already working in such a project — an existing `package.json`, a `src/` tree with imports, or
they name a bundler. Same for **React or any other framework**: only on explicit request. Don't offer
a framework version alongside the vanilla one "in case", and don't reach for a framework because the
task looks app-shaped.
When the user is in an npm project, the translation is mechanical: swap the CDN tags for
`import * as mapsgl from '@xweather/mapsgl'` plus the map library's own import, and replace
`aerisweather.mapsgl.` with `mapsgl.`. Everything else — the controller API, layer codes, paint
objects, expressions — is identical, so the rest of this skill applies unchanged.
## Core concepts
| Concept | What it is |
|---|---|
| `Account` | Wraps client id/secret credentials used for all data requests |
| `MapController` | Adapter between the underlying map instance (Mapbox/MapLibre/Google/Leaflet) and MapsGL — the object almost everything below is called on |
| `DataSource` | Where layer data comes from: `raster`, `vector`, `geojson`, or `encoded` (RGBA-packed weather grids) |
| `WebGLLayer` | A visual rendering of a source: `raster`, `fill`, `line`, `circle`, `sample`, `grid`, `contour`, `particle`, `heatmap`, `symbol` |
| `paint` | Per-layer style config (colors, radii, colorscales, etc.), keyed by render type — see `references/styles.md` |
| Expressions | `['operator', ...args]` arrays for data-driven paint values and `filter`s — see `references/expressions.md` |
| `ColorScale` | Maps a continuous data range to a color gradient/steps, used by `paint.sample`/`paint.heatmap` and gradient legends — see `references/color-scales.md` |
| Legend (`LegendControl`) | An on-map UI element showing what a layer's colors/symbols mean; categorical (`points`) or gradient (`bar`) — see `references/legends.md` |
| `DataInspectorControl` | An on-map UI element that shows raw layer values at the clicked/hovered point |
| `timeline` (`Timeline`) | Drives time-based animation (play/pause/scrub) across every animated layer on a controller at once — see `references/timeline.md` |
Built-in **weather layers** are pre-wired combinations of an encoded source + one or more styled
layers, addressed by a single string code (e.g. `'temperatures'`, `'radar'`, `'alerts'`). Prefer
these over hand-building sources/layers unless visualizing custom or non-weather data.
## Usage is measured in sessions
MapsGL bills in **sessions**, not tiles, layers, or requests. A session is a continuous interaction
with a MapsGL map for **up to 5 minutes**, starting when any weather layer is added. On a Weather API
and Maps subscription, **1 session = 150 accesses** (a 150× multiplier).
Three rules produce every answer:
1. **Sessions align to the wall clock** — boundaries at `:00`, `:05`, `:10`, `:15`. Not a rolling
window from first interaction. What matters is how many 5-minute buckets the viewing touches, not
how long it lasted.
2. **At least one session per data request.** No proration — 150 accesses is the floor.
3. **Inside a session, everything is free**: panning, zooming, animating, refreshing, and toggling
layers. **Layer count does not affect cost.**
**When asked how usage is measured, show the arithmetic** — buckets → sessions → accesses — rather
than just a number. The documented example:
> A user views a radar layer from **8:03 to 8:07** — 4 minutes, but it straddles the `:05` boundary,
> so it touches two buckets (8:00–8:05 and 8:05–8:10) = **2 sessions = 300 accesses**.
>
> The same 4 minutes from **8:05 to 8:09** touches one bucket = **1 session = 150 accesses**. Same
> duration, half the cost, purely from clock alignment.
Two consequences worth volunteering unprompted, because they invert the intuition people bring from
tile-based pricing:
> **Adding layers is free.** Five layers viewed for four minutes costs 1 session — the same as one
> layer. There is no cost reason to limit how many layers a user enables. (On Raster Maps the same
> five layers would cost 5×.)
>
> **Short visits get almost no discount.** A 30-second view averages ~1.1 sessions and a 5-minute
> view ~2.0, so a visit ten times shorter costs 55% as much, not 10%. Drive-by page loads are the
> expensive traffic shape.
For capacity planning, a view of `d` minutes starting at a random time averages
`floor(d/5) + 1 + (d mod 5)/5` sessions — **not** `d/5`. Assuming one session per short view
underestimates by 10–20%. Table of common durations in `references/sessions.md`.
The only real lever is **when layers are on the map**: don't call `addWeatherLayer` until the user
asks for weather, and `removeWeatherLayer` when the map goes out of view or idle. Optimising layer
count, animation, or interaction is pointless — those are free.
Full model, more worked examples (long-running displays, high-traffic short visits), the MapsGL vs.
Raster Maps billing comparison, and reduction tactics: `references/sessions.md`.
## Setup
### 1. Get API credentials
MapsGL needs **two separate sets of credentials**, and both are required — the map won't render
without either one:
1. **Xweather account keys** (`CLIENT_ID` / `CLIENT_SECRET`) — generated from the account portal at
**https://data.portal.xweather.com/account/keys**. These authenticate MapsGL's own data
requests and are what get passed to `new aerisweather.mapsgl.Account(id, secret)`.
2. **The underlying map provider's own key/token** — independent of Xweather:
- Mapbox GL → `mapboxgl.accessToken` (Mapbox account access token)
- MapLibre GL → no key needed, but the `style` URL usually comes from a tile provider (e.g.
MapTiler) that does require its own key
- Google Maps → a Google Maps JavaScript API key, plus a **Map ID** with vector-map
support enabled (required — MapsGL renders as a WebGL overlay on the vector basemap)
- Leaflet → no key needed for the base `L.tileLayer`, though the tile provider used may require one
If a user reports "nothing renders" or an auth error, check both credential sets before digging
into MapsGL-specific config.
### 2. Install
Every install needs `@xweather/mapsgl` **plus** the map library being wrapped — MapsGL does not
bundle it, whether installing via CDN or npm.
**CDN** — include the map provider's own `<link>`/`<script>` tags *in addition to* MapsGL's, not
instead of them. MapsGL's CDN build exposes everything under `window.aerisweather.mapsgl`.
### MapsGL's own assets come from `cdn.aerisapi.com` — nowhere else
Copy these two lines verbatim, changing only the version number:
```html
<link href="https://cdn.aerisapi.com/sdk/js/mapsgl/1.9.4/aerisweather.mapsgl.css" rel="stylesheet" />
<script defer src="https://cdn.aerisapi.com/sdk/js/mapsgl/1.9.4/aerisweather.mapsgl.js"></script>
```
```
https://cdn.aerisapi.com/sdk/js/mapsgl/{version}/aerisweather.mapsgl.js
https://cdn.aerisapi.com/sdk/js/mapsgl/{version}/aerisweather.mapsgl.css
```
**Never serve MapsGL from `unpkg.com`, `cdn.jsdelivr.net`, `esm.sh`, or any other npm mirror**, and
never invent a filename like `mapsgl.js` or `@xweather/mapsgl/dist/...` for a `<script>` tag. This is
worth stating flatly because the wrong URL *appears to work*:
- `https://unpkg.com/@xweather/mapsgl/dist/mapsgl.js` returns **HTTP 200**. So does the `.css` beside
it. There is no network error to notice.
- But that is the **npm** build, whose UMD wrapper assigns `globalThis.mapsgl` — **not**
`aerisweather.mapsgl`. The page then dies at runtime with
`ReferenceError: aerisweather is not defined`, which looks like a MapsGL bug rather than a bad URL.
If a user reports `aerisweather is not defined`, check the `<script src>` host first — it is almost
always this.
The unpkg/jsdelivr paths are only meaningful in a bundled project that imports `@xweather/mapsgl` as a
module, and even there the import comes from npm, not a CDN URL.
### The map library's own CDN tags
These are separate from MapsGL and *do* legitimately come from unpkg for some providers:
| Provider | CDN tags for the **map library only** |
|---|---|
| Mapbox GL | `https://api.mapbox.com/mapbox-gl-js/<version>/mapbox-gl.{css,js}` |
| MapLibre GL | e.g. `https://unpkg.com/maplibre-gl@<version>/dist/maplibre-gl.{css,js}` |
| Leaflet | e.g. `https://unpkg.com/leaflet@<version>/dist/leaflet.{css,js}` |
| Google Maps | `<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY"></script>` (no separate CSS) |
Don't let the unpkg entries in this table bleed into MapsGL's URLs — the rows above cover Mapbox,
MapLibre, Leaflet and Google *only*. MapsGL always comes from `cdn.aerisapi.com`.
A complete pair of includes, MapsGL plus its map library:
```html
<!-- 1. The map library — Mapbox GL shown; swap for MapLibre/Leaflet/Google per the table above -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.css" rel="stylesheet" />
<script defer src="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.js"></script>
<!-- 2. MapsGL itself — always cdn.aerisapi.com -->
<link href="https://cdn.aerisapi.com/sdk/js/mapsgl/1.9.4/aerisweather.mapsgl.css" rel="stylesheet" />
<script defer src="https://cdn.aerisapi.com/sdk/js/mapsgl/1.9.4/aerisweather.mapsgl.js"></script>
```
Pin an explicit version for every `<script>`/`<link>` tag (MapsGL's and the map library's) rather
than `latest`, for anything beyond a quick prototype. `cdn.aerisapi.com/sdk/js/mapsgl/latest/…` does
resolve if you need it.
For MapsGL's version, use the releases endpoint — **not** npm, which can be ahead of the current
release:
```bash
curl -s https://www.xweather.com/docs/api/releases/versions \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["products"]["mapsgl"]["version"])'
```
**npm** — *only when the user is already in a bundled project or asks for it.* Install
`@xweather/mapsgl` plus whichever provider package applies:
```bash
npm install --save @xweather/mapsgl mapbox-gl # Mapbox GL
npm install --save @xweather/mapsgl maplibre-gl # MapLibre GL
npm install --save @xweather/mapsgl leaflet # Leaflet
npm install --save @xweather/mapsgl # Google Maps — loaded via <script> from Google, no npm package needed
```
```javascript
import * as mapsgl from '@xweather/mapsgl';
import '@xweather/mapsgl/dist/mapsgl.css';
```
### 3. Container markup
The map container needs explicit dimensions — a blank/invisible map is almost always a missing
CSS rule, not a JS error:
```html
<div id="map"></div>
<style>
body, html { margin: 0; padding: 0; }
#map { width: 100%; height: 100vh; }
</style>
```
### 4. Initialize a controller
Every provider follows the same pattern: create the native map, wrap it in the matching
`*MapController`, wait for `load`, then add layers. Only the map-creation step differs.
```javascript
mapboxgl.accessToken = 'MAPBOX_TOKEN';
const map = new mapboxgl.Map({
container: document.getElementById('map'),
style: 'mapbox://styles/mapbox/light-v11',
center: [-74.5, 40],
zoom: 3
});
const account = new aerisweather.mapsgl.Account('CLIENT_ID', 'CLIENT_SECRET');
const controller = new aerisweather.mapsgl.MapboxMapController(map, { account });
controller.on('load', () => {
controller.addWeatherLayer('temperatures');
});
```
| Provider | Controller class | Map constructor |
|---|---|---|
| Mapbox GL | `MapboxMapController` | `new mapboxgl.Map({...})` |
| MapLibre GL | `MaplibreMapController` | `new maplibregl.Map({...})` |
| Google Maps | `GoogleMapController` | `new google.maps.Map(el, { mapId: '...', ... })` |
| Leaflet | `LeafletMapController` | `L.map('map').setView([lat, lon], zoom)` |
All four take `(map, { account, units?, animation? })`. Google's variant also accepts
`interleaved?: boolean`. **Always gate MapsGL calls behind `controller.on('load', ...)`** —
calling layer/source methods before load throws.
The four controller constructors and the complete controller API (properties, events, all methods)
are in `references/api-reference.md`.
## Complete example
**This is the shape to produce by default** — one file, saveable and openable in a browser. Adapt it
rather than starting from scratch: swap the map provider's CDN tags and constructor, change the
`addWeatherLayer` codes, and adjust `center`/`zoom`.
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MapsGL + Mapbox GL</title>
<link href="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.css" rel="stylesheet" />
<script defer src="https://api.mapbox.com/mapbox-gl-js/v3.12.0/mapbox-gl.js"></script>
<link href="https://cdn.aerisapi.com/sdk/js/mapsgl/1.9.4/aerisweather.mapsgl.css" rel="stylesheet" />
<script defer src="https://cdn.aerisapi.com/sdk/js/mapsgl/1.9.4/aerisweather.mapsgl.js"></script>
<style>
body, html { margin: 0; padding: 0; }
#map { height: 100vh; width: 100%; }
/* Attribution is required wherever Xweather data is displayed. */
#attribution {
position: absolute; bottom: 8px; right: 8px; z-index: 1;
background: rgba(255, 255, 255, 0.85); border-radius: 3px;
padding: 3px 6px; font: 12px system-ui, sans-serif;
}
#attribution a { color: #333; text-decoration: none; }
</style>
</head>
<body>
<div id="map"></div>
<div id="attribution">
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">Powered by Vaisala Xweather</a>
</div>
<script>
window.addEventListener('load', () => {
mapboxgl.accessToken = 'MAPBOX_TOKEN';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/light-v11',
center: [-85.5, 40],
zoom: 3
});
const account = new aerisweather.mapsgl.Account('CLIENT_ID', 'CLIENT_SECRET');
const controller = new aerisweather.mapsgl.MapboxMapController(map, { account });
controller.on('load', () => {
// built-in weather layer, unstyled
controller.addWeatherLayer('radar');
// built-in weather layer with a custom style override
controller.addWeatherLayer('alerts-outline', {
paint: {
opacity: 0.5
}
});
});
});
</script>
</body>
</html>
```
Source: https://www.xweather.com/docs/mapsgl/examples/mapbox
Two details that matter in the single-file form specifically:
- The CDN tags use `defer`, so wrap the setup in `window.addEventListener('load', ...)`. Without it,
`mapboxgl` and `aerisweather` aren't defined yet and the script throws.
- `#map` needs an explicit height. A silently blank map is nearly always this, not a JS error.
### npm / ES modules — only when asked
Same structure: swap the CDN `<script>` tags for package imports, and `aerisweather.mapsgl.` for the
imported `mapsgl.` namespace. The `window.addEventListener('load', ...)` wrapper is unnecessary since
bundlers execute the module after the DOM is parsed (keep a `defer`/module script tag, or bundle into
the page's entry point). Nothing else changes.
```javascript
// main.js — loaded via <script type="module" src="./main.js"></script>
import mapboxgl from 'mapbox-gl';
import * as mapsgl from '@xweather/mapsgl';
import 'mapbox-gl/dist/mapbox-gl.css';
import '@xweather/mapsgl/dist/mapsgl.css';
mapboxgl.accessToken = 'MAPBOX_TOKEN';
const map = new mapboxgl.Map({ container: 'map', style: 'mapbox://styles/mapbox/light-v11', center: [-85.5, 40], zoom: 3 });
const account = new mapsgl.Account('CLIENT_ID', 'CLIENT_SECRET');
const controller = new mapsgl.MapboxMapController(map, { account });
controller.on('load', () => {
controller.addWeatherLayer('radar');
});
```
The host page is then just the container plus `<script type="module" src="./main.js"></script>` — the
`#map` height rule still applies.
## Adding, removing, and listing weather layers
```javascript
controller.addWeatherLayer('radar');
controller.addWeatherLayer('wind-particles');
// with overrides (data quality, time clamping, paint, legend, mask, filter, ...)
controller.addWeatherLayer('temperatures', {
data: { quality: aerisweather.mapsgl.DataQuality.low },
});
controller.hasWeatherLayer('radar'); // boolean
controller.getWeatherLayer('temperatures'); // WebGLLayer | WebGLLayer[] | undefined
controller.setWeatherLayerVisibility('radar', false); // hide without disposing
controller.removeWeatherLayer('radar'); // fully removes + frees resources
controller.weatherLayerIds; // currently-active weather layer codes
```
**A weather layer's code (e.g. `'temperatures'`) is not the same string as its actual layer id.**
If you'll need to update a weather layer's style/opacity/visibility-via-`.show()`/`.hide()` later,
capture what `addWeatherLayer` returns (or call `getWeatherLayer(code)` later) and operate on that
`WebGLLayer` instance directly — don't pass the code to `controller.setPaintProperty()`,
`getLayer()`, or `moveLayer()`, which expect the real layer id and will silently no-op on a code
they don't recognize. Composite codes return an **array** of layers to iterate over. Full
explanation and verified example in `references/weather-layers.md`.
**Never guess a layer code — look it up.** `references/layers.md` lists every layer by category
with their render type, animatability, cost multiplier, coverage, data range, and update interval. It
is generated from the public catalog and refreshed weekly, so grep it first; no network call needed.
If a code isn't there, or the snapshot looks stale, fetch the live catalog:
```
https://www.xweather.com/docs/api/mapsgl/layers
```
A plain public JSON endpoint (no auth) returning `{ layers: [{ id, title, description, type,
categories, animatable, dataRange, dataCoverage, updateInterval, multiplier }, ...] }`. It overrides
the snapshot. For what the **authenticated account** can actually render — neither file knows about
entitlements — ask at runtime:
```javascript
controller.weatherProvider.getLayerMetadata().then((data) => console.log(data));
```
### No separate forecast layers — one layer spans past and future
Raster Maps splits time across two layers: `temperatures` is observed, `ftemperatures` is forecast.
**MapsGL does not.** A single `temperatures` layer covers `-7 to +15 days`, and the timeline decides
which interval renders.
So don't look for `ftemperatures`, `fradar`, or `fwind-speeds` here — they don't exist. Twelve Raster
Maps pairs collapse into one MapsGL layer each:
`dew-points` · `feels-like` · `heat-index` · `humidity` · `radar` · `satellite` · `snow-depth` ·
`temperatures` · `visibility` · `wind-chill` · `wind-gusts` · `wind-speeds`
Reach a forecast interval by moving the timeline, not by adding a different layer —
`controller.timeline.containsFuture` reports whether the current range includes one. See
`references/timeline.md`.
**Range is per-layer, so check it rather than assuming.** `layers.md` lists it for every code. Two
cases don't follow the pattern:
- **`satellite` is past-only** (`-7 days`). Raster Maps has `fsatellite` reaching +15 days; MapsGL has
no forecast satellite at all. That's a missing capability, not a renamed one — don't promise a
satellite forecast on MapsGL.
- **Road weather keeps an `f` split, meaning something different.** `road-weather-*` is a +2 hour
nowcast refreshed every 15 minutes; `froad-weather-*` is a +24 hour forecast refreshed every 6
hours. Both are forecasts, so there the prefix marks *range*, not past-versus-future.
Some codes are **composite** (expand to multiple sub-layers, e.g. `boundaries`, `roads`,
`stormcells`) — `addWeatherLayer` returns an array for these, and `overrides.childLayers` can
target one sub-layer by id. All 14 composite codes are listed together at the top of
`references/layers.md`; they're the ones with render type `none`.
A layer's **render type also tells you how to style it** — a `sample` layer takes
`paint.sample.colorscale`, a `line` layer takes `paint.stroke`. Reading the type out of
`layers.md` before writing a `paint` override saves a round of guessing.
## Styling layers
Pass a `paint` object namespaced by render type. Full property tables for every render type
(`raster`, `fill`, `stroke`/`line`, `circle`, `sample`, `grid`, `contour`, `particle`, `heatmap`,
`icon`, `symbol`, `text`) are in `references/styles.md`.
**Always use MapsGL expressions — `['operator', ...args]` arrays — for any data-driven paint
value** (color/size/opacity derived from a feature property), not evaluator functions or the
`{ property }` shorthand. See `references/expressions.md` for the full operator reference.
```javascript
controller.addWeatherLayer('temperatures', {
paint: {
sample: {
colorscale: {
stops: [-40, '#58005b', 0, '#81e8ff', 20, '#ecf93d', 40, '#6b0001'],
interval: 5
}
}
}
});
```
Update a style after the layer exists — **for built-in weather layers, get the actual layer
instance first; the weather layer code is not a valid layer id** (see the "code vs. id" gotcha
below):
```javascript
const tempLayer = controller.getWeatherLayer('temperatures'); // or capture addWeatherLayer's return value
tempLayer.setPaintProperty('sample.colorscale', newColorScale);
```
For a layer you created yourself with `addLayer(id, ...)`, the id you chose *is* the real layer id,
so `controller.setPaintProperty(id, prop, value)` works directly.
**Custom (non-weather) layer**, styled with a static or data-driven fill:
```javascript
controller.addSource('alerts', {
type: 'vector',
url: 'https://maps{s}.aerisapi.com/CLIENT_ID_CLIENT_SECRET/alerts/{z}/{x}/{y}/0.pbf'
});
controller.addLayer('alerts-fill', {
type: 'fill',
source: 'alerts',
paint: { fill: { color: ['get', 'COLOR'], opacity: 0.6 } }
});
// ...
controller.removeLayer('alerts-fill');
controller.removeSource('alerts'); // only after no layers reference it
```
For color scales (built-in named palettes + custom stops) see `references/color-scales.md`.
For expression syntax (data-driven values and `filter`) see `references/expressions.md`.
For layer masking (e.g. clip a weather layer to land/water or another layer's geometry) and
`filter`, see the bottom of `references/styles.md`.
## Custom data sources
Four source types: `raster`, `vector` (MVT), `geojson`, `encoded` (RGBA-packed grids — used
internally by weather layers, rarely built by hand). See `references/api-reference.md` for full
constructor options per type. Quick pattern:
```javascript
controller.addSource('earthquakes', {
type: 'geojson',
data: 'https://data.api.xweather.com/earthquakes/search?query=mag:1&limit=200&format=geojson&client_id=ID&client_secret=SECRET'
});
controller.getSource('earthquakes').setUrl('...'); // swap remote data
controller.getSource('earthquakes').setData({ ... }); // set static data directly
```
## Animating over time
`controller.timeline` controls playback across every animated layer at once. Full API
(setting ranges by Date/offset/relative string, speed, play/pause/goTo) is in
`references/timeline.md`. Quick start:
```javascript
controller.on('load', () => {
controller.timeline.setStartDateUsingRelativeTime('-3 hours');
controller.timeline.duration = 1.5; // seconds per loop
controller.timeline.play();
});
```
**To show one specific time, set the range before seeking to it.** `goToDate(date)` moves *within*
`startDate`…`endDate` — it never widens the window, and a date outside the range simply doesn't
display, with no error:
```javascript
const target = new Date('2026-08-09T18:00:00Z');
controller.timeline.startDate = new Date(target.getTime() - 3 * 3600 * 1000);
controller.timeline.endDate = new Date(target.getTime() + 3 * 3600 * 1000);
controller.timeline.goToDate(target);
```
This is the most common reason a "jump to this timestamp" feature silently does nothing. If the target
can be arbitrary, test it against the current range and widen when it falls outside — worked example in
`references/timeline.md`. The window also has to sit inside the layer's own `dataRange`.
## Legends & data inspection
```javascript
controller.addLegendControl('#legend-container'); // auto-syncs with active weather layers
controller.removeLegendControl();
controller.addDataInspectorControl({ event: 'click' }); // click/hover to inspect raw values
controller.removeDataInspectorControl();
```
**If you override a layer's `paint` colors, override its `legend` too.** Auto-detection only works for
unmodified color scales, so a custom paint with a default legend produces a legend that lies about the
map. Use `legend: { points: {...} }` for categorical data and `legend: { bar: {...} }` for a
continuous gradient — and for a gradient, pass the **same `colorscale` stops** to both so they can't
drift apart:
```javascript
controller.addWeatherLayer('temperatures', {
paint: { sample: { colorscale: myColorscale } },
legend: { bar: { colorscale: myColorscale, measurement: { type: 'temperature', units: 'C' } } }
});
```
Full field reference plus complete categorical and gradient examples: `references/legends.md`.
## Querying data at a point
```javascript
const results = controller.query({ lat: 40, lon: -74.5 }); // sync
const results = await controller.queryPromise({ lat: 40, lon: -74.5 });
// -> { [layerId]: sampled value(s) / feature(s) at that coordinate }
```
## Checklist for common tasks
- **"Build me a map / show me an example"** → one self-contained HTML file, vanilla JS, CDN tags,
`aerisweather.mapsgl.*`. No bundler or framework unless explicitly requested. See
"How to write MapsGL code examples" above.
- **"Add a weather layer"** → `controller.addWeatherLayer(code)` inside `on('load', ...)`; look the
code up in `references/layers.md`, or fetch
`https://www.xweather.com/docs/api/mapsgl/layers` if it isn't listed there.
- **"Remove/hide a layer"** → `removeWeatherLayer` (frees resources) vs
`setWeatherLayerVisibility(code, false)` (cheap toggle, keeps resources loaded).
- **"Change the colors/thresholds of a layer"** → override `paint.sample.colorscale` (see
`references/color-scales.md`); remember stop values must be in the data's native metric units.
- **"Toggle/update a weather layer's opacity or paint from a UI control (slider, checkbox, etc.)"**
→ don't call `controller.setPaintProperty(code, ...)` with the weather layer code — get the real
layer with `controller.getWeatherLayer(code)` (or the value returned by `addWeatherLayer`) and
call `.setPaintProperty(...)` on it directly; handle the array case for composite codes. This is
the single most common silent-failure bug with built-in weather layers — see
`references/weather-layers.md`.
- **"Only show values above/below X"** → `paint.sample.drawRange` for continuous data, or a
`filter` expression for vector/geojson layers.
- **"Style based on a feature property"** → an expression: `['get', 'FIELD']` for a direct value,
`['match', ['get', 'FIELD'], ...]` for categorical colors/sizes, `['interpolate', ['linear'],
['get', 'FIELD'], ...]` for continuous ranges. See `references/expressions.md`.
- **"Animate over time / add a time slider"** → `controller.timeline`, see `references/timeline.md`.
- **"Jump to a specific timestamp" / `goToDate` does nothing** → the date is outside
`startDate`…`endDate`. `goToDate` seeks within the range and never widens it, so set the range
first, then seek. Silent failure, no error thrown.
- **"Show a legend"** → `addLegendControl`; override `legend.points` (categorical) or `legend.bar`
(gradient) if paint was customized — see `references/legends.md`.
- **"Add my own data (not a built-in weather layer)"** → `addSource` + `addLayer` with an explicit
`type`/`paint`; see `references/api-reference.md`.
- **"What layers/options are available?"** → `references/layers.md` for the full listing by category;
`https://www.xweather.com/docs/api/mapsgl/layers` if that snapshot might be stale; or
`controller.weatherProvider.getLayerMetadata()` at runtime for account-specific availability. Never
invent a code from memory — one of these three always has the answer.
- **"How many accesses / how much does this cost?"** → sessions, not tiles or layers: count the
5-minute clock buckets the viewing touches, × 150 accesses. Show the arithmetic and mention that
layers and interaction are free inside a session. See `references/sessions.md`.
- **`aerisweather is not defined` / `Cannot read properties of undefined`** → the `<script src>` is
pointing at an npm mirror (`unpkg.com`, `cdn.jsdelivr.net`) instead of
`https://cdn.aerisapi.com/sdk/js/mapsgl/<version>/aerisweather.mapsgl.js`. The mirror returns 200 and
loads a build that defines `globalThis.mapsgl` rather than `aerisweather.mapsgl`, so there's no
network error to spot — only the runtime failure. Fix the host, don't rename the global.
- **"Handle load errors / show an error state"** → there is no `controller.on('error', ...)` —
that event doesn't exist on `MapController` and will never fire. See the events note in
`references/api-reference.md`.
## Attribution is required
Xweather requires attribution wherever its data or imagery is displayed. This applies to **all
products** — Weather API, Raster Maps, and MapsGL alike. Build it into anything you produce, and say
so when handing over code or URLs that will end up in front of users.
The minimum is a link to `https://www.xweather.com/` reading "Powered by Vaisala Xweather":
```html
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">Powered by Vaisala Xweather</a>
```
The logo may be substituted for the "Xweather" text. Light and dark variants exist in SVG and PNG:
```html
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">
<img src="https://www.xweather.com/assets/logos/vaisala-xweather-logo-dark.svg" alt="Vaisala Xweather" height="40" />
</a>
```
Swap `-dark` for `-light` over a dark background, or `.svg` for `.png`. Using the logo brings rules:
keep it unmodified, leave at least a **10px buffer** of space around it, and only adjust lightness or
opacity in greyscale. Don't rotate it, don't recolour it (monotone black or white excepted), and don't
use the symbol without the Xweather name.
Full guide: https://www.xweather.com/docs/weather-api/resources/attribution
## Reference files
- `references/api-reference.md` — full `Account`, `MapController`, and `DataSource` API (all methods, properties, events, per-provider setup)
- `references/layers.md` — every weather layer by category: code, description, render type, animatability, cost multiplier, coverage, data range, update interval; composite codes and cost multipliers grouped up front
- `references/weather-layers.md` — how to discover layer codes, the catalog schema, and the code-vs-layer-id gotcha that silently breaks style updates
- `references/styles.md` — paint property spec for every render type, plus filters and masks
- `references/color-scales.md` — color scale config format and built-in named palettes
- `references/expressions.md` — style/filter expression operator reference
- `references/legends.md` — `points` (categorical) and `bar` (gradient) legend config reference
- `references/timeline.md` — animation/timeline API
- `references/sessions.md` — how MapsGL usage is measured: the session model, clock-aligned billing, worked examples, the MapsGL vs. Raster Maps comparison, and how to reduce consumption
Referenced files: 9
mapsgl-android25.2 KB
---
name: mapsgl-android
description: >-
This skill should be used when working with the Xweather MapsGL Android SDK
(mapsgl-android-sdk / com.xweather.mapsgl) - setting up MapboxMapController,
adding or removing weather layers via LayerCode or WeatherService configs,
styling with StyleValue and Expression, custom sources and layers, legends,
data inspector presentations, timeline animation, layer masks, or integrating
the AAR/JitPack dependency into an Android app. Use it whenever a task mentions
MapsGL Android, MapboxMapController, addWeatherLayer, LayerCode,
WeatherService, XweatherAccount, or weather overlays on Mapbox Maps SDK for
Android. Also covers MapsGL session-based usage/cost (shared with the JS SDK)
and common Android gotchas (Mercator, OpenGL ES 3.0, minSdk 28, Mapbox peer
dependency). Documents the MapsGL Android 1.6.1 release - every API here is in
that published build, with no unreleased or development-branch surface. When docs
and SDK disagree, prefer the SDK (source / KDoc / demos) over xweather.com
documentation.
license: MIT
---
# MapsGL Android
MapsGL Android renders weather and custom map data on top of the **Mapbox Maps
SDK for Android** (encoded grids via OpenGL ES custom layers; many vector
weather layers via Mapbox style layers). Requires an Xweather account (Weather
API + Maps) **and** Mapbox access / downloads tokens.
## Source of truth (read this first)
When answering or writing code, resolve conflicts in this order:
1. **The MapsGL Android SDK** - public Kotlin APIs in `mapsglmaps` / published AAR,
in-repo demos under `app/`, and generated KDoc
2. **This skill** (kept to match the SDK)
3. **https://www.xweather.com/docs/mapsgl-android-sdk/** - useful for tutorials and
recipes, but often lagging (deprecated ctors, wrong `removeWeatherLayer` args,
missing Mapbox peer dep, "coming soon" for shipped features, invented overload
shapes)
**Prefer the SDK to the docs.** If a docs snippet disagrees with a real method
signature, package, or demo in the SDK, follow the SDK and say so. Never invent an
API - if it isn't in the SDK source, KDoc or a demo, it doesn't exist.
Docs hub (optional context only):
https://www.xweather.com/docs/mapsgl-android-sdk/
**API scope:** public MapsGL Android APIs only - no internals.
**This skill documents the 1.6.1 release.** Every API described here exists in the
published 1.6.1 artifact - there is no unreleased or development-branch surface to
filter out, and nothing is marked *(unreleased)*.
That also bounds what the skill can offer. Features added after 1.6.1 - the
`["map-time"]` filter expression, MapsGL's own GLES vector rendering pipeline, and
the `*-text` data-query layers - are deliberately absent. If a task needs one of
those, say it is not available in 1.6.1 rather than writing code against it.
**Never hardcode a version number.** Resolve the current release when you need
one:
```bash
curl -s https://www.xweather.com/docs/api/releases/versions \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["products"]["mapsgl-android-sdk"]["version"])'
```
That endpoint is the release source of truth for every Xweather product, keyed by
product id - `mapsgl-android-sdk` here, alongside `mapsgl`, `mapsgl-apple-sdk`,
`weather-api`, `maps`, and others. It's a small public JSON document, no auth
needed.
A version is only needed for a deliberate Gradle pin or an API-reference URL.
Where this skill's `references/` note behaviour "on 1.6.x", that records what the
guidance was checked against - verify against the release you're actually on
before relying on it.
## How to write examples
**Default:** one Kotlin `Activity` / Fragment with ViewBinding + Mapbox `MapView`.
No Compose unless asked. Match the surrounding project when it disagrees with that
default - if it is a Fragment codebase, or already uses Compose interop, follow it.
## API reference
`references/api-reference.md` carries real signatures for every public type. The
published KDoc is the per-version authority:
```
https://cdn.aerisapi.com/sdk/android/mapsgl/docs/v{version}/mapsglmaps/{package}/{-class-name}/index.html
```
There is **no `latest` alias** - `/docs/latest/` returns 404. Resolve the version
first (above). Class paths dash-case the name: `MapController` becomes
`-map-controller`.
## How much of this has been proven
Not all of it to the same standard, and the difference matters when something here
disagrees with what you observe.
**Built and run.** The Setup section and the Complete example below were applied to
a blank Android project, compiled, and run against MapsGL Android 1.6.1. That
covers `XweatherAccount`, `MapboxMapController`, `setCenter`/`setZoom`,
`onLoadStart`/`onLoadComplete`, `subscribeMapLoaded`, the Mercator `setProjection`,
`LegendControl`, `addDataInspectorControl`, `animationOptions.shouldPreloadData`,
`timeline.setStartDateUsingRelativeTime`/`end`/`play`/`pause`,
`addWeatherLayer`/`removeWeatherLayer`, `LayerCode.RADAR`, and the five
`com.xweather.mapsgl.*` import paths those need. The build also confirmed that a
consuming app's merged manifest picks up `largeHeap="true"` and the GLES 3.0
`uses-feature` from the SDK.
**Generated from the SDK.** `references/layers.md` is produced mechanically from
the `LayerCode` enum and the `WeatherService` factories at the `release/1.6.1` tag,
and re-checked against the released 1.6.1 KDoc.
**Compiled against the published artifact.** The Kotlin snippets across the
reference files were extracted and compiled against `mapsgl-android-sdk:v1.6.1`
resolved from JitPack - 40 of the 68 self-contained ones compile clean. That pass
is what caught the `interpolateExponential` / `interpolateCubicBezier` argument
orders and the fact that `StyleColor` is minified out of the published artifact,
none of which reading the source could show.
**Read from the SDK source, not compiled.** The remainder - the rest of
`references/api-reference.md`, and the snippets that need an Activity or
surrounding declarations to compile on their own. The signatures were read out of
the SDK at `release/1.6.1` rather than written from memory, but no build has
exercised them.
Note the gap those two tiers expose: **the published AAR is minified, so its public
surface is narrower than the source tree.** When a symbol is visible in the SDK
source but a consumer cannot resolve it, the artifact wins.
If a snippet from the third group does not compile, trust the SDK and say so.
## Core concepts
| Concept | What it is |
|---|---|
| `XweatherAccount` | Client id/secret |
| `MapboxMapController` | Mapbox adapter (`MapController` APIs) |
| `WeatherService` / `LayerCode` | Built-in weather configs / codes |
| Source / layer descriptors | Custom data + renderers |
| `StyleValue` / `Expression` | Paint + data-driven style |
| `LegendControl` / `DataInspectorControl` | On-map UI |
| `timeline` / `animationOptions` | Shared animation clock |
## Setup
### 1. Credentials - both sets are required
| | Where | Used for |
|---|---|---|
| Xweather client id + secret | https://data.portal.xweather.com/account/keys | `XweatherAccount(id, secret)` |
| Mapbox access token | `mapbox_access_token` string resource | Map rendering at runtime |
| Mapbox downloads token | `MAPBOX_DOWNLOADS_TOKEN` in `gradle.properties` | Resolving the Mapbox SDK at build time |
If nothing renders or auth fails, check **both** credential sets before digging
into MapsGL. A missing Mapbox token looks like a MapsGL failure but isn't.
### 2. Install
**Mapbox is a peer dependency** - MapsGL does not bring it transitively, and the
official getting-started page shows only JitPack. Add both repositories and both
dependencies:
```gradle
// settings.gradle
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven {
url = uri("https://jitpack.io")
// Prefer POM + artifact over JitPack's rewritten *.module, which breaks IDE KDoc
metadataSources { mavenPom(); artifact() }
}
maven {
url = uri("https://api.mapbox.com/downloads/v2/releases/maven")
authentication { basic(BasicAuthentication) }
credentials { username = "mapbox"; password = MAPBOX_DOWNLOADS_TOKEN }
}
// Required: the SDK has a transitive `api` dependency on
// no.ecc.vectortile:java-vector-tile, which is published only here.
maven { url = uri("https://maven.ecc.no/releases") }
}
}
```
**All four repositories are required.** Omitting `maven.ecc.no` fails at dependency
resolution with `Could not find no.ecc.vectortile:java-vector-tile`, which reads
like a broken SDK release rather than a missing repository.
```gradle
// app/build.gradle - resolve vX.Y.Z from the releases endpoint, don't copy a literal
android {
compileSdk 36
defaultConfig { minSdk 28 }
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = '17' }
buildFeatures { viewBinding true } // the examples below use ViewBinding
}
dependencies {
implementation "com.github.vaisala-xweather:mapsgl-android-sdk:vX.Y.Z"
implementation "com.mapbox.maps:android-ndk27:11.15.3"
}
```
Do **not** also add a `...:mapsglmaps` artifact - that duplicates the SDK.
### 3. Create the controller, then wait for the map to load
Two things have to be true before adding weather layers: the `MapView` must be
attached, and the Mapbox map must have loaded. Adding layers earlier silently
does nothing.
```kotlin
val controller = MapboxMapController(mapView, account)
mapView.mapboxMap.subscribeMapLoaded {
// safe to add weather layers here
}
```
Use `MapboxMapController(mapView, account)`. The 4-argument constructor taking a
`Context` and `LifecycleOwner` is **deprecated** and merely delegates to this one,
despite still appearing throughout the website documentation.
Full install detail, credential wiring and the string resources:
`references/setup.md`.
## Complete example
A single Activity that renders an animated radar layer with a legend, tears down
on `onStop` so it stops consuming sessions, and carries the required attribution.
**This example has been built and run** - see "How much of this has been proven"
above.
```xml
<!-- res/layout/activity_weather_map.xml -->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.mapbox.maps.MapView
android:id="@+id/mapView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/attribution"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp"
android:text="Powered by Vaisala Xweather"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
```
```kotlin
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.ViewTreeObserver
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import com.example.yourapp.databinding.ActivityWeatherMapBinding // generated by ViewBinding
import com.mapbox.maps.extension.style.layers.properties.generated.ProjectionName
import com.mapbox.maps.extension.style.projection.generated.projection
import com.mapbox.maps.extension.style.projection.generated.setProjection
import com.xweather.mapsgl.config.weather.account.XweatherAccount
import com.xweather.mapsgl.controls.legend.LegendControl
import com.xweather.mapsgl.types.Coordinate
import com.xweather.mapsgl.map.mapbox.MapboxMapController
import com.xweather.mapsgl.weather.LayerCode
import java.util.Date
class WeatherMapActivity : AppCompatActivity() {
private lateinit var binding: ActivityWeatherMapBinding
private var controller: MapboxMapController? = null
private val activeCodes = listOf(LayerCode.RADAR)
private var weatherAttached = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityWeatherMapBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.attribution.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.xweather.com/")))
}
val account = XweatherAccount(
getString(R.string.xweather_client_id),
getString(R.string.xweather_client_secret),
)
// Wait for the MapView to be attached before constructing the controller.
binding.mapView.viewTreeObserver.addOnGlobalLayoutListener(
object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
binding.mapView.viewTreeObserver.removeOnGlobalLayoutListener(this)
if (binding.mapView.parent == null) return
setUpMap(account)
}
})
}
private fun setUpMap(account: XweatherAccount) {
val c = MapboxMapController(binding.mapView, account)
controller = c
c.setCenter(Coordinate(39.5, -98.0))
c.setZoom(4.0)
// Loading UI, driven by the controller's own signals.
c.onLoadStart.observe(this) { binding.progress.isVisible = true }
c.onLoadComplete.observe(this) { binding.progress.isVisible = false }
binding.mapView.mapboxMap.subscribeMapLoaded {
// MapsGL requires Mercator.
binding.mapView.mapboxMap.style?.setProjection(projection(ProjectionName.MERCATOR))
c.add(LegendControl().apply { mapView = binding.mapView })
c.addDataInspectorControl(binding.mapView)
// Pre-fetch tiles across the range so play() doesn't stall.
c.animationOptions.shouldPreloadData = true
c.timeline.setStartDateUsingRelativeTime("-1 day")
c.timeline.end = Date()
attachWeather()
}
}
private fun attachWeather() {
val c = controller ?: return
if (weatherAttached) return
activeCodes.forEach { c.addWeatherLayer(it) }
weatherAttached = true
c.timeline.play()
}
override fun onStart() {
super.onStart()
if (controller != null) attachWeather()
}
// Sessions accrue while layers are attached — detach when not visible.
override fun onStop() {
super.onStop()
val c = controller ?: return
c.timeline.pause()
activeCodes.forEach { c.removeWeatherLayer(it) }
weatherAttached = false
}
}
```
Points worth carrying into any example you write: construct only after the view is
attached, set Mercator, add layers inside `subscribeMapLoaded`, and detach in
`onStop`.
## Weather layers
```kotlin
controller.addWeatherLayer(LayerCode.TEMPERATURES) // defaults
controller.addWeatherLayer(WeatherService.Temperatures(controller.service)) // to override paint
controller.addWeatherLayer(LayerCode.RADAR) { config -> /* tweak */ } // configure lambda
controller.removeWeatherLayer(LayerCode.TEMPERATURES) // code only
controller.setWeatherLayerVisibility(LayerCode.RADAR, false)
```
`removeWeatherLayer` takes **only** the code - the extra-argument forms in the
website docs don't exist.
**`LayerCode` is not the style layer id.** To stack something relative to a
weather layer, get the real id first:
```kotlin
val id = controller.getWeatherLayer(LayerCode.TEMPERATURES)?.id
controller.addWeatherLayer(WeatherService.WindParticles(controller.service), beforeId = id)
```
Which codes exist, with wire code, factory and render type: `references/layers.md`.
Add/remove detail: `references/weather-layers.md`.
## Styling
Paint lives on the configuration's `layer.paint`, and its concrete type depends on
the layer's render type. `opacity` is a plain `Float`:
```kotlin
val config = WeatherService.Temperatures(controller.service) as WeatherLayerConfiguration<*, *>
val paint = config.layer.paint as SampleLayerPaint
paint.opacity = 1.0f
paint.sample.colorScale = ColorScaleOptions(stops = listOf(/* ... */))
controller.addWeatherLayer(config)
```
Render types and their paint namespaces are listed per section in
`references/layers.md`. `DataQuality` (`exact`, `high`, `medium`, `normal`, `low`)
trades resolution for bandwidth - a performance lever, never a cost one.
Paint by render type: `references/weather-styling.md`. Descriptor and paint
overview: `references/styles.md`. `StyleValue` / `Expression`:
`references/expressions.md`. Data-driven cookbooks: `references/data-driven.md`.
## Custom sources and layers
```kotlin
val source = controller.addSource(
GeoJSONSourceDescriptor(id = "my-data", url = "https://example.com/data.geojson")
)
controller.addLayer(FillLayerDescriptor(/* ... */), beforeID = null)
controller.removeLayer("my-layer")
controller.removeSource("my-data")
```
Note `addLayer` takes **`beforeID`** while `addWeatherLayer` and `moveLayer` take
**`beforeId`**, and `removeLayer`'s second parameter is spelled `isCompounLayer` in
the public API.
Source descriptors: `references/sources.md`. Layer descriptors and `addLayer`
recipes: `references/custom-layers.md`.
## Animating over time
```kotlin
controller.animationOptions.shouldPreloadData = true // false by default
controller.timeline.setStartDateUsingRelativeTime("-1 day")
controller.timeline.end = Date()
controller.timeline.play()
```
`shouldPreloadData` is the difference between playback that starts immediately and
playback that stalls while tiles arrive. Playback, range, events and the load-UI
signals: `references/timeline.md`.
## Legends and data inspection
```kotlin
controller.add(LegendControl().apply { mapView = binding.mapView })
val inspector = controller.addDataInspectorControl(binding.mapView)
inspector.setPresentation(layerId, presentation)
```
`setPresentation` keys on the **style layer id**, not `LayerCode`.
`LegendControl.backgroundColor` is a Compose `Color`, not `android.graphics.Color`.
Presentations, units and custom legends: `references/legends-inspector.md`.
## Querying data at a point
Tapping is handled for you by `DataInspectorControl` - prefer it. For a
programmatic hit test, `MapboxMapController` exposes a suspend query:
```kotlin
suspend fun queryFeatures(
point: Point,
vectorLayerList: List<VectorTileLayer>,
onTouch: Boolean = true,
): HashMap<String, FeatureQueryResult>?
```
It takes the vector layers to test explicitly and returns results keyed by layer
id, or null when nothing was queried or the timeline is blocking queries. It is a
`suspend` function - call it from a coroutine, not from a click listener directly.
## Usage is measured in sessions
MapsGL bills in **sessions** - clock-aligned 5-minute buckets that start when a
weather layer is added - not per tile, layer, or request. **The model is
identical on Android and on the web, and this skill is not its source of truth.**
For anything quantitative - the billing rules, the access multiplier, worked
examples, capacity-planning figures, the Raster Maps comparison - use the
authoritative source rather than answering from memory: the `mapsgl` skill's
`references/sessions.md` (both skills ship in the same plugin), or
https://www.xweather.com/docs/mapsgl/getting-started/sessions.
What matters here is the **Android-specific** consequence: since interaction
inside a session is free and layer count doesn't affect cost, consumption is
governed purely by *how long weather layers are attached to a map*. On Android
that means lifecycle -
- add layers when the weather UI is reached, not when the controller is built;
- remove them in `onStop`, not `onDestroy`, which isn't guaranteed to run;
- an app pocketed on the weather screen keeps billing - the failure mode with no
web analogue;
- treat always-on kiosk and wall displays as the expensive pattern, and say so
unprompted.
Two traps worth stating whenever cost comes up: `setWeatherLayerVisibility` is
the cheap toggle but `removeWeatherLayer` is the one that stops consumption, and
**`DataQuality` is a performance lever, not a cost lever** - it cuts requests,
and sessions don't count requests.
Code for each of these, and the full list of what is *not* worth optimizing:
`references/sessions.md`.
## Android rules
- minSdk **28**, GLES **3.0** for encoded paths, **Mercator** required
- Mapbox is a **peer** dependency
- Prefer public APIs
More: `references/android-gotchas.md`.
## Checklist for common tasks
- **"Add a weather map to my app"** -> the complete example above. Construct after
the view is attached, set Mercator, add layers inside `subscribeMapLoaded`.
- **"How do I install it / which version"** -> both repositories and both
dependencies; resolve the version from the releases endpoint, never a literal.
`references/setup.md`.
- **"Add layer X"** -> find the `LayerCode` in `references/layers.md` first; the
enum name is not a transform of the wire code. Every code in that catalog ships
in 1.6.1; if a code isn't listed, it isn't available in this release.
- **"Nothing renders"** -> check Mapbox tokens *and* Xweather credentials, then
that layers were added after `subscribeMapLoaded`, then Mercator.
- **"Restyle a layer"** -> cast `config.layer.paint` to the paint type for its
render type (`references/layers.md` groups by descriptor), set fields, then add.
Opacity is a `Float`.
- **"Animate over time / add a scrubber"** -> `controller.timeline`, and set
`animationOptions.shouldPreloadData = true`. `references/timeline.md`.
- **"Show a legend"** -> `LegendControl` + `controller.add(legendControl)`; set its
`mapView`. Override `config.legend` when you customized a categorical paint.
- **"Show values on tap"** -> `addDataInspectorControl(mapView)`, customize with
`setPresentation(layerId, presentation)` keyed by style layer id.
- **"Stack layers in a specific order"** -> resolve the real style layer id via
`getWeatherLayer(code)?.id` and pass it as `beforeId`.
- **"How many accesses will this cost?"** -> sessions, not tiles or layers. Get the
model and arithmetic from the `mapsgl` skill's `references/sessions.md` or the
public docs, then apply the Android lifecycle guidance in
`references/sessions.md`.
- **"Where are the API docs?"** -> releases endpoint for the version, then the
KDoc URL pattern above. There is no `latest` alias.
## Attribution is required
Xweather requires attribution wherever its data or imagery is displayed. This applies to **all
products** - Weather API, Raster Maps, and MapsGL alike. Build it into anything you produce, and say
so when handing over code that will end up in front of users.
The minimum is a link to `https://www.xweather.com/` reading "Powered by Vaisala Xweather":
```kotlin
findViewById<TextView>(R.id.attribution).apply {
text = "Powered by Vaisala Xweather"
setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.xweather.com/")))
}
}
```
The logo may be substituted for the "Xweather" text. Light and dark variants exist in SVG and PNG at
`https://www.xweather.com/assets/logos/vaisala-xweather-logo-dark.svg` - swap `-dark` for `-light`
over a dark background, or `.svg` for `.png`. Bundle the asset as a drawable rather than loading it
over the network in a shipping app. Using the logo brings rules: keep it unmodified, leave at least a
**10dp buffer** of space around it, and only adjust lightness or opacity in greyscale. Don't rotate
it, don't recolour it (monotone black or white excepted), and don't use the symbol without the
Xweather name.
Full guide: https://www.xweather.com/docs/weather-api/resources/attribution
## Reference index
| File | Use when |
|---|---|
| `references/setup.md` | Install, MapLoaded, credentials |
| `references/layers.md` | The layer catalog - every `LayerCode`, its wire code, factory and render type |
| `references/api-reference.md` | Real signatures for every public type, and the KDoc URL pattern |
| `references/weather-layers.md` | LayerCode / WeatherService add/remove |
| `references/weather-styling.md` | Raster/sample/particle/grid paint |
| `references/timeline.md` | Range, playback, events, load UI |
| `references/styles.md` | Descriptor/paint overview |
| `references/expressions.md` | StyleValue / Expression |
| `references/data-driven.md` | match/get/concat cookbooks |
| `references/sources.md` | Vector / GeoJSON / encoded sources |
| `references/custom-layers.md` | addLayer fill/circle/... |
| `references/legends-inspector.md` | Legends + Presentation |
| `references/sessions.md` | The Android half of session cost: lifecycle teardown + traps. Points at the `mapsgl` skill for the billing model itself |
| `references/android-gotchas.md` | Platform pitfalls |
Referenced files: 14
mapsgl-apple33.3 KB
---
name: mapsgl-apple
description: This skill should be used when working with the Xweather MapsGL SDK for Apple platforms (the MapsGL iOS/iPadOS/macCatalyst/visionOS SDK) — installing it via Swift Package Manager, CocoaPods, Carthage or xcframeworks, creating a MapboxMapController or MapLibreMapController, and adding, removing, styling, animating or inspecting MapsGL weather layers in Swift. Use it whenever a task mentions MapsGL on iOS or Apple platforms, mapsgl-apple-sdk, MapsGLMaps, MapsGLMapbox, MapsGLMapLibre, XweatherAccount, WeatherService.LayerCode, addWeatherLayer in Swift, or a native weather map in SwiftUI or UIKit. Also use it for questions about MapsGL session usage or cost in an Apple app — sessions, the 5-minute clock intervals, the access multiplier, and which view and app lifecycle events should attach and detach weather layers. Also covers Xweather's attribution requirement — the 'Powered by Vaisala Xweather' credit and logo rules that apply wherever Xweather data or imagery is displayed.
license: MIT
---
# MapsGL for Apple platforms
The Xweather MapsGL SDK for Apple platforms renders weather and custom map data client-side with
Metal, layered on top of **Mapbox Maps** or **MapLibre Native**. It requires an active Xweather
account with Weather API + Maps access (client id + secret).
Platform support comes from the package manifest: **iOS 16+, macCatalyst 16+, visionOS 1+**. There is
no native macOS (AppKit) target — a "macOS" app here means Mac Catalyst.
Docs: https://www.xweather.com/docs/mapsgl-apple-sdk/getting-started ·
Distribution + demo app: https://github.com/vaisala-xweather/mapsgl-apple-sdk
## Ask which map provider unless the context tells you
The map provider is not a stylistic preference that can be defaulted: Mapbox and MapLibre resolve
*different Swift Package branches*, different transitive SDKs, and different map-view types. Getting
it wrong means the code doesn't compile and the package graph has to be redone.
So **infer it when the context actually says, and ask when it doesn't.** Never pick one by default.
**Infer it** from evidence like:
- the user named the provider in their request;
- the project already integrates one — an `import MapsGLMapbox` / `import MapsGLMapLibre`, a resolved
`mapbox-maps-ios` or `maplibre-gl-native-distribution` dependency, a `Podfile` naming one, an
`MLNMapView` or `MapboxMaps.MapView` in the source, or a `MBXAccessToken` in an Info.plist;
- the project already uses the provider's SDK elsewhere, even without MapsGL — a Mapbox-based map
screen means Mapbox.
When you infer, **say which provider you picked and what told you**, so a wrong read is cheap to
correct.
**Ask** when the evidence is absent or contradictory — a greenfield app, a project with no map
dependency yet, or one carrying traces of both. Weak circumstantial signals ("we want a dark map
style", "our designer sent Mapbox screenshots") are not evidence of an integration; ask rather than
build the whole package graph on them.
Trade-offs to offer alongside the question, briefly:
| | Mapbox Maps | MapLibre Native |
|---|---|---|
| Basemap key | Mapbox account access token required, plus a secret token to download the SDK | None — but the style URL's tile provider may need one (CARTO's public styles don't) |
| Cost | Mapbox map-load pricing applies | No basemap vendor cost |
| SwiftUI | Native `Map` view | `MLNMapView` wrapped in a `UIViewRepresentable` |
| MapsGL constraint | Must set the **mercator** projection — the default globe projection is incompatible | None |
## How to write MapsGL Apple examples
**Default to SwiftUI.** Produce UIKit only when the project is UIKit (a `UIViewController`-based app,
storyboards/XIBs, an `AppDelegate`/`SceneDelegate` pair with no SwiftUI `App`) or the user asks for
it. Match the surrounding project over the default whenever the two disagree — including Swift
concurrency style, view-model conventions, and how the app already stores secrets.
**Never hardcode a version number.** Resolve the current release when you need one:
```bash
curl -s https://www.xweather.com/docs/api/releases/versions \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["products"]["mapsgl-apple-sdk"]["version"])'
```
That endpoint is the release source of truth for every Xweather product, keyed by product id —
`mapsgl-apple-sdk` here, alongside `mapsgl`, `weather-api`, `maps`, and others. It's a small public
JSON document, no auth needed.
Most of the time you don't need a version at all: **prefer the Swift Package branch channels**
(below), which track the latest release without a pin. A version is only needed for a deliberate
pin, a CocoaPods/Carthage requirement, or an API-reference URL.
**Credentials never go in source.** Put the Xweather client id/secret and any Mapbox token in a
gitignored plist, xcconfig, or the keychain — whatever the project already uses — and read them at
runtime. The demo app's `AccessKeys.plist` pattern is a reasonable model when the project has none.
Write `"FILL_IN_WITH_YOUR_CLIENT_ID"`-style placeholders rather than inventing plausible keys.
**Every example must include the Xweather attribution.** It's a requirement of using the product, not
a nicety, so build it into the view rather than mentioning it afterwards. See "Attribution is
required".
## API reference
The full API reference is DocC, published per SDK version:
```
https://cdn.aerisapi.com/sdk/ios/mapsgl/docs/v{version}/documentation/mapsglmaps
```
Substitute the version from the releases endpoint above — **there is no `latest` alias**;
`.../docs/latest/...` 404s. Only the `mapsglmaps` module is published; `MapsGLCore`,
`MapsGLRenderer`, and the two adapter modules have no hosted DocC.
The version index page, which lists every published version, is
https://www.xweather.com/docs/mapsgl-apple-sdk/api-reference.
A machine-readable symbol index sits alongside it at
`https://cdn.aerisapi.com/sdk/ios/mapsgl/docs/v{version}/index/index.json` — useful for checking
whether a symbol exists in a given release before writing code against it.
`references/api-reference.md` carries the surface an agent needs most (controller, service, timeline,
controls, descriptors) so the common cases need no network call.
## Core concepts
| Concept | What it is |
|---|---|
| `XweatherAccount(id:secret:)` | Wraps client id/secret credentials used for all data requests |
| `MapController` | Adapter between the underlying map (`MapboxMaps.MapboxMap` / `MLNMapView`) and MapsGL — the object almost everything below is called on. Concrete: `MapboxMapController`, `MapLibreMapController` |
| `WeatherService` | The account-bound weather data service, reachable as `controller.service`. Namespaces every built-in layer configuration and `WeatherService.LayerCode` |
| `WeatherService.LayerCode` | Enum identifying a built-in weather layer — `.radar`, `.temperatures`, `.windParticles` |
| `WeatherService.<Name>` | Per-layer configuration struct (`WeatherService.Temperatures`) holding `layer`, `legend`, and `presentation`. Instantiate it to override defaults |
| Source descriptors | Where custom layer data comes from — `ImageSourceDescriptor`, `EncodedSourceDescriptor`, `VectorSourceDescriptor`, `GeoJSONSourceDescriptor` |
| Layer descriptors | How data is rendered — `RasterLayerDescriptor`, `SampleLayerDescriptor`, `ParticleLayerDescriptor`, `GridLayerDescriptor`, `ContourLayerDescriptor`, `FillLayerDescriptor`, `LineLayerDescriptor`, `CircleLayerDescriptor`, `SymbolLayerDescriptor`, `HeatmapLayerDescriptor` |
| `paint` | Per-descriptor style config, namespaced by render type — `paint.sample`, `paint.fill`, `paint.stroke`. See `references/styles.md` |
| `Expression` | Data-driven paint values and layer filters, built with static factories — `Expression.get("COLOR")`. See `references/expressions.md` |
| `ColorScaleOptions` / `ColorStop` | Maps a continuous data range to colors, used by `paint.sample.colorScale` and bar legends |
| `LegendControl` | Manages the legends visible on a map; auto-syncs with built-in weather layers. See `references/legends.md` |
| `DataInspectorControl` | Tap-to-inspect callout showing raw layer values at a coordinate |
| `controller.timeline` | Drives time animation across every animated layer at once. See `references/timeline.md` |
Built-in **weather layers** are pre-wired combinations of a source + styled layer(s), addressed by a
single `LayerCode` case. Prefer these over hand-building sources and layers unless visualizing custom
or non-weather data.
## Setup
### 1. Credentials
Two independent sets, both required:
1. **Xweather account keys** — `CLIENT_ID` / `CLIENT_SECRET` from
https://data.portal.xweather.com/account/keys. Passed as `XweatherAccount(id:secret:)`.
2. **The map provider's own credentials** —
- Mapbox → a public access token set on `MapboxOptions.accessToken`, **plus** a secret download
token configured in `~/.netrc` so SPM/CocoaPods can fetch the Mapbox SDK at all. The secret
token is a build-time requirement; forgetting it fails resolution, not runtime.
- MapLibre → nothing for the SDK. The basemap `styleURL` points at a tile provider, which may need
its own key (CARTO's public Positron/Dark Matter styles do not).
If nothing renders, check both sets before investigating MapsGL.
### 2. Install
**Swift Package Manager (preferred).** Add `https://github.com/vaisala-xweather/mapsgl-apple-sdk` and
pick the **branch matching your provider** — the package manifest at the repo root is
provider-specific per branch, so the branch *is* the provider choice:
| Channel | Branch | Resolves | Product |
|---|---|---|---|
| Latest Mapbox | `master` | `mapbox-maps-ios` 11.x | `MapsGL` |
| Latest MapLibre | `maplibre` | `maplibre-gl-native-distribution` 6.18+ | `MapsGL` |
| Pinned Mapbox | `release/x.y.z` | as above, frozen | `MapsGL` |
| Pinned MapLibre | `release/maplibre/x.y.z` | as above, frozen | `MapsGL` |
The product name is `MapsGL` on every branch; what differs is which adapter target it includes. Add
the `MapsGL` library product to the app target. Xcode resolves the three binary xcframeworks
(`MapsGLCore`, `MapsGLRenderer`, `MapsGLMaps`) plus the provider SDK and `turf-swift` automatically.
Use a branch channel unless the user asked to pin. Pinning to `release/…` is the right call for
release-managed apps; note that it also freezes the provider SDK range.
**CocoaPods** — `pod 'MapsGL'`, then `pod install` and open the generated `.xcworkspace`. CocoaPods
builds a single `MapsGL` module, so **`import MapsGL` replaces the adapter import**
(`import MapsGLMapbox` / `import MapsGLMapLibre`) in every source file. This is the most common
CocoaPods build error.
**Carthage** (`github "vaisala-xweather/mapsgl-apple-sdk" ~> x.y.z`, then
`carthage update --use-xcframeworks`) and **manual xcframework embedding** (download `MapsGL.zip`
from the releases page, embed the three xcframeworks as "Embed & Sign") both require adding the
provider SDK yourself and dropping the matching adapter *source directory*
(`MapsGLMapbox/` or `MapsGLMapLibre/`) straight into the project. When the adapter is compiled into
your target that way, **remove the `import MapsGLMapbox` / `import MapsGLMapLibre` lines** — the
types are already in your module.
### 3. Imports
```swift
import MapsGLMaps // always
import MapsGLMapbox // SPM, Mapbox channel — omit for CocoaPods/Carthage/manual
import MapsGLMapLibre // SPM, MapLibre channel — omit for CocoaPods/Carthage/manual
import MapsGL // CocoaPods only, in place of the adapter import
import Combine // controller events return AnyCancellable
```
### 4. Create the controller, then wait for load
```swift
let account = XweatherAccount(id: clientID, secret: clientSecret)
let controller = MapboxMapController(map: map, account: account)
controller.onLoad.observe { _ in
_ = try? controller.addWeatherLayer(for: .radar)
}.store(in: &cancellables)
```
| Provider | Controller | Map argument |
|---|---|---|
| Mapbox | `MapboxMapController` | `MapboxMaps.MapView`, or `MapboxMaps.MapboxMap` + `window:` |
| MapLibre | `MapLibreMapController` | `MLNMapView` |
**Every layer/source call must be gated behind load**, and the observation returns an `AnyCancellable`
you must retain — `.store(in: &cancellables)`. Drop it and the observer is torn down immediately and
the map stays empty, with no error.
Use `onLoad.observe { … }`. The older `subscribe(to: MapEvents.Load.self) { … }` is **deprecated**
("Use available on<event>.observe() methods instead") — note that the web docs' SwiftUI sample still
shows it, so copying from there warns on a new project. Same for `asyncSubscribe` and
`subscribeToNext`; only `publisher(for:)` survives, for when you want Combine operators.
**The layer and source API is `@MainActor`.** `addWeatherLayer`, `removeWeatherLayer`,
`setWeatherLayerVisibility`, `weatherLayer(for:)`, `addSource`, `addLayer`, `addImage`, and
`add(legendControl:)` are all main-actor-isolated. The `onLoad` observer already runs on the main
thread, so the usual path needs nothing extra — but a call made from a detached task or a
non-isolated callback needs `await MainActor.run { … }`, or it won't compile under strict
concurrency.
**Mapbox requires the mercator projection.** The current Mapbox styles (streets/outdoor/satellite
streets v12, light/dark v11) default to the globe projection, which MapsGL cannot render onto:
```swift
try map.setProjection(.init(name: .mercator))
```
Symptom when missing: the basemap draws normally and MapsGL layers simply never appear.
## Complete example — SwiftUI
**Mapbox.** `MapReader` hands back a `MapboxMap`, so use the `window:`-taking initializer:
```swift
import SwiftUI
import Combine
import MapboxMaps
import MapsGLMaps
import MapsGLMapbox
struct WeatherMapView: View {
// Read these from a gitignored plist / xcconfig / keychain — never commit them.
private let xweatherClientID = "FILL_IN_WITH_YOUR_CLIENT_ID"
private let xweatherClientSecret = "FILL_IN_WITH_YOUR_CLIENT_SECRET"
final class Coordinator: ObservableObject {
var controller: MapboxMapController?
var cancellables: Set<AnyCancellable> = []
}
@StateObject private var coordinator = Coordinator()
var body: some View {
MapReader { proxy in
Map(initialViewport: .camera(
center: CLLocationCoordinate2D(latitude: 39.65, longitude: -93.10),
zoom: 3.5
))
.mapStyle(.light)
.ignoresSafeArea()
.overlay(alignment: .bottomTrailing) { XweatherAttribution() }
.onAppear {
guard let map = proxy.map, coordinator.controller == nil else { return }
// MapsGL cannot render onto Mapbox's default globe projection.
try? map.setProjection(.init(name: .mercator))
let controller = MapboxMapController(
map: map,
window: UIWindow?.none,
account: XweatherAccount(id: xweatherClientID, secret: xweatherClientSecret)
)
coordinator.controller = controller
controller.onLoad.observe { _ in
do {
try controller.addWeatherLayer(for: .radar)
var winds = WeatherService.WindParticles(service: controller.service)
winds.layer.paint.particle.density = .high
try controller.addWeatherLayer(config: winds)
} catch {
NSLog("Failed to add weather layer: \(error)")
}
}.store(in: &coordinator.cancellables)
}
}
}
}
```
Set the Mapbox token once, before any map is created — in the `App` initializer or an
`@main` type's `init()`:
```swift
MapboxOptions.accessToken = "FILL_IN_WITH_YOUR_MAPBOX_PUBLIC_ACCESS_TOKEN"
```
**MapLibre.** MapLibre ships no SwiftUI view, so wrap `MLNMapView`. No token, and no projection call:
```swift
import SwiftUI
import Combine
import MapLibre
import MapsGLMaps
import MapsGLMapLibre
struct WeatherMapView: UIViewRepresentable {
private let xweatherClientID = "FILL_IN_WITH_YOUR_CLIENT_ID"
private let xweatherClientSecret = "FILL_IN_WITH_YOUR_CLIENT_SECRET"
final class Coordinator {
var controller: MapLibreMapController?
var cancellables: Set<AnyCancellable> = []
}
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> MLNMapView {
let mapView = MLNMapView(frame: .zero)
// Any MapLibre-compatible style. CARTO's public styles need no key.
mapView.styleURL = URL(string: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json")!
mapView.setCenter(
CLLocationCoordinate2D(latitude: 39.65, longitude: -93.10),
zoomLevel: 3.5,
animated: false
)
let controller = MapLibreMapController(
map: mapView,
account: XweatherAccount(id: xweatherClientID, secret: xweatherClientSecret)
)
context.coordinator.controller = controller
controller.onLoad.observe { _ in
_ = try? controller.addWeatherLayer(for: .radar)
}.store(in: &context.coordinator.cancellables)
return mapView
}
func updateUIView(_ uiView: MLNMapView, context: Context) {}
}
```
Place the attribution over either map — required in both cases:
```swift
struct XweatherAttribution: View {
var body: some View {
Link(destination: URL(string: "https://www.xweather.com/")!) {
Text("Powered by Vaisala Xweather")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 3)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 3))
}
.padding(8)
}
}
```
For UIKit, the shape is the same — build the map view in `viewDidLoad`, create the controller, and
observe `onLoad`. A worked UIKit example for both providers is in `references/setup.md`, and the
repo's `Demo/UIKit/MapViewController.swift` is the maintained version.
The demo app is the best full reference and covers both channels:
https://github.com/vaisala-xweather/mapsgl-apple-sdk/tree/master/Demo
## Weather layers
```swift
try controller.addWeatherLayer(for: .temperatures)
try controller.addWeatherLayer(for: .windParticles)
for code in [WeatherService.LayerCode.dewPoints, .windParticles] {
try controller.addWeatherLayer(for: code)
}
controller.hasWeatherLayer(for: .radar) // Bool
controller.weatherLayer(for: .temperatures) // (any MapsGLLayer)?
controller.setWeatherLayerVisibility(for: .radar, visible: false) // cheap toggle
controller.removeWeatherLayer(for: .radar) // frees resources
controller.weatherLayerIds // [String] of active layer ids
```
Only `addWeatherLayer` throws. `removeWeatherLayer` and `setWeatherLayerVisibility` do **not** —
some doc pages show `try removeWeatherLayer(…)`, which won't compile.
For toggling a layer on and off from UI, use `setWeatherLayerVisibility` rather than
remove/re-add — the source and layer resources stay loaded instead of being disposed and rebuilt.
**A layer code is not a layer id.** Weather layers get a generated id that accounts for any
customization, so `getLayer(id:)` won't find one by code. Use `weatherLayer(for:)`, and use the
returned layer's `.id` when you need a real id — e.g. to insert another layer relative to it:
```swift
if let temps = controller.weatherLayer(for: .temperatures) {
try controller.addWeatherLayer(for: .windParticles, beforeId: temps.id)
}
```
**Never guess a layer code.** `references/layers.md` lists every `LayerCode` case with the
configuration struct and descriptor type for each — grep it first, no network call needed. The Swift
case names are *not* transforms of the JS/Raster Maps codes (`air-quality-pm2p5` is
`.particulateMatter2p5Micron`), and the Apple SDK supports fewer layers than the MapsGL JavaScript
SDK, so a code
that works on the web may not exist here at all.
For descriptions, animatability, coverage, data range and cost multiplier — data attributes, identical
across SDKs — see https://www.xweather.com/docs/mapsgl/weather-layers. For what the authenticated
account can actually render, ask at runtime:
```swift
controller.service.loadLayerMetadata { result in
if case .success(let metadata) = result { /* [WeatherLayerMetadata] */ }
}
```
## Styling
Override a built-in layer by instantiating its configuration struct, mutating `layer.paint`, and
adding it with `addWeatherLayer(config:)`:
```swift
var config = WeatherService.Temperatures(service: controller.service)
config.layer.paint.opacity = 0.5 // on the layer paint — NOT paint.sample.opacity
config.layer.quality = .low
try controller.addWeatherLayer(config: config)
```
**`opacity` lives on the layer paint, not inside the render-type namespace.**
`paint.sample.opacity` doesn't compile — `SamplePaint` has no such member, though the web
documentation shows it. Same for the other encoded types: it's `paint.opacity` everywhere.
`addWeatherLayer(for:)` takes no overrides — a customized layer must go through
`addWeatherLayer(config:)`.
Which `paint` sub-object to reach for is set by the layer's descriptor type: a `SampleLayerDescriptor`
styles through `paint.sample`, a `LineLayerDescriptor` through `paint.stroke`. `references/layers.md`
gives the descriptor per layer; `references/styles.md` gives the property tables per render type.
Custom color scale — **stop values are in metric units**, always, regardless of display units:
```swift
var scale = ColorScaleOptions(stops: [
ColorStop(-17.78, .fromString("#464ab5")), // 0 °F
ColorStop(0.00, .fromString("#6bea99")), // 32 °F
ColorStop(15.56, .fromString("#fdff87")), // 60 °F
ColorStop(37.78, .fromString("#901436")), // 100 °F
])
scale.interval = 2.775 // hard steps every 5 °F; omit for a smooth gradient
scale.interpolate = false // and disable interpolation for categorical bands
var config = WeatherService.Temperatures(service: controller.service)
config.layer.paint.sample.colorScale = .colorScale(scale)
config.layer.paint.sample.drawRange = ...2.22 // only draw ≤ 36 °F
try controller.addWeatherLayer(config: config)
```
`DataQuality` on Apple has **four** cases — `.low`, `.medium`, `.high`, `.exact`. The web docs table
also lists `minimal` and `normal`; those are JS-only and don't compile here. Lower quality means fewer
tile requests and smoother, less detailed output — a good lever on constrained devices.
Use `Expression` for anything data-driven, on both weather and custom layers:
```swift
paint: .init(fill: .init(color: .expression(Expression.get("COLOR"))))
```
See `references/expressions.md` for the operator set and `references/styles.md` for filters and masks.
## Custom sources and layers
Add the source first, then a layer referencing it by id:
```swift
var source = VectorSourceDescriptor(id: "alerts")
source.url = URL(string: "https://maps{s}.aerisapi.com/[CLIENT_ID]_[CLIENT_SECRET]/alerts/{z}/{x}/{y}/0.pbf")
source.zoomRange = 4...8
_ = try controller.addSource(source)
let layer = FillLayerDescriptor(
id: "alerts-fill",
source: source.id,
paint: .init(
fill: .init(color: .expression(Expression.get("COLOR"))),
stroke: .init(color: .constant(.black))
)
)
_ = try controller.addLayer(layer)
controller.removeLayer(id: "alerts-fill")
controller.removeSource(id: "alerts") // only after no layers reference it
```
For a layer you created, the id you chose *is* the real layer id, so `getLayer(id:)` works directly.
Full source and descriptor options: `references/api-reference.md`.
## Animating over time
`controller.timeline` drives every animated layer at once:
```swift
controller.timeline.setStartDate(usingRelativeTime: "-3 hours")
controller.timeline.setEndDate(usingRelativeTime: "now")
controller.timeline.duration = 2 // seconds per animation loop
controller.timeline.endDelay = 1 // seconds held on the last frame
controller.timeline.play()
```
Full API — offsets, relative-time strings, `goTo`, playback state, and the `onAdvance` signal for
driving a scrubber — in `references/timeline.md`.
## Legends and data inspection
```swift
let legendControl = LegendControl()
controller.add(legendControl: legendControl) // built-in weather legends sync automatically
let inspector = controller.addDataInspectorControl(constrainedTo: mapView)
```
Both controls are UIKit views; you place them yourself. In SwiftUI use the provided wrappers instead
of hosting the raw views:
```swift
LegendControlView(mapControllerProvider: { coordinator.controller })
.frame(maxWidth: 300)
someMapView.dataInspectorOverlay(mapControllerProvider: { coordinator.controller })
```
**If you override a layer's colors, override its legend too.** Bar/color-scale legends are
re-derived from the layer's color scale automatically, but **point legends are not** — a customized
categorical layer keeps the default legend, which then lies about the map:
```swift
config.legend = PointLegend(id: "convective")
.title("Convective")
.items(riskColors.map { PointLegendItem(color: $0.color.cgColor, label: $0.risk) })
```
Field reference and complete examples: `references/legends.md`.
## Querying data at a point
```swift
let results = await controller.query(coord: coordinate, layerIds: nil)
// -> [String: FeatureQueryResult] keyed by layer id
```
`query` is `async` and `layerIds` has no default — pass `nil` to query everything queryable, or a
list of layer ids to narrow it. Feature properties arrive as `[String: Any]`, in metric units — encoded raster
layers put the reading on `"value"`, and vector-valued layers (winds, currents, swell) add `"angle"`.
Note that the stored angle is the direction the data moves *toward*; meteorological wind direction is
the reciprocal.
## Usage is measured in sessions
MapsGL bills in **sessions** — clock-aligned 5-minute buckets that start when a weather layer is
added — not per tile, layer, or request. **The model is identical on Apple platforms and on the web,
and this skill is not its source of truth.**
For anything quantitative — the billing rules, the access multiplier, worked examples,
capacity-planning figures, the Raster Maps comparison — use the authoritative source rather than
answering from memory: the `mapsgl` skill's `references/sessions.md` (both skills ship in the same
plugin), or https://www.xweather.com/docs/mapsgl/getting-started/sessions.
What matters here is the **Apple-specific** consequence: since interaction inside a session is free
and layer count doesn't affect cost, consumption is governed purely by *how long weather layers are
attached to a map*. On iOS that means lifecycle —
- add layers when the weather view appears, not when the map is constructed;
- remove them on `onDisappear` / `viewWillDisappear`;
- remove them when the app backgrounds (`ScenePhase`), the failure mode with no web analogue;
- treat always-on iPad displays as the expensive pattern, and say so unprompted.
Two traps worth stating whenever cost comes up: `setWeatherLayerVisibility` is the cheap toggle but
`removeWeatherLayer` is the one that stops consumption, and **`DataQuality` is a performance lever,
not a cost lever** — it cuts requests, and sessions don't count requests.
Code for each of these, and the full list of what is *not* worth optimizing:
`references/sessions.md`.
## Checklist for common tasks
- **"Add a weather map to my app"** → settle the provider first: infer Mapbox or MapLibre if the
project or request says, otherwise ask. Then SwiftUI unless the project is UIKit. Follow the
complete example above.
- **"Which SPM branch / how do I install"** → branch = provider: `master` for Mapbox, `maplibre` for
MapLibre; product `MapsGL`. See Setup.
- **Build error: `No such module 'MapsGLMapbox'`** → either the CocoaPods case (use `import MapsGL`)
or the wrong SPM branch for the provider (a MapLibre branch has no Mapbox adapter).
- **Mapbox SDK won't resolve / 401 on download** → the Mapbox *secret* download token isn't
configured in `~/.netrc`. Separate from the public access token used at runtime.
- **Basemap renders but no weather layers appear (Mapbox)** → missing
`try map.setProjection(.init(name: .mercator))`.
- **Nothing happens after `onLoad`** → the `AnyCancellable` wasn't retained; `.store(in: &cancellables)`.
- **"Add a weather layer"** → `try controller.addWeatherLayer(for: .code)` inside the load observer;
look the case up in `references/layers.md`.
- **"Toggle a layer from a switch"** → `setWeatherLayerVisibility(for:visible:)`, not
remove/re-add. Neither call throws.
- **"Change a layer's colors/thresholds"** → instantiate its `WeatherService.<Name>` config, set
`layer.paint.sample.colorScale`, add via `addWeatherLayer(config:)`. Stops are metric.
- **"Restyle a composite layer"** (`.stormcells`, `.roads`, `.boundaries`, …) → you can't; its
`layers` member is a `let`. Add the constituent layers individually. Full list in
`references/layers.md`.
- **`getLayer(id:)` returns nil for a weather layer** → expected; a code is not an id. Use
`weatherLayer(for:)`.
- **"Compiler rejects `.normal` / `.minimal` quality"** → Apple has only `.low`, `.medium`, `.high`,
`.exact`.
- **`value of type 'SamplePaint' has no member 'opacity'`** → it's `paint.opacity`, not
`paint.sample.opacity`. The web docs show the wrong path.
- **`'GridLayerDescriptor' cannot be constructed because it has no accessible initializers`** → only
the vector descriptors (`fill`, `line`, `circle`, `symbol`, `heatmap`) have a public init. Start from
a built-in grid layer's config and mutate `config.layer` instead. Same for `SamplePaint`.
- **Deprecation warning on `subscribe(to:)` / `asyncSubscribe` / `subscribeToNext`** → use
`on<Event>.observe { … }`. The web docs' SwiftUI sample still shows the deprecated form.
- **`call to main actor-isolated instance method … in a synchronous nonisolated context`** → the
controller's layer/source API and `LegendControl`'s mutators are `@MainActor`; annotate the helper.
- **"Animate over time / add a time scrubber"** → `controller.timeline`, see `references/timeline.md`.
- **"Show a legend"** → `LegendControl` + `controller.add(legendControl:)`, or `LegendControlView` in
SwiftUI. Override `config.legend` whenever you customized a categorical layer's paint.
- **"Show values on tap"** → `addDataInspectorControl(constrainedTo:)`, or `.dataInspectorOverlay(…)`
in SwiftUI; customize with `DataInspectorPresentation`.
- **"How many accesses / how much does this cost?"** → sessions, not tiles or layers. Get the model
and the arithmetic from the authoritative source — the `mapsgl` skill's `references/sessions.md` or
https://www.xweather.com/docs/mapsgl/getting-started/sessions — then apply the iOS lifecycle
guidance in `references/sessions.md`.
- **"What's the latest version / where are the API docs?"** → releases endpoint for the version, then
`https://cdn.aerisapi.com/sdk/ios/mapsgl/docs/v{version}/documentation/mapsglmaps`. There is no
`latest` alias.
## Attribution is required
Xweather requires attribution wherever its data or imagery is displayed. This applies to **all
products** — Weather API, Raster Maps, and MapsGL alike. Build it into anything you produce, and say
so when handing over code that will end up in front of users.
The minimum is a link to `https://www.xweather.com/` reading "Powered by Vaisala Xweather":
```swift
Link("Powered by Vaisala Xweather", destination: URL(string: "https://www.xweather.com/")!)
```
The logo may be substituted for the "Xweather" text. Light and dark variants exist in SVG and PNG at
`https://www.xweather.com/assets/logos/vaisala-xweather-logo-dark.svg` — swap `-dark` for `-light`
over a dark background, or `.svg` for `.png`. Bundle the asset rather than loading it over the network
in a shipping app. Using the logo brings rules: keep it unmodified, leave at least a **10pt buffer**
of space around it, and only adjust lightness or opacity in greyscale. Don't rotate it, don't recolour
it (monotone black or white excepted), and don't use the symbol without the Xweather name.
Full guide: https://www.xweather.com/docs/weather-api/resources/attribution
## Reference files
- `references/setup.md` — install paths per package manager, provider-specific project setup, UIKit examples, credential handling, and the build errors each mistake produces
- `references/api-reference.md` — `MapController`, `WeatherService`, source and layer descriptors, controls, events, and query API, plus how to reach the hosted DocC for a given version
- `references/layers.md` — every `WeatherService.LayerCode` case with configuration struct, descriptor type and paint namespaces; composite layers listed up front
- `references/styles.md` — paint property spec for every render type, `DataQuality`, color scales, filters and masks
- `references/expressions.md` — `Expression` factory reference for data-driven paint and filters
- `references/legends.md` — `LegendControl`, bar and point legend configuration, SwiftUI and UIKit placement
- `references/timeline.md` — timeline and animation API, including the inherited `TimeAnimation`/`Animation` surface
- `references/sessions.md` — the Apple-specific half of session cost: view and app lifecycle teardown, backgrounding, and the two traps. Points at the `mapsgl` skill and the public docs for the billing model itself
Referenced files: 8
raster-maps18 KB
---
name: raster-maps
description: This skill should be used to build Xweather Raster Maps image URLs (maps.api.xweather.com) — either static map images or XYZ map tile URLs for Leaflet, Mapbox, Google Maps, OpenLayers and similar libraries — from a description of the weather imagery wanted. Use it whenever a task mentions Raster Maps, maps.api.xweather.com, maps.aerisapi.com, an Xweather weather map layer or overlay (radar, satellite, alerts, temperatures, lightning, tropical cyclones, air quality, base maps, admin overlays), a weather map image or tile layer, layer opacity/blur/blend/scale-hsla modifiers, or asks how Raster Maps usage is measured — map units, tile counts, the daily allowance, or how many accesses a static map or tile layer will consume. Also covers Xweather's attribution requirement — the 'Powered by Vaisala Xweather' credit and logo rules that apply wherever Xweather data or imagery is displayed.
compatibility: Skill instructions are provider-neutral. The bundled scripts/xwmap.py needs Python 3 (standard library only) and network access to maps.api.xweather.com.
license: MIT
---
# Xweather Raster Maps URL builder
Turn a description of wanted weather imagery into a `maps.api.xweather.com` URL — as a standalone
static image or as a tile template for an interactive mapping library.
This is a different product from the Weather API: different host, different URL grammar, and
credentials sit **in the path**, not in the query string.
```
https://maps.api.xweather.com/{client_id}_{client_secret}/{layers}/…/{offset}.{format}
```
## Two output methods — ask which one
Every Raster Maps request is one of two shapes, and they are not interchangeable:
| | Static map | Map tiles |
|---|---|---|
| Produces | A single finished image | A 256×256 tile template a library fetches many of |
| Use when | Email, report, dashboard panel, `<img>` tag, Slack, PDF | Leaflet / Mapbox / Google Maps / OpenLayers / Apple Maps |
| Path | `{layers}/{w}x{h}/{place},{zoom}/{offset}.{fmt}` | `{layers}/{z}/{x}/{y}/{offset}.{fmt}` |
| Interactive | No — no pan or zoom | Yes |
**If the user hasn't said which one they want, ask before generating anything.** Use
Ask a direct either/or question — a structured-choice prompt if the agent has one, plain text otherwise.
Don't guess from weak signals and don't produce both by default — a tile template pasted into an
`<img>` tag renders one 256-pixel square, and a static URL handed to Leaflet fails outright.
Signals strong enough to skip the question: the user names a mapping library, says "tile layer" or
"XYZ", or gives `{z}/{x}/{y}` (→ tiles); or names pixel dimensions, says "image", "PNG for a
report", or shows an `<img>` tag (→ static).
## Workflow
1. **Determine the output method** — ask if not already clear (above).
2. **Pick the layers.** Use the intent map below and confirm codes against `references/layers.md`.
Order matters: they composite left to right, so base map first, weather next, labels last.
3. **Add the geography.** Static: `place,zoom` or a `south,west,north,east` bounding box. Tiles: the
library substitutes `{z}/{x}/{y}` — leave the placeholders in.
4. **Add the time offset.** `current` unless the user wants past or forecast imagery.
5. **Pick the format.** `png` for tile overlays (transparency is mandatory); `png` or `jpg` for
static maps that include their own base layer.
6. **Apply modifiers** if the request implies them — opacity, blur, blend, recolour. See
`references/modifiers.md`.
7. **Report the URL with its map-unit cost** (see Map units), then handle credentials (see below).
8. **Include the attribution.** Any markup you hand over — an `<img>` tag, a tile layer, a template —
needs the Xweather credit alongside it. See "Attribution is required" below.
Never invent a layer code. Check `references/layers.md`, or refetch the live catalog:
```bash
curl -s https://www.xweather.com/docs/api/maps/layers
```
That JSON (`{ layers: [{ id, title, description, multiplier, modifiers, categories, dataRange,
dataCoverage, updateInterval }] }`) is the authoritative layer list and is what
`references/layers.md` was generated from.
## Intent → layer
| The user wants | Layer code |
|---|---|
| Radar | `radar` (regional, higher res) · `radar-global` (satellite-derived fill where radar is absent) |
| Future radar | `fradar` — add `-hrrr` / `-nam` / `-gfs` to pick the model |
| *Forecast of any `f`-prefixed layer* | Raster Maps splits observed from forecast — `temperatures` vs `ftemperatures`. **MapsGL doesn't**: one layer spans both there, so don't carry `f`-prefixed codes across. |
| Satellite | `satellite-geocolor` (the good-looking default) · `satellite-visible` · `satellite-infrared-color` · `satellite-water-vapor` |
| Watches and warnings | `alerts` — `-severe`, `-fire`, `-flood`, `-winter`, `-heat`, `-wind`, `-surge`, `-frost-freeze`; `-watches` / `-warnings` |
| Temperature | `temperatures` · forecast `ftemperatures` · labels `temperatures-text` |
| Feels-like, dew point, humidity, wind, gusts, visibility | `feels-like` · `dew-points` · `humidity` · `wind-speeds` · `wind-gusts` · `visibility` — each has an `f`-prefixed forecast twin and a `-text` label variant |
| Lightning | `lightning-strikes` (**×10**) · `lightning-flash` (×1) · `lightning-strike-density` (×1) |
| Storm cells, storm reports | `stormcells` · `stormreports` |
| Severe outlook, fire outlook, drought | `convective` · `fires-outlook` · `drought-monitor` |
| Hurricanes | `tropical-cyclones` plus the `tropical-cyclones-*` family (positions, track lines, forecast cones, icons, names) |
| Air quality | `air-quality-index` / `air-quality-index-categories` (×1) · individual pollutants and national scales (**×5**) |
| Snow, ice, precip accumulation | `snow-depth` · `fqsf-accum` · `fice-accum` · `fqpf-accum` · `precip` |
| Marine | `maritime-wave-heights` · `maritime-swell-*` · `maritime-sst` · `maritime-currents` · `maritime-tides` |
| Fronts and pressure | `surface-analysis` · `surface-analysis-fronts` · `surface-analysis-pressure` |
| Base map | `flat` · `flat-dk` · `terrain` · `terrain-dk` · `blue-marble` |
| Borders, cities, roads | `admin` (combined) · `admin-cities` / `-dk` · `states` · `counties` · `countries-outlines` · `roads` · `interstates` |
| Clip weather to land or water | the `Masks` layers — `land-flat`, `water-flat`, `clip-us-terrain`, … |
Layers with a **Modifier** group in `layers.md` take dash-joined options — `alerts-severe`,
`alerts-severe-warnings`, `temperatures-rtma`, `fradar-hrrr`. One option per group; groups combine.
A few modifier groups are described in the catalog without enumerated options (`radar`'s Region says
"either US or Global" but lists no values). Treat those as unconfirmed: say the modifier exists, and
check the layer's doc page or test the request rather than emitting a guessed suffix.
A conventional stack is base → weather → labels:
```
flat-dk,alerts,radar,admin
terrain,temperatures:blend(overlay),admin-cities
```
Maximum **10 layers** per request.
## URL shapes
When the URL goes into a page, the attribution goes with it:
```html
<img src="https://maps.api.xweather.com/{client_id}_{client_secret}/flat,radar,admin/800x600/minneapolis,mn,7/current.png"
width="800" height="600" alt="Radar" />
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">Powered by Vaisala Xweather</a>
```
For a tile layer, most mapping libraries take it as an attribution option instead — Leaflet and
Mapbox GL both accept an `attribution` string on the layer or source, which is the idiomatic place to
put it.
**Static, centre point** — place and zoom share one comma-joined segment:
```
https://maps.api.xweather.com/{client_id}_{client_secret}/flat,radar,admin/800x600/minneapolis,mn,7/current.png
https://maps.api.xweather.com/{client_id}_{client_secret}/radar/300x300/44.96,-93.27,7/current.png
```
**Static, bounding box** — `south,west,north,east`:
```
https://maps.api.xweather.com/{client_id}_{client_secret}/flat,radar,admin/320x320/30.1010,-85.9578,33.0948,-82.4421/current.png
```
Three comma-separated numbers means `lat,lon,zoom`; four means a bounding box. That count is the only
thing telling them apart, so a dropped coordinate silently changes what the request means.
**Tiles** — hand over the template with placeholders intact:
```
https://maps.api.xweather.com/{client_id}_{client_secret}/radar/{z}/{x}/{y}/current.png
https://maps{s}.api.xweather.com/{client_id}_{client_secret}/radar/{z}/{x}/{y}/current.png # with subdomains: '1234'
```
Pair a tile URL with the matching library snippet — Leaflet, Mapbox GL, Google Maps, and OpenLayers
examples are in `references/url-formats.md`. Give the snippet, not just the URL; the URL alone is
rarely enough to get a layer on screen.
**Time offset:** `current` / `latest`, a relative offset (`-10minutes`, `+1hour`, `-3days`; integers
only, so `-90minutes` not `-1.5hours`), or a UTC valid time `YYYYMMDDhhiiss`.
**Format:** `png` (true colour), `png32`–`png256` (indexed, smaller), `jpg` / `jpg70`–`jpg100`,
`webp`. Prefix with `@2x` for retina. **Tile overlays must be `png` or `webp`** — JPEG has no alpha,
so a JPEG tile paints an opaque block over the base map.
Size limits: 5000×5000 on paid plans, 2000×2000 on the free developer trial.
## Map units — report the cost with the URL
Raster Maps measures usage in **map units**, against a **daily** allowance set by the subscription.
One map unit = **one 256×256 tile carrying one ×1 layer**.
```
tiles = ceil(width / 256) × ceil(height / 256)
map units = tiles × Σ(multiplier of each layer)
```
Most layers are ×1, but `lightning-strikes` and the `lightning-all` family are **×10**, and
individual air-quality pollutant and national-index layers are **×5**. A layer listed twice counts
twice.
**Show the arithmetic, not just the total** — the division is where people get surprised:
> `flat,alerts,radar` at 800×600:
> ```
> 800 / 256 = 3.125 → 4 columns
> 600 / 256 = 2.34 → 3 rows
> 4 × 3 = 12 tiles × 3 layers = 36 map units
> ```
> `flat,lightning-strikes` at 800×600 → 12 tiles × (1 + 10) = **132 map units**, because
> `lightning-strikes` is a ×10 layer. `lightning-flash` or `lightning-strike-density` are ×1 if
> either answers the question.
Two facts worth volunteering, because both surprise people:
> **Combining layers into one request doesn't reduce map units.** `flat,radar,admin` as a single
> comma-joined request costs the same 3× as three separate layers — the docs are explicit that two
> layers cost 2× "either as separate layers or combined". Combining saves round trips and latency,
> not units.
>
> **Cost is quantised in 256-pixel steps.** 512×512 and 500×500 are both 4 tiles; 520×520 jumps to 9.
> Sizing just under a tile boundary is free savings.
### Static vs. interactive
For a **static image** the number is exact — you control the dimensions, so the calculation above is
the answer.
For **tiles**, it's an estimate and you should say so. A ~800×600 viewport is roughly 12 tiles, but
the container's real size and the map's centre shift it, and libraries commonly pull an extra row and
column to make panning smooth. More importantly: **every pan and zoom renders new tiles, each costing
again**, so an interactive map's lifetime cost is driven by user interaction rather than by initial
load. Give a per-viewport figure and name that caveat rather than implying a total.
**Caching is the biggest real-world lever.** Tiles and static images are cached in browser memory for
a period tied to the layer's update interval — radar refreshes every ~6 minutes, temperatures roughly
hourly — so re-requests inside that window don't generate new units. Native apps should implement
equivalent memory or file caching.
If the user is building something animated, multi-layer, and heavily interactive, mention that
**MapsGL bills completely differently** — in 5-minute sessions where layer count and interaction are
free — and may be far cheaper for that pattern. See the `mapsgl` skill.
Usage is visible in the account dashboard, with a Usage tab for history and per-application
breakdown; stats lag slightly behind real time.
`scripts/xwmap.py … --estimate-only` computes all of this from a path, pulling live multipliers from the
catalog. Full model, the multiplier tables, and reduction tactics: `references/map-units.md`.
## Credentials and returning the image
**Without credentials:** hand over the URL with `{client_id}` and `{client_secret}` placeholders and
say where keys come from (the API Keys page at https://data.portal.xweather.com/account/keys). Nothing
else to do.
**With credentials — ask before fetching.** If the user has supplied a client id and secret, ask
whether they want the image requested and shown, or just the URL to copy. Ask; don't assume. Fetching spends real map units against their allowance, and for a tile template there's no
single meaningful image to return anyway.
If they say yes:
```bash
export XWEATHER_CLIENT_ID='…' XWEATHER_CLIENT_SECRET='…'
python3 scripts/xwmap.py 'flat,radar,admin/800x600/minneapolis,mn,7/current.png' -o radar.png
```
Invoke it as `python3 scripts/xwmap.py`, resolved relative to this skill's directory. Some clients
also expose it as a bare `xwmap` command on PATH — use that if available, but don't assume it.
The script prints the placeholder URL and the map-unit estimate, saves the image, and detects the
JSON error body that Raster Maps returns in place of an image on failure. Read the saved file back
to view it, and say where it was written.
For a **tile** URL, offer to render one representative tile (or a small static equivalent of the same
layers) rather than pretending a `{z}/{x}/{y}` template resolves to one image.
### Handling credentials
- **Show the URL with `{client_id}` / `{client_secret}` placeholders in your reply**, not the literal
keys — Raster Maps URLs get pasted into HTML, committed configs, and shared dashboards, and the
credentials are right there in the path. If the user explicitly asks for a populated copy-paste
URL, give it to them; that's their call.
- Don't write credentials into a file or committed config unless asked. Note that a tile URL used in
client-side JavaScript exposes the key pair to anyone viewing the page — which is why Xweather ties
each key pair to a registered namespace (domain or bundle id).
- `403` with `{"error":{"code":"authorization_error"}}` means bad keys or a namespace mismatch. Note
this envelope differs from the Weather API's `{"success","error","response"}`, and the status is
403 rather than 401.
## Gotchas
| Symptom | Cause |
|---|---|
| Tile layer hides the base map entirely | JPEG format on an overlay tile. Use `png` or `webp`. |
| One small square instead of a map | A tile URL used as a static image. Switch to the static form. |
| Blank or transparent image | Layer has no data for that place or time — check the layer's `Coverage` and `Range` in `layers.md`. Radar is regional; `radar-global` fills the gaps. |
| Map is centred wrong or wildly zoomed | Three vs. four coordinates — `lat,lon,zoom` vs. bounding box. |
| Labels buried under the weather | Put `admin` / `admin-cities` last in the layer list. |
| Nothing renders at high zoom | Past the layer's max zoom, or past its data range for the requested offset. |
| Blend has no effect | Two blends on one layer — only one is allowed per layer. |
| Layer code rejected | A legacy alias (`sat`, `cities`, `frad`) — use the catalog code (`satellite-geocolor`, `admin-cities`, `fradar`). |
| Higher bill than expected | A ×10 lightning or ×5 air-quality layer, a layer plotted twice, or an interactive map being panned. |
## Attribution is required
Xweather requires attribution wherever its data or imagery is displayed. This applies to **all
products** — Weather API, Raster Maps, and MapsGL alike. Build it into anything you produce, and say
so when handing over code or URLs that will end up in front of users.
The minimum is a link to `https://www.xweather.com/` reading "Powered by Vaisala Xweather":
```html
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">Powered by Vaisala Xweather</a>
```
The logo may be substituted for the "Xweather" text. Light and dark variants exist in SVG and PNG:
```html
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">
<img src="https://www.xweather.com/assets/logos/vaisala-xweather-logo-dark.svg" alt="Vaisala Xweather" height="40" />
</a>
```
Swap `-dark` for `-light` over a dark background, or `.svg` for `.png`. Using the logo brings rules:
keep it unmodified, leave at least a **10px buffer** of space around it, and only adjust lightness or
opacity in greyscale. Don't rotate it, don't recolour it (monotone black or white excepted), and don't
use the symbol without the Xweather name.
Full guide: https://www.xweather.com/docs/weather-api/resources/attribution
## Reference files
- `references/layers.md` — every layer by category: code, description, multiplier, coverage, data
range, update interval, and each layer's dash-joined modifier options.
- `references/url-formats.md` — static centre-point and bounding-box forms, tile form, library
snippets for Leaflet / Mapbox GL / Google Maps / OpenLayers, time offsets, image-quality
extensions, layer combination rules, error envelope.
- `references/modifiers.md` — colon-attached modifiers: opacity, blur, gray, invert, all blend modes,
and `scale-hsla` with the documented tint / recolour / heatmap / shadow recipes.
- `references/map-units.md` — the cost model, layers grouped by multiplier, caching, and how to
reduce consumption.
- `scripts/xwmap.py` — estimates map units from a path (`--estimate-only`) and fetches
the image using `XWEATHER_CLIENT_ID` / `XWEATHER_CLIENT_SECRET` from the environment.
Related: the `weather-api` skill covers the Weather **data** API
(`data.api.xweather.com`), and `mapsgl` covers the MapsGL JavaScript SDK. Raster Maps is
the server-rendered image product — reach for MapsGL instead when the user wants animated,
styleable, client-side layers.
Referenced files: 5
weather-api23.7 KB
---
name: weather-api
description: Build and run Xweather Weather API request URLs for data.api.xweather.com from plain-language requirements. Use when a task mentions the Xweather or legacy Aeris API, weather endpoints such as observations, conditions, forecasts, alerts, lightning, air quality, tropical cyclones, tides, or road weather; asks for an API URL or query; needs help debugging an empty or failed request; asks about access costs, endpoint multipliers, rate limits, or allowance usage; or needs guidance for the hosted Xweather MCP server at mcp.api.xweather.com, including availability, connection, authentication, and tool scoping. Also covers Xweather's attribution requirement — the 'Powered by Vaisala Xweather' credit and logo rules that apply wherever Xweather data or imagery is displayed.
compatibility: Skill instructions are provider-neutral. The bundled scripts/xwrequest.py needs Python 3 (standard library only) and network access to data.api.xweather.com.
license: MIT
---
# Xweather Weather API URL builder
Turn a description of wanted weather data into a correct `data.api.xweather.com` URL — and, when
credentials are available, execute it and return the data alongside the URL.
## Request anatomy
```
https://data.api.xweather.com/{endpoint}/{action}/{:id}?{params}&client_id=…&client_secret=…
```
| Segment | Example | Notes |
|---|---|---|
| endpoint | `observations`, `conditions/summary` | *What* data. 59 of them — see `references/endpoints.md`. |
| action | `closest`, `within`, `search`, `route`, `contains`, `affects` | *How* to look it up. Omitted entirely for `:id` and `:all`. |
| `:id` | `seattle,wa`, `98109`, `44.97,-93.26`, `KMSP` | The place or record identifier. Goes in `p=` instead when an action occupies the path slot. |
| params | `filter=`, `query=`, `fields=`, `limit=`, `from=`/`to=`, `format=` | Shape the result. |
| credentials | `client_id` + `client_secret` | Query params on every request; no header form exists. |
`api.aerisapi.com` is the legacy host and still works — always emit `data.api.xweather.com`.
## Workflow
1. **Extract from the prompt:** what data, which place(s), what time, and what shape of answer
(one record, N nearest, everything in an area, along a route, is-this-point-inside-a-polygon).
2. **Pick the endpoint.** Use the intent map below; confirm against `references/endpoints.md`.
3. **Pick the action** from the decision table below. Verify the endpoint actually supports it —
`endpoints.md` lists supported actions per endpoint, and an unsupported one returns
`not_implemented`.
4. **Add parameters.** `filter` and `query` tokens are endpoint-specific; only use tokens listed for
that endpoint in `endpoints.md`. Read `references/filters.md` when choosing between similar
tokens (`standard` vs `all` for alerts, `day` vs `daynight` vs `mdnt2mdnt` for forecasts) — grep
for the `## /endpoint` heading rather than reading the whole file.
5. **Sanity-check against a documented example.** `references/examples.md` has the API's own example
requests for every endpoint; `references/recipes.md` has 34 real-world queries by use case. If
the request resembles one, copy its structure instead of inventing parameters.
6. **Emit the URL with its access cost** (see Access cost — always report it), then decide whether to
run it (see Executing the request).
Never invent an endpoint, action, filter token, or query property. If unsure whether one exists,
check `endpoints.md`, or refetch the live catalog:
```bash
curl -s https://www.xweather.com/docs/api/weather-api/endpoints
```
That JSON (`{ endpoint: {...}, action: {...} }`) is the authoritative, always-current list of every
endpoint with its supported actions, params, filters, query properties, and sort fields — it is what
`references/endpoints.md` was generated from. Use it when a user asks about something the reference
doesn't cover, or when a request fails with `invalid_request` / `not_implemented`.
## Intent → endpoint
| The user wants | Endpoint |
|---|---|
| "What's the weather right now" — blended current conditions, global | `/conditions/{place}` |
| An actual reporting station's observation | `/observations/{place}` or `/observations/closest?p=…` |
| Forecast — daily, day/night, hourly, 3-hourly | `/forecasts/{place}?filter=day\|daynight\|1hr\|3hr` |
| "Will it rain in the next hour" | `/conditions/{place}?filter=minutelyprecip` |
| Hourly series across a past or future window | `/conditions/{place}?from=…&to=…` |
| Yesterday's / a past day's high, low, precip total | `/conditions/summary/{place}?from=-1day` or `/observations/summary/{place}` |
| Hour-by-hour history at a station | `/observations/archive/{place}?for=2024-06-05` (this endpoint takes `for=`, not `from`/`to`) |
| 30-year climate normals | `/normals/{place}?filter=daily\|monthly\|annual` |
| A plain-English weather summary sentence | `/phrases/summary/{place}` |
| Warnings, watches, advisories | `/alerts/{place}` — counts across a region: `/alerts/summary` |
| Lightning strikes near a point | `/lightning/closest?p=…&radius=25miles&limit=10` |
| Lightning/thunderstorm nowcast, next ~60 min | `/lightning/threats` |
| Radar-derived storm cells, hail, rotation, TVS | `/stormcells/{place}` · `/stormcells/closest` · `/stormcells/summary` |
| Hail nowcast · localized threat summary | `/hail/threats` · `/threats/{place}` |
| Confirmed storm damage reports (insurance, verification) | `/stormreports/search?query=state:…&filter=hail` |
| Hurricanes / typhoons, active or historical | `/tropicalcyclones` · `/tropicalcyclones/archive` |
| SPC severe convective outlook | `/convective/outlook/contains?p={place}` or `/convective/outlook/{place}` |
| Is this location in a drought area | `/droughts/monitor/contains?p={place}` or `/droughts/monitor/{place}` |
| Wildfires · fire weather outlook | `/fires/closest?p=…` · `/fires/outlook` |
| Earthquakes | `/earthquakes/closest` or `/earthquakes/within` |
| Air quality — current, forecast, historical, index only | `/airquality/{place}` · `/airquality/forecasts` · `/airquality/archive` · `/airquality/index` |
| Health or activity index (migraine, golf, biking, …) | `/indices/{type}/{place}` |
| Operational risk score for an activity | `/impacts/{activity}/{place}` |
| Sunrise, sunset, twilight, moonrise · moon phases | `/sunmoon/{place}` · `/sunmoon/moonphases` |
| Tides | `/tides/{place}?from=now&to=+1day` |
| Offshore / marine — waves, swell, sea temp | `/maritime/{place}` · `/maritime/archive` |
| Road conditions and driving risk | `/roadweather/{place}` · `/roadweather/analytics` · `/roadweather/conditions` |
| River and lake gauges, flood stage | `/rivers/closest` · `/rivers/gauges` |
| Solar irradiance for PV siting or yield | `/renewables/irradiance/summary` · `/archive` · `/tmy` |
| Hail history for a location | `/hail/archive/{place}?from=…&to=…` |
| Lightning climatology · wind-turbine strike risk | `/lightning/density/{place}` · `/lightning/turbinerisk/{place}?height=100m` |
| Geocoding, place/ZIP/airport lookup, nearby cities | `/places/search` · `/places/closest` · `/places/postalcodes` · `/places/airports` · `/countries` |
| Hyperlocal forecast from an Xcast sensor | `/xcast/forecasts/{device_id or place}` |
| **Any of the above along a driving route** | append `/route` and pass `p=lat,lon;lat,lon;…` |
`/conditions` vs `/observations` is the most common fork: `/conditions` is a modeled, gap-free blend
available for any coordinate on earth; `/observations` is what a physical station actually reported.
Reach for `/observations` when the user says "station", "METAR", "airport", or names a station id.
## Action decision table
| The question | Action | Shape |
|---|---|---|
| "…for Denver" | `:id` | `/alerts/denver,co` |
| "…everywhere / all active" | `:all` | `/tropicalcyclones?filter=all` |
| "…nearest N to me" | `closest` | `/lightning/closest?p=…&radius=25miles&limit=10` |
| "…inside this box / circle / polygon" | `within` | `/earthquakes/within?p=43.23,-96.92,45.62,-91.31&limit=10` |
| "…matching these criteria, anywhere" | `search` | `/observations/search?query=country:us&sort=temp:-1` |
| "…along this route" | `route` | `/observations/route?p=44.96,-93.27;44.91,-93.5` |
| "…is this point inside a warned/outlook/drought area" | `contains` | `/convective/outlook/contains?p=denver,co` |
| "…which towns does this storm/quake affect" | `affects` | `/stormcells/affects?p=…` |
**The location goes in `p=` for every action except `:id` and `:all`** — the path slot after the
endpoint is where the action name lives, so `/convective/outlook/contains/denver,co` fails with
`invalid_request: Invalid Action: contains/denver,co`. It's `/convective/outlook/contains?p=denver,co`.
Two more traps: `limit` defaults to **1**, so `closest`/`search`/`within` without it return a single
record; and `closest` with too small a `radius` returns `warn_no_data` rather than an error.
On polygon endpoints, `/{endpoint}/{place}` (the `:id` form) is a documented shorthand for
`contains` — `/droughts/monitor/san diego,ca` ≡ `/droughts/monitor/contains?p=san diego,ca`.
## Parameter essentials
| Parameter | Use |
|---|---|
| `p` | The place, when an action occupies the path slot. Also carries `within` geometry and `route` point lists. |
| `limit` / `skip` | Primary result count / offset. **Default `limit` is 1.** |
| `plimit` / `pskip` / `psort` | Same, for sub-elements (`periods` entries). |
| `radius` / `minradius` / `mindist` | Search radius (`25miles`, `10km`), donut inner radius, minimum spacing between returned points. |
| `filter` | Endpoint-specific selectors. `,` = AND, `;` = OR. |
| `query` | Value filtering, `property:value`. `,` = AND, `;` = OR. |
| `sort` | `property:-1` descending, `:1` ascending. |
| `from` / `to` / `for` | Range, or a single valid time. `now`, `today`, `friday`, `+3days`, `-12hours`, `2024-03-23`, `2024-06-05 16:00:00`. |
| `fields` | Comma list of dot-notated properties to return. |
| `format` | `json` (default), `geojson`, `csv`, `tsv`. |
Two things that bite:
- **`query=` values are metric**, regardless of which units you read back out. `temp`/`dewpt` in
Celsius, `wind`/`gust` in knots, `pressure` in millibars. `query=temp:30` is ≥ 30 °C.
- **A bare number in `query=` means "greater than or equal"**, not "equals". Use `min:max` for a
range, `!` for not-equal, `^` for starts-with, `NULL`/`!NULL` for null checks.
Full detail — every parameter, all place formats, date forms, query operators, sorting, batch
requests, response envelope, error and warning codes, cost headers — is in
`references/parameters.md`.
## Access cost — always report it
One HTTP request is not one access. Every URL you hand over must come with what it will cost against
the subscription allowance, unprompted — a `/impacts` request costs 25× a `/forecasts` one, and a
200-point route request costs 200×, which is not something a user should discover from an invoice.
```
accesses = endpoint multiplier × spatial multiplier × temporal multiplier
```
**The spatial multiplier is always 1** — no current endpoint uses it, so query area, radius and
geometry never affect cost. What's left is:
```
accesses = endpoint multiplier × intervals requested
```
The **endpoint multiplier** is a fixed constant: `Cost: xN` on each entry in
`references/endpoints.md`, or the grouped table in `references/access-cost.md`. The **temporal
multiplier** is the number of days or hours a single request covers, on endpoints that return a series
over a range — `/conditions/summary` bills one access per day, so a 30-day request is 30 accesses, not
one.
Both are knowable up front, so give a real number:
> `https://data.api.xweather.com/airquality/beijing,cn?filter=china&client_id={client_id}&client_secret={client_secret}`
> **Cost: 5 accesses** — `/airquality` is ×5, one point in time.
> `https://data.api.xweather.com/conditions/summary/minneapolis,mn?from=-30days&to=now&client_id={client_id}&client_secret={client_secret}`
> **Cost: 30 accesses** — `/conditions/summary` is ×1 and bills one access per day, so 30 days of
> summaries is 30 accesses. Shortening the range is the only way to reduce it.
> `https://data.api.xweather.com/lightning/within?p=43.23,-96.92,45.62,-91.31&limit=500&client_id={client_id}&client_secret={client_secret}`
> **Cost: 10 accesses** — `/lightning` is ×10. The multi-state bounding box costs nothing extra; area
> is not a cost factor.
Don't tell anyone to shrink a radius or tighten a bounding box to save accesses — it doesn't work.
Where you're unsure whether an endpoint bills per interval, name the range as the thing that could
multiply the cost and point at `X-Cost-Tokens`, rather than inventing a number.
When you actually run the request, `X-Cost-Tokens` is the exact charge — quote it instead of the
estimate.
Cases with an exact documented rule, worth calling out whenever they apply:
- **`route` charges one access per point**, times the endpoint multiplier. 200 points against
`/roadweather/analytics` (×10) is ~2,000 accesses. Always state the point count and the product.
- **`batch` charges each sub-request separately.** It saves round trips, not accesses. Max 31.
- **4xx and 5xx cost nothing.** Only 2xx is charged, so retrying a corrected URL is free.
- **`fields=` and `limit` don't reduce cost.** They shrink the payload, not the charge.
The expensive endpoints, worth flagging when one is chosen: `/impacts` (×25); `/hail/archive`,
`/hail/threats`, `/lightning/analytics` (×12); `/lightning`, `/lightning/archive`,
`/lightning/threats`, `/renewables/irradiance/summary`, `/roadweather/analytics` (×10); `/airquality`
and its archive/forecasts, `/maritime/archive`, `/roadweather/conditions` (×5). If a cheaper endpoint
answers the same question — `/airquality/index` (×1) for just the index, `/lightning/summary` (×1) for
aggregate counts, `/roadweather` (×1) without analytics fields — say so.
Full model, the complete multiplier table, and cost-reduction tactics: `references/access-cost.md`.
## Executing the request
Default behavior with no credentials: **produce the URL only**, with `{client_id}` and
`{client_secret}` placeholders, and explain what it returns. Mention that keys from the API Keys page
of https://data.portal.xweather.com/account/keys let you run it and return live data.
When the user supplies a client id and secret — in the prompt, in a `.env`, or already exported —
run the request and return **both the response and the URL**. Never leave the URL out; it is half
the deliverable.
Preferred: put the credentials in the environment and use the bundled helper, which keeps the secret
out of the command line and out of its own output.
```bash
export XWEATHER_CLIENT_ID='…' XWEATHER_CLIENT_SECRET='…'
python3 scripts/xwrequest.py '/observations/seattle,wa?filter=allstations&limit=3'
```
Invoke it as `python3 scripts/xwrequest.py`, resolved relative to this skill's directory. Some
clients also expose it as a bare `xwrequest` command on PATH — use that if available, but don't
assume it.
It prints the URL with credential placeholders, the HTTP status, the accesses charged with the
`endpoint`/`spatial`/`temporal` breakdown, the remaining minutely and period allowance, and the
pretty-printed body. `--post file.json` sends a JSON body for long `/route` requests; `--raw` skips
pretty-printing for CSV/TSV.
Plain `curl` works too, with the credentials referenced as shell variables rather than pasted:
```bash
curl -s "https://data.api.xweather.com/observations/seattle,wa?limit=3&client_id=$XWEATHER_CLIENT_ID&client_secret=$XWEATHER_CLIENT_SECRET"
```
### Handling credentials
- **Show the URL with `{client_id}` / `{client_secret}` placeholders in your reply**, not the literal
key values — replies get pasted into tickets, chats, and commits. If the user explicitly asks for
a fully populated copy-paste URL, give it to them; that's their call to make.
- Don't write credentials into a file, a script, or a committed config unless asked. If they're
already in a `.env` that the project reads, use that.
- A `401` / `invalid_client` means the keys are wrong. `unauthorized_namespace` means the keys are
valid but the request came from outside the domain or bundle id they were registered against —
common when testing server-side keys locally, and not something a different URL will fix.
### What to report back
1. The URL, with credential placeholders.
2. **The access cost** — `X-Cost-Tokens` when the request ran, the endpoint-multiplier floor when it
didn't. This goes in every reply that contains a URL, whether or not the user asked.
3. A one-line reading of the data that answers what was actually asked ("62 °F, overcast, wind
9 mph from the NNE at KBFI as of 14:53 local"), not just a JSON dump.
4. The relevant slice of the response — trimmed if it's long, since a 168-period hourly forecast is
not a useful thing to paste in full.
5. Anything worth knowing: warnings in the `error` field even on a 200, a cost that's high because of
the time range, remaining allowance if it's running low, or an empty result that means "widen the
radius".
If the reply contains several URLs, give each its own cost line and a total.
## Debugging a request that doesn't work
| Symptom | Likely cause |
|---|---|
| `invalid_location` | Place string didn't resolve — try another supported format (ZIP, `city,state`, lat/lon). |
| `warn_location` on a 200 | No state given for a US/CA city; the API guessed by population. Add the state. |
| `warn_no_data`, empty array | `closest`/`within` radius too small, or the time window has no records. Widen it. |
| One result when several were expected | `limit` defaults to 1. |
| `not_implemented` | That action isn't supported on that endpoint — check `endpoints.md`. |
| `warn_invalid_param`, parameter silently dropped | Parameter not supported on that endpoint, or not included in the account's plan. |
| `insufficient_scope` | Dataset isn't on the subscription (historical add-on, premium polygons, etc.). |
| HTTP 200 with `invalid_request: Invalid Action: …` | A place was put in the path *after* an action name. Move it to `p=`. |
| `404` | Endpoint path is wrong. |
| `429` with `maxhits_min` / `maxhits` | Per-minute or subscription-period access limit hit — check the `X-RateLimit-*` headers and see `access-cost.md`. |
| Nothing wrong, but huge response | Add `fields=`, lower `limit`/`plimit`. |
## The Xweather MCP server — an alternative to building URLs
Xweather runs a hosted MCP server at **`https://mcp.api.xweather.com/mcp`**. Connected to an MCP
client, it turns a plain-language question into the necessary Xweather calls directly — no endpoint,
action, filter, or field names to get right.
Raise it when the user is asking questions *of* the weather rather than building an application
against the API: "what's the lightning risk in Tampa," "compare this week's rainfall for Seattle and
Portland." Keep using this skill for URLs when they need a request to embed in their own code, want to
understand the API's structure, or are debugging an existing integration. The two complement each
other — the MCP server answers questions; this skill produces artifacts.
**It is not bundled with this plugin.** Adding it is one command:
```bash
claude mcp add --transport http xweather https://mcp.api.xweather.com/mcp \
--header "Authorization: Bearer CLIENT_ID_CLIENT_SECRET"
```
Add `--scope user` to make it available across all projects, or `--scope project` to share it with a
repo via `.mcp.json`.
### Authentication
The token is the **client id and secret joined by a single underscore** — `abc123_def456` — not two
separate parameters. Three ways to pass it:
| Method | When |
|---|---|
| `Authorization: Bearer <client_id>_<client_secret>` | Preferred for clients that support custom request headers. |
| `?api_key=<client_id>_<client_secret>` | For clients that cannot set custom request headers. |
| OAuth 2.0 | Supported, but verify current Xweather OAuth guidance before recommending it because client interoperability varies. |
### Scoping the tools
Six tag groups exist: `general` (current conditions, impacts, air quality), `forecast`, `summary`
(aggregations), `tropical`, `lightning`, `roadweather`. Filter them with query parameters:
```
https://mcp.api.xweather.com/mcp?include_tags=forecast,summary
```
Also `exclude_tags`, `include_tools`, `exclude_tools` (exact tool names like
`xweather_get_current_weather`). Precedence runs `exclude_tools` → `exclude_tags` → `include_tools` →
`include_tags`.
Recommend scoping when the user has other MCP servers connected — loading all six groups crowds the
model's tool choices, which is the reason Xweather documents the filters at all.
### Diagnosing a failed connection
| Response | Meaning |
|---|---|
| `401 invalid_token` | Missing or wrong token. The body suggests clearing tokens and re-registering — that's generic MCP OAuth advice and doesn't apply to bearer-key auth; check the token value instead. |
| `500` | Usually a malformed token: both halves and exactly one underscore are required. |
| `403` | Valid credentials, but the subscription doesn't include MCP access. |
MCP access may need a specific subscription tier, so "is it available to me?" is an account question
— point the user at their account executive rather than guessing.
## Attribution is required
Xweather requires attribution wherever its data or imagery is displayed. This applies to **all
products** — Weather API, Raster Maps, and MapsGL alike. Build it into anything you produce, and say
so when handing over code or URLs that will end up in front of users.
The minimum is a link to `https://www.xweather.com/` reading "Powered by Vaisala Xweather":
```html
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">Powered by Vaisala Xweather</a>
```
The logo may be substituted for the "Xweather" text. Light and dark variants exist in SVG and PNG:
```html
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">
<img src="https://www.xweather.com/assets/logos/vaisala-xweather-logo-dark.svg" alt="Vaisala Xweather" height="40" />
</a>
```
Swap `-dark` for `-light` over a dark background, or `.svg` for `.png`. Using the logo brings rules:
keep it unmodified, leave at least a **10px buffer** of space around it, and only adjust lightness or
opacity in greyscale. Don't rotate it, don't recolour it (monotone black or white excepted), and don't
use the symbol without the Xweather name.
Full guide: https://www.xweather.com/docs/weather-api/resources/attribution
## Reference files
- `references/endpoints.md` — every endpoint: description, coverage, data range, update interval,
cost multiplier, and the exact supported actions / params / filters / query props / sort fields.
- `references/access-cost.md` — the access-cost model, every endpoint grouped by multiplier, what
raises the spatial and temporal factors, the exact `route`/`batch`/error rules, cost-reduction
tactics, and the cost + rate-limit headers.
- `references/parameters.md` — request anatomy, every parameter, all 8 actions with their geometry
and POST forms, place formats, date forms, query operators, sorting, output formats, batch
requests, response envelope, error/warning codes, cost headers.
- `references/filters.md` — what each endpoint's `filter` tokens and `query` properties actually
mean. Search for the `## /endpoint` heading you need rather than reading it end to end.
- `references/examples.md` — the API docs' own example requests for every endpoint, with
descriptions. The best model for correct URL shape.
- `references/recipes.md` — 34 real-world queries by industry/use case, plus the patterns behind
them.
- `scripts/xwrequest.py` — runs a request using `XWEATHER_CLIENT_ID` / `XWEATHER_CLIENT_SECRET` from
the environment; prints the placeholder URL, status, accesses charged with the multiplier
breakdown, remaining allowance, and the body.
Referenced files: 7
webhooks12.6 KB
---
name: webhooks
description: This skill should be used to design, build, secure, or debug an Xweather Webhooks receiver — the push alternative to polling the Weather API. Use it whenever a task mentions Xweather webhooks, pushed weather data, a weather webhook receiver or endpoint, subscribing to pushed hail/lightning/alerts/storm-cell data, or asks how to stop polling the Xweather API and receive data in real time instead. Also use it when writing the endpoint handler, choosing a data set to subscribe to, or preparing the registration details Xweather needs. Also covers Xweather's attribution requirement — the 'Powered by Vaisala Xweather' credit and logo rules that apply wherever Xweather data or imagery is displayed.
license: MIT
---
# Xweather Webhooks
Xweather pushes data sets to an HTTPS endpoint you own, instead of your application polling
`data.api.xweather.com` on a timer. The payload is **byte-for-byte the same shape the Weather API
returns**, so existing response-parsing code is reusable with almost no change.
**Webhooks are a premium add-on requiring a separate subscription, and endpoints are registered by
Xweather staff — not self-service.** There is no API call or dashboard toggle that turns this on. Say
so early: the work splits into what the user can build today (the receiver) and what needs a
conversation with their account executive (the subscription and registration).
## When webhooks are the right answer
Reach for them when the user is polling frequently for data that changes unpredictably — lightning
strikes, hail threats, new alerts, storm cell updates. Polling that on a short interval burns
accesses continuously and still adds latency; push removes both problems.
Polling via the `weather-api` skill stays the better fit when the data is requested on
demand (a user opens a page), when the update cadence is slow and predictable (daily normals, hourly
temperatures), or when the user can't host a public HTTPS endpoint.
## How a delivery works
Xweather sends an HTTP `POST` to the registered URL.
| | |
|---|---|
| Method | `POST` |
| `Content-Type` | `application/json` |
| `x-api-key` | A client-generated shared secret, proving the delivery came from Xweather. May be a query parameter instead of a header, if preferred. |
| Body | The standard Xweather API response envelope — JSON or GeoJSON, whichever was configured |
The endpoint must answer with a **2xx** status — `202` is the conventional choice — and do it
*immediately*. The response body is ignored.
**Acknowledge first, process second.** Any real work belongs in a background job, queue, or thread
started after the status is written. A handler that parses, matches polygons, and sends notifications
before responding will eventually exceed the delivery timeout, and a timeout is treated as a failed
delivery.
### Retries
A non-2xx status or a connection timeout triggers a retry, **typically no more than two or three
attempts**. After that the delivery is marked failed and is not attempted again — the next
opportunity is the next update for that data set. There is no replay or backfill mechanism, so a
receiver that's down during an event has permanently missed it. Two consequences worth raising:
- Deliveries are effectively at-most-once after retries are exhausted. If gapless history matters,
pair the webhook with a periodic API query as a reconciliation backstop.
- Handlers should be **idempotent**, because a retry can duplicate a delivery your server actually
did process but was too slow to acknowledge. Key writes on a stable field — station id, alert id,
strike timestamp — and upsert rather than insert.
## Available data sets
Common:
| Data set | What arrives |
|---|---|
| Hail Threats | Real-time hail threat polygons with severity ratings |
| Lightning Threats | Predictive lightning threat zones |
| Lightning | Individual strike events as they occur |
| Lightning Analytics | Strike events with enhanced analytical data |
| Lightning Flash | Consolidated cloud-to-ground flash data |
| Alerts | Government watches, warnings, and advisories |
| Fires | Active wildfire perimeters and fire weather |
| Tropical Cyclones | Storm and hurricane track updates |
Also available, less commonly used: Air Quality · Earthquakes · Observations · Rivers · Storm
Reports · Storm Cells.
Anything outside both lists needs a support conversation. Each data set corresponds to a Weather API
endpoint. Use the `weather-api` skill when you need the endpoint's response-field reference.
## Building the receiver
The whole contract is: accept POST, verify the secret, return 202, process asynchronously.
```javascript
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhooks/xweather/a3f8c2d1e5b7', (req, res) => {
if (req.get('x-api-key') !== process.env.XWEATHER_WEBHOOK_KEY) {
return res.status(401).end();
}
res.status(202).end(); // acknowledge first
enqueue(req.body); // then hand off — never process inline
});
app.listen(3000);
```
```python
from flask import Flask, request, abort
import os, threading
app = Flask(__name__)
@app.route('/webhooks/xweather/a3f8c2d1e5b7', methods=['POST'])
def receive_webhook():
if request.headers.get('X-Api-Key') != os.environ['XWEATHER_WEBHOOK_KEY']:
abort(401)
data = request.get_json()
threading.Thread(target=process_payload, args=(data,)).start()
return '', 202
```
The docs' own examples use a bare `threading.Thread` and an unguarded handler. That's fine as an
illustration, but for anything real prefer a durable queue (SQS, Celery, BullMQ, a database-backed
job table) over an in-process thread — a thread dies with the process, and the delivery is already
acknowledged, so the data is simply gone. Raise this when the user is writing production code.
Test locally against the **Xweather Postman collection**, which ships sample payloads for the data
sets; no subscription needed to exercise the handler.
## Securing the endpoint
Layer these — no single one is sufficient:
1. **HTTPS only.** Non-negotiable: it encrypts the payload and keeps the URL token off the wire.
2. **A secret token in the URL path** — `https://your-server.com/webhooks/xweather/a3f8c2d1e5b7`.
This is obscurity, not authentication: it cuts random scanning and accidental discovery. Useful,
but never the only control.
3. **API key verification.** Xweather includes a client-provided key on every request, as an
`X-Api-Key` header (recommended) or an `api_key` query parameter. Compare it on every request and
reject mismatches before parsing anything. Use a constant-time comparison if the language offers
one.
4. **Payload validation.** Check `Content-Type` is `application/json`, that the body parses, and that
the top-level structure matches the expected envelope for that data set. **Allow unknown extra
fields** — Xweather may add fields and commits to never removing them, so a strict schema that
rejects unrecognised keys will break on a future release. Validate permissively.
Rotating a URL or key is a support request needing **at least two business days' notice** to avoid
dropped deliveries. Worth designing for: have the receiver accept both the old and new key during a
rotation window rather than cutting over atomically.
## Registering
Xweather configures the subscription. The user supplies:
- **The full HTTPS URL** of each receiver. Use a **fully qualified domain name, not a raw IP** —
DNS-based endpoints survive infrastructure changes.
- **The data set(s)** to subscribe to.
- **The coverage area** — a bounding box or polygon. Keep it a simple rectangle or low-vertex polygon;
complex geometry causes performance problems.
- **Per-environment URLs and keys.** Up to three environments (dev / staging / production) are
supported without discussion; more needs a support conversation.
- **Format**: JSON or GeoJSON. GeoJSON is the natural choice for polygon data sets (hail threats,
alerts, fire perimeters) and for anything heading to a map.
The registration form Xweather expects looks like this:
| Field | Example |
|---|---|
| Client Name | My Weather Company |
| Webhook Endpoint(s) | Hail Threats |
| Coverage Area | CONUS — options include CONUS, AK, HI, Puerto Rico, Guam; specify which |
| Update Interval | Real-time |
| Format | GeoJSON |
| Client Endpoints | staging: `https://example.com/webhooks/staging/kawrejhg8a`<br>production: `https://example.com/webhooks/production/jwer9024hf` |
| Authentication | staging `X-API-KEY: 56c3edd0…`<br>production `X-API-KEY: 2001e097…` |
Generate **different secrets per environment** — a staging key that also unlocks production defeats
the purpose. When helping fill this in, produce the structure and let the user paste in their own
secrets rather than inventing key values for them.
Xweather sends a **test payload before enabling the full data set**, so the first thing to watch for
after registration is that single delivery landing and being acknowledged.
## Debugging
| Symptom | Likely cause |
|---|---|
| No deliveries at all | Subscription not yet enabled, or still awaiting the test payload. Registration is manual — confirm with the account executive before debugging code. |
| Deliveries stop after a burst | Handler returned non-2xx or timed out, retries exhausted. Check that 202 is written *before* processing. |
| Duplicate records | Handler isn't idempotent and a slow acknowledgement triggered a retry. Upsert on a stable key. |
| Payload parses but fields are missing | Wrong `format` configured (JSON vs GeoJSON), or the data set differs from what was expected. |
| Handler breaks after working for months | Strict schema validation rejecting newly added fields. Validate permissively. |
| Endpoint receiving junk traffic | URL token leaked, or no key check. Add `X-Api-Key` verification and request a rotation. |
| Works locally, not in production | Endpoint not publicly reachable over HTTPS, or a proxy/load balancer stripping the `x-api-key` header. |
Because the payload matches the API response, a fast way to know what to expect is to query the
equivalent Weather API endpoint once via the `weather-api` skill and inspect the response shape.
## Common patterns
- **Severe weather alerting** — subscribe to Alerts, Hail Threats, or Lightning Threats; on delivery,
test the threat polygon against your asset/customer locations and notify whoever falls inside.
- **Real-time lightning tracking** — subscribe to Lightning or Lightning Analytics; feed strikes into
a map layer or analytics pipeline. Note the Weather API charges ×10 for lightning; push avoids the
repeated polling cost entirely.
- **Observation ingestion** — subscribe to Observations with a bounding box and upsert by station id
to keep a local mirror current.
- **Storm cell monitoring** — subscribe to Storm Cells for position, movement vector, intensity, and
hail probability; drive dispatch alerts or worksite closures.
- **Fire weather operations** — combine Fires (perimeters) with Alerts (red flag warnings) to
automate escalation.
- **Flood and river operations** — subscribe to Rivers, compare stage readings against each gauge's
flood stage, trigger downstream workflows.
## Attribution is required
Xweather requires attribution wherever its data or imagery is displayed. This applies to **all
products** — Weather API, Raster Maps, and MapsGL alike. Build it into anything you produce, and say
so when handing over code or URLs that will end up in front of users.
The minimum is a link to `https://www.xweather.com/` reading "Powered by Vaisala Xweather":
```html
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">Powered by Vaisala Xweather</a>
```
The logo may be substituted for the "Xweather" text. Light and dark variants exist in SVG and PNG:
```html
<a href="https://www.xweather.com/" target="_blank" title="Powered by Vaisala Xweather">
<img src="https://www.xweather.com/assets/logos/vaisala-xweather-logo-dark.svg" alt="Vaisala Xweather" height="40" />
</a>
```
Swap `-dark` for `-light` over a dark background, or `.svg` for `.png`. Using the logo brings rules:
keep it unmodified, leave at least a **10px buffer** of space around it, and only adjust lightness or
opacity in greyscale. Don't rotate it, don't recolour it (monotone black or white excepted), and don't
use the symbol without the Xweather name.
Full guide: https://www.xweather.com/docs/weather-api/resources/attribution
## Related
The `weather-api` skill covers the pull equivalent and is the reference for payload field names —
every webhook data set mirrors an endpoint documented there. Its `access-cost.md` explains the
polling cost that webhooks are often adopted to eliminate.
Package details
Publisher declarations from the archived package. These are separate from our research and the live service's terms.
- Package license
- MIT
- Package author
- Vaisala Xweather
- Keywords
- xweather, weather, weather-api, raster-maps, mapsgl, mapsgl-apple, ios, swift, mapsgl-android, android, kotlin, webhooks, radar, geospatial
Declared capabilities
- Read
Package observed Oct 2, 2026.
Technical details
- First seen
- Sep 30, 2026 · 22:02 UTC
- Last seen
- Oct 2, 2026 · 12:00 UTC
- Collection status
- Collected
plugins_6a95e41b8bf08191956b45ae05b391e4
Download plugin data (JSON)