MCPcopy Index your code
hub / github.com/carbonplan/zarr-layer

github.com/carbonplan/zarr-layer @v0.6.0

Chat with this repo
repository ↗ · DeepWiki ↗ · release v0.6.0 ↗ · + Follow
542 symbols 1,258 edges 62 files 123 documented · 23%
What it actually does AI analysis from the code graph — generated when you open this
loading…
README

@carbonplan/zarr-layer

NPM Version License: MIT

Custom layer for rendering Zarr datasets in MapLibre or Mapbox GL, inspired (and borrowing significant code and concepts from) zarr-gl, zarr-cesium, @carbonplan/maps, and deck-gl-raster. Uses CustomLayerInterface to render data directly to the map and supports rendering to globe and mercator projections for both MapLibre and Mapbox. Input data are reprojected on the fly.

This is an active experiment so expect to run into some bugs! Please report them.

demo

See the demo for a quick tour of capabilities. Code for the demo is in the /demo folder.

data requirements

Supports v2 and v3 zarr stores via zarrita. Arbitrary CRS support via proj4 reprojection for 'untiled' data. Tiled data need to be in EPSG:4326 or EPSG:3857.

Multiscales

High resolution datasets require multiscales. There are two main ways to store these. We recommend the untiled path for ease of data creation. Performance appears similar between the two options in most cases.

Tiled

Classic approach that requires reprojection to either web mercator or WGS84. Breaks data down into chunks that correspond exactly to web map slippy map tile conventions (XYZ). See ndpyramid. Data creation for this format can be resource intensive, see the untiled section below for an easier alternative.

  • Limited to EPSG:4326 or EPSG:3857

Untiled

Support for the emerging multiscales convention (non-slippy map conforming) is experimental! We also try to interpret other multiscale formats. Chunks are loaded based on viewport intersection and zoom level.

  • Supports client side CRS reprojection via proj4 and @developmentseed/raster-reproject

See topozarr for a look at how to create these datasets.

globe rendering and polar coverage

Web Mercator rendering clips near ±85° latitude, leaving visible "pole holes" on globe projections. For untiled EPSG:4326 and proj4 datasets:

MapLibre — Full polar coverage is always enabled via a direct ECEF rendering path. No configuration needed.

Mapbox — Set renderPoles: true to enable an experimental direct ECEF path that bypasses tile draping. This relies on Mapbox internal APIs and may break across Mapbox GL JS versions. During Mapbox's globe-to-mercator zoom morph the layer automatically falls back to the standard draped path (with pole holes visible). Incompatible with draping the zarr layer over Mapbox terrain — when terrain is enabled the layer always uses the draped tile path.

new ZarrLayer({
  // ...
  renderPoles: true, // Mapbox only — MapLibre always renders to the poles
})

install

npm install @carbonplan/zarr-layer

build locally

npm install
npm run build

usage

import maplibregl from 'maplibre-gl' // or mapbox
import { ZarrLayer } from '@carbonplan/zarr-layer'

const map = new maplibregl.Map({container: 'map'})
const layer = new ZarrLayer({
  id: 'zarr-layer',
  source: 'https://example.com/my.zarr',
  variable: 'temperature',
  clim: [270, 310],
  colormap: ['#000000', '#ffffff', ...],
  selector: { month: 1 },
})
map.on('load', () => {
  map.addLayer(layer)
  // optionally add before id to slot data into map layer stack.
  // map.addLayer(layer, 'beforeID')
})

options

Required: | Option | Type | Description | |--------|------|-------------| | id | string | Unique layer identifier | | source | string | Zarr store URL (required unless store is provided) | | variable | string | Variable name to render | | colormap | array | Array of hex strings or [r,g,b] values | | clim | [min, max] | Color scale limits |

Optional: | Option | Type | Default | Description | |--------|------|---------|-------------| | store | Readable | - | Custom zarrita-compatible store (e.g., IcechunkStore). When provided, source becomes optional. | | selector | object | {} | Dimension selector (unspecified dims default to index 0) | | opacity | number | 1 | Layer opacity (0-1) | | zarrVersion | 2 | 3 | auto | Zarr format version (tries v3 first, falls back to v2) | | minzoom | number | 0 | Minimum zoom level for rendering | | maxzoom | number | Infinity | Maximum zoom level for rendering | | fillValue | number | auto | No-data value (from metadata if not set) | | spatialDimensions | object | auto | Custom { lat, lon } dim names | | crs | string | auto | CRS identifier for built-in projections (EPSG:4326 or EPSG:3857). For other CRS, use proj4. | | proj4 | string | - | Proj4 definition string for CRS reprojection (bounds recommended, else derived from coordinates) | | bounds | array | auto | [xMin, yMin, xMax, yMax] in source CRS units (degrees for EPSG:4326, meters for EPSG:3857). These are interpreted as edge bounds (not center-to-center) | | latIsAscending | boolean | auto | Latitude orientation | | renderingMode | '2d' | '3d' | '3d' | Custom layer rendering mode | | customFrag | string | - | Custom fragment shader | | uniforms | object | - | Shader uniform values (requires customFrag) | | onLoadingStateChange | function | - | Loading state callback | | transformRequest | function | - | Transform request URLs and add headers/credentials (see authentication) | | renderPoles | boolean | false | Enable polar coverage in Mapbox globe for untiled EPSG:4326/proj4 datasets (see globe rendering). No effect on tiled or EPSG:3857 data. MapLibre always renders to the poles. |

