Skip to content

Commit 179ec42

Browse files
feat(stac): add Zarr assets to the map (#2005)
* feat(stac): add Zarr assets to the map with a variable picker * test(stac): cover Zarr assets against captured catalog responses * fix(stac): carry a Zarr store's CRS and refuse one that cannot be opened * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix(stac): keep a Zarr href's query out of the store path and the asset label honest * fix(stac): add a Zarr variable only when the store says it is an array * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix(stac): treat only metadata that names an array as drawable * style: auto-format (ruff + oxfmt) [pre-commit.ci] * refactor(stac): name the reason Add is disabled instead of nesting ternaries * fix(stac): recognize a Zarr href that names an array inside the store * fix(stac): say which problem stopped a Zarr variable from being added * fix(stac): recognize a Zarr v2 group by its own metadata file * fix(stac): name an address that cannot be read key by key as its own problem * fix(stac): translate the variable picker's tooltip * fix(stac): keep an undrawable keyed asset from offering the item's other variables * fix(stac): restore the translated not-addable label * fix(stac): keep probing when a store answers with something that is not metadata * fix(stac): keep asking after a refusal, and declare the storage options instead of casting * test(stac): pin the preflight to the array an embedded href names * test(stac): assert the Zarr layer reaches the Layers panel, not either match * fix(stac): believe a store's metadata only when it parses * fix(stac): draw only variables spanning the horizontal axes * fix(stac): read an axis whatever its case, and ignore one the spec never defines * refactor(stac): reuse the storage-option types and drop guards the renderer makes dead * fix(stac): recognize an Icechunk repository declared on the item * fix(stac): keep probing when one key's request is refused outright * fix(stac): refuse a keyless-unfriendly Zarr address before the click --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent e6b3949 commit 179ec42

12 files changed

Lines changed: 1702 additions & 22 deletions

File tree

apps/geolibre-desktop/src/components/layout/TopToolbar.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@ interface TopToolbarProps {
195195
viewer?: boolean;
196196
}
197197

198+
/** Translation keys for the reasons a Zarr variable cannot be added. */
199+
const ZARR_PROBLEM_KEYS = {
200+
group: "stacPlugin.zarrProblemGroup",
201+
unauthorized: "stacPlugin.zarrProblemUnauthorized",
202+
"unsupported-url": "stacPlugin.zarrProblemUnsupportedUrl",
203+
unavailable: "stacPlugin.zarrProblemUnavailable",
204+
} as const;
205+
198206
export function TopToolbar({
199207
compact = false,
200208
diagnosticsErrorCount,
@@ -953,7 +961,12 @@ export function TopToolbar({
953961
formatGeoJson: t("stacPlugin.formatGeoJson"),
954962
formatPmtiles: t("stacPlugin.formatPmtiles"),
955963
formatParquet: t("stacPlugin.formatParquet"),
964+
formatZarr: t("stacPlugin.formatZarr"),
956965
formatUnknown: t("stacPlugin.formatUnknown"),
966+
addNoTarget: t("stacPlugin.addNoTarget"),
967+
addIcechunk: t("stacPlugin.addIcechunk"),
968+
zarrProblem: (problem) => t(ZARR_PROBLEM_KEYS[problem]),
969+
chooseTarget: t("stacPlugin.chooseTarget"),
957970
notAddable: t("stacPlugin.notAddable"),
958971
});
959972
}, [t]);

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3438,14 +3438,22 @@
34383438
"download": "Download",
34393439
"adding": "Adding {{asset}}…",
34403440
"added": "Added {{asset}} to the map.",
3441-
"addUnsupported": "Only GeoTIFF/COG, GeoJSON, GeoParquet, and PMTiles assets can be added to the map",
3441+
"addUnsupported": "Only GeoTIFF/COG, GeoJSON, GeoParquet, PMTiles, and Zarr assets can be added to the map",
34423442
"addFailed": "Could not add asset",
34433443
"addNoSourceLayers": "This archive lists no layers to draw",
3444+
"addNoTarget": "This asset lists nothing to draw",
3445+
"addIcechunk": "Icechunk stores cannot be read yet",
3446+
"zarrProblemGroup": "This asset names a group of arrays, not one that can be drawn",
3447+
"zarrProblemUnauthorized": "This Zarr store needs credentials GeoLibre cannot supply yet",
3448+
"zarrProblemUnsupportedUrl": "This Zarr store's address cannot be read one key at a time",
3449+
"zarrProblemUnavailable": "This Zarr store could not be opened",
3450+
"chooseTarget": "Choose what to add",
34443451
"cogUnsupported": "This GeoLibre host cannot visualize remote GeoTIFF assets",
34453452
"formatCog": "COG",
34463453
"formatGeoJson": "GeoJSON",
34473454
"formatPmtiles": "PMTiles",
34483455
"formatParquet": "Parquet",
3456+
"formatZarr": "Zarr",
34493457
"formatUnknown": "Unknown format",
34503458
"notAddable": "not addable"
34513459
},

