-
-
Notifications
You must be signed in to change notification settings - Fork 670
feat(stac): add Zarr assets to the map #2005
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 28 commits
2fdc196
e15abec
62aca06
2919be2
9103315
e9764aa
8df2956
efb6755
347e8ec
a6d070a
f755e71
9192c95
b30444e
10ed2f5
e8499d1
aaca785
a5bddfd
8ce805d
f3cb0e1
6e4ed4e
78285a5
50b690f
3154c4d
b2936e6
bd3f4ff
cf38010
57da32f
9707f77
15fe65e
3220333
e8f6f35
1d6b598
238c59a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -193,6 +193,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, | ||||||||
|
|
@@ -947,7 +955,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"), | ||||||||
| addIcechunk: t("stacPlugin.addIcechunk"), | ||||||||
| zarrProblem: (problem) => t(ZARR_PROBLEM_KEYS[problem]), | ||||||||
|
clintonlunn marked this conversation as resolved.
|
||||||||
| chooseTarget: t("stacPlugin.chooseTarget"), | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||
| notAddable: t("stacPlugin.notAddable"), | ||||||||
| }); | ||||||||
| }, [t]); | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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`. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Quality (medium confidence): This doc now says the plain-
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missed that one — fixed in 3220333. |
||
|
|
||
| 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. | ||
|
|
||
|
|
||
| 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"); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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`. */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Confidence: medium (inferred from the doc change plus how
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch on the doc drift — fixed in cf38010. |
||
| url: string; | ||
| /** Layer name shown in the Layers panel. Defaults to `<store> - <variable>`. */ | ||
| name?: string; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.