@walkthru-earth/objex 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +9 -2
  2. package/dist/components/browser/FileBrowser.svelte +53 -41
  3. package/dist/components/browser/FileRow.svelte +8 -3
  4. package/dist/components/browser/FileTreeSidebar.svelte +2 -4
  5. package/dist/components/layout/AboutSheet.svelte +126 -0
  6. package/dist/components/layout/AboutSheet.svelte.d.ts +6 -0
  7. package/dist/components/layout/ConnectionDialog.svelte +186 -138
  8. package/dist/components/layout/ConnectionDialog.svelte.d.ts +1 -0
  9. package/dist/components/layout/Sidebar.svelte +19 -3
  10. package/dist/components/layout/TabBar.svelte +4 -7
  11. package/dist/components/viewers/CodeViewer.svelte +17 -9
  12. package/dist/components/viewers/ImageViewer.svelte +6 -16
  13. package/dist/components/viewers/MarkdownViewer.svelte +8 -16
  14. package/dist/components/viewers/MediaViewer.svelte +6 -17
  15. package/dist/components/viewers/ModelViewer.svelte +4 -2
  16. package/dist/components/viewers/NotebookViewer.svelte +90 -40
  17. package/dist/components/viewers/PdfViewer.svelte +5 -3
  18. package/dist/components/viewers/RawViewer.svelte +4 -2
  19. package/dist/components/viewers/TableGrid.svelte +3 -2
  20. package/dist/components/viewers/ZarrMapViewer.svelte +334 -40
  21. package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +3 -8
  22. package/dist/components/viewers/ZarrViewer.svelte +459 -178
  23. package/dist/components/viewers/map/AttributeTable.svelte +1 -6
  24. package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +2 -6
  25. package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +96 -22
  26. package/dist/constants.d.ts +28 -0
  27. package/dist/constants.js +34 -0
  28. package/dist/file-icons/index.js +6 -0
  29. package/dist/i18n/ar.js +34 -0
  30. package/dist/i18n/en.js +34 -0
  31. package/dist/index.d.ts +13 -1
  32. package/dist/index.js +16 -1
  33. package/dist/query/wasm.js +5 -4
  34. package/dist/storage/browser-cloud.d.ts +7 -0
  35. package/dist/storage/browser-cloud.js +74 -7
  36. package/dist/storage/providers.d.ts +53 -0
  37. package/dist/storage/providers.js +318 -0
  38. package/dist/stores/connections.svelte.js +8 -34
  39. package/dist/stores/files.svelte.d.ts +1 -6
  40. package/dist/stores/files.svelte.js +4 -36
  41. package/dist/stores/query-history.svelte.js +5 -28
  42. package/dist/stores/settings.svelte.d.ts +1 -0
  43. package/dist/stores/settings.svelte.js +11 -31
  44. package/dist/types.d.ts +2 -2
  45. package/dist/utils/clipboard.d.ts +13 -0
  46. package/dist/utils/clipboard.js +38 -0
  47. package/dist/utils/cloud-url.d.ts +27 -0
  48. package/dist/utils/cloud-url.js +61 -0
  49. package/dist/utils/error.d.ts +8 -0
  50. package/dist/utils/error.js +12 -0
  51. package/dist/utils/export.d.ts +22 -2
  52. package/dist/utils/export.js +35 -10
  53. package/dist/utils/file-sort.d.ts +20 -0
  54. package/dist/utils/file-sort.js +41 -0
  55. package/dist/utils/format.d.ts +10 -0
  56. package/dist/utils/format.js +22 -0
  57. package/dist/utils/host-detection.js +78 -18
  58. package/dist/utils/local-storage.d.ts +16 -0
  59. package/dist/utils/local-storage.js +37 -0
  60. package/dist/utils/notebook.d.ts +59 -0
  61. package/dist/utils/notebook.js +211 -0
  62. package/dist/utils/parquet-metadata.js +1 -1
  63. package/dist/utils/pmtiles-tile.js +2 -1
  64. package/dist/utils/pmtiles.js +2 -1
  65. package/dist/utils/storage-url.d.ts +1 -1
  66. package/dist/utils/storage-url.js +82 -24
  67. package/dist/utils/url-state.js +2 -7
  68. package/dist/utils/url.d.ts +0 -2
  69. package/dist/utils/url.js +3 -29
  70. package/dist/utils/zarr.d.ts +60 -20
  71. package/dist/utils/zarr.js +450 -103
  72. package/package.json +66 -54
  73. package/dist/assets/favicon.svg +0 -17
  74. package/dist/components/CLAUDE.md +0 -44
  75. package/dist/components/viewers/CLAUDE.md +0 -60
  76. package/dist/file-icons/CLAUDE.md +0 -21
  77. package/dist/i18n/CLAUDE.md +0 -19
  78. package/dist/query/CLAUDE.md +0 -22
  79. package/dist/storage/CLAUDE.md +0 -23
  80. package/dist/stores/CLAUDE.md +0 -29
  81. package/dist/types/notebookjs.d.ts +0 -14
  82. package/dist/utils/CLAUDE.md +0 -54
  83. package/dist/utils/analytics.d.ts +0 -10
  84. package/dist/utils/analytics.js +0 -38
