@openmeteo/weather-map-layer
v0.3.0
Published
Weather Map Layer for MapLibre/Mapbox GL JS powered by Open-Meteo OMfiles
Downloads
4,663
Readme
Open-Meteo Weather Map Layer
⚠️ Notice This package is still under construction and is not yet fully production-ready. API changes may occur and some features might be incomplete.
Overview
This repository serves as a demonstration of the Open-Meteo File Protocol (om://) with Mapbox / MapLibre GL JS. The om:// scheme is a custom MapLibre protocol registered via addProtocol. The .om files are hosted on an S3 bucket and can be accessed directly through the protocol handler.
The core weather data generation and API is hosted in the open-meteo/open-meteo repository.
An interactive demo is available at maps.open-meteo.com.
Installation
Node
npm install @openmeteo/weather-map-layer// ...
import { omProtocol } from '@openmeteo/weather-map-layer';
// Standard MapLibre GL JS setup
// ...
maplibregl.addProtocol('om', omProtocol);
const omUrl = `https://openmeteo.s3.amazonaws.com/data_spatial/dwd_icon/latest.json?variable=temperature_2m`;
map.on('load', () => {
map.addSource('omFileSource', {
url: 'om://' + omUrl,
type: 'raster'
});
map.addLayer({
id: 'omFileLayer',
type: 'raster',
source: 'omFileSource',
paint: {
'raster-opacity': 0.75
}
});
});Two files ship next to the module and are referenced with new URL(..., import.meta.url): the tile render worker (dist/worker.js) and the file reader's WebAssembly binary (dist/om_file_format.web.wasm). Bundlers that understand that pattern (Vite, webpack 5, Rollup, Parcel) copy them into their output as assets without configuration. The worker starts with the protocol; the binary is fetched on the first data read, not at import time.
HTML / UNPKG
The package ships as an ES module only, so load it from a <script type="module">. The render worker and the .wasm binary are fetched from the same directory; the worker is started through a same-origin blob when the module comes from another origin, as a worker script itself must be same-origin. For a standalone example, see examples/temperature.html.
<script type="module">
import * as maplibregl from 'https://unpkg.com/[email protected]/dist/maplibre-gl.mjs';
import * as OMWeatherMapLayer from 'https://unpkg.com/@openmeteo/[email protected]/dist/index.mjs'; // x-release-please-version
// Standard MapLibre GL JS setup
// ...
maplibregl.addProtocol('om', OMWeatherMapLayer.omProtocol);
const omUrl = `https://openmeteo.s3.amazonaws.com/data_spatial/dwd_icon/latest.json?variable=temperature_2m`;
map.on('load', () => {
map.addSource('omFileSource', {
url: 'om://' + omUrl,
type: 'raster'
});
map.addLayer({
id: 'omFileLayer',
type: 'raster',
source: 'omFileSource',
paint: {
'raster-opacity': 0.75
}
});
});
</script>Development
The examples can be served locally, providing direct access to the bundled assets and data files. To launch the development server, execute:
npm run serveThis command initiates a lightweight static server, enabling the interactive demos to be viewed in a browser while reflecting any code changes in real time.
Examples
Raster sources
The repository contains an examples directory with ready-to-run demos:
examples/temperature.html– shows temperature data from an OM file.examples/precipitation.html– displays precipitation using a similar setup.examples/wind.html– displays wind values, for arrows overlay see Vector sources.examples/combined-variables.html– shows multiple data sources on the same map.examples/partial-requests.html– demonstrates partial / incremental data requests.examples/interpolation.html– switch the interpolation method live via theinterpolationURL parameter.
Run the examples by opening the corresponding .html file in a browser.
Vector sources
For directional arrows / contouring / gridpoints, an additional source must be added, since these features use vector tiles instead of raster tiles.
...
map.on('load', () => {
map.addSource('omFileVectorSource', {
url: 'om://' + omUrl,
type: 'vector'
});
map.addLayer({
id: 'omFileVectorLayer',
type: 'line',
source: 'omFileVectorSource',
'source-layer': 'contours',
paint: {
'line-color': 'black',
'line-width': 4
}
});
});For the vector source examples there is the examples/vector sub-directory with ready-to-run demos:
examples/vector/grid-points.html– displays all grid points for a model, with value data on each point.examples/vector/temperature-anomaly.html– shows a seasonal forecast map with temperature anomalies.examples/vector/temperature-labels.html– displays all grid points for a model, using value data to show temperature labels.examples/vector/wind-arrows.html– displays wind map with directional arrows.
Framework Adapters
The core omProtocol handler is designed for MapLibre GL JS, but this package also ships adapters for Mapbox GL JS, Leaflet, OpenLayers and CesiumJS. Each adapter provides addProtocol / removeProtocol plus factory methods for creating map-library-native source or layer objects. See examples/leaflet, examples/openlayers, examples/mapbox and examples/cesium.
- Leaflet –
addLeafletProtocolSupport(L)givescreateTileLayerandcreateVectorTileLayer, both plainL.GridLayers. They default totileSize: 512, zoomOffset: -1: the protocol renders 512 px tiles and sizes its arrow/barb lattice for them, so this reproduces the MapLibre look 1:1. PasstileSize: 256, zoomOffset: 0for 256 px tiles. - OpenLayers –
addOpenLayersProtocolSupport(ol)givescreateRasterSource(aDataTilesource forWebGLTilelayers) andcreateVectorTileSource(an MVTVectorTilesource). - Mapbox GL JS –
addMapboxProtocolSupport()givescreateRasterSource, atype: 'custom'raster source formap.addSource, andaddVectorSource(map, sourceId, url), which keeps a GeoJSON source in sync with the vector tiles of the viewport (Mapbox custom sources carry raster data only). Style layers select their features with a filter on thelayerproperty, e.g.['==', ['get', 'layer'], 'wind-arrows'], instead ofsource-layer. - CesiumJS –
addCesiumProtocolSupport(Cesium)givescreateImageryProviderandcreateVectorImageryProvider, both resolving to anImageryProvideronce the TileJSON is known; add them withviewer.imageryLayers.add(Cesium.ImageryLayer.fromProviderAsync(provider)). Cesium has no vector tile renderer, so the vector provider draws the arrows, barbs or contours onto raster tiles with a style function, like the Leaflet vector tile layer. Do not limit the data window withupdateCurrentBoundsin Cesium: it requests the root tiles and the ancestors of what it shows, which cover the whole domain anyway, plus tiles beyond the view while the camera moves, and keeps them all. A window would save nothing and leave those tiles partly empty until their level changes.
Cross-origin isolation
The protocol shares the decoded weather data with its render workers through SharedArrayBuffer, which browsers only enable on cross-origin isolated pages. Serve your page with
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp(require-corp is what the Open-Meteo maps app uses; every cross-origin resource then needs CORS or a Cross-Origin-Resource-Policy header. credentialless is a more lenient alternative where supported.) Without these headers the protocol still works, but every tile request has to copy the whole variable into a worker, which blocks the main thread noticeably while panning and zooming. npm run serve sends them (scripts/serve-examples.js); crossOriginIsolated in the console tells whether a page has them.
Maptiler SDK
The Maptiler SDK natively supports addProtocol, but unfortunately it mangles the param url, by removing the second :, this small snippet will fix that:
// MapTiler SDK mangles URLs like `om://https://...` into `om://https//...`
maptilersdk.addProtocol('om', (params, abortController) => {
params.url = params.url.replace('https//', 'https://');
return omProtocol(params, abortController, omProtocolSettings);
});Contouring
examples/vector/contouring/contouring-pressure.html– shows how to use contouring with a pressure map.examples/vector/contouring/contouring-on-colorscale.html– shows how to use contouring to follow the breakpoints in the colorscale.examples/vector/contouring/custom-contouring-intervals.html– shows how to use contouring with a custom contouring interval.
Colors
If you’re rendering tiles on a dark base‑map or simply want to experiment with alternative color schemes, the documentation includes several example pages that illustrate all the available color‑scale options:
examples/colorscales/darkmode.html– demonstrates thedark=trueURL parameter, which automatically switches to palettes fine‑tuned for dark backgrounds.examples/colorscales/custom-rgba.html– shows how to build a linear gradient from a user‑defined array of RGBA values.examples/colorscales/custom-breakpoints.html– demonstrates how to insert your own breakpoints into the scale definitions.
Callbacks
In scenarios where post‑loading transformations of weather data is required, the protocol provides a post‑read callback. This callback is invoked immediately after the data has been parsed by omFileReader, allowing transformations before the data is forwarded to the rendering pipeline. A typical usage pattern is illustrated below:
const omProtocolOptions = OMWeatherMapLayer.defaultOmProtocolSettings;
omProtocolOptions.postReadCallback = (omFileReader, data, state) => {
if (data.values) {
data.values = data.values?.map((value) => value / 100);
}
};
maplibregl.addProtocol('om', (params, abortController) =>
OMWeatherMapLayer.omProtocol(params, abortController, omProtocolOptions)
);An example implementation with a useful case is available in the examples/callbacks sub-directory.
Derivation rules
Some variables are not stored in the .om files but computed while reading them: wind speed and direction come from the u/v components, wave height is paired with the wave direction, and vapour_pressure_deficit, wet_bulb_temperature_2m and apparent_temperature are computed from the 2 m temperature and humidity (the Magnus formula for the dew point, Stull's fit for the wet-bulb temperature and Steadman's apparent temperature, as the Open-Meteo backend computes them). Each of these is a derivation rule, matching the requested variable name and listing the variables it is read from.
What a model stores differs: snowfall is stored by some models and derived from snowfall_water_equivalent on others, wind comes as u/v components or as speed and direction, and ECMWF stores the dew point where other models store the relative humidity. A rule therefore sees the variables of the file it reads from and decides whether it applies. The stored field wins over a derived one, and a derived field needs all of its sources in the file (the first file of a run has no accumulations yet). Where not stored, the defaults derive snowfall, rain, dew_point_2m, relative_humidity_2m, shortwave_radiation, direct_radiation, diffuse_radiation and wind_speed_*/wind_direction_* from the components, and the humidity-derived fields take the dew point, the wind speed or the direct and diffuse radiation where a model stores those instead. The precipitation family (precipitation, rain, showers, snowfall, snowfall_water_equivalent and their ensemble _mean and _spread), which the files store as sums over each time step, is always shown per hour (mm/h, cm/h for snowfall), stored or derived, so a series of maps reads the same across a model's hourly, 3-hourly and 6-hourly steps. That needs the length of the file's time step, which the om protocol takes from the delta_time_hours parameter of the om URL: the host application sets it from the run's valid times (the gap to the previous valid time; the first step takes the gap to the next one), and a Capture API URL (latest.json, in-progress.json) gets it set while resolving, from the valid times it fetches anyway.
The defaults are exported as defaultDerivationRules; own rules are passed through fileReaderConfig:
// Fresh snow from the water equivalent with a ratio that follows the
// temperature, instead of the fixed 0.7 cm per mm of the default rule.
// Listed first, this rule takes `variable=snowfall` over.
const snowfallRule = {
pattern: /^snowfall$/,
provides: { directions: false, barbs: false },
scaleFactor: 'primary',
// A model storing snowfall itself keeps it; otherwise both sources are needed
getSourceVars: ({ variable, stored }) =>
!stored.has(variable) && stored.has('snowfall_water_equivalent') && stored.has('temperature_2m')
? ['snowfall_water_equivalent', 'temperature_2m']
: null,
process: ([waterEquivalent, temperature]) => ({
// 1 cm per mm near freezing, up to 2 cm per mm of cold, dry snow
values: waterEquivalent.map((mm, i) => mm * Math.min(2, Math.max(1, 1 - temperature[i] / 10))),
directions: undefined
})
};
const omProtocolOptions = OMWeatherMapLayer.defaultOmProtocolSettings;
omProtocolOptions.fileReaderConfig = {
...omProtocolOptions.fileReaderConfig,
// The first rule claiming a name wins, so own rules go in front of the defaults.
derivationRules: [snowfallRule, ...OMWeatherMapLayer.defaultDerivationRules]
};A string pattern matches anywhere in the name, which is why the rule above anchors a RegExp: a plain 'snowfall' would also claim snowfall_water_equivalent. getSourceVars receives the requested name and the set of variables stored in the file; returning null passes the name on to the next matching rule, and when no rule claims it the variable is read as stored. A rule lists as many source variables as it needs: a single one to convert units, two for the u/v pairs, more to combine fields. They all have to live in the same file and share its dimensions, and process receives their data in the order getSourceVars returns, along with the same file context. A rule producing a member of the precipitation family returns the per-step sum; the reader divides it by the step after the rule. A rule needing the step for anything else sets usesTimeStep and gets it in hours as context.timeStepHours. variableUsesTimeStep(variable) tells a host which variables need delta_time_hours, and timeStepHoursAt(validTime, validTimes) computes it from a run's valid times; a direct readVariable call passes it as its last argument, and a read of such a variable without it is rejected.
provides is declared up front because it is needed before any data is read: directions: true makes a variable eligible for arrows, and barbs: true additionally marks its values as a wind speed, since barbs encode knots. Both are also queried through variableHasDirections(variable, rules) and variableSupportsBarbs(variable, rules), for a UI that offers arrow styles. They are answered from the first rule matching the name, before any file is open, so rules matching the same name have to agree on them.
Since the rules replace the defaults rather than extending them, leaving out defaultDerivationRules is how a default rule is dropped. The list is read once, when the protocol creates its shared file reader, so it has to be set before the first tile request.
examples/custom-derivation-rules.html– snowfall from the water equivalent with a temperature-dependent ratio, in front of the default rule.
Clipping
To restrict weather data to a geometric boundary, the clipping parameters can be supplied during the instantiation of the omProtocol.
const omProtocolOptions = OMWeatherMapLayer.defaultOmProtocolSettings;
omProtocolOptions.clippingOptions = {
geojson: geojson, // optionally clip weather data to geojson
bounds: clipBbox // optionally limit tile generation to bbox bounds, automatically generated from geojson when left blank
};
...The clipping examples require npm run serve, as they load GeoJSON files over HTTP.
examples/clipping/raster/clip-switzerland.html– Demonstrates temperature raster data clipped to the geographical contour of Switzerland.examples/clipping/arrows/clip-italy.html– Shows wind velocity raster and vector arrow fields clipped to the contour of Italy.examples/clipping/contours/clip-france.html– Illustrates temperature and isocontour overlays confined to the French boundary.examples/clipping/bounds/clip-germany-bounds.html– Restricts tile generation to a bounding box around Germany.examples/clipping/oceans/clip-oceans.html– Depicts the exclusion of oceanic regions from a global model, thereby hiding weather data on ocean surfaces.
Capture API
⚠️ Using the Capture API will add 0.5-1s delay for each request, because it must first fetch a metadata JSON file to resolve the latest model run before requesting the actual
.omtile data.
Because the use of OM files on the S3 storage is often quite ambiguous, a Capture API is added, that will automatically produce the correct file paths for you.
Note on endpoints:
https://openmeteo.s3.amazonaws.com/data_spatialis the raw AWS S3 endpoint, used throughout this README and the examples. The official maps app useshttps://data-spatial.open-meteo.com/data_spatial, which only accepts requests with alocalhostor*.open-meteo.comreferer.
For each Weather Model, there will be a latest.json and in-progress.json metadata file, containing data like valid time steps, valid variables and reference times.
An example can be found here, for DWD Icon Global:
https://openmeteo.s3.amazonaws.com/data_spatial/dwd_icon/latest.json{
"completed": true,
"last_modified_time": "2025-11-11T09:42:17Z",
"reference_time": "2025-11-11T06:00:00Z",
"valid_times": ["2025-11-11T06:00Z", "2025-11-11T07:00Z", "...+91"],
"variables": ["cape", "cloud_cover", "cloud_cover_high", "...+120"]
}Using the Capture API
If you don't want to select a particular model run, but instead always want to use the latest available run. Instead of using the model run in the URL you replace that part with latest.json
For example, with the link below replace the highlighted part:
With latest.json:
If you want to show the closest current time, or a pick a different valid time than the first one, you could use:
or the 5th index of the valid_times array
Time Step Modifiers
The modifier suffix on current_time_ controls the rounding granularity when snapping to the nearest available time step. For example, current_time_1H rounds to the nearest hour, while current_time_30M rounds to the nearest 30 minutes.
| modifier | Alteration | | -------- | ---------- | | M | Minutes | | H | Hours | | d | Days | | m | Months |
Seamless Composite Domains
Several domains support a seamless mode: a composite of a base model and one or more finer regional ones. At every point the finest model that is active at the current zoom level and has data there is shown, so the map switches to a finer model over its area and falls back to the coarser one elsewhere.
The protocol handler resolves the seamless domain on the client side: the latest.json metadata is fetched from the base model, every layer is requested at the base model's run and time step, and tiles are composited per pixel from the active sub-domains. Regional models with a shorter forecast horizon drop out past it (maxForecastHours), so a composite stays valid for the full range of its base model.
Available seamless domains
| Domain value | Constituent models (finest → base) |
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| dwd_icon_seamless | dwd_icon_d2 (zoom 3+) → dwd_icon_eu (zoom 2+) → dwd_icon |
| ncep_gfs_seamless | ncep_hrrr_conus (zoom 2+) → ncep_gfs025 |
| meteofrance_seamless | meteofrance_arome_france0025 (zoom 3+) → meteofrance_arpege_europe (zoom 2+) → meteofrance_arpege_world025 |
| cmc_gem_seamless | cmc_gem_hrdps_west (zoom 4+) → cmc_gem_hrdps (zoom 3+) → cmc_gem_rdps_10km (zoom 2+) → cmc_gem_gdps_15km |
| jma_seamless | jma_msm (zoom 3+) → jma_gsm |
| ukmo_seamless | ukmo_uk_deterministic_2km (zoom 3+) → ukmo_global_deterministic_10km |
| knmi_seamless | knmi_harmonie_arome_netherlands (zoom 3+) → knmi_harmonie_arome_europe |
| chmi_seamless | chmi_aladin_cz_1km (zoom 3+) → chmi_aladin_central_europe_2km |
A ready-to-run example is available at examples/seamless.html.
License
This project is licensed under the GNU General Public License v2.0.
