Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2fdc196
feat(stac): add Zarr assets to the map with a variable picker
clintonlunn Aug 19, 2026
e15abec
test(stac): cover Zarr assets against captured catalog responses
clintonlunn Aug 19, 2026
62aca06
fix(stac): carry a Zarr store's CRS and refuse one that cannot be opened
clintonlunn Aug 19, 2026
2919be2
Merge remote-tracking branch 'origin/main' into feat/stac-zarr-assets
clintonlunn Aug 19, 2026
9103315
style: auto-format (ruff + oxfmt) [pre-commit.ci]
pre-commit-ci[bot] Aug 19, 2026
e9764aa
fix(stac): keep a Zarr href's query out of the store path and the ass…
clintonlunn Aug 19, 2026
8df2956
Merge branch 'main' into feat/stac-zarr-assets
clintonlunn Aug 19, 2026
efb6755
fix(stac): add a Zarr variable only when the store says it is an array
clintonlunn Aug 19, 2026
347e8ec
style: auto-format (ruff + oxfmt) [pre-commit.ci]
pre-commit-ci[bot] Aug 19, 2026
a6d070a
fix(stac): treat only metadata that names an array as drawable
clintonlunn Aug 19, 2026
f755e71
style: auto-format (ruff + oxfmt) [pre-commit.ci]
pre-commit-ci[bot] Aug 19, 2026
9192c95
refactor(stac): name the reason Add is disabled instead of nesting te…
clintonlunn Aug 19, 2026
b30444e
fix(stac): recognize a Zarr href that names an array inside the store
clintonlunn Aug 19, 2026
10ed2f5
fix(stac): say which problem stopped a Zarr variable from being added
clintonlunn Aug 19, 2026
e8499d1
fix(stac): recognize a Zarr v2 group by its own metadata file
clintonlunn Aug 19, 2026
aaca785
fix(stac): name an address that cannot be read key by key as its own …
clintonlunn Aug 19, 2026
a5bddfd
fix(stac): translate the variable picker's tooltip
clintonlunn Aug 19, 2026
8ce805d
fix(stac): keep an undrawable keyed asset from offering the item's ot…
clintonlunn Aug 19, 2026
f3cb0e1
fix(stac): restore the translated not-addable label
clintonlunn Aug 19, 2026
6e4ed4e
Merge branch 'main' into feat/stac-zarr-assets
clintonlunn Aug 19, 2026
78285a5
fix(stac): keep probing when a store answers with something that is n…
clintonlunn Aug 19, 2026
50b690f
fix(stac): keep asking after a refusal, and declare the storage optio…
clintonlunn Aug 19, 2026
3154c4d
test(stac): pin the preflight to the array an embedded href names
clintonlunn Aug 20, 2026
b2936e6
test(stac): assert the Zarr layer reaches the Layers panel, not eithe…
clintonlunn Aug 20, 2026
bd3f4ff
fix(stac): believe a store's metadata only when it parses
clintonlunn Aug 20, 2026
cf38010
fix(stac): draw only variables spanning the horizontal axes
clintonlunn Aug 20, 2026
57da32f
fix(stac): read an axis whatever its case, and ignore one the spec ne…
clintonlunn Aug 20, 2026
9707f77
refactor(stac): reuse the storage-option types and drop guards the re…
clintonlunn Aug 20, 2026
15fe65e
Merge branch 'main' into feat/stac-zarr-assets
clintonlunn Aug 20, 2026
3220333
fix(stac): recognize an Icechunk repository declared on the item
clintonlunn Aug 20, 2026
e8f6f35
fix(stac): keep probing when one key's request is refused outright
clintonlunn Aug 20, 2026
1d6b598
Merge remote-tracking branch 'origin/main' into feat/stac-zarr-assets
clintonlunn Aug 20, 2026
238c59a
fix(stac): refuse a keyless-unfriendly Zarr address before the click
clintonlunn Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/geolibre-desktop/src/components/layout/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ interface TopToolbarProps {
viewer?: boolean;
}

/** Translation keys for the reasons a Zarr variable cannot be added. */
const ZARR_PROBLEM_KEYS = {
group: "stacPlugin.zarrProblemGroup",
unauthorized: "stacPlugin.zarrProblemUnauthorized",
"unsupported-url": "stacPlugin.zarrProblemUnsupportedUrl",
unavailable: "stacPlugin.zarrProblemUnavailable",
} as const;

