@aeroswarm/weather-layer
Animated weather on a map: wind particles, clouds, rain/snow, fog and lightning, plus sea layers (waves, ocean
currents, sea level). It takes the AnimationState produced by @aeroswarm/weather (decoded
METAR/SPECI/TAF) and fields from @aeroswarm/marine, and draws them. No network access.
Works with LuciadRIA, MapLibre/Mapbox GL, Leaflet and CesiumJS (section D).
A. Flat overlay (2D and 3D)
import { animationSnapshot } from "@aeroswarm/weather";
import { createWeatherOverlay } from "@aeroswarm/weather-layer";
const overlay = createWeatherOverlay(map); // transparent canvases on map.domNode
const snapshot = animationSnapshot({ observation: metar, forecast: taf });
if (snapshot) overlay.setState(snapshot.state);
overlay.setVisible(false); // toggle from your layer panel
overlay.setOpacity(0.8);
overlay.destroy();
React:
import { useWeatherOverlay } from "@aeroswarm/weather-layer/react";
useWeatherOverlay(map, snapshot?.state, { visible, opacity });
Wind direction follows the map rotation (mapScreenOffset; override with getScreenOffset).
The overlay is a canvas above the map, so it is not part of map.layerTree: wire its visible and
opacity to your own layer panel. Honours prefers-reduced-motion (draws a still frame).
B. 3D clouds at real altitude
import { FeatureLayer } from "@mid/ria/view/feature/FeatureLayer";
import { createCloudLayer3D } from "@aeroswarm/weather-layer";
const clouds = createCloudLayer3D(
{ reference, Feature, FeatureModel, MemoryStore, FeatureLayer, FeaturePainter, createPoint },
{ center: { lat: -7.3798, lon: 112.7869 }, elevationM: 3 },
);
map.layerTree.addChild(clouds.layer);
clouds.setState(snapshot.state);
Cloud height = aerodrome elevation + cloud base above ground level (METAR reports AGL). Sprites are
sized in metres, so they scale with zoom and perspective. Drawn only when the map is 3D
(EPSG:4978) unless only3D: false. The RIA classes are passed in so this package never bundles
its own copy of RIA.
C. Windy-style fields (flow particles + colour maps)
Wind particles that follow a gridded wind field, coloured by speed, plus colour maps for cloud cover,
rain or temperature. The grid can come from anywhere (see @aeroswarm/weather: windGridFromStations
for several METARs, parseGrib2Json, parseGridJson, stationWindsFromModelPoints).
import { createFieldOverlay, createRiaUnproject, PRECIPITATION_RAMP } from "@aeroswarm/weather-layer";
const unproject = createRiaUnproject(map, { wgs84, createPoint, createTransformation });
const overlay = createFieldOverlay(map, { unproject });
overlay.setWind(windGrid); // particles
overlay.setScalar(cloudGrid); // cloud cover (default ramp)
overlay.setScalar(rainGrid, PRECIPITATION_RAMP); // or rain, temperature, ...
The field is mapped onto a screen grid whenever the map moves (throttled), so the particles read a small array instead of projecting every particle on every frame. Points that are not on the map (3D: off the globe) have no wind.
D. Other maps (MapLibre, Leaflet, CesiumJS)
The package core never imports a map library. A map is described by a small MapAdapter
(container, unproject(x, y), screenOffset(), onChange(cb)), and each map has an adapter on its own
subpath. Adapters only call methods on the map object you pass in, so there is nothing extra to install and
importing /maplibre never pulls Cesium or RIA.
import { createFieldOverlay } from "@aeroswarm/weather-layer";
import { maplibreAdapter } from "@aeroswarm/weather-layer/maplibre"; // also works with Mapbox GL
const overlay = createFieldOverlay(maplibreAdapter(map));
import { leafletAdapter } from "@aeroswarm/weather-layer/leaflet";
const overlay = createFieldOverlay(leafletAdapter(map)); // no rotation unless you pass { bearing }
import { cesiumAdapter } from "@aeroswarm/weather-layer/cesium";
import * as Cesium from "cesium";
const overlay = createFieldOverlay(
cesiumAdapter(viewer, { Cartesian2: Cesium.Cartesian2, Cartographic: Cesium.Cartographic, toDegrees: Cesium.Math.toDegrees }),
);
import { riaAdapter } from "@aeroswarm/weather-layer/ria";
const overlay = createFieldOverlay(riaAdapter(map, { wgs84, createPoint, createTransformation }));
createWeatherOverlay accepts an adapter the same way. The old createFieldOverlay(map, { unproject })
call for RIA keeps working. Notes: on Cesium, screen points that miss the globe have no wind; with a tilted
camera (MapLibre pitch, Cesium, RIA 3D) the wind direction on screen is an approximation. 3D clouds
(section B) are still RIA only.
E. Ready-made UI (/react-ui)
React components so you do not assemble the controller, overlay and time UI yourself. One <WeatherLayer>
per map; the components inside read its state, so two maps on one page do not interfere.
import { maplibreAdapter } from "@aeroswarm/weather-layer/maplibre";
import { WeatherLayer, WeatherLayerSwitch, WeatherLegend, WeatherTimeline } from "@aeroswarm/weather-layer/react-ui";
import "@aeroswarm/weather-layer/react-ui/weather-ui.css"; // optional default look
const adapter = useMemo(() => (map ? maplibreAdapter(map) : null), [map]);
<WeatherLayer adapter={adapter} store={store} grid={grid} series={series} locale="id" timeZone="Asia/Jakarta">
<WeatherLayerSwitch />
<WeatherTimeline />
<WeatherLegend />
</WeatherLayer>
| Export | What it does |
|---|---|
WeatherLayer | mounts the overlay and the controller, provides context. Props: adapter (or RIA MapLike + unproject), store, grid, series, blend, locale, timeZone, messages |
WeatherLayerSwitch | Wind, Cloud, Rain, Temperature. All off until turned on; colour layers are exclusive |
WeatherTimeline | slider with TAF period blocks, play/pause, "Now", window (today / next 3 days) |
WeatherLegend | colour bar and unit of the active layer |
WeatherPreviewTable | hourly values at a point (model, reports, or model blended with reports) |
useWeatherController(), useWeatherSnapshot(station) | controller + state, and the station weather at the displayed time |
windowFor(range, now, timeZone), previewRows(...) | the pure functions behind them |
- Language:
locale="id"gives Indonesian, anything else English; replace single texts withmessages={{ wind: "Wind speed" }}. - Time zone: IANA name, default
UTC. "Today" is the 24 hours of that zone's day. - Styling: no forced position, size or layout; put the components wherever your layout wants them. Classes are
prefixed
aw-(documented at the top ofweather-ui.css) and colours come from--aw-*CSS variables, light and dark. The stylesheet is optional and does not depend on Tailwind or any UI kit. - Accessibility: native buttons and range input,
radiogroupwith arrow keys,aria-valuetexton the slider,WeatherSummaryfor a screen-reader sentence (aria-live),WeatherMotionToggle; both default themes meet WCAG AA contrast and the components pass an axe-core audit (jsdom, which cannot check layout-dependent rules; not tested with a real screen reader). - Motion: the particle overlay follows
prefers-reduced-motion(still image) and the user can turn motion back on; particle count drops automatically on slow devices. - React 18+ is a peer dependency, optional for the rest of the package.
F. Sea layers and extra data (flow layers, providers)
Besides wind, the controller can draw several flow layers (particles that follow a vector field) and colour layers at once.
| Kind | Ids | Source |
|---|---|---|
| Flow | wind, current, wave | weather grid, ocean current, wave propagation |
| Colour | cloud, rain, temperature, waveHeight, seaLevel | weather grids, marine grids |
controller.toggleFlowLayer("current"); // also setFlowLayers([...]); state.flows lists the ones on
controller.toggleColorLayer("waveHeight"); // several colour layers can be on together
controller.setLayerOpacity("seaLevel", 0.6);
Ramps: LAYER_RAMPS and FLOW_RAMPS (WAVE_HEIGHT_RAMP, SEA_LEVEL_RAMP, CURRENT_RAMP, WAVE_FLOW_RAMP); replace any of them with
<WeatherLayer ramps={{ waveHeight: [...] }}> (stops are [value, r, g, b, alpha?]). Each flow layer has its own canvas, ramp and motion scale.
Ocean currents are drawn much faster than their real speed, otherwise nothing would be seen to move. WeatherLayerSwitch accepts the new layers
and its onChange also reports flows.
Providers bring fields from outside weather. A FieldProvider is { id, range(), fieldsAt(time) }; its time range extends the timeline,
so the existing slider and play button animate it. @aeroswarm/marine ships ready-made ones:
import { marineFieldProvider, zoneTimeProvider } from "@aeroswarm/marine";
controller.setProviders([zoneTimeProvider(zones), marineFieldProvider(series)]); // or pass `providers` in the options
G. Clip a layer to polygons (mask)
Sea layers must not paint over land, and their edge should follow the coast, not the grid squares. setMask limits chosen layers to a set of
GeoJSON-style polygons (for example the sea zones from @aeroswarm/marine):
controller.setMask(polygons); // default layers: waveHeight, seaLevel, current, wave
controller.setMask(polygons, ["waveHeight"]); // only these
controller.setMask(null); // remove
- It uses CSS
clip-pathon the layer canvas, so the renderers pay nothing per frame. It needsMapAdapter.project, which the MapLibre, Leaflet and RIA adapters provide; without itsetMaskdoes nothing. - Ring winding is normalised, so touching or overlapping polygons merge and holes stay holes (
normalizeMask,maskClipPathare exported). - Thousands of polygon points make the
clip-pathstring long. If a map gets slow, simplify the polygons first. - The overlay is rebuilt when the map switches between 2D and 3D; call
setMaskagain after the new controller exists. - Pair it with
fillMarineGapsfrom@aeroswarm/marineso the strip between the sea values and the polygon edge is not blank.
Documentation
Stability and versioning policy (STABILITY.md), limits of use
(LIMITS.md) and measured performance and size
(PERFORMANCE.md) are shipped with @aeroswarm/weather; changes are in
CHANGELOG.md.
Limits
METAR and TAF describe one point (the aerodrome). The animation is a representation around the airport, not the real position of clouds or rain. There is no terrain; the only land cut-out is the polygon mask of section G, which you supply. Sea layers are indicative (model resolution about 5-10 km), not for navigation.