methods

layer.setOpacity(0.8)
layer.setClim([0, 100])
layer.setColormap(['#000', '#fff'])
layer.setSelector({ time: 5 })
layer.setVariable('precipitation') // async - reloads metadata
layer.setUniforms({ u_weight: 1.5 }) // no-op unless layer has customFrag

throttling rapid selector changes

setSelector is synchronous and kicks off a fetch on every call. For UIs that fire rapid updates (e.g. dragging a time slider), debounce the value on your side before handing it to the layer so you aren't firing one abort-cancelled fetch per pointer event.

selectors

Selectors specify which slice of your multidimensional data to render. Dimensions not specified default to index 0.

Basic syntax:

// Simple value - matches exact value in coordinate array
{ time: 5 }
{ time: '2024-01-15' }

// Explicit index - uses array index directly (no coordinate lookup)
{ time: { selected: 5, type: 'index' } }

// Explicit value - same as simple syntax, matches exact value
{ time: { selected: 5, type: 'value' } }

Multi-band selection (for custom shaders):

// String values use the string directly as the shader variable name
{ band: ['tavg', 'prec'] }
// exposes as: tavg, prec

// Numeric values are prefixed with the dimension key (required for valid GLSL identifiers)
{ month: [1, 2, 3] }
// exposes as: month_1, month_2, month_3

// Mix with other dimensions
{ band: ['red', 'green', 'blue'], time: 0 }

Query-specific selectors:

// Array of values for time series queries
const result = await layer.queryData(
  { type: 'Point', coordinates: [lng, lat] },
  { time: [0, 1, 2, 3, 4] } // returns data for all 5 time steps
)

Type options:

Type Behavior
'value' (default) Matches exact value in coordinate array (throws if not found)
'index' Uses value directly as array index

custom shaders and uniforms

Custom fragment shaders let you do math on your data to change how it's displayed. This can be useful for things like log scales, combining bands, or aggregating data over a time window. In tiled mode, data for all selected bands must be in the same chunk. In untiled mode, bands can span separate chunks — each band is fetched in parallel and combined for rendering. You can pass in uniforms to allow user interaction to influence the custom shader code.

Band names are automatically sanitized to valid GLSL identifiers: any characters that aren't letters, digits, or underscores are replaced with underscores, and names starting with a digit are prefixed with an underscore. For example, s2med_harvest:B02 becomes s2med_harvest_B02 and 123band becomes _123band.

When a customFrag is supplied, it owns discarding. Missing/fill pixels are surfaced as NaN instead of being dropped automatically, so you can aggregate over partial coverage (e.g. a mean over bands with differing coverage) rather than just their intersection. Note that NaN propagates through arithmetic (even NaN * 0.0 is NaN), so guard values before multiplying or accumulating, e.g. isnan(x) ? 0.0 : x. Add your own discard for pixels you want to drop:

new ZarrLayer({
  // ...
  customFrag: `
    uniform float u_weight;
    if (isnan(band_a)) {
      discard;
    }
    float val = band_a * u_weight;
    float norm = (val - clim.x) / (clim.y - clim.x);
    vec4 c = texture(colormap, vec2(clamp(norm, 0.0, 1.0), 0.5));
    fragColor = vec4(c.rgb, opacity);
  `,
  uniforms: { u_weight: 1.0 },
})

NDVI example

Here's an example of computing NDVI (Normalized Difference Vegetation Index) using custom shaders:

new ZarrLayer({
  source: 'https://example.com/sentinel2.zarr',
  variable: 'data',
  colormap: [
    /* gradient */
  ],
  selector: { band: ['B08', 'B04'], time: 0 },
  clim: [-1, 1],
  customFrag: `
    if (isnan(B08) || isnan(B04)) {
      discard;
    }
    float ndvi = (B08 - B04) / (B08 + B04);
    float norm = (ndvi - clim.x) / (clim.y - clim.x);
    vec4 c = texture(colormap, vec2(clamp(norm, 0.0, 1.0), 0.5));
    fragColor = vec4(c.rgb, opacity);
  `,
})

custom projections

For datasets in non-standard projections (e.g., Lambert Conformal Conic, UTM), provide a proj4 definition string. Specifying bounds in source CRS units is recommended for performance (otherwise derived from coordinate arrays). If you set crs to a non-EPSG:4326/EPSG:3857 value without proj4, the renderer will warn and fall back to inferred CRS.

