Edit

GeoZarr Multi-Group

zarr4 geozarr4 sentinel-23 cloud1 masking1

Cloud-masked Sentinel-2 imagery using bands from multiple Zarr groups.

The GeoZarr source supports loading bands from multiple groups within the same Zarr hierarchy. In this example, a true-color composite is rendered from B02 (blue), B03 (green), and B04 (red) reflectance values from the measurements/reflectance group. The cloud probability (cld) from the quality/probability group is used to fade out cloudy areas, revealing the OSM basemap underneath.

When bands come from different groups, each band is specified as an object with a name and a group path. The group is resolved relative to the url, which can be any common ancestor in the Zarr hierarchy — not necessarily the store root.

Since the cloud probability data is only available at 20 m resolution, it is resampled at higher zoom levels to match the 10 m reflectance bands.

main.js
import Map from 'ol/Map.js';
import {getView, withExtentCenter, withHigherResolutions} from 'ol/View.js';
import Link from 'ol/interaction/Link.js';
import TileLayer from 'ol/layer/WebGLTile.js';
import GeoZarr from 'ol/source/GeoZarr.js';
import OSM from 'ol/source/OSM.js';

const source = new GeoZarr({
  url: 'https://s3.explorer.eopf.copernicus.eu/esa-zarr-sentinel-explorer-fra/tests-output/sentinel-2-l2a/S2B_MSIL2A_20260120T125339_N0511_R138_T27VWL_20260120T131151.zarr',
  bands: [
    {name: 'b04', group: 'measurements/reflectance'},
    {name: 'b03', group: 'measurements/reflectance'},
    {name: 'b02', group: 'measurements/reflectance'},
    {name: 'cld', group: 'quality/probability'},
  ],
});

const layer = new TileLayer({
  style: {
    variables: {threshold: 50},
    gamma: 1.5,
    color: [
      'color',
      ['interpolate', ['linear'], ['band', 1], 0, 0, 0.5, 255],
      ['interpolate', ['linear'], ['band', 2], 0, 0, 0.5, 255],
      ['interpolate', ['linear'], ['band', 3], 0, 0, 0.5, 255],
      // Hide pixels whose cloud probability exceeds the threshold.
      ['case', ['>', ['band', 4], ['var', 'threshold']], 0, 1],
    ],
  },
  source,
});

const map = new Map({
  layers: [
    new TileLayer({
      source: new OSM(),
    }),
    layer,
  ],
  target: 'map',
  view: getView(source, withHigherResolutions(2), withExtentCenter()),
});

map.addInteraction(new Link());

const thresholdSlider = document.getElementById('threshold');
const thresholdValue = document.getElementById('threshold-value');
thresholdSlider.addEventListener('input', function () {
  thresholdValue.textContent = thresholdSlider.value;
  layer.updateStyleVariables({threshold: parseFloat(thresholdSlider.value)});
});
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>GeoZarr Multi-Group</title>
    <link rel="stylesheet" href="node_modules/ol/ol.css">
    <style>
      .map {
        width: 100%;
        height: 400px;
      }
    </style>
  </head>
  <body>
    <div id="map" class="map"></div>
    <div class="row mt-2">
      <div class="col-auto">
        <label class="form-label" for="threshold">Cloud threshold: <span id="threshold-value">50</span>%</label>
        <input type="range" class="form-range" id="threshold" min="0" max="100" value="50">
      </div>
    </div>

    <script type="module" src="main.js"></script>
  </body>
</html>
package.json
{
  "name": "geozarr-groups",
  "dependencies": {
    "ol": "10.9.0"
  },
  "devDependencies": {
    "vite": "^3.2.3"
  },
  "scripts": {
    "start": "vite",
    "build": "vite build"
  }
}