Skip to content

Commit 4868b0b

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/earthdata-gis-plugin
# Conflicts: # apps/geolibre-desktop/src/hooks/usePlugins.ts
2 parents 69f0dde + 691d633 commit 4868b0b

12 files changed

Lines changed: 494 additions & 33 deletions

File tree

apps/geolibre-desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"maplibre-gl": "^5.24.0",
6565
"maplibre-gl-3d-tiles": "^0.5.4",
6666
"maplibre-gl-basemap-control": "^0.13.0",
67-
"maplibre-gl-components": "^0.28.1",
67+
"maplibre-gl-components": "^0.29.0",
6868
"maplibre-gl-duckdb": "^0.2.3",
6969
"maplibre-gl-earth-engine": "^0.4.2",
7070
"maplibre-gl-enviroatlas": "^0.1.1",

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import {
1616
openRasterLayerPanel,
1717
openSplattingLayerPanel,
1818
openStacSearchLayerPanel,
19+
openZarrLayerPanel,
1920
openThreeDTilesLayerPanel,
2021
openVectorLayerPanel,
21-
openZarrLayerPanel,
2222
setAnnotationLabels,
2323
setBasemapControlLabels,
2424
setGraticuleLabels,

apps/geolibre-desktop/src/hooks/usePlugins.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
TIME_SLIDER_PLUGIN_ID,
1313
type TemporalLayerAdapter,
1414
setZarrLayerSelector,
15+
setZarrLocalStoreProvider,
1516
maplibreAnnotationsPlugin,
1617
maplibreBasemapControlPlugin,
1718
maplibreComponentsPlugin,
@@ -99,6 +100,7 @@ import {
99100
type InstalledWebPlugin,
100101
} from "../lib/external-plugins";
101102
import { appendDiagnostic } from "../lib/diagnostics";
103+
import { pickZarrDirectory, zarrDirectoryPickerSupported } from "../lib/zarr-directory-picker";
102104
import { openExternalLink } from "../lib/open-external";
103105
import { fetchUrlBytes } from "../lib/native-http";
104106
import { partitionProjectPluginManifestUrls } from "../lib/plugin-trust";
@@ -224,6 +226,15 @@ setEarthdataCogSaver(async (geoTiffBytes, defaultName) => {
224226
return saved !== null;
225227
});
226228

229+
// The Zarr panel can open a store from a folder on disk, but reading a folder
230+
// needs a filesystem API the plugins package does not have, so the picker is
231+
// injected here the same way. Registered only where a folder dialog exists (the
232+
// desktop app, or a browser with the File System Access API); elsewhere the
233+
// panel shows no Browse folder button rather than one that cannot deliver.
234+
if (zarrDirectoryPickerSupported()) {
235+
setZarrLocalStoreProvider(pickZarrDirectory);
236+
}
237+
227238
let externalPluginsLoaded = false;
228239
let externalPluginsLoadPromise: Promise<void> | null = null;
229240
let externalPluginsLoadKey: string | null = null;
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Picking a Zarr store that lives in a folder on disk.
2+
//
3+
// A Zarr store is a directory tree, so opening one locally means read access to
4+
// a folder rather than to a file. Two platforms offer that, and this module
5+
// hides the difference behind `@geolibre/plugins`' `ZarrDirectoryReader`:
6+
//
7+
// - the desktop app, through the Tauri folder dialog, whose `recursive: true`
8+
// grant extends the `fs` scope to the picked folder's whole subtree;
9+
// - a Chromium browser, through the File System Access API's
10+
// `showDirectoryPicker()`, which hands back a handle the page may walk.
11+
//
12+
// Firefox and Safari implement neither, so `zarrDirectoryPickerSupported()`
13+
// reports the feature as unavailable there rather than opening a dialog that
14+
// cannot deliver.
15+
16+
import { open } from "@tauri-apps/plugin-dialog";
17+
import { readFile } from "@tauri-apps/plugin-fs";
18+
import type { ZarrDirectoryReader } from "@geolibre/plugins";
19+
import { isTauri } from "./is-tauri";
20+
21+
/** The subset of `FileSystemDirectoryHandle` this module uses. */
22+
interface DirectoryHandle {
23+
name: string;
24+
kind: "directory";
25+
getDirectoryHandle(name: string): Promise<DirectoryHandle>;
26+
getFileHandle(name: string): Promise<{ getFile(): Promise<Blob> }>;
27+
}
28+
29+
interface DirectoryPickerWindow extends Window {
30+
showDirectoryPicker?: (options?: { mode?: "read" | "readwrite" }) => Promise<DirectoryHandle>;
31+
}
32+
33+
/**
34+
* Whether this build can open a Zarr store from a local folder: always on
35+
* desktop, and in a browser only where the File System Access API exists.
36+
*
37+
* @returns True when {@link pickZarrDirectory} can present a folder dialog.
38+
*/
39+
export function zarrDirectoryPickerSupported(): boolean {
40+
if (isTauri()) return true;
41+
return typeof (window as DirectoryPickerWindow).showDirectoryPicker === "function";
42+
}
43+
44+
/**
45+
* Open the folder dialog and return read access to the chosen folder.
46+
*
47+
* @returns A reader over the picked folder, or null when the dialog was
48+
* dismissed or no folder picker is available.
49+
*/
50+
export async function pickZarrDirectory(): Promise<ZarrDirectoryReader | null> {
51+
if (isTauri()) {
52+
const selected = await open({ directory: true, multiple: false, recursive: true });
53+
return typeof selected === "string" ? createTauriDirectoryReader(selected) : null;
54+
}
55+
56+
const picker = (window as DirectoryPickerWindow).showDirectoryPicker;
57+
if (!picker) return null;
58+
try {
59+
const handle = await picker.call(window, { mode: "read" });
60+
return handle ? createHandleDirectoryReader(handle) : null;
61+
} catch (error) {
62+
// Dismissing the dialog rejects with AbortError; that is not a failure.
63+
if (error instanceof DOMException && error.name === "AbortError") return null;
64+
throw error;
65+
}
66+
}
67+
68+
/** The last segment of a local path, used as the store's display name. */
69+
function directoryName(path: string): string {
70+
return (
71+
path
72+
.replace(/[/\\]+$/, "")
73+
.split(/[/\\]/)
74+
.pop() || path
75+
);
76+
}
77+
78+
/** Read a picked folder through Tauri's `fs` plugin. */
79+
function createTauriDirectoryReader(root: string): ZarrDirectoryReader {
80+
// Join with the folder's own separator style so a Windows path stays
81+
// all-backslash; store keys always arrive with `/`.
82+
const separator = root.includes("\\") ? "\\" : "/";
83+
const base = /[/\\]$/.test(root) ? root.slice(0, -1) : root;
84+
const resolve = (path: string) =>
85+
path ? `${base}${separator}${path.split("/").join(separator)}` : base;
86+
87+
return {
88+
name: directoryName(root),
89+
async readFile(path: string) {
90+
try {
91+
return await readFile(resolve(path));
92+
} catch {
93+
// A Zarr store writes no file for an all-fill chunk, so a missing key
94+
// is ordinary rather than an error worth surfacing.
95+
return undefined;
96+
}
97+
},
98+
};
99+
}
100+
101+
/** Read a picked folder through a File System Access directory handle. */
102+
function createHandleDirectoryReader(root: DirectoryHandle): ZarrDirectoryReader {
103+
const resolveDirectory = async (path: string): Promise<DirectoryHandle | null> => {
104+
let handle = root;
105+
for (const segment of path.split("/").filter(Boolean)) {
106+
handle = await handle.getDirectoryHandle(segment);
107+
}
108+
return handle;
109+
};
110+
111+
return {
112+
name: root.name || "zarr",
113+
async readFile(path: string) {
114+
const segments = path.split("/").filter(Boolean);
115+
const fileName = segments.pop();
116+
if (!fileName) return undefined;
117+
try {
118+
const directory = await resolveDirectory(segments.join("/"));
119+
if (!directory) return undefined;
120+
const file = await directory.getFileHandle(fileName);
121+
return new Uint8Array(await (await file.getFile()).arrayBuffer());
122+
} catch {
123+
// As above: an absent key is how a store expresses an empty chunk.
124+
return undefined;
125+
}
126+
},
127+
};
128+
}

docs/plugin-api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,10 @@ if (layerId) {
526526

527527
`addZarrLayer` is headless: it does not open the Zarr panel (the user can still open it from **Add Data → Zarr Layer** to tweak colormap and color limits). It resolves with the new layer's id once the layer is registered, and rejects when `variable` is missing or the store cannot be read. The layer supports visibility, opacity, ordering, and removal from the Layers panel like any other layer.
528528

529+
`selector` picks a slice by coordinate **value**, not by index: on a `month` axis of 1-12, December is `{ month: 12 }`. The panel's Selector (JSON) field means the same thing, and editing it now re-slices the layers already on the map.
530+
531+
The panel can also open a store from a folder on disk, via a **Browse folder** button next to the Zarr URL. It appears in the desktop app and in browsers with the File System Access API (Chromium), since reading a folder needs a filesystem API the plugin cannot supply; elsewhere the panel is unchanged. A local cube binds to the Time Slider like a remote one — its CF `units` are read out of the folder, because its recorded URL is an identifier rather than an address.
532+
529533
## Driving a layer's own time dimension from the Time Slider
530534

531535
The Time Slider understands three kinds of temporal layer. Two are built in: a **vector** layer filtered by a timestamp property, and a **raster time series** of dated sources the dock steps between. The third is for a layer that is *one store* with time as an **internal dimension** — a Zarr data cube, or a plugin's own frame-based layer — where the timeline picks a slice rather than a source.

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/plugins/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"maplibre-gl": "^5.24.0",
3939
"maplibre-gl-3d-tiles": "^0.5.4",
4040
"maplibre-gl-basemap-control": "^0.13.0",
41-
"maplibre-gl-components": "^0.28.1",
41+
"maplibre-gl-components": "^0.29.0",
4242
"maplibre-gl-duckdb": "^0.2.3",
4343
"maplibre-gl-earth-engine": "^0.4.2",
4444
"maplibre-gl-enviroatlas": "^0.1.1",

packages/plugins/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ export {
108108
type CloudNetcdfLayerOptions,
109109
addZarrRasterLayer,
110110
setZarrLayerSelector,
111+
setZarrLocalStoreProvider,
111112
type ZarrRasterLayerOptions,
113+
type ZarrReadableStore,
114+
type ZarrTimeAttributesReader,
112115
setBookmarkLabels,
113116
setViewStateLabels,
114117
subscribeBookmarkPanel,
@@ -131,6 +134,14 @@ export {
131134
type KerchunkRefs,
132135
type KerchunkVariable,
133136
} from "./plugins/kerchunk-reference-store";
137+
export {
138+
ZarrDirectoryStore,
139+
createDirectoryZarrMetadataReader,
140+
localZarrStoreUrl,
141+
normalizeZarrKey,
142+
type ZarrDirectoryReader,
143+
type ZarrMetadataReader,
144+
} from "./plugins/zarr-directory-store";
134145
export {
135146
openLocalNetcdf,
136147
buildInlineZarrRefs,

0 commit comments

Comments
 (0)