docs/plugin-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,7 @@ The helpers are typed optional for forward-compatibility with host variants, so
552552
553553
## Zarr layers
554554

555-
`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`.
555+
`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`.
556556

557557
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.
558558

e2e/stac-zarr.spec.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
import { waitForMap } from "./helpers";
3+
4+
// A Zarr store is a directory read key by key, so only a run through the real panel shows that the
5+
// store URL, the chosen variable and the reader's own requests line up.
6+
const API = "https://api.stac.test/v1";
7+
const STORE = "https://store.stac.test/mini.zarr";
8+
9+
/**
10+
* A Zarr v2 store, built here rather than committed: it is a directory of a dozen tiny files, and
11+
* every byte of it is derivable from the shape below.
12+
*/
13+
const N = 8;
14+
const zarray = (shape: number[]) =>
15+
JSON.stringify({
16+
chunks: shape,
17+
compressor: null,
18+
dtype: "<f4",
19+
fill_value: 0,
20+
filters: null,
21+
order: "C",
22+
shape,
23+
zarr_format: 2,
24+
});
25+
const zattrs = (dimensions: string[], unit?: string) =>
26+
JSON.stringify({ _ARRAY_DIMENSIONS: dimensions, ...(unit ? { units: unit } : {}) });
27+
28+
const STORE_METADATA: Record<string, string> = {
29+
".zgroup": JSON.stringify({ zarr_format: 2 }),
30+
"lat/.zarray": zarray([N]),
31+
"lat/.zattrs": zattrs(["lat"]),
32+
"lon/.zarray": zarray([N]),
33+
"lon/.zattrs": zattrs(["lon"]),
34+
"temperature/.zarray": zarray([N, N]),
35+
"temperature/.zattrs": zattrs(["lat", "lon"], "degC"),
36+
"precipitation/.zarray": zarray([N, N]),
37+
"precipitation/.zattrs": zattrs(["lat", "lon"], "mm"),
38+
};
39+
STORE_METADATA[".zmetadata"] = JSON.stringify({
40+
metadata: Object.fromEntries(
41+
Object.entries(STORE_METADATA).map(([key, value]) => [key, JSON.parse(value)]),
42+
),
43+
zarr_consolidated_format: 1,
44+
});
45+
46+
/** A chunk of `count` float32 values, ascending so the array is not uniformly the fill value. */
47+
function chunk(count: number): Buffer {
48+
const values = Float32Array.from({ length: count }, (_, index) => index);
49+
return Buffer.from(values.buffer);
50+
}
51+
52+
const STORE_CHUNKS: Record<string, Buffer> = {
53+
"lat/0": chunk(N),
54+
"lon/0": chunk(N),
55+
"temperature/0.0": chunk(N * N),
56+
"precipitation/0.0": chunk(N * N),
57+
};
58+
59+
const COLLECTIONS = [
60+
{ id: "cube", title: "Demo cubes", extent: { spatial: { bbox: [[-114, 37, -109, 42]] } } },
61+
];
62+
63+
function item(): Record<string, unknown> {
64+
return {
65+
type: "Feature",
66+
stac_version: "1.0.0",
67+
id: "cube-1",
68+
collection: "cube",
69+
stac_extensions: ["https://stac-extensions.github.io/datacube/v2.2.0/schema.json"],
70+
bbox: [-114, 37, -109, 42],
71+
geometry: {
72+
type: "Polygon",
73+
coordinates: [
74+
[
75+
[-114, 37],
76+
[-109, 37],
77+
[-109, 42],
78+
[-114, 42],
79+
[-114, 37],
80+
],
81+
],
82+
},
83+
properties: {
84+
datetime: "2024-05-01T00:00:00Z",
85+
"cube:dimensions": {
86+
lat: { type: "spatial", axis: "y" },
87+
lon: { type: "spatial", axis: "x" },
88+
},
89+
"cube:variables": {
90+
// Spans no two spatial dimensions, so it must not be offered as something to draw.
91+
lat_bounds: { dimensions: ["lat"], type: "data" },
92+
temperature: { dimensions: ["lat", "lon"], type: "data", unit: "degC" },
93+
precipitation: { dimensions: ["lat", "lon"], type: "data", unit: "mm" },
94+
},
95+
},
96+
assets: { data: { href: STORE, type: "application/vnd+zarr", title: "Demo cube" } },
97+
links: [],
98+
};
99+
}
100+
101+
async function serveApi(page: Page): Promise<void> {
102+
await page.route("https://api.stac.test/**", async (route) => {
103+
const url = route.request().url();
104+
const json = (body: unknown) =>
105+
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
106+
107+
if (url.endsWith("/collections")) return json({ collections: COLLECTIONS });
108+
if (url.includes("/search")) {
109+
return json({ type: "FeatureCollection", features: [item()], numberMatched: 1, links: [] });
110+
}
111+
return json({
112+
type: "Catalog",
113+
id: "api",
114+
title: "E2E STAC API",
115+
conformsTo: [
116+
"https://api.stacspec.org/v1.0.0/core",
117+
"https://api.stacspec.org/v1.0.0/item-search",
118+
],
119+
links: [
120+
{ rel: "data", href: `${API}/collections` },
121+
{ rel: "search", href: `${API}/search`, method: "POST" },
122+
],
123+
});
124+
});
125+
}
126+
127+
/** Serves the store key by key, so the reader's own requests decide whether the layer loads. */
128+
async function serveStore(page: Page, reads: string[]): Promise<void> {
129+
await page.route("https://store.stac.test/**", async (route) => {
130+
const key = new URL(route.request().url()).pathname.replace(/^\/mini\.zarr\/?/, "");
131+
reads.push(key);
132+
if (STORE_METADATA[key]) {
133+
return route.fulfill({
134+
status: 200,
135+
contentType: "application/json",
136+
body: STORE_METADATA[key],
137+
});
138+
}
139+
if (STORE_CHUNKS[key]) return route.fulfill({ status: 200, body: STORE_CHUNKS[key] });
140+
// A store is probed for keys it need not have (`zarr.json` on a v2 store, say).
141+
return route.fulfill({ status: 404, body: "" });
142+
});
143+
}
144+
145+
test("a Zarr asset from a STAC item reaches the map as the chosen variable", async ({ page }) => {
146+
const reads: string[] = [];
147+
await serveApi(page);
148+
await serveStore(page, reads);
149+
await waitForMap(page);
150+
151+
await page.getByRole("button", { name: "Plugins", exact: true }).click();
152+
await page.getByRole("menuitem", { name: "Web Services" }).click();
153+
await page.getByRole("menuitem", { name: "STAC Catalogs" }).click();
154+
await page.getByPlaceholder("https://example.org/stac/").fill(API);
155+
await page.getByRole("button", { name: "Connect", exact: true }).click();
156+
157+
await page.getByLabel("Limit search to the current map extent").uncheck();
158+
const collection = page.getByRole("option", { name: "Demo cubes" });
159+
await collection.click();
160+
await collection.dblclick();
161+
await expect(page.getByText(/Showing \d+ of \d+ items\./)).toBeVisible();
162+
163+
// The asset names its format, and the store's drawable arrays are offered — bounds excluded.
164+
await expect(page.getByRole("combobox").filter({ hasText: "Demo cube — Zarr" })).toBeVisible();
165+
const targets = page.getByRole("combobox").filter({ hasText: "temperature (degC)" });
166+
await expect(targets).toBeVisible();
167+
await expect(targets.getByRole("option")).toHaveText([
168+
"temperature (degC)",
169+
"precipitation (mm)",
170+
]);
171+
172+
await targets.selectOption({ label: "precipitation (mm)" });
173+
const add = page.getByRole("button", { name: "Add", exact: true }).first();
174+
await expect(add).toBeEnabled();
175+
await add.click();
176+
177+
// Named for the item, its asset and the variable actually drawn. Scoped to the panel because
178+
// the name also renders in the on-map layer control.
179+
const layers = page.getByRole("complementary", { name: "Layers" });
180+
await expect(layers.getByText("cube-1 — Demo cube — precipitation")).toBeVisible();
181+
await expect(page.getByText("Added Demo cube to the map.")).toBeVisible();
182+
183+
// The reader was pointed at the store and read its metadata from there. Chunk reads are left
184+
// out on purpose: those only happen once deck.gl paints, which headless WebGL may never do.
185+
expect(reads).toContain(".zmetadata");
186+
});

packages/plugins/src/plugins/maplibre-components.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2349,7 +2349,7 @@ function applyZarrLayerBounds(layerId: string, bounds: [number, number, number,
23492349

23502350
/** Options for {@link addZarrRasterLayer}. */
23512351
export interface ZarrRasterLayerOptions {
2352-
/** URL of the Zarr store (Zarr v2/v3, Icechunk over HTTP). */
2352+
/** URL of a plain Zarr store (v2/v3). Anything else is read through `store`. */
23532353
url: string;
23542354
/** Layer name shown in the Layers panel. Defaults to `<store> - <variable>`. */
23552355
name?: string;

0 commit comments

Comments
 (0)