export function TopToolbar({
compact = false,
diagnosticsErrorCount,
Expand Down Expand Up @@ -948,7 +956,12 @@ export function TopToolbar({
formatGeoJson: t("stacPlugin.formatGeoJson"),
formatPmtiles: t("stacPlugin.formatPmtiles"),
formatParquet: t("stacPlugin.formatParquet"),
formatZarr: t("stacPlugin.formatZarr"),
formatUnknown: t("stacPlugin.formatUnknown"),
addNoTarget: t("stacPlugin.addNoTarget"),
Comment thread
clintonlunn marked this conversation as resolved.
addIcechunk: t("stacPlugin.addIcechunk"),
zarrProblem: (problem) => t(ZARR_PROBLEM_KEYS[problem]),
Comment thread
clintonlunn marked this conversation as resolved.
chooseTarget: t("stacPlugin.chooseTarget"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my comment above: the suggestion there was malformed (it only targets line 960, so applying it would duplicate the addIcechunk/zarrProblem/chooseTarget lines). Here's a suggestion scoped correctly to just this line, appending the missing notAddable after it:

Suggested change
chooseTarget: t("stacPlugin.chooseTarget"),
chooseTarget: t("stacPlugin.chooseTarget"),
notAddable: t("stacPlugin.notAddable"),

notAddable: t("stacPlugin.notAddable"),
});
}, [t]);
Expand Down
10 changes: 9 additions & 1 deletion apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3438,14 +3438,22 @@
"download": "Download",
"adding": "Adding {{asset}}…",
"added": "Added {{asset}} to the map.",
"addUnsupported": "Only GeoTIFF/COG, GeoJSON, GeoParquet, and PMTiles assets can be added to the map",
"addUnsupported": "Only GeoTIFF/COG, GeoJSON, GeoParquet, PMTiles, and Zarr assets can be added to the map",
"addFailed": "Could not add asset",
"addNoSourceLayers": "This archive lists no layers to draw",
"addNoTarget": "This asset lists nothing to draw",
"addIcechunk": "Icechunk stores cannot be read yet",
"zarrProblemGroup": "This asset names a group of arrays, not one that can be drawn",
"zarrProblemUnauthorized": "This Zarr store needs credentials GeoLibre cannot supply yet",
"zarrProblemUnsupportedUrl": "This Zarr store's address cannot be read one key at a time",
"zarrProblemUnavailable": "This Zarr store could not be opened",
"chooseTarget": "Choose what to add",
"cogUnsupported": "This GeoLibre host cannot visualize remote GeoTIFF assets",
"formatCog": "COG",
"formatGeoJson": "GeoJSON",
"formatPmtiles": "PMTiles",
"formatParquet": "Parquet",
"formatZarr": "Zarr",
"formatUnknown": "Unknown format",
"notAddable": "not addable"
},
Expand Down
2 changes: 1 addition & 1 deletion docs/plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ The helpers are typed optional for forward-compatibility with host variants, so

## Zarr layers

`addZarrLayer` renders a Zarr store (Zarr v2/v3, Icechunk over HTTP, kerchunk-backed cloud NetCDF) through **GeoLibre's own** `@carbonplan/zarr-layer` instance and mirrors the result into the Layers panel. It is the Zarr counterpart of `addCogLayer`.
`addZarrLayer` renders a Zarr store (Zarr v2/v3 over HTTP, or a kerchunk-backed cloud NetCDF through a custom store) through **GeoLibre's own** `@carbonplan/zarr-layer` instance and mirrors the result into the Layers panel. It is the Zarr counterpart of `addCogLayer`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (medium confidence): This doc now says the plain-url path only reads Zarr v2/v3 over HTTP (Icechunk removed), and ZarrRasterLayerOptions.url's docstring in maplibre-components.ts was updated the same way in this PR. But packages/plugins/src/types.ts (GeoLibreAppAPI.addZarrLayer, ~line 408) still documents that same public API as reading "Zarr v2/v3, Icechunk over HTTP" — and addZarrLayer in usePlugins.ts is a thin wrapper over the very addZarrRasterLayer whose docstring was just narrowed here. That leaves the plugin-facing public type declaration contradicting both this doc and the STAC panel's new explicit refusal of Icechunk assets. Worth updating types.ts in the same pass so external plugin authors aren't told a URL-only Icechunk store will work when it won't.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed that one — fixed in 3220333. GeoLibreAppAPI.addZarrLayer in packages/plugins/src/types.ts now says "Zarr v2/v3 over HTTP" too, so the plugin-facing type, the renderer docstring and the doc all agree.