new ZarrLayer({
  // ...
  spatialDimensions: {
    lat: 'projection_y_coordinate',
    lon: 'projection_x_coordinate',
  },
  proj4:
    '+proj=lcc +lat_1=38.5 +lat_2=38.5 +lat_0=38.5 +lon_0=-97.5 +x_0=0 +y_0=0 +R=6371229 +units=m +no_defs',
  bounds: [-2697520, -1587306, 2697480, 1586694], // recommended: edge bounds [xMin, yMin, xMax, yMax] in source CRS units
})

The data will be reprojected to Web Mercator for display using GPU-accelerated mesh reprojection powered by @developmentseed/raster-reproject. Find proj4 strings at epsg.io or in your dataset's metadata.

queries

Supports Point, Polygon, and MultiPolygon geometries in geojson format. You can optionally pass in a custom selector to override the visualization selector.

// Point query
const result = await layer.queryData(
  { type: 'Point', coordinates: [lng, lat] },
  // optional selector override (useful for e.g. time series creation)
  { time: [0, 1, 2] }
)

// Polygon query
const result = await layer.queryData({
  type: 'Polygon',
  coordinates: [[...]],
})

// Returns:
// {
//   [variable]: number[],
//   dimensions: ['<store-y-axis>', '<store-x-axis>'],
//   coordinates: { '<store-y-axis>': number[], '<store-x-axis>': number[] }
// }

Spatial query results are returned in the dataset's source CRS, under the store's own spatial axis names. An EPSG:3857 dataset with y/x axes returns Web Mercator meters under y/x; an EPSG:4326 dataset with latitude/longitude axes returns degrees under latitude/longitude; a custom-proj4 dataset returns its source-CRS values under whatever the store calls them. Input geometries are still supplied as GeoJSON lon/lat regardless of the source CRS.

You can pass a third options argument to control query behavior:

const result = await layer.queryData(geometry, selector, {
  signal: abortController.signal, // cancel in-flight query
  includeSpatialCoordinates: false, // omit per-pixel coordinates for slimmer results
})

Note: Query results match rendered values (scale_factor/add_offset applied, fillValue/NaN filtered).

authentication

Use transformRequest to add headers or credentials to requests. The function receives the fully resolved URL for each request, enabling per-path authentication like presigned S3 URLs. Supports any fetch options.

// Static auth (same headers for all requests)
transformRequest: (url) => ({
  url,
  headers: { Authorization: `Bearer ${token}` },
})

// Presigned URLs (path-specific signatures)
transformRequest: async (url) => ({
  url: await getPresignedUrl(url),
})

custom stores

For advanced use cases like Icechunk, you can pass a custom zarrita-compatible store directly. When using a custom store, source becomes optional:

```ts import { IcechunkStore } from 'icechunk-js'

const store = await IcechunkStore.open(...)

new ZarrLayer({ id: 'icechunk-layer', store, variable: 'temperature', colormap: [...], clim: [0, 100], }) `

Extension points exported contracts — how you extend this code

ZarrMode (Interface)
(no doc) [4 implementers]
src/zarr-mode.ts
Wgs84Transformer (Interface)
* A transformer for converting coordinates between source CRS and EPSG:4326 (WGS84).
src/projection-utils.ts
FullTileBounds (Interface)
Full tile bounds with all required fields (lat/lon + mercator)
src/tiled-mode.ts
BindBandTexturesOptions (Interface)
Options for band texture binding
src/render-helpers.ts
TileMercatorBounds (Interface)
Extended MercatorBounds with lat/lon for EPSG:4326 tiles
src/mapbox-tile-renderer.ts
RegionState (Interface)
State for a single region (chunk/shard) in region-based loading
src/untiled-mode.ts
SplitResult (Interface)
* Result of processing triangles at the antimeridian.
src/mesh-reprojector.ts
TileCacheLike (Interface)
(no doc) [1 implementers]
src/map-utils.ts

Core symbols most depended-on inside this repo

get
called by 47
src/map-utils.ts
tileToKey
called by 21
src/map-utils.ts
set
called by 16
src/decoded-chunk-cache.ts
clear
called by 11
src/tiles.ts
_getArray
called by 10
src/zarr-store.ts
mustGetUniformLocation
called by 9
src/webgl-utils.ts
makeRegionKey
called by 8
src/untiled-mode.ts
normalizeLon180
called by 8
src/mesh-reprojector.ts

Shape

Function 238
Method 212
Interface 76
Class 16

Languages

TypeScript100%

Modules by API surface

src/untiled-mode.ts67 symbols
src/query/query-utils.ts38 symbols
src/zarr-layer.ts34 symbols
src/zarr-store.ts33 symbols
src/map-utils.ts33 symbols
src/tiles.ts29 symbols
src/tiled-mode.ts28 symbols
src/types.ts21 symbols
demo/components/map-shared.tsx21 symbols
src/zarr-mode.ts20 symbols
src/mesh-reprojector.ts16 symbols
src/mode-utils.ts15 symbols

For agents

$ claude mcp add zarr-layer \
  -- python -m otcore.mcp_server <graph>

⬇ download graph artifact