@@ -1,11 +1,123 @@
1
1
  /**
2
2
  * Zarr metadata parsing utilities.
3
3
  *
4
- * Handles consolidated metadata for both Zarr v2 (.zmetadata) and v3 (zarr.json),
5
- * plus a zarrita fallback for non-consolidated stores.
4
+ * Builds a hierarchical tree of groups and arrays from consolidated metadata
5
+ * (Zarr v2 .zmetadata, v3 zarr.json), with zarrita fallback for non-consolidated stores.
6
6
  */
7
+ import { formatFileSize } from './format.js';
8
+ // ---------------------------------------------------------------------------
9
+ // Register numcodecs-wrapped codecs with zarrita's codec registry.
10
+ // Zarr v3 stores produced by Python's zarr-python may wrap codecs with the
11
+ // "numcodecs." prefix (e.g. "numcodecs.zlib", "numcodecs.shuffle").
12
+ // zarrita only registers the bare names, so we add aliases + a byte shuffle.
13
+ // ---------------------------------------------------------------------------
14
+ /**
15
+ * Byte shuffle codec (HDF5 / numcodecs byte shuffle).
16
+ * Rearranges bytes within elements to improve downstream compression ratios.
17
+ * Operates as a bytes_to_bytes filter in the codec pipeline.
18
+ */
19
+ const ShuffleCodec = {
20
+ kind: 'bytes_to_bytes',
21
+ fromConfig(config) {
22
+ const elementsize = config?.elementsize ?? 4;
23
+ return {
24
+ kind: 'bytes_to_bytes',
25
+ encode(data) {
26
+ const count = data.length / elementsize;
27
+ const out = new Uint8Array(data.length);
28
+ for (let i = 0; i < count; i++) {
29
+ for (let j = 0; j < elementsize; j++) {
30
+ out[j * count + i] = data[i * elementsize + j];
31
+ }
32
+ }
33
+ return out;
34
+ },
35
+ decode(data) {
36
+ const count = data.length / elementsize;
37
+ const out = new Uint8Array(data.length);
38
+ for (let i = 0; i < count; i++) {
39
+ for (let j = 0; j < elementsize; j++) {
40
+ out[i * elementsize + j] = data[j * count + i];
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+ };
46
+ }
47
+ };
48
+ /** Register numcodecs-prefixed aliases with zarrita's codec registry. */
49
+ async function registerNumcodecs() {
50
+ try {
51
+ const { registry } = await import('zarrita');
52
+ // Alias existing codecs with numcodecs. prefix
53
+ for (const name of ['zlib', 'gzip', 'blosc', 'lz4', 'zstd']) {
54
+ const existing = registry.get(name);
55
+ if (existing)
56
+ registry.set(`numcodecs.${name}`, existing);
57
+ }
58
+ // Register shuffle codec
59
+ registry.set('numcodecs.shuffle', () => Promise.resolve(ShuffleCodec));
60
+ registry.set('shuffle', () => Promise.resolve(ShuffleCodec));
61
+ }
62
+ catch {
63
+ // zarrita not available — skip registration
64
+ }
65
+ }
66
+ let _codecsPromise = null;
67
+ /** Ensure numcodecs codecs are registered. Await before creating ZarrLayer. */
68
+ export function ensureCodecsRegistered() {
69
+ if (!_codecsPromise)
70
+ _codecsPromise = registerNumcodecs();
71
+ return _codecsPromise;
72
+ }
73
+ // Start registration immediately at module load
74
+ ensureCodecsRegistered();
75
+ /** Zarr store marker files — presence of any indicates a Zarr store. */
76
+ export const ZARR_MARKER_FILES = new Set([
77
+ 'zarr.json',
78
+ '.zmetadata',
79
+ '.zgroup',
80
+ '.zarray',
81
+ '.zattrs'
82
+ ]);
83
+ /** Zarr marker file suffixes used in URLs (with leading slash). */
84
+ const ZARR_MARKER_SUFFIXES = ['/zarr.json', '/.zmetadata', '/.zgroup', '/.zarray', '/.zattrs'];
85
+ /**
86
+ * Detect whether a set of file names contains Zarr marker files.
87
+ * Returns the detected version (2 or 3) or null if not detected.
88
+ */
89
+ export function detectZarrMarkers(fileNames) {
90
+ let hasV3 = false;
91
+ let hasV2 = false;
92
+ for (const name of fileNames) {
93
+ if (name === 'zarr.json')
94
+ hasV3 = true;
95
+ else if (ZARR_MARKER_FILES.has(name))
96
+ hasV2 = true;
97
+ }
98
+ if (hasV3)
99
+ return { detected: true, version: 3 };
100
+ if (hasV2)
101
+ return { detected: true, version: 2 };
102
+ return { detected: false, version: null };
103
+ }
104
+ /**
105
+ * If a URL points to a Zarr marker file, strip the marker suffix and return the store URL.
106
+ * Returns null if the URL doesn't end with a known marker suffix.
107
+ */
108
+ export function extractZarrStoreUrl(url) {
109
+ for (const suffix of ZARR_MARKER_SUFFIXES) {
110
+ if (url.endsWith(suffix)) {
111
+ return url.slice(0, -suffix.length);
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ // ---------------------------------------------------------------------------
117
+ // Kept helpers
118
+ // ---------------------------------------------------------------------------
7
119
  /** Dimension-like variable names treated as coordinates. */
8
- const DIM_LIKE_NAMES = new Set([
120
+ export const DIM_LIKE_NAMES = new Set([
9
121
  'x',
10
122
  'y',
11
123
  'lat',
@@ -36,110 +148,279 @@ export function formatShape(shape) {
36
148
  return 'scalar';
37
149
  return `[${shape.join(' × ')}]`;
38
150
  }
39
- /** Collect coordinate variable names from `coordinates` attributes across all entries. */
40
- function collectCoordNames(entries) {
41
- const names = new Set();
42
- for (const [, info] of entries) {
43
- const coordStr = info.attributes?.coordinates;
44
- if (typeof coordStr === 'string') {
45
- for (const c of coordStr.split(/\s+/))
46
- names.add(c);
151
+ // ---------------------------------------------------------------------------
152
+ // Metadata display helpers
153
+ // ---------------------------------------------------------------------------
154
+ /** Byte size of a dtype string. Handles v2 (`<f4`, `|u1`) and v3 (`float32`, `uint8`). */
155
+ export function dtypeByteSize(dtype) {
156
+ // v2 style: last char(s) are the byte width e.g. "<f4" → 4, "|u1" → 1
157
+ const v2 = /^[<>|]?[a-zA-Z](\d+)$/.exec(dtype);
158
+ if (v2)
159
+ return Number(v2[1]);
160
+ // v3 style: "float32" → 4, "int16" → 2, "uint8" → 1, "complex128" → 16
161
+ const v3 = /(\d+)$/.exec(dtype);
162
+ if (v3)
163
+ return Number(v3[1]) / 8;
164
+ // bool
165
+ if (dtype === 'bool')
166
+ return 1;
167
+ return 1; // fallback
168
+ }
169
+ /** Format chunk count: `"36 [6 × 6]"` */
170
+ export function computeChunkCount(shape, chunks) {
171
+ if (!shape?.length || !chunks?.length || shape.length !== chunks.length)
172
+ return null;
173
+ const dims = shape.map((s, i) => Math.ceil(s / chunks[i]));
174
+ const total = dims.reduce((a, b) => a * b, 1);
175
+ return `${total.toLocaleString()} [${dims.join(' × ')}]`;
176
+ }
177
+ /** Format chunk size in bytes: `"817.6 KB"` */
178
+ export function computeChunkSize(chunks, dtype) {
179
+ if (!chunks?.length || !dtype)
180
+ return null;
181
+ const elements = chunks.reduce((a, b) => a * b, 1);
182
+ return formatFileSize(elements * dtypeByteSize(dtype));
183
+ }
184
+ /** Format uncompressed size: `"28.7 MB"` */
185
+ export function computeUncompressed(shape, dtype) {
186
+ if (!shape?.length || !dtype)
187
+ return null;
188
+ const elements = shape.reduce((a, b) => a * b, 1);
189
+ return formatFileSize(elements * dtypeByteSize(dtype));
190
+ }
191
+ /** Format codec pipeline for display. */
192
+ export function formatCodecs(node) {
193
+ // v3: codecs array
194
+ if (node.codecs?.length) {
195
+ return node.codecs
196
+ .map((c) => {
197
+ const name = c.name ?? c.codec_id ?? (typeof c === 'string' ? c : '');
198
+ if (!name)
199
+ return JSON.stringify(c);
200
+ const cfg = c.configuration ?? c.codec_config ?? {};
201
+ const parts = [];
202
+ if (cfg.cname)
203
+ parts.push(cfg.cname);
204
+ const level = cfg.clevel ?? cfg.level;
205
+ if (level != null)
206
+ parts.push(`level ${level}`);
207
+ if (cfg.shuffle != null)
208
+ parts.push(cfg.shuffle ? 'shuffle' : 'no shuffle');
209
+ if (cfg.typesize)
210
+ parts.push(`typesize ${cfg.typesize}`);
211
+ if (cfg.elementsize)
212
+ parts.push(`elementsize ${cfg.elementsize}`);
213
+ return parts.length ? `${name} (${parts.join(', ')})` : name;
214
+ })
215
+ .join(' → ');
216
+ }
217
+ // v2: compressor + filters
218
+ if (node.compressor) {
219
+ const c = node.compressor;
220
+ const name = c.id ?? c.codec ?? 'unknown';
221
+ const parts = [];
222
+ if (c.cname)
223
+ parts.push(c.cname);
224
+ if (c.clevel != null)
225
+ parts.push(`level ${c.clevel}`);
226
+ if (c.shuffle != null)
227
+ parts.push(c.shuffle ? 'shuffle' : 'no shuffle');
228
+ const base = parts.length ? `${name} (${parts.join(', ')})` : name;
229
+ if (node.filters?.length) {
230
+ const filterStr = node.filters.map((f) => f.id ?? JSON.stringify(f)).join(', ');
231
+ return `${filterStr} → ${base}`;
47
232
  }
233
+ return base;
48
234
  }
49
- return names;
235
+ return null;
50
236
  }
51
- /** Parse Zarr v3 consolidated metadata (zarr.json). */
52
- export function parseV3Consolidated(data) {
53
- const meta = data.consolidated_metadata?.metadata ?? {};
54
- const attrs = data.attributes ?? {};
55
- const vars = [];
56
- const coords = [];
57
- let srAttrs = null;
58
- const coordNames = collectCoordNames(Object.entries(meta));
59
- for (const [name, info] of Object.entries(meta)) {
60
- if (!info.shape)
61
- continue;
62
- const v = {
63
- name,
64
- shape: info.shape,
65
- dtype: info.data_type ?? 'unknown',
66
- dims: info.attributes?._ARRAY_DIMENSIONS ?? inferDims(name, info.shape),
67
- chunks: info.chunk_grid?.configuration?.chunk_shape ?? [],
68
- attributes: info.attributes ?? {}
69
- };
70
- if (name === 'spatial_ref') {
71
- srAttrs = v.attributes;
72
- coords.push(v);
237
+ /** Format chunk_key_encoding for display: `"default (sep: "/")"` */
238
+ export function formatChunkKeys(node) {
239
+ return node.chunkKeyEncoding ?? null;
240
+ }
241
+ /** Find a node by slash-delimited path. */
242
+ export function findNodeByPath(root, path) {
243
+ if (path === '/' || path === '')
244
+ return root;
245
+ const parts = path.replace(/^\//, '').split('/');
246
+ let current = root;
247
+ for (const part of parts) {
248
+ const child = current.children.find((c) => c.name === part);
249
+ if (!child)
250
+ return null;
251
+ current = child;
252
+ }
253
+ return current;
254
+ }
255
+ // ---------------------------------------------------------------------------
256
+ // Tree builders
257
+ // ---------------------------------------------------------------------------
258
+ function makeNode(path, kind, attrs) {
259
+ const name = path === '/' ? '/' : path.split('/').pop();
260
+ return { path, name, kind, children: [], attributes: attrs };
261
+ }
262
+ /** Ensure all intermediate groups exist and return the parent for `path`. */
263
+ function ensureParent(root, path) {
264
+ const parts = path.replace(/^\//, '').split('/');
265
+ parts.pop(); // remove leaf
266
+ let current = root;
267
+ let currentPath = '';
268
+ for (const part of parts) {
269
+ currentPath += `/${part}`;
270
+ let child = current.children.find((c) => c.name === part);
271
+ if (!child) {
272
+ child = makeNode(currentPath, 'group', {});
273
+ current.children.push(child);
73
274
  }
74
- else if (coordNames.has(name) || DIM_LIKE_NAMES.has(name) || v.shape.length <= 1) {
75
- coords.push(v);
275
+ current = child;
276
+ }
277
+ return current;
278
+ }
279
+ /** Build tree from Zarr v3 consolidated metadata (zarr.json). */
280
+ export function buildV3Tree(data) {
281
+ const meta = data.consolidated_metadata?.metadata ?? {};
282
+ const rootAttrs = data.attributes ?? {};
283
+ const root = makeNode('/', 'group', rootAttrs);
284
+ let totalNodes = 1;
285
+ let spatialRefAttrs = null;
286
+ for (const [key, info] of Object.entries(meta)) {
287
+ const path = `/${key}`;
288
+ const attrs = info.attributes ?? {};
289
+ if (info.node_type === 'group' || (!info.shape && !info.data_type)) {
290
+ // Group node
291
+ const node = makeNode(path, 'group', attrs);
292
+ const parent = ensureParent(root, path);
293
+ // Avoid duplicating if ensureParent already created this node
294
+ const existing = parent.children.find((c) => c.name === node.name);
295
+ if (existing) {
296
+ existing.attributes = { ...existing.attributes, ...attrs };
297
+ }
298
+ else {
299
+ parent.children.push(node);
300
+ }
301
+ totalNodes++;
76
302
  }
77
303
  else {
78
- vars.push(v);
304
+ // Array node
305
+ const node = makeNode(path, 'array', attrs);
306
+ node.shape = info.shape;
307
+ node.dtype = info.data_type ?? 'unknown';
308
+ node.dims =
309
+ info.dimension_names ?? attrs._ARRAY_DIMENSIONS ?? inferDims(node.name, node.shape ?? []);
310
+ node.chunks = info.chunk_grid?.configuration?.chunk_shape ?? [];
311
+ node.fillValue = info.fill_value;
312
+ node.codecs = info.codecs ?? [];
313
+ // Parse chunk_key_encoding for v3
314
+ const cke = info.chunk_key_encoding;
315
+ if (cke) {
316
+ const sep = cke.configuration?.separator ?? '/';
317
+ node.chunkKeyEncoding = `${cke.name ?? 'default'} (sep: "${sep}")`;
318
+ }
319
+ const parent = ensureParent(root, path);
320
+ parent.children.push(node);
321
+ totalNodes++;
322
+ if (node.name === 'spatial_ref') {
323
+ spatialRefAttrs = attrs;
324
+ }
79
325
  }
80
326
  }
81
- return { storeAttrs: attrs, variables: vars, coords, spatialRefAttrs: srAttrs };
327
+ // Sort children alphabetically, groups first
328
+ sortTree(root);
329
+ return {
330
+ root,
331
+ zarrVersion: 3,
332
+ totalNodes,
333
+ storeAttrs: rootAttrs,
334
+ spatialRefAttrs
335
+ };
82
336
  }
83
- /** Parse Zarr v2 consolidated metadata (.zmetadata). */
84
- export function parseV2Consolidated(data) {
337
+ /** Build tree from Zarr v2 consolidated metadata (.zmetadata). */
338
+ export function buildV2Tree(data) {
85
339
  const meta = data.metadata ?? {};
86
- const attrs = meta['.zattrs'] ?? {};
87
- const vars = [];
88
- const coords = [];
89
- let srAttrs = null;
90
- const arrayKeys = Object.keys(meta).filter((k) => k.endsWith('/.zarray'));
91
- // Collect coordinate names from variable attributes
92
- const coordNames = new Set();
93
- for (const key of arrayKeys) {
94
- const name = key.replace('/.zarray', '');
95
- const varAttrs = meta[`${name}/.zattrs`] ?? {};
96
- const coordStr = varAttrs.coordinates;
97
- if (typeof coordStr === 'string') {
98
- for (const c of coordStr.split(/\s+/))
99
- coordNames.add(c);
340
+ const rootAttrs = meta['.zattrs'] ?? {};
341
+ const root = makeNode('/', 'group', rootAttrs);
342
+ let totalNodes = 1;
343
+ let spatialRefAttrs = null;
344
+ // Collect group paths
345
+ const groupKeys = Object.keys(meta).filter((k) => k.endsWith('/.zgroup'));
346
+ for (const key of groupKeys) {
347
+ const name = key.replace('/.zgroup', '');
348
+ if (!name)
349
+ continue; // root
350
+ const path = `/${name}`;
351
+ const attrs = meta[`${name}/.zattrs`] ?? {};
352
+ const parent = ensureParent(root, path);
353
+ const existing = parent.children.find((c) => c.name === name.split('/').pop());
354
+ if (existing) {
355
+ existing.attributes = { ...existing.attributes, ...attrs };
356
+ }
357
+ else {
358
+ parent.children.push(makeNode(path, 'group', attrs));
100
359
  }
360
+ totalNodes++;
101
361
  }
362
+ // Collect array paths
363
+ const arrayKeys = Object.keys(meta).filter((k) => k.endsWith('/.zarray'));
102
364
  for (const key of arrayKeys) {
103
365
  const name = key.replace('/.zarray', '');
366
+ const path = `/${name}`;
104
367
  const zarray = meta[key];
105
- const varAttrs = meta[`${name}/.zattrs`] ?? {};
106
- const v = {
107
- name,
108
- shape: zarray.shape ?? [],
109
- dtype: zarray.dtype ?? 'unknown',
110
- dims: varAttrs._ARRAY_DIMENSIONS ?? [],
111
- chunks: zarray.chunks ?? [],
112
- attributes: varAttrs
113
- };
114
- if (name === 'spatial_ref') {
115
- srAttrs = v.attributes;
116
- coords.push(v);
117
- }
118
- else if (coordNames.has(name) || DIM_LIKE_NAMES.has(name) || v.shape.length <= 1) {
119
- coords.push(v);
120
- }
121
- else {
122
- vars.push(v);
368
+ const attrs = meta[`${name}/.zattrs`] ?? {};
369
+ const shape = zarray.shape ?? [];
370
+ const node = makeNode(path, 'array', attrs);
371
+ node.shape = shape;
372
+ node.dtype = zarray.dtype ?? 'unknown';
373
+ node.dims = attrs._ARRAY_DIMENSIONS ?? inferDims(node.name, shape);
374
+ node.chunks = zarray.chunks ?? [];
375
+ node.fillValue = zarray.fill_value;
376
+ node.compressor = zarray.compressor ?? null;
377
+ node.filters = zarray.filters ?? [];
378
+ const parent = ensureParent(root, path);
379
+ parent.children.push(node);
380
+ totalNodes++;
381
+ if (node.name === 'spatial_ref') {
382
+ spatialRefAttrs = attrs;
123
383
  }
124
384
  }
125
- return { storeAttrs: attrs, variables: vars, coords, spatialRefAttrs: srAttrs };
385
+ sortTree(root);
386
+ return {
387
+ root,
388
+ zarrVersion: 2,
389
+ totalNodes,
390
+ storeAttrs: rootAttrs,
391
+ spatialRefAttrs
392
+ };
393
+ }
394
+ /** Recursively sort children: groups first, then alphabetically. */
395
+ function sortTree(node) {
396
+ node.children.sort((a, b) => {
397
+ if (a.kind !== b.kind)
398
+ return a.kind === 'group' ? -1 : 1;
399
+ return a.name.localeCompare(b.name);
400
+ });
401
+ for (const child of node.children) {
402
+ sortTree(child);
403
+ }
126
404
  }
405
+ // ---------------------------------------------------------------------------
406
+ // Fetchers
407
+ // ---------------------------------------------------------------------------
127
408
  /**
128
- * Fetch consolidated metadata from a Zarr store URL.
129
- * Tries v3 (zarr.json) first, then v2 (.zmetadata).
409
+ * Fetch hierarchy from a Zarr store URL.
410
+ * Tries v3 (zarr.json) first, then v2 (.zmetadata), then zarrita fallback.
130
411
  */
131
- export async function fetchConsolidated(storeUrl) {
412
+ export async function fetchHierarchy(storeUrl, storeName, signal) {
132
413
  // Try Zarr v3 zarr.json
133
414
  try {
134
- const res = await fetch(`${storeUrl}/zarr.json`);
415
+ const res = await fetch(`${storeUrl}/zarr.json`, { signal });
135
416
  if (res.ok) {
136
417
  const data = await res.json();
137
418
  if (data.zarr_format === 3) {
138
419
  if (data.consolidated_metadata) {
139
- return { ...parseV3Consolidated(data), zarrVersion: 3 };
420
+ return buildV3Tree(data);
140
421
  }
141
- // v3 without consolidated metadata — skip v2 probe
142
- return null;
422
+ // V3 without consolidated metadata — discover children
423
+ return discoverV3Children(storeUrl, data, signal);
143
424
  }
144
425
  }
145
426
  }
@@ -148,53 +429,119 @@ export async function fetchConsolidated(storeUrl) {
148
429
  }
149
430
  // Try Zarr v2 .zmetadata
150
431
  try {
151
- const res = await fetch(`${storeUrl}/.zmetadata`);
432
+ const res = await fetch(`${storeUrl}/.zmetadata`, { signal });
152
433
  if (res.ok) {
153
434
  const data = await res.json();
154
435
  if (data.metadata) {
155
- return { ...parseV2Consolidated(data), zarrVersion: 2 };
436
+ return buildV2Tree(data);
156
437
  }
157
438
  }
158
439
  }
159
440
  catch {
160
441
  /* ignore */
161
442
  }
162
- return null;
443
+ // Fallback: zarrita probe
444
+ return probeHierarchy(storeUrl, storeName);
445
+ }
446
+ /**
447
+ * Discover children of a v3 store without consolidated metadata.
448
+ * Probes child paths from zarr.json attributes (e.g. multiscales convention)
449
+ * and by fetching individual zarr.json files for discovered paths.
450
+ */
451
+ async function discoverV3Children(storeUrl, rootData, signal) {
452
+ const rootAttrs = rootData.attributes ?? {};
453
+ const root = makeNode('/', 'group', rootAttrs);
454
+ let totalNodes = 1;
455
+ // Collect candidate child names from conventions and common patterns
456
+ const candidates = new Set();
457
+ // Multiscales convention: layout[].asset lists child array names
458
+ const multiscales = rootAttrs.multiscales;
459
+ if (multiscales?.layout && Array.isArray(multiscales.layout)) {
460
+ for (const entry of multiscales.layout) {
461
+ if (entry.asset)
462
+ candidates.add(String(entry.asset));
463
+ }
464
+ }
465
+ // Probe each candidate path for zarr.json
466
+ const probes = [...candidates].map(async (name) => {
467
+ try {
468
+ const res = await fetch(`${storeUrl}/${name}/zarr.json`, { signal });
469
+ if (!res.ok)
470
+ return null;
471
+ const data = await res.json();
472
+ if (data.node_type === 'array' && data.shape) {
473
+ const node = makeNode(`/${name}`, 'array', data.attributes ?? {});
474
+ node.shape = data.shape;
475
+ node.dtype = data.data_type ?? 'unknown';
476
+ node.dims = data.dimension_names ?? inferDims(name, data.shape);
477
+ node.chunks = data.chunk_grid?.configuration?.chunk_shape ?? [];
478
+ node.fillValue = data.fill_value;
479
+ node.codecs = data.codecs ?? [];
480
+ const cke = data.chunk_key_encoding;
481
+ if (cke) {
482
+ const sep = cke.configuration?.separator ?? '/';
483
+ node.chunkKeyEncoding = `${cke.name ?? 'default'} (sep: "${sep}")`;
484
+ }
485
+ return node;
486
+ }
487
+ if (data.node_type === 'group') {
488
+ return makeNode(`/${name}`, 'group', data.attributes ?? {});
489
+ }
490
+ return null;
491
+ }
492
+ catch {
493
+ return null;
494
+ }
495
+ });
496
+ const results = await Promise.all(probes);
497
+ for (const node of results) {
498
+ if (node) {
499
+ root.children.push(node);
500
+ totalNodes++;
501
+ }
502
+ }
503
+ sortTree(root);
504
+ return {
505
+ root,
506
+ zarrVersion: 3,
507
+ totalNodes,
508
+ storeAttrs: rootAttrs,
509
+ spatialRefAttrs: null
510
+ };
163
511
  }
164
512
  /**
165
513
  * Fallback: probe a Zarr store using zarrita when consolidated metadata is unavailable.
166
- * @param storeName - Display name for the root array (e.g. file name without .zarr)
167
514
  */
168
- export async function probeWithZarrita(storeUrl, storeName) {
515
+ export async function probeHierarchy(storeUrl, storeName) {
169
516
  const zarrita = await import('zarrita');
170
517
  const store = new zarrita.FetchStore(storeUrl);
171
518
  try {
172
519
  const arr = await zarrita.open(store, { kind: 'array' });
173
- const v = {
174
- name: storeName,
175
- shape: arr.shape ?? [],
176
- dtype: String(arr.dtype ?? 'unknown'),
177
- dims: arr.attrs?._ARRAY_DIMENSIONS ?? [],
178
- chunks: arr.chunks ?? [],
179
- attributes: arr.attrs ?? {}
180
- };
520
+ const root = makeNode('/', 'group', {});
521
+ const node = makeNode(`/${storeName}`, 'array', arr.attrs ?? {});
522
+ node.shape = arr.shape ?? [];
523
+ node.dtype = String(arr.dtype ?? 'unknown');
524
+ node.dims = arr.attrs?._ARRAY_DIMENSIONS ?? [];
525
+ node.chunks = arr.chunks ?? [];
526
+ root.children.push(node);
181
527
  return {
528
+ root,
529
+ zarrVersion: null,
530
+ totalNodes: 2,
182
531
  storeAttrs: {},
183
- variables: [v],
184
- coords: [],
185
- spatialRefAttrs: null,
186
- zarrVersion: null
532
+ spatialRefAttrs: null
187
533
  };
188
534
  }
189
535
  catch {
190
536
  try {
191
- await zarrita.open(store, { kind: 'group' });
537
+ const grp = await zarrita.open(store, { kind: 'group' });
538
+ const attrs = grp.attrs ?? {};
192
539
  return {
193
- storeAttrs: {},
194
- variables: [],
195
- coords: [],
196
- spatialRefAttrs: null,
197
- zarrVersion: null
540
+ root: makeNode('/', 'group', attrs),
541
+ zarrVersion: null,
542
+ totalNodes: 1,
543
+ storeAttrs: attrs,
544
+ spatialRefAttrs: null
198
545
  };
199
546
  }
200
547
  catch {