Do not bundle `@carbonplan/zarr-layer` in a plugin: a second copy ships a duplicate numcodecs WASM payload, and adding the renderer's layer yourself with `getMap().addLayer()` produces a MapLibre **custom** layer, which has no paint properties for the Style panel to drive.

Expand Down
186 changes: 186 additions & 0 deletions e2e/stac-zarr.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { expect, test, type Page } from "@playwright/test";
import { waitForMap } from "./helpers";

// A Zarr store is a directory read key by key, so only a run through the real panel shows that the
// store URL, the chosen variable and the reader's own requests line up.
const API = "https://api.stac.test/v1";
const STORE = "https://store.stac.test/mini.zarr";

/**
* A Zarr v2 store, built here rather than committed: it is a directory of a dozen tiny files, and
* every byte of it is derivable from the shape below.
*/
const N = 8;
const zarray = (shape: number[]) =>
JSON.stringify({
chunks: shape,
compressor: null,
dtype: "<f4",
fill_value: 0,
filters: null,
order: "C",
shape,
zarr_format: 2,
});
const zattrs = (dimensions: string[], unit?: string) =>
JSON.stringify({ _ARRAY_DIMENSIONS: dimensions, ...(unit ? { units: unit } : {}) });

const STORE_METADATA: Record<string, string> = {
".zgroup": JSON.stringify({ zarr_format: 2 }),
"lat/.zarray": zarray([N]),
"lat/.zattrs": zattrs(["lat"]),
"lon/.zarray": zarray([N]),
"lon/.zattrs": zattrs(["lon"]),
"temperature/.zarray": zarray([N, N]),
"temperature/.zattrs": zattrs(["lat", "lon"], "degC"),
"precipitation/.zarray": zarray([N, N]),
"precipitation/.zattrs": zattrs(["lat", "lon"], "mm"),
};
STORE_METADATA[".zmetadata"] = JSON.stringify({
metadata: Object.fromEntries(
Object.entries(STORE_METADATA).map(([key, value]) => [key, JSON.parse(value)]),
),
zarr_consolidated_format: 1,
});

/** A chunk of `count` float32 values, ascending so the array is not uniformly the fill value. */
function chunk(count: number): Buffer {
const values = Float32Array.from({ length: count }, (_, index) => index);
return Buffer.from(values.buffer);
}

const STORE_CHUNKS: Record<string, Buffer> = {
"lat/0": chunk(N),
"lon/0": chunk(N),
"temperature/0.0": chunk(N * N),
"precipitation/0.0": chunk(N * N),
};

const COLLECTIONS = [
{ id: "cube", title: "Demo cubes", extent: { spatial: { bbox: [[-114, 37, -109, 42]] } } },
];

function item(): Record<string, unknown> {
return {
type: "Feature",
stac_version: "1.0.0",
id: "cube-1",
collection: "cube",
stac_extensions: ["https://stac-extensions.github.io/datacube/v2.2.0/schema.json"],
bbox: [-114, 37, -109, 42],
geometry: {
type: "Polygon",
coordinates: [
[
[-114, 37],
[-109, 37],
[-109, 42],
[-114, 42],
[-114, 37],
],
],
},
properties: {
datetime: "2024-05-01T00:00:00Z",
"cube:dimensions": {
lat: { type: "spatial", axis: "y" },
lon: { type: "spatial", axis: "x" },
},
"cube:variables": {
// Spans no two spatial dimensions, so it must not be offered as something to draw.
lat_bounds: { dimensions: ["lat"], type: "data" },
temperature: { dimensions: ["lat", "lon"], type: "data", unit: "degC" },
precipitation: { dimensions: ["lat", "lon"], type: "data", unit: "mm" },
},
},
assets: { data: { href: STORE, type: "application/vnd+zarr", title: "Demo cube" } },
links: [],
};
}

async function serveApi(page: Page): Promise<void> {
await page.route("https://api.stac.test/**", async (route) => {
const url = route.request().url();
const json = (body: unknown) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });

if (url.endsWith("/collections")) return json({ collections: COLLECTIONS });
if (url.includes("/search")) {
return json({ type: "FeatureCollection", features: [item()], numberMatched: 1, links: [] });
}
return json({
type: "Catalog",
id: "api",
title: "E2E STAC API",
conformsTo: [
"https://api.stacspec.org/v1.0.0/core",
"https://api.stacspec.org/v1.0.0/item-search",
],
links: [
{ rel: "data", href: `${API}/collections` },
{ rel: "search", href: `${API}/search`, method: "POST" },
],
});
});
}

/** Serves the store key by key, so the reader's own requests decide whether the layer loads. */
async function serveStore(page: Page, reads: string[]): Promise<void> {
await page.route("https://store.stac.test/**", async (route) => {
const key = new URL(route.request().url()).pathname.replace(/^\/mini\.zarr\/?/, "");
reads.push(key);
if (STORE_METADATA[key]) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: STORE_METADATA[key],
});
}
if (STORE_CHUNKS[key]) return route.fulfill({ status: 200, body: STORE_CHUNKS[key] });
// A store is probed for keys it need not have (`zarr.json` on a v2 store, say).
return route.fulfill({ status: 404, body: "" });
});
}

test("a Zarr asset from a STAC item reaches the map as the chosen variable", async ({ page }) => {
const reads: string[] = [];
await serveApi(page);
await serveStore(page, reads);
await waitForMap(page);

await page.getByRole("button", { name: "Plugins", exact: true }).click();
await page.getByRole("menuitem", { name: "Web Services" }).click();
await page.getByRole("menuitem", { name: "STAC Catalogs" }).click();
await page.getByPlaceholder("https://example.org/stac/").fill(API);
await page.getByRole("button", { name: "Connect", exact: true }).click();

await page.getByLabel("Limit search to the current map extent").uncheck();
const collection = page.getByRole("option", { name: "Demo cubes" });
await collection.click();
await collection.dblclick();
await expect(page.getByText(/Showing \d+ of \d+ items\./)).toBeVisible();

// The asset names its format, and the store's drawable arrays are offered — bounds excluded.
await expect(page.getByRole("combobox").filter({ hasText: "Demo cube — Zarr" })).toBeVisible();
const targets = page.getByRole("combobox").filter({ hasText: "temperature (degC)" });
await expect(targets).toBeVisible();
await expect(targets.getByRole("option")).toHaveText([
"temperature (degC)",
"precipitation (mm)",
]);

await targets.selectOption({ label: "precipitation (mm)" });
const add = page.getByRole("button", { name: "Add", exact: true }).first();
await expect(add).toBeEnabled();
await add.click();

// Named for the item, its asset and the variable actually drawn. Scoped to the panel because
// the name also renders in the on-map layer control.
const layers = page.getByRole("complementary", { name: "Layers" });
await expect(layers.getByText("cube-1 — Demo cube — precipitation")).toBeVisible();
await expect(page.getByText("Added Demo cube to the map.")).toBeVisible();

// The reader was pointed at the store and read its metadata from there. Chunk reads are left
// out on purpose: those only happen once deck.gl paints, which headless WebGL may never do.
expect(reads).toContain(".zmetadata");
});
2 changes: 1 addition & 1 deletion packages/plugins/src/plugins/maplibre-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2349,7 +2349,7 @@ function applyZarrLayerBounds(layerId: string, bounds: [number, number, number,

/** Options for {@link addZarrRasterLayer}. */
export interface ZarrRasterLayerOptions {
/** URL of the Zarr store (Zarr v2/v3, Icechunk over HTTP). */
/** URL of a plain Zarr store (v2/v3). Anything else is read through `store`. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc correction ("plain Zarr store (v2/v3)... anything else is read through store") implies addZarrRasterLayer/ZarrLayerControl.addLayer can't actually open an Icechunk repository via a bare URL — which lines up with the STAC panel's new isIcechunkAsset refusal in maplibre-stac.ts. But docs/plugin-api.md (the "Zarr layers" section, ~line 555) still tells external plugin authors that addZarrLayer — which is a thin wrapper around this same url option (see usePlugins.ts's addZarrLayer) — supports "Icechunk over HTTP". That public-facing doc looks stale now and worth fixing in this PR or a follow-up, since it directly contradicts what this PR just learned.

Confidence: medium (inferred from the doc change plus how addZarrLayer is wired to addZarrRasterLayer, not from testing @carbonplan/zarr-layer itself).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the doc drift — fixed in cf38010. docs/plugin-api.md now reads "Zarr v2/v3 over HTTP, or a kerchunk-backed cloud NetCDF through a custom store", which matches what the URL option can actually open.

url: string;
/** Layer name shown in the Layers panel. Defaults to `<store> - <variable>`. */
name?: string;
Expand Down
Loading
Loading